Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 13 additions & 2 deletions src/backend/src/controllers/rules.controllers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,14 @@ export default class RulesController {
static async getActiveRuleset(req: Request, res: Response, next: NextFunction) {
try {
const { rulesetTypeId } = req.params as Record<string, string>;
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);
Expand Down Expand Up @@ -137,7 +144,11 @@ export default class RulesController {
static async getRulesetsByRulesetType(req: Request, res: Response, next: NextFunction) {
try {
const { rulesetTypeId } = req.params as Record<string, string>;
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);
Expand Down
9 changes: 7 additions & 2 deletions src/backend/src/routes/rules.routes.ts
Original file line number Diff line number Diff line change
@@ -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(
Expand Down
38 changes: 30 additions & 8 deletions src/backend/src/services/rules.services.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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!');

Expand All @@ -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()
});

Expand Down Expand Up @@ -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<Ruleset[]> {
static async getRulesetsByRulesetType(rulesetTypeId: string, organizationId: string, carId?: string): Promise<Ruleset[]> {
const rulesets = await prisma.ruleset.findMany({
where: {
rulesetTypeId,
deletedByUserId: null,
rulesetType: {
organizationId
}
},
...(carId && { carId })
},
orderBy: {
dateCreated: 'desc'
Expand Down Expand Up @@ -1046,8 +1068,7 @@ export default class RulesService {
organizationId: organization.organizationId,
dateDeleted: null
}
},
include: { wbsElement: true }
}
});

if (!car) {
Expand Down Expand Up @@ -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
Expand All @@ -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({
Expand Down
127 changes: 125 additions & 2 deletions src/backend/tests/unit/rule.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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,
Expand All @@ -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);
});
});
});
Expand Down Expand Up @@ -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', () => {
Expand Down
3 changes: 2 additions & 1 deletion src/frontend/src/apis/rules.api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Ruleset>(apiUrls.rulesGetActiveRuleset(rulesetTypeId), {
params: { carNumber },
transformResponse: (data) => rulesetTransformer(JSON.parse(data))
});
};
Expand Down
22 changes: 14 additions & 8 deletions src/frontend/src/hooks/rules.hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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<Ruleset | undefined, Error>(
['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
Expand Down Expand Up @@ -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<Ruleset[], Error>(['rulesets', rulesetTypeId], async () => {
const { data } = await getRulesetsByRulesetType(rulesetTypeId);
return data;
});
const { selectedCar } = useGlobalCarFilter();
return useQuery<Ruleset[], Error>(
['rulesets', rulesetTypeId, selectedCar === 'all-cars' ? 'all-cars' : selectedCar.id],
async () => {
const { data } = await getRulesetsByRulesetType(rulesetTypeId);
return data;
}
);
};

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading