From 21c9f07a848bc0dddc5512e7089576ee9be37b4c Mon Sep 17 00:00:00 2001 From: Ciel Bellerose Date: Fri, 24 Jul 2026 13:01:46 -0400 Subject: [PATCH 1/5] #4317 add car field to getRulesetsByRulesetType --- src/backend/src/controllers/rules.controllers.ts | 6 +++++- src/backend/src/services/rules.services.ts | 6 ++++-- src/frontend/src/hooks/rules.hooks.ts | 13 +++++++++---- 3 files changed, 18 insertions(+), 7 deletions(-) diff --git a/src/backend/src/controllers/rules.controllers.ts b/src/backend/src/controllers/rules.controllers.ts index f2f92fd9e8..304858cd1d 100644 --- a/src/backend/src/controllers/rules.controllers.ts +++ b/src/backend/src/controllers/rules.controllers.ts @@ -137,7 +137,11 @@ export default class RulesController { static async getRulesetsByRulesetType(req: Request, res: Response, next: NextFunction) { try { const { rulesetTypeId } = req.params as Record; - const rulesets = await RulesService.getRulesetsByRulesetType(rulesetTypeId, req.organization.organizationId); + const rulesets = await RulesService.getRulesetsByRulesetType( + rulesetTypeId, + req.organization.organizationId, + req.currentCar?.carId + ); res.status(200).json(rulesets); } catch (error: unknown) { next(error); diff --git a/src/backend/src/services/rules.services.ts b/src/backend/src/services/rules.services.ts index ae78f7d75e..9b97c94f2e 100644 --- a/src/backend/src/services/rules.services.ts +++ b/src/backend/src/services/rules.services.ts @@ -772,16 +772,18 @@ export default class RulesService { * Gets rulesets for a given ruleset type * @param rulesetTypeId id of ruleset type * @param organizationId id of organization + * @param carId optional id of the car to filter rulesets by * @returns rulesets associated with provided ruleset type */ - static async getRulesetsByRulesetType(rulesetTypeId: string, organizationId: string): Promise { + static async getRulesetsByRulesetType(rulesetTypeId: string, organizationId: string, carId?: string): Promise { const rulesets = await prisma.ruleset.findMany({ where: { rulesetTypeId, deletedByUserId: null, rulesetType: { organizationId - } + }, + ...(carId && { carId }) }, orderBy: { dateCreated: 'desc' diff --git a/src/frontend/src/hooks/rules.hooks.ts b/src/frontend/src/hooks/rules.hooks.ts index 759ba11595..1ed44cf2ef 100644 --- a/src/frontend/src/hooks/rules.hooks.ts +++ b/src/frontend/src/hooks/rules.hooks.ts @@ -35,6 +35,7 @@ import { getRulesetType } from '../apis/rules.api'; import { useToast } from './toasts.hooks'; +import { useGlobalCarFilter } from '../app/AppGlobalCarFilterContext'; /** * Hook to supply all ruleset types. @@ -375,10 +376,14 @@ export const useSetRuleCompletion = (rulesetId: string, projectId: string) => { * @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; - }); + const { selectedCar } = useGlobalCarFilter(); + return useQuery( + ['rulesets', rulesetTypeId, selectedCar === 'all-cars' ? 'all-cars' : selectedCar.id], + async () => { + const { data } = await getRulesetsByRulesetType(rulesetTypeId); + return data; + } + ); }; /** From 68d1d4ec897217687c42f49d9d09c269d8116c06 Mon Sep 17 00:00:00 2001 From: Ciel Bellerose Date: Sat, 25 Jul 2026 11:07:53 -0400 Subject: [PATCH 2/5] #4317 one active ruleset per car --- .../src/controllers/rules.controllers.ts | 9 +++++- src/backend/src/routes/rules.routes.ts | 9 ++++-- src/backend/src/services/rules.services.ts | 29 ++++++++++++++++--- src/backend/tests/unit/rule.test.ts | 2 +- src/frontend/src/apis/rules.api.ts | 3 +- src/frontend/src/hooks/rules.hooks.ts | 9 +++--- .../ProjectRules/ProjectRulesTab.tsx | 5 ++-- 7 files changed, 51 insertions(+), 15 deletions(-) diff --git a/src/backend/src/controllers/rules.controllers.ts b/src/backend/src/controllers/rules.controllers.ts index 304858cd1d..d3e75ca083 100644 --- a/src/backend/src/controllers/rules.controllers.ts +++ b/src/backend/src/controllers/rules.controllers.ts @@ -7,7 +7,14 @@ export default class RulesController { static async getActiveRuleset(req: Request, res: Response, next: NextFunction) { try { const { rulesetTypeId } = req.params as Record; - const rulesetType = await RulesService.getActiveRuleset(req.currentUser, rulesetTypeId, req.organization); + const { carNumber } = req.query; + const parsedCarNumber = typeof carNumber === 'string' ? Number(carNumber) : undefined; + const rulesetType = await RulesService.getActiveRuleset( + req.currentUser, + rulesetTypeId, + req.organization, + parsedCarNumber + ); res.status(200).json(rulesetType); } catch (error: unknown) { next(error); diff --git a/src/backend/src/routes/rules.routes.ts b/src/backend/src/routes/rules.routes.ts index a6355469ee..7627316e7f 100644 --- a/src/backend/src/routes/rules.routes.ts +++ b/src/backend/src/routes/rules.routes.ts @@ -1,13 +1,18 @@ 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 { body, query } 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( + '/rulesetType/:rulesetTypeId/active', + query('carNumber').optional().isInt(), + validateInputs, + RulesController.getActiveRuleset +); rulesRouter.get('/ruleset/:rulesetId', RulesController.getRulesetById); rulesRouter.post( diff --git a/src/backend/src/services/rules.services.ts b/src/backend/src/services/rules.services.ts index 9b97c94f2e..72d4238aee 100644 --- a/src/backend/src/services/rules.services.ts +++ b/src/backend/src/services/rules.services.ts @@ -37,13 +37,14 @@ import { uploadFile, downloadFile } from '../utils/google-integration.utils.js'; export default class RulesService { /** - * Gets the active ruleset for the given ruleset type ID + * Gets the active ruleset for the given ruleset type ID and car * @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 + * @param carNumber the car number to scope the active ruleset to, since each car can have its own active ruleset per type * @returns a ruleset with the given id if it exists, otherwise throws an error */ - static async getActiveRuleset(user: User, rulesetTypeId: string, organization: Organization) { + static async getActiveRuleset(user: User, rulesetTypeId: string, organization: Organization, carNumber?: number) { if (!(await userHasPermission(user.userId, organization.organizationId, notGuest))) throw new AccessDeniedException('only members and above can view ruleset types!'); @@ -59,8 +60,27 @@ export default class RulesService { throw new DeletedException('Ruleset Type', rulesetTypeId); } + let carId: string | undefined; + if (carNumber !== undefined) { + const car = await prisma.car.findFirst({ + where: { + wbsElement: { + carNumber, + organizationId: organization.organizationId, + dateDeleted: null + } + } + }); + + if (!car) { + throw new NotFoundException('Car', carNumber); + } + + carId = car.carId; + } + const activeRuleset = await prisma.ruleset.findFirst({ - where: { rulesetTypeId, deletedByUserId: null, active: true }, + where: { rulesetTypeId, deletedByUserId: null, active: true, ...(carId && { carId }) }, ...getRulesetQueryArgs() }); @@ -1219,6 +1239,7 @@ export default class RulesService { const activeRuleset = await prisma.ruleset.findFirst({ where: { active: true, + carId: rulesetExists.carId, rulesetType: { rulesetTypeId: rulesetExists.rulesetTypeId, organizationId @@ -1228,7 +1249,7 @@ export default class RulesService { }); if (activeRuleset) { - throw new HttpException(400, 'There is already an active ruleset for this ruleset type'); + throw new HttpException(400, 'There is already an active ruleset for this ruleset type and car'); } } const ruleset = await prisma.ruleset.update({ diff --git a/src/backend/tests/unit/rule.test.ts b/src/backend/tests/unit/rule.test.ts index 6353420a03..a0a2462829 100644 --- a/src/backend/tests/unit/rule.test.ts +++ b/src/backend/tests/unit/rule.test.ts @@ -825,7 +825,7 @@ describe('Create Rules Tests', () => { ); 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')); + ).rejects.toThrow(new HttpException(400, 'There is already an active ruleset for this ruleset type and car')); }); }); }); diff --git a/src/frontend/src/apis/rules.api.ts b/src/frontend/src/apis/rules.api.ts index d0058387e9..e2df230649 100644 --- a/src/frontend/src/apis/rules.api.ts +++ b/src/frontend/src/apis/rules.api.ts @@ -77,8 +77,9 @@ export const getAllRulesetTypes = () => { /** * Gets the active ruleset for a given ruleset type. */ -export const getActiveRuleset = (rulesetTypeId: string) => { +export const getActiveRuleset = (rulesetTypeId: string, carNumber?: number) => { return axios.get(apiUrls.rulesGetActiveRuleset(rulesetTypeId), { + params: { carNumber }, transformResponse: (data) => rulesetTransformer(JSON.parse(data)) }); }; diff --git a/src/frontend/src/hooks/rules.hooks.ts b/src/frontend/src/hooks/rules.hooks.ts index 1ed44cf2ef..8e3cddf799 100644 --- a/src/frontend/src/hooks/rules.hooks.ts +++ b/src/frontend/src/hooks/rules.hooks.ts @@ -48,14 +48,15 @@ export const useAllRulesetTypes = () => { }; /** - * Hook to get the active ruleset for a given ruleset type. + * Hook to get the active ruleset for a given ruleset type scoped to a car + * Each car can have its own active ruleset per ruleset type. */ -export const useActiveRuleset = (rulesetTypeId: string) => { +export const useActiveRuleset = (rulesetTypeId: string, carNumber?: number) => { return useQuery( - ['rules', 'activeRuleset', rulesetTypeId], + ['rules', 'activeRuleset', rulesetTypeId, carNumber], async () => { try { - const { data } = await getActiveRuleset(rulesetTypeId); + const { data } = await getActiveRuleset(rulesetTypeId, carNumber); return data; } catch { // Return undefined if no active ruleset exists diff --git a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectRules/ProjectRulesTab.tsx b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectRules/ProjectRulesTab.tsx index f46ceb7e73..bfc58c8c72 100644 --- a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectRules/ProjectRulesTab.tsx +++ b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectRules/ProjectRulesTab.tsx @@ -64,9 +64,10 @@ export const ProjectRulesTab = ({ project }: ProjectRulesTabProps) => { // Get the currently selected ruleset type const selectedRulesetType = rulesetTypes?.[selectedRulesetTypeIndex]; - // Fetch the active ruleset for the selected ruleset type + // Fetch the active ruleset for the selected ruleset type, scoped to this project's own car const { data: activeRuleset, isLoading: activeRulesetLoading } = useActiveRuleset( - selectedRulesetType?.rulesetTypeId || '' + selectedRulesetType?.rulesetTypeId || '', + project.wbsNum.carNumber ); // Fetch project rules for the active ruleset From 851c14fd32707fe8486558639cf771dbeab460c0 Mon Sep 17 00:00:00 2001 From: Ciel Bellerose Date: Thu, 30 Jul 2026 21:52:52 +0100 Subject: [PATCH 3/5] #4317 added car active ruleset tests --- src/backend/tests/unit/rule.test.ts | 120 ++++++++++++++++++++++++++++ 1 file changed, 120 insertions(+) diff --git a/src/backend/tests/unit/rule.test.ts b/src/backend/tests/unit/rule.test.ts index a0a2462829..8ab47febf2 100644 --- a/src/backend/tests/unit/rule.test.ts +++ b/src/backend/tests/unit/rule.test.ts @@ -611,6 +611,48 @@ describe('Create Rules Tests', () => { expect(rulesets[0].name).toBe('2025 FSAE Rules2'); expect(rulesets[1].name).toBe('2025 FSAE Rules'); }); + + it('Successful get rulesets by ruleset types filtered by car', async () => { + const otherCar = await prisma.car.create({ + data: { + wbsElement: { + create: { + name: 'Other Car', + carNumber: 1, + projectNumber: 0, + workPackageNumber: 0, + organizationId: orgId + } + } + } + }); + + const otherCarRuleset = await prisma.ruleset.create({ + data: { + fileId: 'other-car-file-id', + name: 'Other Car FSAE Rules', + active: false, + rulesetType: { connect: { rulesetTypeId: rulesetType.rulesetTypeId } }, + car: { connect: { carId: otherCar.carId } }, + createdBy: { connect: { userId: batman.userId } } + } + }); + + // 2 total rulesets for this type + const allRulesets = await RulesService.getRulesetsByRulesetType(rulesetType.rulesetTypeId, orgId); + expect(allRulesets.length).toBe(2); + + // 1 ruleset when filtered to the original car + const originalCarRulesets = await RulesService.getRulesetsByRulesetType(rulesetType.rulesetTypeId, orgId, carId); + expect(originalCarRulesets.length).toBe(1); + expect(originalCarRulesets[0].rulesetId).toBe(rulesetId); + + const otherCarRulesets = await RulesService.getRulesetsByRulesetType(rulesetType.rulesetTypeId, orgId, otherCar.carId); + + // 1 ruleset when filtered to the other car + expect(otherCarRulesets.length).toBe(1); + expect(otherCarRulesets[0].rulesetId).toBe(otherCarRuleset.rulesetId); + }); }); describe('Get Child Rules', () => { @@ -827,6 +869,52 @@ describe('Create Rules Tests', () => { 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 and car')); }); + it('Update active ruleset successful with active ruleset for a different car', async () => { + // ensure ruleset for car 0 already has an active ruleset + const originalRulesetBefore = await prisma.ruleset.findUniqueOrThrow({ where: { rulesetId } }); + expect(originalRulesetBefore.active).toBe(true); + + const otherCar = await prisma.car.create({ + data: { + wbsElement: { + create: { + name: 'Other Car', + carNumber: 1, + projectNumber: 0, + workPackageNumber: 0, + organizationId: orgId + } + } + }, + include: { wbsElement: true } + }); + + // create inactive ruleset for the other car, under the same ruleset type as the already active car 0 ruleset + const otherCarRuleset = await RulesService.createRuleset( + superman, + organization, + 'other car ruleset name', + rulesetType.rulesetTypeId, + otherCar.wbsElement.carNumber, + false, + 'fileId' + ); + + // activating another car's ruleset should succeed since "already active" check is scoped per car + const updatedOtherCarRuleset = await RulesService.updateRuleset( + batman, + orgId, + otherCarRuleset.rulesetId, + 'name', + true + ); + expect(updatedOtherCarRuleset.active).toBe(true); + + // original car's ruleset is untouched + // two active rulesets for one ruleset type but different cars + const originalRulesetAfter = await prisma.ruleset.findUniqueOrThrow({ where: { rulesetId } }); + expect(originalRulesetAfter.active).toBe(true); + }); }); }); @@ -1482,6 +1570,38 @@ describe('Rule Tests', () => { expect(activeRuleset.name).toBe('FSAE Rules 2025'); expect(activeRuleset.active).toBe(true); }); + + it('Fails if the given carNumber does not exist in the org', async () => { + await expect(RulesService.getActiveRuleset(admin, fsaeRulesetType.rulesetTypeId, organization, 999)).rejects.toThrow( + new NotFoundException('Car', 999) + ); + }); + + it('Scopes the active ruleset to the given car when multiple cars each have their own active ruleset', async () => { + // fsaeRulesetType contains two active ruleset, one for carA one for carB + const carA = await createUniqueCar(orgId); + const carB = await createUniqueCar(orgId); + await setupRules(carA); + await setupRules(carB); + + // fetching carA's active ruleset returns carA's, not carB's, even though both are active for the same ruleset type + const activeRulesetForCarA = await RulesService.getActiveRuleset( + admin, + fsaeRulesetType.rulesetTypeId, + organization, + carA.wbsElement.carNumber + ); + expect(activeRulesetForCarA.car.carId).toBe(carA.carId); + + // asking for carB's active ruleset must return carB's, not carA's + const activeRulesetForCarB = await RulesService.getActiveRuleset( + admin, + fsaeRulesetType.rulesetTypeId, + organization, + carB.wbsElement.carNumber + ); + expect(activeRulesetForCarB.car.carId).toBe(carB.carId); + }); }); describe('Toggle Rule Team', () => { From 2801c7b55f0d75a7dec525fd2eb850983b89d6e5 Mon Sep 17 00:00:00 2001 From: Ciel Bellerose Date: Thu, 30 Jul 2026 22:16:35 +0100 Subject: [PATCH 4/5] #4317 test for double car active fail --- src/backend/src/services/rules.services.ts | 3 +-- src/backend/tests/unit/rule.test.ts | 5 ++++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/backend/src/services/rules.services.ts b/src/backend/src/services/rules.services.ts index 72d4238aee..11996c4f4f 100644 --- a/src/backend/src/services/rules.services.ts +++ b/src/backend/src/services/rules.services.ts @@ -1068,8 +1068,7 @@ export default class RulesService { organizationId: organization.organizationId, dateDeleted: null } - }, - include: { wbsElement: true } + } }); if (!car) { diff --git a/src/backend/tests/unit/rule.test.ts b/src/backend/tests/unit/rule.test.ts index 8ab47febf2..e45ced0f6f 100644 --- a/src/backend/tests/unit/rule.test.ts +++ b/src/backend/tests/unit/rule.test.ts @@ -855,7 +855,8 @@ describe('Create Rules Tests', () => { 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 () => { + it('update ruleset status - fails if another ruleset for the same car and type is already active', async () => { + // car 0 already has an active ruleset, so activating ruleset2 for car 0 should fail const ruleset2 = await RulesService.createRuleset( superman, organization, @@ -1572,6 +1573,8 @@ describe('Rule Tests', () => { }); it('Fails if the given carNumber does not exist in the org', async () => { + await setupRules(await createUniqueCar(orgId)); + await expect(RulesService.getActiveRuleset(admin, fsaeRulesetType.rulesetTypeId, organization, 999)).rejects.toThrow( new NotFoundException('Car', 999) ); From c5f62ff3335b26a7716903061b7e1104973d9bfb Mon Sep 17 00:00:00 2001 From: Ciel Bellerose Date: Thu, 30 Jul 2026 22:22:38 +0100 Subject: [PATCH 5/5] #4317 linting --- src/backend/src/services/rules.services.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/backend/src/services/rules.services.ts b/src/backend/src/services/rules.services.ts index 11996c4f4f..5070b11f94 100644 --- a/src/backend/src/services/rules.services.ts +++ b/src/backend/src/services/rules.services.ts @@ -76,7 +76,7 @@ export default class RulesService { throw new NotFoundException('Car', carNumber); } - carId = car.carId; + ({ carId } = car); } const activeRuleset = await prisma.ruleset.findFirst({