diff --git a/src/backend/src/controllers/rules.controllers.ts b/src/backend/src/controllers/rules.controllers.ts index f2f92fd9e8..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); @@ -137,7 +144,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/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 ae78f7d75e..5070b11f94 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); + } + const activeRuleset = await prisma.ruleset.findFirst({ - where: { rulesetTypeId, deletedByUserId: null, active: true }, + where: { rulesetTypeId, deletedByUserId: null, active: true, ...(carId && { carId }) }, ...getRulesetQueryArgs() }); @@ -772,16 +792,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' @@ -1046,8 +1068,7 @@ export default class RulesService { organizationId: organization.organizationId, dateDeleted: null } - }, - include: { wbsElement: true } + } }); if (!car) { @@ -1217,6 +1238,7 @@ export default class RulesService { const activeRuleset = await prisma.ruleset.findFirst({ where: { active: true, + carId: rulesetExists.carId, rulesetType: { rulesetTypeId: rulesetExists.rulesetTypeId, organizationId @@ -1226,7 +1248,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..e45ced0f6f 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', () => { @@ -813,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, @@ -825,7 +868,53 @@ 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')); + }); + 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 +1571,40 @@ 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 setupRules(await createUniqueCar(orgId)); + + 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', () => { 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 759ba11595..8e3cddf799 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. @@ -47,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 @@ -375,10 +377,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; + } + ); }; /** diff --git a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectRules/ProjectRulesTab.tsx b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectRules/ProjectRulesTab.tsx index 3b4e52064c..41d033a874 100644 --- a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectRules/ProjectRulesTab.tsx +++ b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectRules/ProjectRulesTab.tsx @@ -63,9 +63,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