diff --git a/src/backend/index.ts b/src/backend/index.ts index cde8783ab0..71e6ddbbd9 100644 --- a/src/backend/index.ts +++ b/src/backend/index.ts @@ -26,6 +26,7 @@ import statisticsRouter from './src/routes/statistics.routes.js'; import retrospectiveRouter from './src/routes/retrospective.routes.js'; import partsRouter from './src/routes/parts.routes.js'; import financeRouter from './src/routes/finance.routes.js'; +import rulesRouter from './src/routes/rules.routes.js'; import calendarRouter from './src/routes/calendar.routes.js'; import prospectiveSponsorRouter from './src/routes/prospective-sponsor.routes.js'; import attendanceRouter from './src/routes/attendance.routes.js'; @@ -136,6 +137,7 @@ app.use('/statistics', statisticsRouter); app.use('/retrospective', retrospectiveRouter); app.use('/parts', partsRouter); app.use('/finance', financeRouter); +app.use('/rules', rulesRouter); app.use('/calendar', calendarRouter); app.use('/prospective-sponsors', prospectiveSponsorRouter); app.use('/attendance', attendanceRouter); diff --git a/src/backend/package.json b/src/backend/package.json index 7206b9b274..5757a45ef0 100644 --- a/src/backend/package.json +++ b/src/backend/package.json @@ -39,6 +39,7 @@ "multer": "^1.4.5-lts.1", "node-ical": "^0.26.1", "nodemailer": "^6.9.1", + "pdf-parse-new": "^1.4.1", "prisma": "^6.2.1", "shared": "1.0.0", "twisters": "^1.1.0", diff --git a/src/backend/src/controllers/agent.controllers.ts b/src/backend/src/controllers/agent.controllers.ts index e3745c46a9..e0d02f1eeb 100644 --- a/src/backend/src/controllers/agent.controllers.ts +++ b/src/backend/src/controllers/agent.controllers.ts @@ -1,6 +1,7 @@ import { NextFunction, Request, Response } from 'express'; import { getRoleInOrganization } from '../utils/mcp-auth.utils.js'; import McpService from '../services/mcp.services.js'; +import SlackServices from '../services/slack.services.js'; export default class AgentController { /** @@ -86,4 +87,20 @@ export default class AgentController { next(error); } } + + static async sendSlackMessage(req: Request, res: Response, next: NextFunction) { + try { + const { channelId, message } = req.body; + const sentMessage = await SlackServices.sendMessageToChannel( + req.currentUser.userId, + req.organization, + channelId, + message + ); + + res.status(200).json(sentMessage); + } catch (error: unknown) { + next(error); + } + } } diff --git a/src/backend/src/controllers/rules.controllers.ts b/src/backend/src/controllers/rules.controllers.ts new file mode 100644 index 0000000000..06d985a09a --- /dev/null +++ b/src/backend/src/controllers/rules.controllers.ts @@ -0,0 +1,423 @@ +import { NextFunction, Request, Response } from 'express'; +import RulesService from '../services/rules.services.js'; +import { ProjectRule, Rule, Ruleset, RuleStatus, RuleStatusHistoryEntry, RuleStatusUpdate } from 'shared'; +import { HttpException } from '../utils/errors.utils.js'; + +export default class RulesController { + static async getActiveRuleset(req: Request, res: Response, next: NextFunction) { + try { + const { rulesetTypeId } = req.params as Record; + const { 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); + } + } + + static async createRule(req: Request, res: Response, next: NextFunction) { + try { + const { ruleCode, ruleContent, rulesetId, parentRuleId, referencedRules, imageFileIds } = req.body; + + const rule = await RulesService.createRule( + req.currentUser, + ruleCode, + ruleContent, + rulesetId, + req.organization, + parentRuleId, + referencedRules || [], + imageFileIds || [] + ); + + res.status(201).json(rule); + } catch (error: unknown) { + next(error); + } + } + + static async deleteRule(req: Request, res: Response, next: NextFunction) { + try { + const { ruleId } = req.params as Record; + const deletedRule = await RulesService.deleteRule(ruleId, req.currentUser, req.organization); + res.status(200).json(deletedRule); + } catch (error: unknown) { + next(error); + } + } + + static async createRulesetType(req: Request, res: Response, next: NextFunction) { + try { + const { name } = req.body; + const rulesetType = await RulesService.createRulesetType(req.currentUser, name, req.organization); + res.status(200).json(rulesetType); + } catch (error: unknown) { + next(error); + } + } + + static async createProjectRule(req: Request, res: Response, next: NextFunction) { + try { + const { ruleId, projectId } = req.body; + const projectRule: ProjectRule = await RulesService.createProjectRule( + req.currentUser, + req.organization, + ruleId, + projectId + ); + + res.status(200).json(projectRule); + } catch (error: unknown) { + next(error); + } + } + + static async editRule(req: Request, res: Response, next: NextFunction) { + try { + const { ruleId } = req.params as Record; + const { ruleContent, ruleCode, imageFileIds, parentRuleId } = req.body; + + const rule = await RulesService.editRule( + req.currentUser, + ruleContent, + ruleId, + ruleCode, + imageFileIds, + req.organization, + parentRuleId + ); + res.status(200).json(rule); + } catch (error: unknown) { + next(error); + } + } + + static async addRuleReferences(req: Request, res: Response, next: NextFunction) { + try { + const { ruleId } = req.params as Record; + const { referencedRuleId } = req.body; + + const rule = await RulesService.addRuleReferences(req.currentUser, ruleId, referencedRuleId, req.organization); + res.status(200).json(rule); + } catch (error: unknown) { + next(error); + } + } + + static async removeRuleReferences(req: Request, res: Response, next: NextFunction) { + try { + const { ruleId } = req.params as Record; + const { referencedRuleId } = req.body; + + const rule = await RulesService.removeRuleReferences(req.currentUser, ruleId, referencedRuleId, req.organization); + 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.currentUser, req.organization, req.currentCar?.carId); + res.status(200).json(rulesets); + } catch (error: unknown) { + next(error); + } + } + + static async getRulesetsByRulesetType(req: Request, res: Response, next: NextFunction) { + try { + const { rulesetTypeId } = req.params as Record; + const rulesets = await RulesService.getRulesetsByRulesetType( + req.currentUser, + rulesetTypeId, + req.organization.organizationId, + req.currentCar?.carId + ); + res.status(200).json(rulesets); + } catch (error: unknown) { + next(error); + } + } + + static async getRulesetType(req: Request, res: Response, next: NextFunction) { + try { + const { rulesetTypeId } = req.params as Record; + const rulesetType = await RulesService.getRulesetType( + req.currentUser, + rulesetTypeId, + req.organization.organizationId, + req.currentCar?.carId + ); + res.status(200).json(rulesetType); + } catch (error: unknown) { + next(error); + } + } + + static async deleteRuleset(req: Request, res: Response, next: NextFunction) { + try { + const { rulesetId } = req.params as Record; + const ruleset = await RulesService.deleteRuleset(rulesetId, req.currentUser.userId, req.organization.organizationId); + res.status(200).json(ruleset); + } catch (error: unknown) { + next(error); + } + } + + static async deleteProjectRule(req: Request, res: Response, next: NextFunction) { + try { + const { projectRuleId } = req.params as Record; + const deletedProjectRule = await RulesService.deleteProjectRule(projectRuleId, req.currentUser, req.organization); + res.status(200).json(deletedProjectRule); + } catch (error: unknown) { + next(error); + } + } + + static async setRuleStatus(req: Request, res: Response, next: NextFunction) { + try { + const { ruleId } = req.params as Record; + const { status } = req.body as { status: RuleStatus }; + + const statusUpdate: RuleStatusUpdate = await RulesService.setRuleStatus( + req.currentUser, + req.organization, + ruleId, + status + ); + + res.status(200).json(statusUpdate); + } catch (error: unknown) { + next(error); + } + } + + static async setProjectRuleStatus(req: Request, res: Response, next: NextFunction) { + try { + const { projectRuleId } = req.params as Record; + const { status } = req.body as { status: RuleStatus }; + + const projectRule: ProjectRule = await RulesService.setProjectRuleStatus( + req.currentUser, + req.organization, + projectRuleId, + status + ); + + res.status(200).json(projectRule); + } catch (error: unknown) { + next(error); + } + } + + static async resetRulesetStatuses(req: Request, res: Response, next: NextFunction) { + try { + const { rulesetId } = req.params as Record; + const count = await RulesService.resetRulesetStatuses(req.currentUser, req.organization, rulesetId); + res.status(200).json({ count }); + } catch (error: unknown) { + next(error); + } + } + + static async resetProjectRuleStatuses(req: Request, res: Response, next: NextFunction) { + try { + const { rulesetId, projectId } = req.params as Record; + const count = await RulesService.resetProjectRuleStatuses(req.currentUser, req.organization, rulesetId, projectId); + res.status(200).json({ count }); + } catch (error: unknown) { + next(error); + } + } + + static async getRuleStatusHistory(req: Request, res: Response, next: NextFunction) { + try { + const { ruleId } = req.params as Record; + const { projectRuleId } = req.query as { projectRuleId?: string }; + + const history: RuleStatusHistoryEntry[] = await RulesService.getRuleStatusHistory( + req.currentUser, + req.organization, + ruleId, + projectRuleId + ); + + res.status(200).json(history); + } catch (error: unknown) { + next(error); + } + } + + static async toggleRuleTeam(req: Request, res: Response, next: NextFunction) { + try { + const { ruleId } = req.params as Record; + const { teamId } = req.body; + + const changedRule = await RulesService.toggleRuleTeam(ruleId, teamId, req.currentUser, req.organization); + + res.status(200).json(changedRule); + } catch (error: unknown) { + next(error); + } + } + + static async createRuleset(req: Request, res: Response, next: NextFunction) { + try { + const { name, rulesetTypeId, carNumber, active, fileId } = req.body; + + const ruleset = await RulesService.createRuleset( + req.currentUser, + req.organization, + name, + rulesetTypeId, + carNumber, + active, + fileId + ); + + res.status(200).json(ruleset); + } catch (error: unknown) { + next(error); + } + } + + static async deleteRulesetType(req: Request, res: Response, next: NextFunction) { + try { + const { rulesetTypeId } = req.params as Record; + + const rulesetType = await RulesService.deleteRulesetType(req.currentUser, rulesetTypeId, req.organization); + + res.status(200).json(rulesetType); + } catch (error: unknown) { + next(error); + } + } + + static async updateRuleset(req: Request, res: Response, next: NextFunction) { + try { + const { rulesetId } = req.params as Record; + const { name, isActive } = req.body; + + const ruleset: Ruleset = await RulesService.updateRuleset( + req.currentUser, + req.organization.organizationId, + rulesetId, + name, + isActive + ); + + res.status(200).json(ruleset); + } catch (error: unknown) { + next(error); + } + } + + static async getChildRules(req: Request, res: Response, next: NextFunction) { + try { + const { ruleId: parentRuleId } = req.params as Record; + const childrenRules: Rule[] = await RulesService.getChildRules(req.currentUser, parentRuleId, req.organization); + + res.status(200).json(childrenRules); + } catch (error: unknown) { + next(error); + } + } + + static async getUnassignedRulesForProjectRuleset(req: Request, res: Response, next: NextFunction) { + try { + const { rulesetId, projectId } = req.params as Record; + + const rules = await RulesService.getUnassignedRulesForProjectRuleset( + req.currentUser, + rulesetId, + projectId, + req.organization.organizationId + ); + res.status(200).json(rules); + } catch (error: unknown) { + next(error); + } + } + + static async getProjectRules(req: Request, res: Response, next: NextFunction) { + try { + const { rulesetId, projectId } = req.params as Record; + + const projectRules = await RulesService.getProjectRules(req.currentUser, rulesetId, projectId, req.organization); + + res.status(200).json(projectRules); + } catch (error: unknown) { + next(error); + } + } + + static async getTopLevelRules(req: Request, res: Response, next: NextFunction) { + try { + const { rulesetId } = req.params as Record; + const rules = await RulesService.getTopLevelRules(req.currentUser, rulesetId, req.organization.organizationId); + res.status(200).json(rules); + } catch (error: unknown) { + next(error); + } + } + + static async getAllRulesForRuleset(req: Request, res: Response, next: NextFunction) { + try { + const { rulesetId } = req.params as Record; + const rules = await RulesService.getAllRulesForRuleset(req.currentUser, rulesetId, req.organization.organizationId); + res.status(200).json(rules); + } catch (error: unknown) { + next(error); + } + } + + static async parseRuleset(req: Request, res: Response, next: NextFunction) { + try { + const { fileId, parserType, firstRulePage } = req.body; + const { rulesetId } = req.params as Record; + + const parseResult = await RulesService.parseRuleset( + req.currentUser, + req.organization.organizationId, + fileId, + rulesetId, + parserType, + firstRulePage + ); + + res.status(200).json(parseResult); + } catch (error: unknown) { + next(error); + } + } + + static async uploadRulesetFile(req: Request, res: Response, next: NextFunction) { + try { + if (!req.file) { + throw new HttpException(400, 'Invalid or undefined file data'); + } + + const fileId = await RulesService.uploadRulesetFile(req.file, req.currentUser, req.organization); + res.status(200).json(fileId); + } catch (error: unknown) { + next(error); + } + } + + static async getSingleRuleset(req: Request, res: Response, next: NextFunction) { + try { + const { rulesetId } = req.params as Record; + const ruleset = await RulesService.getSingleRuleset(req.currentUser, rulesetId, req.organization); + res.status(200).json(ruleset); + } catch (error: unknown) { + next(error); + } + } +} diff --git a/src/backend/src/controllers/slack.controllers.ts b/src/backend/src/controllers/slack.controllers.ts index 55098e3703..c31717e0d9 100644 --- a/src/backend/src/controllers/slack.controllers.ts +++ b/src/backend/src/controllers/slack.controllers.ts @@ -15,7 +15,8 @@ export default class SlackController { const nerSlackWorkspaceId = await getWorkspaceId(); const relatedOrganization = organizations.find((org) => org.slackWorkspaceId === nerSlackWorkspaceId); if (relatedOrganization) { - SlackServices.processMessageSent(event, relatedOrganization.organizationId); + // awaited so a failure lands in the catch below, an unawaited rejection crashes the whole process + await SlackServices.processMessageSent(event, relatedOrganization.organizationId); } } catch (error: unknown) { console.log(error); diff --git a/src/backend/src/integrations/slack.ts b/src/backend/src/integrations/slack.ts index 589f5f08ea..b52e64056b 100644 --- a/src/backend/src/integrations/slack.ts +++ b/src/backend/src/integrations/slack.ts @@ -62,7 +62,6 @@ export const sendMessage = async (slackId: string, message: string, link?: strin if (!client) return; const block = generateSlackTextBlock(message, link, linkButtonText); - try { const response = await client.chat.postMessage({ channel: slackId, @@ -396,6 +395,50 @@ export const checkBotInChannel = async (channelId: string): Promise => } }; +/** + * Looks up a channel directly from Slack, bypassing the name cache so the result reflects the + * channel's current state. + * @param channelId the id of the slack channel + * @returns the channel's name, archived status, and whether the bot is a member, or undefined if slack has no such channel + * @throws if slack is not configured or the lookup fails for any reason other than the channel not existing + */ +export const getChannelInfo = async ( + channelId: string +): Promise<{ name: string; isArchived: boolean; isMember: boolean } | undefined> => { + const client = getSlackClient(); + if (!client) throw new HttpException(500, 'Slack client not configured'); + + try { + const { channel } = await client.conversations.info({ channel: channelId }); + return { name: channel?.name ?? channelId, isArchived: !!channel?.is_archived, isMember: !!channel?.is_member }; + } catch (error) { + const slackError = (error as { data?: { error?: string } }).data?.error; + if (slackError === 'channel_not_found') return undefined; + throw new HttpException(502, 'Error looking up slack channel: ' + (slackError ?? (error as Error).message)); + } +}; + +/** + * Posts a plain text message to a channel. Unlike sendMessage, failures are thrown rather than + * swallowed so the caller can report them. + * @param channelId the id of the slack channel + * @param text the text content of the message + * @returns the channel id and timestamp of the created slack message + * @throws if slack is not configured or rejects the message + */ +export const postMessageToChannel = async (channelId: string, text: string): Promise<{ channelId: string; ts: string }> => { + const client = getSlackClient(); + if (!client) throw new HttpException(500, 'Slack client not configured'); + + try { + const response = await client.chat.postMessage({ channel: channelId, text, unfurl_links: false }); + return { channelId: response.channel, ts: response.ts }; + } catch (error) { + const slackError = (error as { data?: { error?: string } }).data?.error; + throw new HttpException(502, 'Error sending slack message: ' + (slackError ?? (error as Error).message)); + } +}; + /** * Fetches a user's display name from Slack. * @param userId the id of the slack user 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..c3e4e47fa9 --- /dev/null +++ b/src/backend/src/prisma-query-args/rules.query-args.ts @@ -0,0 +1,176 @@ +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: { + // check since rules are soft deleted + where: { dateDeleted: null }, + select: { + ruleId: true + } + }, + referencedRule: { + select: { + ruleId: true, + ruleCode: true + } + }, + teams: { + select: { + teamId: true, + teamName: true + } + }, + projects: { + where: { dateDeleted: null }, + select: { + projectRuleId: true, + status: true, + statusUpdatedAt: true, + statusUpdatedBy: { + select: { + firstName: true, + lastName: true + } + }, + project: { + select: { + projectId: true, + wbsElement: { + select: { + name: true + } + }, + teams: { + select: { + teamId: true + } + } + } + }, + _count: { + select: { + statusHistory: true + } + } + } + }, + statusUpdatedBy: { + select: { + firstName: true, + lastName: true + } + }, + _count: { + select: { + statusHistory: true + } + } + } + }); + +export type ProjectRuleQueryArgs = ReturnType; + +export const getProjectRuleQueryArgs = () => + Prisma.validator()({ + include: { + rule: getRulePreviewQueryArgs(), + statusUpdatedBy: { + select: { + firstName: true, + lastName: true + } + }, + _count: { + select: { + statusHistory: true + } + } + } + }); + +export type RuleStatusHistoryQueryArgs = ReturnType; + +export const getRuleStatusHistoryQueryArgs = () => + Prisma.validator()({ + include: { + updatedBy: { + select: { + firstName: true, + lastName: true + } + }, + projectRule: { + select: { + project: { + select: { + wbsElement: { + select: { + name: true + } + } + } + } + } + } + } + }); + +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 + } + } + } + }); + +export type RulesetTypeQueryArgs = ReturnType; + +export const getRulesetTypeQueryArgs = (carId?: string) => + Prisma.validator()({ + include: { + revisionFiles: { where: carId ? { carId } : {} } + } + }); diff --git a/src/backend/src/prisma/factories/competition-documents-summary.factory.ts b/src/backend/src/prisma/factories/competition-documents-summary.factory.ts new file mode 100644 index 0000000000..9e8f7d3a29 --- /dev/null +++ b/src/backend/src/prisma/factories/competition-documents-summary.factory.ts @@ -0,0 +1,49 @@ +import { Faker } from '@faker-js/faker'; +import { Data_Source, Prisma } from '@prisma/client'; + +// Chance a given executive summary has a documents summary at all - optional 1:1, so partial +// coverage rather than every season. +export const DOCUMENTS_SUMMARY_CHANCE = 0.6; + +// Chance the documents summary was synced automatically vs entered by hand. +export const AUTO_SOURCE_CHANCE = 0.4; + +// Chance a given count is missing entirely - older, backfilled seasons don't always have +// complete records. +export const COUNT_MISSING_CHANCE = 0.2; + +export type PlannedCompetitionDocumentsSummary = { + submittedOnTimeCount: number | undefined; + firstSubmissionRejectedCount: number | undefined; + source: Data_Source; + dateSynced: Date | undefined; +}; + +export const planCompetitionDocumentsSummary = ( + faker: Faker, + dateRange: { from: Date; to: Date } +): PlannedCompetitionDocumentsSummary => { + const isAuto = faker.datatype.boolean({ probability: AUTO_SOURCE_CHANCE }); + return { + submittedOnTimeCount: faker.datatype.boolean({ probability: COUNT_MISSING_CHANCE }) + ? undefined + : faker.number.int({ min: 0, max: 20 }), + firstSubmissionRejectedCount: faker.datatype.boolean({ probability: COUNT_MISSING_CHANCE }) + ? undefined + : faker.number.int({ min: 0, max: 10 }), + source: isAuto ? Data_Source.AUTO : Data_Source.MANUAL, + // Only an AUTO source has ever actually run a sync. + dateSynced: isAuto ? faker.date.between(dateRange) : undefined + }; +}; + +export const competitionDocumentsSummaryCreateInput = ( + executiveSummaryId: string, + planned: PlannedCompetitionDocumentsSummary +): Prisma.Competition_Documents_SummaryCreateInput => ({ + executiveSummary: { connect: { executiveSummaryId } }, + submittedOnTimeCount: planned.submittedOnTimeCount ?? null, + firstSubmissionRejectedCount: planned.firstSubmissionRejectedCount ?? null, + source: planned.source, + dateSynced: planned.dateSynced ?? null +}); diff --git a/src/backend/src/prisma/factories/competition-performance.factory.ts b/src/backend/src/prisma/factories/competition-performance.factory.ts new file mode 100644 index 0000000000..25011a6184 --- /dev/null +++ b/src/backend/src/prisma/factories/competition-performance.factory.ts @@ -0,0 +1,62 @@ +import { Faker } from '@faker-js/faker'; +import { Competition, Prisma } from '@prisma/client'; + +// Chance a given executive summary has a recorded performance for a given competition - +// partial coverage, since not every season's team competes in (or has data for) both. +export const COMPETITION_PERFORMANCE_CHANCE = 0.75; + +export const STATIC_EVENT_FIXTURES = ['Cost Report', 'Design Event', 'Business Presentation']; +export const DYNAMIC_EVENT_FIXTURES = ['Acceleration', 'Skidpad', 'Autocross', 'Endurance']; + +export const ALL_COMPETITIONS: Competition[] = [Competition.FSAE, Competition.FHE]; + +export type PlannedCompetitionPerformance = { + competition: Competition; + finalPlace: number; + totalPointsEarned: number; + bestStaticEvent: string; + worstStaticEvent: string; + bestDynamicEvent: string; + worstDynamicEvent: string; + accelerationTopTimeSeconds: number; + autocrossTopTimeSeconds: number; + enduranceLapsCompleted: number; + enduranceAvgLapTimeSeconds: number; +}; + +export const planCompetitionPerformance = (faker: Faker, competition: Competition): PlannedCompetitionPerformance => { + const [bestStaticEvent, worstStaticEvent] = faker.helpers.shuffle([...STATIC_EVENT_FIXTURES]); + const [bestDynamicEvent, worstDynamicEvent] = faker.helpers.shuffle([...DYNAMIC_EVENT_FIXTURES]); + + return { + competition, + finalPlace: faker.number.int({ min: 1, max: 100 }), + totalPointsEarned: faker.number.int({ min: 0, max: 1000 }), + bestStaticEvent, + worstStaticEvent, + bestDynamicEvent, + worstDynamicEvent, + accelerationTopTimeSeconds: faker.number.int({ min: 4, max: 8 }), + autocrossTopTimeSeconds: faker.number.int({ min: 45, max: 70 }), + enduranceLapsCompleted: faker.number.int({ min: 0, max: 22 }), + enduranceAvgLapTimeSeconds: faker.number.int({ min: 60, max: 100 }) + }; +}; + +export const competitionPerformanceCreateInput = ( + executiveSummaryId: string, + planned: PlannedCompetitionPerformance +): Prisma.Competition_PerformanceCreateInput => ({ + executiveSummary: { connect: { executiveSummaryId } }, + competition: planned.competition, + finalPlace: planned.finalPlace, + totalPointsEarned: planned.totalPointsEarned, + bestStaticEvent: planned.bestStaticEvent, + worstStaticEvent: planned.worstStaticEvent, + bestDynamicEvent: planned.bestDynamicEvent, + worstDynamicEvent: planned.worstDynamicEvent, + accelerationTopTimeSeconds: planned.accelerationTopTimeSeconds, + autocrossTopTimeSeconds: planned.autocrossTopTimeSeconds, + enduranceLapsCompleted: planned.enduranceLapsCompleted, + enduranceAvgLapTimeSeconds: planned.enduranceAvgLapTimeSeconds +}); diff --git a/src/backend/src/prisma/factories/event.factory.ts b/src/backend/src/prisma/factories/event.factory.ts index fe3c8b8655..0f3a97e87f 100644 --- a/src/backend/src/prisma/factories/event.factory.ts +++ b/src/backend/src/prisma/factories/event.factory.ts @@ -51,7 +51,6 @@ export const EVENTS_PER_PROJECT = 3; export const DOCUMENT_PROBABILITY = 0.6; export const MEETING_ATTENDANCE_PROBABILITY = 0.4; const EVENT_DATE_BUFFER_DAYS = 7; -const MAX_ATTENDEES = 10; export const DAYS_AFTER_NO_EVENT = 20; const CONFIRMED_WINDOW = 14; @@ -80,8 +79,9 @@ export const shouldCreateDocument = (faker: Faker): boolean => faker.datatype.bo export const shouldCreateMeetingAttendance = (faker: Faker): boolean => faker.datatype.boolean({ probability: MEETING_ATTENDANCE_PROBABILITY }); -export const generateAttendeeCount = (faker: Faker, maxUsers: number): number => - faker.number.int({ min: 2, max: Math.min(MAX_ATTENDEES, maxUsers) }); +// Picks between 40% and 100% of the team's roster to attend, always at least one person. +export const generateRosterAttendeeCount = (faker: Faker, rosterSize: number): number => + Math.max(1, Math.round(rosterSize * faker.number.float({ min: 0.4, max: 1 }))); export const generateLocation = (faker: Faker): string | null => arrayOrNull(faker, EVENT_LOCATIONS, 0.5); diff --git a/src/backend/src/prisma/factories/executive-summary.factory.ts b/src/backend/src/prisma/factories/executive-summary.factory.ts new file mode 100644 index 0000000000..1eeb3d83d4 --- /dev/null +++ b/src/backend/src/prisma/factories/executive-summary.factory.ts @@ -0,0 +1,28 @@ +import { Faker } from '@faker-js/faker'; +import { Prisma } from '@prisma/client'; + +// Executive summaries are official leadership-level records, so soft-deletes are rare. +export const EXECUTIVE_SUMMARY_DELETED_CHANCE = 0.05; + +export const generateNotes = (faker: Faker): string => faker.lorem.paragraph(); + +export const executiveSummaryCreateInput = ( + carId: string, + seasonStartDate: Date, + seasonEndDate: Date, + notes: { goals: string; winsAndImprovements: string; budgetNotes: string; recruitmentNotes: string }, + userCreatedId: string, + dateCreated: Date, + deleted: { dateDeleted: Date; deletedByUserId: string } | undefined +): Prisma.Executive_SummaryCreateInput => ({ + car: { connect: { carId } }, + seasonStartDate, + seasonEndDate, + goals: notes.goals, + winsAndImprovements: notes.winsAndImprovements, + budgetNotes: notes.budgetNotes, + recruitmentNotes: notes.recruitmentNotes, + dateCreated, + userCreated: { connect: { userId: userCreatedId } }, + ...(deleted ? { dateDeleted: deleted.dateDeleted, deletedBy: { connect: { userId: deleted.deletedByUserId } } } : {}) +}); diff --git a/src/backend/src/prisma/factories/graphs.factory.ts b/src/backend/src/prisma/factories/graphs.factory.ts index 60cb11a634..b00297f63b 100644 --- a/src/backend/src/prisma/factories/graphs.factory.ts +++ b/src/backend/src/prisma/factories/graphs.factory.ts @@ -48,7 +48,9 @@ const GRAPH_TITLE_BY_TYPE: Record = { [Graph_Type.REIMBURSEMENT_TOTAL_BY_TEAM]: 'Reimbursement Total by Team', [Graph_Type.REIMBURSEMENT_TOTAL_BY_DIVISION]: 'Reimbursement Total by Division', [Graph_Type.CHANGE_REQUESTS_BY_STATUS]: 'Change Requests by Status', - [Graph_Type.PROJECT_BUDGET_VS_REIMBURSED_AMOUNT]: 'Project Budget vs Reimbursed Amount' + [Graph_Type.PROJECT_BUDGET_VS_REIMBURSED_AMOUNT]: 'Project Budget vs Reimbursed Amount', + [Graph_Type.ATTENDANCE_BY_TEAM]: 'Attendance by Team', + [Graph_Type.ATTENDANCE_BY_DIVISION]: 'Attendance by Division' }; export type GraphCollectionPlan = { @@ -113,7 +115,9 @@ const pickGraphType = (faker: Faker): Graph_Type => { weight: 8, value: Graph_Type.CHANGE_REQUESTS_BY_TEAM }, { weight: 8, value: Graph_Type.CHANGE_REQUESTS_BY_DIVISION }, { weight: 8, value: Graph_Type.CHANGE_REQUESTS_BY_STATUS }, - { weight: 6, value: Graph_Type.PROJECT_BUDGET_VS_REIMBURSED_AMOUNT } + { weight: 6, value: Graph_Type.PROJECT_BUDGET_VS_REIMBURSED_AMOUNT }, + { weight: 8, value: Graph_Type.ATTENDANCE_BY_TEAM }, + { weight: 6, value: Graph_Type.ATTENDANCE_BY_DIVISION } ]); const pickDisplayType = (faker: Faker, graphType: Graph_Type): Graph_Display_Type => @@ -206,6 +210,26 @@ const planGraph = ( }; }; +// Builds an all-time attendance graph (no date-range filter) so it always renders seeded +// Meeting_Attendance data. Attendance graphs have no car relation, so carIds is left empty. +const planAttendanceGraph = ( + faker: Faker, + creators: GraphActor[], + creationWindow: DateRange, + graphType: Graph_Type, + collectionIndex: number +): GraphPlan => ({ + title: GRAPH_TITLE_BY_TYPE[graphType], + graphType, + displayGraphType: Graph_Display_Type.BAR, + measure: faker.helpers.arrayElement([Measure.SUM, Measure.AVG]), + specialPermissions: [], + creatorId: faker.helpers.arrayElement(creators).userId, + dateCreated: generateRandomDate(faker, creationWindow.start, creationWindow.end), + carIds: [], + collectionIndex +}); + export const planGraphs = ( faker: Faker, collectionPlans: GraphCollectionPlan[], @@ -221,6 +245,10 @@ export const planGraphs = ( // just anywhere in the overall car span. const standaloneWindow: DateRange = { start: span.start, end: new Date(Math.min(span.end.getTime(), now.getTime())) }; + // Pick a visible (non-deleted) collection to showcase BOTH attendance graph types together. + // If every collection is deleted, findIndex returns -1 and no collection gets the showcase. + const showcaseIndex = collectionPlans.findIndex((collectionPlan) => !collectionPlan.dateDeleted); + collectionPlans.forEach((collectionPlan, collectionIndex) => { const count = graphsPerCollection(faker); const creationWindow: DateRange = { @@ -231,6 +259,17 @@ export const planGraphs = ( for (let i = 0; i < count; i++) { graphs.push(planGraph(faker, creators, cars, creationWindow, collectionIndex)); } + + // Guarantee every collection surfaces an attendance graph so the new attendance statistics are + // visible when browsing collections, not just as standalone graphs. One showcase collection gets + // both Team and Division; the rest alternate to keep a single attendance graph each. + if (collectionIndex === showcaseIndex) { + graphs.push(planAttendanceGraph(faker, creators, creationWindow, Graph_Type.ATTENDANCE_BY_TEAM, collectionIndex)); + graphs.push(planAttendanceGraph(faker, creators, creationWindow, Graph_Type.ATTENDANCE_BY_DIVISION, collectionIndex)); + } else { + const attendanceType = collectionIndex % 2 === 0 ? Graph_Type.ATTENDANCE_BY_TEAM : Graph_Type.ATTENDANCE_BY_DIVISION; + graphs.push(planAttendanceGraph(faker, creators, creationWindow, attendanceType, collectionIndex)); + } }); for (let i = 0; i < standaloneCount; i++) { diff --git a/src/backend/src/prisma/factories/organization.factory.ts b/src/backend/src/prisma/factories/organization.factory.ts index bc24b80ff1..def97b4687 100644 --- a/src/backend/src/prisma/factories/organization.factory.ts +++ b/src/backend/src/prisma/factories/organization.factory.ts @@ -25,6 +25,7 @@ export const organizationCreateInput = (userCreatedId: string): Prisma.Organizat platformLogoImageId: '1auQO3GYydZOo1-vCn0D2iyCfaxaVFssx', onboardingText: 'Thank you for applying to Northeastern Electric Racing! After reviewing your application, we are very excited to officially welcome you to our team.', + slackWorkspaceId: 'T7MHAQ5TL', userCreated: { connect: { userId: userCreatedId } } diff --git a/src/backend/src/prisma/factories/recruitment-cycle.factory.ts b/src/backend/src/prisma/factories/recruitment-cycle.factory.ts new file mode 100644 index 0000000000..74640627aa --- /dev/null +++ b/src/backend/src/prisma/factories/recruitment-cycle.factory.ts @@ -0,0 +1,43 @@ +import { Faker } from '@faker-js/faker'; +import { Prisma, Term } from '@prisma/client'; + +// Chance a given executive summary has a recruitment cycle for a given term - partial +// coverage, since older seasons may not have both terms' data recorded. +export const RECRUITMENT_CYCLE_CHANCE = 0.8; + +export const ALL_TERMS: Term[] = [Term.FALL, Term.SPRING]; + +export type PlannedRecruitmentCycle = { + term: Term; + eventsHeld: number; + signUps: number; + onboarded: number; + activeMembers: number; +}; + +export const planRecruitmentCycle = (faker: Faker, term: Term): PlannedRecruitmentCycle => { + const signUps = faker.number.int({ min: 20, max: 150 }); + // Onboarded and active-after-onboarding are both bounded by the pool above them, never larger. + const onboarded = Math.round(signUps * faker.number.float({ min: 0.3, max: 0.7 })); + const activeMembers = Math.round(onboarded * faker.number.float({ min: 0.5, max: 0.9 })); + + return { + term, + eventsHeld: faker.number.int({ min: 3, max: 10 }), + signUps, + onboarded, + activeMembers + }; +}; + +export const recruitmentCycleCreateInput = ( + executiveSummaryId: string, + planned: PlannedRecruitmentCycle +): Prisma.Recruitment_CycleCreateInput => ({ + executiveSummary: { connect: { executiveSummaryId } }, + term: planned.term, + eventsHeld: planned.eventsHeld, + signUps: planned.signUps, + onboarded: planned.onboarded, + activeMembers: planned.activeMembers +}); diff --git a/src/backend/src/prisma/factories/recruitment-division-count.factory.ts b/src/backend/src/prisma/factories/recruitment-division-count.factory.ts new file mode 100644 index 0000000000..2365d1d03f --- /dev/null +++ b/src/backend/src/prisma/factories/recruitment-division-count.factory.ts @@ -0,0 +1,29 @@ +import { Faker } from '@faker-js/faker'; +import { Prisma } from '@prisma/client'; + +// Splits `total` into `parts` non-negative integers that sum back to `total`, so a +// recruitment cycle's per-division new-member counts never overshoot its own onboarded count. +export const splitIntoParts = (faker: Faker, total: number, parts: number): number[] => { + if (parts <= 0) return []; + const weights = Array.from({ length: parts }, () => faker.number.float({ min: 0, max: 1 })); + const weightSum = weights.reduce((a, b) => a + b, 0); + const shares = weights.map((w) => Math.floor((weightSum === 0 ? 1 / parts : w / weightSum) * total)); + // Floor rounding can leave a remainder - hand it to the first division rather than dropping it. + const remainder = total - shares.reduce((a, b) => a + b, 0); + shares[0] += remainder; + return shares; +}; + +export const returningMembersForDivision = (faker: Faker): number => faker.number.int({ min: 0, max: 20 }); + +export const recruitmentDivisionCountCreateInput = ( + recruitmentCycleId: string, + teamTypeId: string, + newMembers: number, + returningMembers: number +): Prisma.Recruitment_Division_CountCreateInput => ({ + recruitmentCycle: { connect: { recruitmentCycleId } }, + teamType: { connect: { teamTypeId } }, + newMembers, + returningMembers +}); diff --git a/src/backend/src/prisma/factories/teams.factory.ts b/src/backend/src/prisma/factories/teams.factory.ts index b90c459c9c..736b16f287 100644 --- a/src/backend/src/prisma/factories/teams.factory.ts +++ b/src/backend/src/prisma/factories/teams.factory.ts @@ -9,6 +9,7 @@ export type SeedTeamConfig = { description: string; teamTypeName: string; financeTeam?: boolean; + operationsTeam?: boolean; }; export const seedTeamConfigs: SeedTeamConfig[] = [ @@ -80,7 +81,8 @@ export const seedTeamConfigs: SeedTeamConfig[] = [ { teamName: 'Operations', description: 'Coordinates logistics, planning, and internal processes.', - teamTypeName: 'Business' + teamTypeName: 'Business', + operationsTeam: true }, { teamName: 'Finance', @@ -150,6 +152,7 @@ export const teamCreateInput = ( slackId: slackIdForTeam(faker, config.teamName), description: config.description, financeTeam: config.financeTeam ?? false, + operationsTeam: config.operationsTeam ?? false, head: { connect: { userId: head.userId } }, diff --git a/src/backend/src/prisma/migrations/20260704134750_rules_dashboard/migration.sql b/src/backend/src/prisma/migrations/20260704134750_rules_dashboard/migration.sql new file mode 100644 index 0000000000..bd8378db93 --- /dev/null +++ b/src/backend/src/prisma/migrations/20260704134750_rules_dashboard/migration.sql @@ -0,0 +1,196 @@ +-- CreateEnum +CREATE TYPE "Rule_Status" AS ENUM ('PENDING', 'PASS', 'FAIL'); + +-- CreateTable +CREATE TABLE "Ruleset_Type" ( + "rulesetTypeId" TEXT NOT NULL, + "name" TEXT NOT NULL, + "lastUpdated" TIMESTAMP(3) NOT NULL, + "dateCreated" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "createdByUserId" TEXT NOT NULL, + "dateDeleted" TIMESTAMP(3), + "deletedByUserId" TEXT, + "organizationId" TEXT NOT NULL, + + CONSTRAINT "Ruleset_Type_pkey" PRIMARY KEY ("rulesetTypeId") +); + +-- CreateTable +CREATE TABLE "Ruleset" ( + "rulesetId" TEXT NOT NULL, + "fileId" TEXT NOT NULL, + "name" TEXT NOT NULL, + "active" BOOLEAN NOT NULL, + "rulesetTypeId" TEXT NOT NULL, + "carId" TEXT NOT NULL, + "dateCreated" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "createdByUserId" TEXT NOT NULL, + "dateDeleted" TIMESTAMP(3), + "deletedByUserId" TEXT, + + CONSTRAINT "Ruleset_pkey" PRIMARY KEY ("rulesetId") +); + +-- CreateTable +CREATE TABLE "Rule" ( + "ruleId" TEXT NOT NULL, + "ruleCode" TEXT NOT NULL, + "ruleContent" TEXT NOT NULL, + "imageFileIds" TEXT[], + "rulesetId" TEXT NOT NULL, + "parentRuleId" TEXT, + "status" "Rule_Status" NOT NULL DEFAULT 'PENDING', + "statusUpdatedByUserId" TEXT, + "statusUpdatedAt" TIMESTAMP(3), + "dateCreated" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "dateUpdated" TIMESTAMP(3), + "dateDeleted" TIMESTAMP(3), + "createdByUserId" TEXT NOT NULL, + "updatedByUserId" TEXT, + "deletedByUserId" TEXT, + + CONSTRAINT "Rule_pkey" PRIMARY KEY ("ruleId") +); + +-- CreateTable +CREATE TABLE "Project_Rule" ( + "projectRuleId" TEXT NOT NULL, + "ruleId" TEXT NOT NULL, + "projectId" TEXT NOT NULL, + "status" "Rule_Status" NOT NULL DEFAULT 'PENDING', + "statusUpdatedByUserId" TEXT, + "statusUpdatedAt" TIMESTAMP(3), + "dateCreated" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "createdByUserId" TEXT NOT NULL, + "dateDeleted" TIMESTAMP(3), + "deletedByUserId" TEXT, + + CONSTRAINT "Project_Rule_pkey" PRIMARY KEY ("projectRuleId") +); + +-- CreateTable +CREATE TABLE "_ruleReferences" ( + "A" TEXT NOT NULL, + "B" TEXT NOT NULL, + + CONSTRAINT "_ruleReferences_AB_pkey" PRIMARY KEY ("A","B") +); + +-- CreateTable +CREATE TABLE "_teamRules" ( + "A" TEXT NOT NULL, + "B" TEXT NOT NULL, + + CONSTRAINT "_teamRules_AB_pkey" PRIMARY KEY ("A","B") +); + +-- CreateIndex +CREATE INDEX "Ruleset_Type_organizationId_idx" ON "Ruleset_Type"("organizationId"); + +-- CreateIndex +CREATE INDEX "Rule_parentRuleId_rulesetId_ruleCode_idx" ON "Rule"("parentRuleId", "rulesetId", "ruleCode"); + +-- CreateIndex +CREATE UNIQUE INDEX "Rule_rulesetId_ruleCode_key" ON "Rule"("rulesetId", "ruleCode"); + +-- CreateIndex +CREATE UNIQUE INDEX "Project_Rule_ruleId_projectId_key" ON "Project_Rule"("ruleId", "projectId"); + +-- CreateIndex +CREATE INDEX "_ruleReferences_B_index" ON "_ruleReferences"("B"); + +-- CreateIndex +CREATE INDEX "_teamRules_B_index" ON "_teamRules"("B"); + +-- AddForeignKey +ALTER TABLE "Ruleset_Type" ADD CONSTRAINT "Ruleset_Type_createdByUserId_fkey" FOREIGN KEY ("createdByUserId") REFERENCES "User"("userId") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Ruleset_Type" ADD CONSTRAINT "Ruleset_Type_deletedByUserId_fkey" FOREIGN KEY ("deletedByUserId") REFERENCES "User"("userId") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Ruleset_Type" ADD CONSTRAINT "Ruleset_Type_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("organizationId") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Ruleset" ADD CONSTRAINT "Ruleset_rulesetTypeId_fkey" FOREIGN KEY ("rulesetTypeId") REFERENCES "Ruleset_Type"("rulesetTypeId") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Ruleset" ADD CONSTRAINT "Ruleset_carId_fkey" FOREIGN KEY ("carId") REFERENCES "Car"("carId") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Ruleset" ADD CONSTRAINT "Ruleset_createdByUserId_fkey" FOREIGN KEY ("createdByUserId") REFERENCES "User"("userId") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Ruleset" ADD CONSTRAINT "Ruleset_deletedByUserId_fkey" FOREIGN KEY ("deletedByUserId") REFERENCES "User"("userId") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Rule" ADD CONSTRAINT "Rule_rulesetId_fkey" FOREIGN KEY ("rulesetId") REFERENCES "Ruleset"("rulesetId") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Rule" ADD CONSTRAINT "Rule_parentRuleId_fkey" FOREIGN KEY ("parentRuleId") REFERENCES "Rule"("ruleId") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Rule" ADD CONSTRAINT "Rule_statusUpdatedByUserId_fkey" FOREIGN KEY ("statusUpdatedByUserId") REFERENCES "User"("userId") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Rule" ADD CONSTRAINT "Rule_createdByUserId_fkey" FOREIGN KEY ("createdByUserId") REFERENCES "User"("userId") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Rule" ADD CONSTRAINT "Rule_updatedByUserId_fkey" FOREIGN KEY ("updatedByUserId") REFERENCES "User"("userId") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Rule" ADD CONSTRAINT "Rule_deletedByUserId_fkey" FOREIGN KEY ("deletedByUserId") REFERENCES "User"("userId") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Project_Rule" ADD CONSTRAINT "Project_Rule_ruleId_fkey" FOREIGN KEY ("ruleId") REFERENCES "Rule"("ruleId") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Project_Rule" ADD CONSTRAINT "Project_Rule_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("projectId") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Project_Rule" ADD CONSTRAINT "Project_Rule_statusUpdatedByUserId_fkey" FOREIGN KEY ("statusUpdatedByUserId") REFERENCES "User"("userId") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Project_Rule" ADD CONSTRAINT "Project_Rule_createdByUserId_fkey" FOREIGN KEY ("createdByUserId") REFERENCES "User"("userId") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Project_Rule" ADD CONSTRAINT "Project_Rule_deletedByUserId_fkey" FOREIGN KEY ("deletedByUserId") REFERENCES "User"("userId") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "_ruleReferences" ADD CONSTRAINT "_ruleReferences_A_fkey" FOREIGN KEY ("A") REFERENCES "Rule"("ruleId") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "_ruleReferences" ADD CONSTRAINT "_ruleReferences_B_fkey" FOREIGN KEY ("B") REFERENCES "Rule"("ruleId") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "_teamRules" ADD CONSTRAINT "_teamRules_A_fkey" FOREIGN KEY ("A") REFERENCES "Rule"("ruleId") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "_teamRules" ADD CONSTRAINT "_teamRules_B_fkey" FOREIGN KEY ("B") REFERENCES "Team"("teamId") ON DELETE CASCADE ON UPDATE CASCADE; + +-- CreateTable +CREATE TABLE "Rule_Status_History" ( + "ruleStatusHistoryId" TEXT NOT NULL, + "ruleId" TEXT NOT NULL, + "projectRuleId" TEXT, + "status" "Rule_Status" NOT NULL, + "updatedByUserId" TEXT NOT NULL, + "dateCreated" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "Rule_Status_History_pkey" PRIMARY KEY ("ruleStatusHistoryId") +); + +-- CreateIndex +CREATE INDEX "Rule_Status_History_ruleId_idx" ON "Rule_Status_History"("ruleId"); + +-- CreateIndex +CREATE INDEX "Rule_Status_History_projectRuleId_idx" ON "Rule_Status_History"("projectRuleId"); + +-- AddForeignKey +ALTER TABLE "Rule_Status_History" ADD CONSTRAINT "Rule_Status_History_ruleId_fkey" FOREIGN KEY ("ruleId") REFERENCES "Rule"("ruleId") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Rule_Status_History" ADD CONSTRAINT "Rule_Status_History_projectRuleId_fkey" FOREIGN KEY ("projectRuleId") REFERENCES "Project_Rule"("projectRuleId") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Rule_Status_History" ADD CONSTRAINT "Rule_Status_History_updatedByUserId_fkey" FOREIGN KEY ("updatedByUserId") REFERENCES "User"("userId") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/src/backend/src/prisma/migrations/20260818000000_attendance_graph_type/migration.sql b/src/backend/src/prisma/migrations/20260818000000_attendance_graph_type/migration.sql new file mode 100644 index 0000000000..263b250c28 --- /dev/null +++ b/src/backend/src/prisma/migrations/20260818000000_attendance_graph_type/migration.sql @@ -0,0 +1,10 @@ +-- AlterEnum +-- This migration adds more than one value to an enum. +-- With PostgreSQL versions 11 and earlier, this is not possible +-- in a single migration. This can be worked around by creating +-- multiple migrations, each migration adding only one value to +-- the enum. + + +ALTER TYPE "Graph_Type" ADD VALUE 'ATTENDANCE_BY_TEAM'; +ALTER TYPE "Graph_Type" ADD VALUE 'ATTENDANCE_BY_DIVISION'; diff --git a/src/backend/src/prisma/migrations/20260914011423_executive_summary/migration.sql b/src/backend/src/prisma/migrations/20260914011423_executive_summary/migration.sql new file mode 100644 index 0000000000..fb52682e67 --- /dev/null +++ b/src/backend/src/prisma/migrations/20260914011423_executive_summary/migration.sql @@ -0,0 +1,135 @@ +-- CreateEnum +CREATE TYPE "Term" AS ENUM ('FALL', 'SPRING'); + +-- CreateEnum +CREATE TYPE "Data_Source" AS ENUM ('AUTO', 'MANUAL'); + +-- CreateEnum +CREATE TYPE "Competition" AS ENUM ('FSAE', 'FHE'); + +-- AlterTable +ALTER TABLE "Team" ADD COLUMN "operationsTeam" BOOLEAN NOT NULL DEFAULT false; + +-- CreateTable +CREATE TABLE "Executive_Summary" ( + "executiveSummaryId" TEXT NOT NULL, + "carId" TEXT NOT NULL, + "seasonStartDate" TIMESTAMP(3), + "seasonEndDate" TIMESTAMP(3), + "goals" TEXT NOT NULL DEFAULT '', + "winsAndImprovements" TEXT NOT NULL DEFAULT '', + "budgetNotes" TEXT NOT NULL DEFAULT '', + "recruitmentNotes" TEXT NOT NULL DEFAULT '', + "dateCreated" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "userCreatedId" TEXT NOT NULL, + "dateDeleted" TIMESTAMP(3), + "deletedByUserId" TEXT, + + CONSTRAINT "Executive_Summary_pkey" PRIMARY KEY ("executiveSummaryId") +); + +-- CreateTable +CREATE TABLE "Competition_Performance" ( + "competitionPerformanceId" TEXT NOT NULL, + "executiveSummaryId" TEXT NOT NULL, + "competition" "Competition" NOT NULL, + "finalPlace" INTEGER, + "totalPointsEarned" INTEGER, + "bestStaticEvent" TEXT, + "worstStaticEvent" TEXT, + "bestDynamicEvent" TEXT, + "worstDynamicEvent" TEXT, + "accelerationTopTimeSeconds" INTEGER, + "autocrossTopTimeSeconds" INTEGER, + "enduranceLapsCompleted" INTEGER, + "enduranceAvgLapTimeSeconds" INTEGER, + + CONSTRAINT "Competition_Performance_pkey" PRIMARY KEY ("competitionPerformanceId") +); + +-- CreateTable +CREATE TABLE "Competition_Documents_Summary" ( + "competitionDocumentsSummaryId" TEXT NOT NULL, + "executiveSummaryId" TEXT NOT NULL, + "submittedOnTimeCount" INTEGER, + "firstSubmissionRejectedCount" INTEGER, + "source" "Data_Source" NOT NULL DEFAULT 'MANUAL', + "dateSynced" TIMESTAMP(3), + + CONSTRAINT "Competition_Documents_Summary_pkey" PRIMARY KEY ("competitionDocumentsSummaryId") +); + +-- CreateTable +CREATE TABLE "Recruitment_Cycle" ( + "recruitmentCycleId" TEXT NOT NULL, + "executiveSummaryId" TEXT NOT NULL, + "term" "Term" NOT NULL, + "eventsHeld" INTEGER, + "signUps" INTEGER, + "onboarded" INTEGER, + "activeMembers" INTEGER, + + CONSTRAINT "Recruitment_Cycle_pkey" PRIMARY KEY ("recruitmentCycleId") +); + +-- CreateTable +CREATE TABLE "Recruitment_Division_Count" ( + "recruitmentDivisionCountId" TEXT NOT NULL, + "recruitmentCycleId" TEXT NOT NULL, + "teamTypeId" TEXT NOT NULL, + "newMembers" INTEGER, + "returningMembers" INTEGER, + + CONSTRAINT "Recruitment_Division_Count_pkey" PRIMARY KEY ("recruitmentDivisionCountId") +); + +-- CreateIndex +CREATE UNIQUE INDEX "Executive_Summary_carId_key" ON "Executive_Summary"("carId"); + +-- CreateIndex +CREATE INDEX "Competition_Performance_executiveSummaryId_idx" ON "Competition_Performance"("executiveSummaryId"); + +-- CreateIndex +CREATE UNIQUE INDEX "Competition_Performance_executiveSummaryId_competition_key" ON "Competition_Performance"("executiveSummaryId", "competition"); + +-- CreateIndex +CREATE UNIQUE INDEX "Competition_Documents_Summary_executiveSummaryId_key" ON "Competition_Documents_Summary"("executiveSummaryId"); + +-- CreateIndex +CREATE INDEX "Recruitment_Cycle_executiveSummaryId_idx" ON "Recruitment_Cycle"("executiveSummaryId"); + +-- CreateIndex +CREATE UNIQUE INDEX "Recruitment_Cycle_executiveSummaryId_term_key" ON "Recruitment_Cycle"("executiveSummaryId", "term"); + +-- CreateIndex +CREATE INDEX "Recruitment_Division_Count_recruitmentCycleId_idx" ON "Recruitment_Division_Count"("recruitmentCycleId"); + +-- CreateIndex +CREATE INDEX "Recruitment_Division_Count_teamTypeId_idx" ON "Recruitment_Division_Count"("teamTypeId"); + +-- CreateIndex +CREATE UNIQUE INDEX "Recruitment_Division_Count_recruitmentCycleId_teamTypeId_key" ON "Recruitment_Division_Count"("recruitmentCycleId", "teamTypeId"); + +-- AddForeignKey +ALTER TABLE "Executive_Summary" ADD CONSTRAINT "Executive_Summary_carId_fkey" FOREIGN KEY ("carId") REFERENCES "Car"("carId") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Executive_Summary" ADD CONSTRAINT "Executive_Summary_userCreatedId_fkey" FOREIGN KEY ("userCreatedId") REFERENCES "User"("userId") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Executive_Summary" ADD CONSTRAINT "Executive_Summary_deletedByUserId_fkey" FOREIGN KEY ("deletedByUserId") REFERENCES "User"("userId") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Competition_Performance" ADD CONSTRAINT "Competition_Performance_executiveSummaryId_fkey" FOREIGN KEY ("executiveSummaryId") REFERENCES "Executive_Summary"("executiveSummaryId") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Competition_Documents_Summary" ADD CONSTRAINT "Competition_Documents_Summary_executiveSummaryId_fkey" FOREIGN KEY ("executiveSummaryId") REFERENCES "Executive_Summary"("executiveSummaryId") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Recruitment_Cycle" ADD CONSTRAINT "Recruitment_Cycle_executiveSummaryId_fkey" FOREIGN KEY ("executiveSummaryId") REFERENCES "Executive_Summary"("executiveSummaryId") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Recruitment_Division_Count" ADD CONSTRAINT "Recruitment_Division_Count_recruitmentCycleId_fkey" FOREIGN KEY ("recruitmentCycleId") REFERENCES "Recruitment_Cycle"("recruitmentCycleId") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Recruitment_Division_Count" ADD CONSTRAINT "Recruitment_Division_Count_teamTypeId_fkey" FOREIGN KEY ("teamTypeId") REFERENCES "Team_Type"("teamTypeId") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/src/backend/src/prisma/migrations/20260914135734_change_request_changes/migration.sql b/src/backend/src/prisma/migrations/20260914135734_change_request_changes/migration.sql new file mode 100644 index 0000000000..8083595ae6 --- /dev/null +++ b/src/backend/src/prisma/migrations/20260914135734_change_request_changes/migration.sql @@ -0,0 +1,10 @@ +-- AlterTable +ALTER TABLE "Change_Request" ADD COLUMN "snapshotBlockingCount" INTEGER, +ADD COLUMN "snapshotDuration" INTEGER, +ADD COLUMN "snapshotStartDate" DATE; + +-- AlterTable +ALTER TABLE "Organization" ADD COLUMN "activationBufferDays" INTEGER NOT NULL DEFAULT 7; + +-- AlterTable +ALTER TABLE "Work_Package_Proposed_Changes" ADD COLUMN "timelineChangeFromUnmarkedBlocker" BOOLEAN NOT NULL DEFAULT false; diff --git a/src/backend/src/prisma/schema.prisma b/src/backend/src/prisma/schema.prisma index 48fb8dcf84..0fee0783ed 100644 --- a/src/backend/src/prisma/schema.prisma +++ b/src/backend/src/prisma/schema.prisma @@ -32,6 +32,12 @@ enum Task_Status { DONE } +enum Rule_Status { + PENDING + PASS + FAIL +} + enum WBS_Element_Status { INACTIVE ACTIVE @@ -101,6 +107,9 @@ enum Graph_Type { CHANGE_REQUESTS_BY_STATUS PROJECT_BUDGET_VS_REIMBURSED_AMOUNT + + ATTENDANCE_BY_TEAM + ATTENDANCE_BY_DIVISION } enum Graph_Display_Type { @@ -171,6 +180,21 @@ enum Team_Join_Request_Status { DENIED } +enum Term { + FALL + SPRING +} + +enum Data_Source { + AUTO + MANUAL +} + +enum Competition { + FSAE + FHE +} + model User { userId String @id @default(uuid()) firstName String @@ -286,6 +310,18 @@ model User { deletedSponsorTiers Sponsor_Tier[] financeDelegateForOrganizations Organization[] @relation(name: "financeDelegates") assignedReimbursementRequests Reimbursement_Request[] @relation(name: "reimbursementRequestAssignee") + ruleStatusUpdates Rule[] @relation(name: "ruleStatusUpdater") + 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") + projectRuleStatusUpdates Project_Rule[] @relation(name: "projectRuleStatusUpdater") + ruleStatusHistoryUpdates Rule_Status_History[] @relation(name: "ruleStatusHistoryUpdater") deletedGuestDefinitions Guest_Definition[] @relation(name: "guestDefinitionDeleter") createdGuestDefinitions Guest_Definition[] @relation(name: "guestDefinitionCreator") requiredEvents Event[] @relation(name: "requiredEventAttendee") @@ -302,6 +338,8 @@ model User { createdTaskLabels Task_Label[] @relation(name: "taskLabelCreator") deletedTaskLabels Task_Label[] @relation(name: "taskLabelDeleter") dashboards Dashboard[] @relation(name: "userDashboards") + createdExecutiveSummaries Executive_Summary[] @relation(name: "executiveSummaryCreator") + deletedExecutiveSummaries Executive_Summary[] @relation(name: "executiveSummaryDeleter") } model Role { @@ -322,6 +360,7 @@ model Team { slackId String description String @default("") financeTeam Boolean @default(false) + operationsTeam Boolean @default(false) projects Project[] @relation(name: "assignedBy") members User[] @relation(name: "teamsAsMember") leads User[] @relation(name: "teamsAsLead") @@ -340,6 +379,7 @@ model Team { projectTemplates Project_Template[] meetingAttendances Meeting_Attendance[] joinRequests Team_Join_Request[] @relation(name: "teamJoinRequests") + rules Rule[] @relation(name: "teamRules") @@index([headId]) @@index([organizationId]) @@ -414,6 +454,9 @@ model Change_Request { notificationSlackThreads Message_Info[] wbsProposedChangesId String? @unique wbsProposedChanges Wbs_Proposed_Changes? @relation("wbsProposedChanges", fields: [wbsProposedChangesId], references: [wbsProposedChangesId]) + snapshotBlockingCount Int? + snapshotStartDate DateTime? @db.Date + snapshotDuration Int? @@unique([identifier, organizationId], name: "uniqueChangeRequest") @@index([reviewerId]) @@ -558,6 +601,7 @@ model Project { featuredByOrganization Organization? @relation(fields: [featuredByOrganizationId], references: [organizationId]) abbreviation String? parts Part[] + rules Project_Rule[] @relation(name: "projectsForRule") @@index([carId]) } @@ -579,16 +623,16 @@ model Work_Package { } model Link_Type { - id String @id @default(uuid()) - name String - dateCreated DateTime @default(now()) - iconName String - required Boolean - creatorId String - creator User @relation(name: "linkTypeCreator", fields: [creatorId], references: [userId]) - links Link[] @relation(name: "linkTypes") - organizationId String - organization Organization @relation(fields: [organizationId], references: [organizationId]) + id String @id @default(uuid()) + name String + dateCreated DateTime @default(now()) + iconName String + required Boolean + creatorId String + creator User @relation(name: "linkTypeCreator", fields: [creatorId], references: [userId]) + links Link[] @relation(name: "linkTypes") + organizationId String + organization Organization @relation(fields: [organizationId], references: [organizationId]) isOnGuestHomePage Boolean @default(false) isOnNewMemberDashboard Boolean @default(false) isOnOnboardingDashboard Boolean @default(false) @@ -1257,10 +1301,11 @@ model Team_Type { usersOnboarding User[] @relation(name: "onboardingTeamTypes") usersOnboarded User[] @relation(name: "onboardedTeamTypes") - dateDeleted DateTime? - deletedById String? - deletedBy User? @relation(name: "teamTypeDeleter", fields: [deletedById], references: [userId]) - events Event[] + dateDeleted DateTime? + deletedById String? + deletedBy User? @relation(name: "teamTypeDeleter", fields: [deletedById], references: [userId]) + events Event[] + recruitmentDivisionCounts Recruitment_Division_Count[] @@unique([name, organizationId], name: "uniqueTeamType") @@index([organizationId]) @@ -1322,15 +1367,16 @@ model Project_Proposed_Changes { } model Work_Package_Proposed_Changes { - workPackageProposedChangesId String @id @default(uuid()) - startDate DateTime @db.Date - duration Int - blockedBy WBS_Element[] @relation(name: "proposedBlockedBy") - stage Work_Package_Stage? - wbsProposedChanges Wbs_Proposed_Changes @relation(name: "wpProposedChanges", fields: [wbsProposedChangesId], references: [wbsProposedChangesId]) - wbsProposedChangesId String @unique - projectProposedChanges Project_Proposed_Changes? @relation(fields: [projectProposedChangesId], references: [projectProposedChangesId]) - projectProposedChangesId String? + workPackageProposedChangesId String @id @default(uuid()) + startDate DateTime @db.Date + duration Int + blockedBy WBS_Element[] @relation(name: "proposedBlockedBy") + stage Work_Package_Stage? + wbsProposedChanges Wbs_Proposed_Changes @relation(name: "wpProposedChanges", fields: [wbsProposedChangesId], references: [wbsProposedChangesId]) + wbsProposedChangesId String @unique + projectProposedChanges Project_Proposed_Changes? @relation(fields: [projectProposedChangesId], references: [projectProposedChangesId]) + projectProposedChangesId String? + timelineChangeFromUnmarkedBlocker Boolean @default(false) @@index([wbsProposedChangesId]) @@index([projectProposedChangesId]) @@ -1388,6 +1434,8 @@ model Car { wbsElementId String @unique wbsElement WBS_Element @relation(fields: [wbsElementId], references: [wbsElementId]) linkedGraphs Graph[] @relation(name: "graphCars") + executiveSummary Executive_Summary? + rulesets Ruleset[] @relation(name: "rulesetCar") @@index([wbsElementId]) } @@ -1415,6 +1463,7 @@ model Organization { sponsorshipNotificationsSlackChannelId String? platformDescription String @default("") platformLogoImageId String? + activationBufferDays Int @default(7) // Relation references wbsElements WBS_Element[] @@ -1450,6 +1499,7 @@ model Organization { prospectiveSponsors Prospective_Sponsor[] indexCodes Index_Code[] financeDelegates User[] @relation(name: "financeDelegates") + rulesetTypes Ruleset_Type[] guestDefinitions Guest_Definition[] shops Shop[] machineries Machinery[] @@ -1819,6 +1869,110 @@ enum Guest_Definition_Type { INFO_PAGE } +model Ruleset_Type { + rulesetTypeId String @id @default(uuid()) + name String + lastUpdated DateTime @updatedAt + revisionFiles Ruleset[] @relation(name: "rulesetFileType") + dateCreated DateTime @default(now()) + createdByUserId String + createdBy User @relation(name: "rulesetTypeCreator", fields: [createdByUserId], references: [userId]) + dateDeleted DateTime? + deletedByUserId String? + deletedBy User? @relation(name: "rulesetTypeDeleter", fields: [deletedByUserId], references: [userId]) + organizationId String + organization Organization @relation(fields: [organizationId], references: [organizationId]) + + @@index([organizationId]) +} + +model Ruleset { + rulesetId String @id @default(uuid()) + fileId String + name String + active Boolean + rules Rule[] + rulesetTypeId String + rulesetType Ruleset_Type @relation(name: "rulesetFileType", fields: [rulesetTypeId], references: [rulesetTypeId]) + carId String + car Car @relation(name: "rulesetCar", fields: [carId], references: [carId]) + dateCreated DateTime @default(now()) + createdByUserId String + createdBy User @relation(name: "rulesetCreator", fields: [createdByUserId], references: [userId]) + dateDeleted DateTime? + deletedByUserId String? + deletedBy User? @relation(name: "rulesetDeleter", fields: [deletedByUserId], references: [userId]) +} + +model Rule { + ruleId String @id @default(uuid()) + ruleCode String + ruleContent String + imageFileIds String[] + rulesetId String + ruleset Ruleset @relation(fields: [rulesetId], references: [rulesetId]) + parentRuleId String? + parentRule Rule? @relation(name: "subRules", fields: [parentRuleId], references: [ruleId]) + subRules Rule[] @relation(name: "subRules") + referencedRule Rule[] @relation(name: "ruleReferences") + referencedBy Rule[] @relation(name: "ruleReferences") + projects Project_Rule[] @relation(name: "rulesInProject") + teams Team[] @relation(name: "teamRules") + status Rule_Status @default(PENDING) + statusUpdatedByUserId String? + statusUpdatedBy User? @relation(name: "ruleStatusUpdater", fields: [statusUpdatedByUserId], references: [userId]) + statusUpdatedAt DateTime? + statusHistory Rule_Status_History[] + dateCreated DateTime @default(now()) + dateUpdated DateTime? @updatedAt + dateDeleted DateTime? + createdByUserId String + createdBy User @relation(name: "ruleCreator", fields: [createdByUserId], references: [userId]) + updatedByUserId String? + updatedBy User? @relation(name: "ruleUpdater", fields: [updatedByUserId], references: [userId]) + deletedByUserId String? + deletedBy User? @relation(name: "ruleDeletor", fields: [deletedByUserId], references: [userId]) + + @@unique([rulesetId, ruleCode]) + @@index([parentRuleId, rulesetId, ruleCode]) +} + +model Project_Rule { + projectRuleId String @id @default(uuid()) + ruleId String + rule Rule @relation(name: "rulesInProject", fields: [ruleId], references: [ruleId]) + projectId String + project Project @relation(name: "projectsForRule", fields: [projectId], references: [projectId]) + status Rule_Status @default(PENDING) + statusUpdatedByUserId String? + statusUpdatedBy User? @relation(name: "projectRuleStatusUpdater", fields: [statusUpdatedByUserId], references: [userId]) + statusUpdatedAt DateTime? + statusHistory Rule_Status_History[] + 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 Rule_Status_History { + ruleStatusHistoryId String @id @default(uuid()) + ruleId String + rule Rule @relation(fields: [ruleId], references: [ruleId]) + projectRuleId String? + projectRule Project_Rule? @relation(fields: [projectRuleId], references: [projectRuleId]) + status Rule_Status + updatedByUserId String + updatedBy User @relation(name: "ruleStatusHistoryUpdater", fields: [updatedByUserId], references: [userId]) + dateCreated DateTime @default(now()) + + @@index([ruleId]) + @@index([projectRuleId]) +} + model Guest_Definition { definitionId String @id @default(uuid()) term String @@ -1857,3 +2011,87 @@ model Meeting_Attendance { @@index([organizationId]) @@index([teamId]) } + +model Executive_Summary { + executiveSummaryId String @id @default(uuid()) + carId String @unique + car Car @relation(fields: [carId], references: [carId]) + seasonStartDate DateTime? + seasonEndDate DateTime? + + goals String @default("") + winsAndImprovements String @default("") + budgetNotes String @default("") + recruitmentNotes String @default("") + + dateCreated DateTime @default(now()) + userCreatedId String + userCreated User @relation(fields: [userCreatedId], references: [userId], name: "executiveSummaryCreator") + dateDeleted DateTime? + deletedByUserId String? + deletedBy User? @relation(fields: [deletedByUserId], references: [userId], name: "executiveSummaryDeleter") + + competitionPerformances Competition_Performance[] + competitionDocumentsSummary Competition_Documents_Summary? + recruitmentCycles Recruitment_Cycle[] +} + +model Competition_Performance { + competitionPerformanceId String @id @default(uuid()) + executiveSummaryId String + executiveSummary Executive_Summary @relation(fields: [executiveSummaryId], references: [executiveSummaryId]) + competition Competition + + finalPlace Int? + totalPointsEarned Int? + bestStaticEvent String? + worstStaticEvent String? + bestDynamicEvent String? + worstDynamicEvent String? + accelerationTopTimeSeconds Int? + autocrossTopTimeSeconds Int? + enduranceLapsCompleted Int? + enduranceAvgLapTimeSeconds Int? + + @@unique([executiveSummaryId, competition], name: "uniqueExecSummaryCompetition") + @@index([executiveSummaryId]) +} + +model Competition_Documents_Summary { + competitionDocumentsSummaryId String @id @default(uuid()) + executiveSummaryId String @unique + executiveSummary Executive_Summary @relation(fields: [executiveSummaryId], references: [executiveSummaryId]) + submittedOnTimeCount Int? + firstSubmissionRejectedCount Int? + source Data_Source @default(MANUAL) + dateSynced DateTime? +} + +model Recruitment_Cycle { + recruitmentCycleId String @id @default(uuid()) + executiveSummaryId String + executiveSummary Executive_Summary @relation(fields: [executiveSummaryId], references: [executiveSummaryId]) + term Term + eventsHeld Int? + signUps Int? + onboarded Int? + activeMembers Int? + divisionCounts Recruitment_Division_Count[] + + @@unique([executiveSummaryId, term], name: "uniqueRecruitmentCycleTerm") + @@index([executiveSummaryId]) +} + +model Recruitment_Division_Count { + recruitmentDivisionCountId String @id @default(uuid()) + recruitmentCycleId String + recruitmentCycle Recruitment_Cycle @relation(fields: [recruitmentCycleId], references: [recruitmentCycleId]) + teamTypeId String + teamType Team_Type @relation(fields: [teamTypeId], references: [teamTypeId]) + newMembers Int? + returningMembers Int? + + @@unique([recruitmentCycleId, teamTypeId], name: "uniqueDivisionCountPerCycle") + @@index([recruitmentCycleId]) + @@index([teamTypeId]) +} 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..272e78b86e --- /dev/null +++ b/src/backend/src/prisma/seed-data/rules.seed.ts @@ -0,0 +1,715 @@ +import type { Prisma } from '@prisma/client'; +import { Organization, PrismaClient } from '@prisma/client'; +import RulesService from '../../services/rules.services.js'; +import { User, RuleStatus } from 'shared'; + +// ruleset types +const rulesetTypeFSAE = (userCreatedId: string, organizationId: string): Prisma.Ruleset_TypeCreateInput => { + return { + name: 'FSAE', + createdBy: { connect: { userId: userCreatedId } }, + organization: { connect: { organizationId } } + }; +}; + +const rulesetTypeFHE = (userCreatedId: string, organizationId: string): Prisma.Ruleset_TypeCreateInput => { + return { + name: 'FHE', + createdBy: { connect: { userId: userCreatedId } }, + organization: { connect: { organizationId } } + }; +}; + +// rulesets +const rulesetFSAE = (carId: string, userCreatedId: string, rulesetTypeId: string): Prisma.RulesetCreateInput => { + return { + name: 'Mock FSAE', + fileId: 'mock-fsae-rules', + active: true, + dateCreated: new Date('2025-01-01T10:00:00Z'), + car: { connect: { carId } }, + createdBy: { connect: { userId: userCreatedId } }, + rulesetType: { connect: { rulesetTypeId } } + }; +}; + +const rulesetFHE = (carId: string, userCreatedId: string, rulesetTypeId: string): Prisma.RulesetCreateInput => { + return { + name: 'Mock FHE', + fileId: 'mock-fhe-rules', + active: true, + dateCreated: new Date('2024-12-31T10:00:00Z'), + car: { connect: { carId } }, + createdBy: { connect: { userId: userCreatedId } }, + rulesetType: { connect: { rulesetTypeId } } + }; +}; + +// project rules +const projectRule1 = (projectId: string, ruleId: string, createdByUserId: string): Prisma.Project_RuleCreateInput => { + return { + rule: { connect: { ruleId } }, + project: { connect: { projectId } }, + createdBy: { connect: { userId: createdByUserId } } + }; +}; + +const projectRule2 = (projectId: string, ruleId: string, createdByUserId: string): Prisma.Project_RuleCreateInput => { + return { + rule: { connect: { ruleId } }, + project: { connect: { projectId } }, + createdBy: { connect: { userId: createdByUserId } } + }; +}; + +export const seedRulesetType = async (submitter: User, name: string, organization: Organization) => { + const createdRulesetType = await RulesService.createRulesetType(submitter, name, organization); + return createdRulesetType; +}; + +/** + * Seeds the FSAE and FHE rulesets, including parent/child relationships and + * cross-references between rules. Also assigns a leaf rule to the given project + * and marks it complete to demonstrate project rules and global rule completion. + * + * @param prisma the prisma client used by the seed script + * @param fsaeRulesetId fsae mock ruleset the bulk of the rules belong to + * @param fheRulesetId fhe mock ruleset + * @param users the users credited as rule creators + * @param organization the organization the rules/project belong to + * @param projectId the project a leaf rule is assigned to and completed in + * @param huskyTeamId the team a leaf rule is assigned to + */ +export const seedFsaeRules = async ( + prisma: PrismaClient, + fsaeRulesetId: string, + fheRulesetId: string, + users: { batman: User; thomasEmrax: User; joeShmoe: User; joeBlow: User; superman: User }, + organization: Organization, + projectId: string, + huskyTeamId: string +) => { + const { batman, thomasEmrax, joeShmoe, joeBlow, superman } = users; + + // Technical Rules (from FSAE 2026 rules) + const topLevelTechnical = await prisma.rule.create({ + data: { + ruleCode: 'T', + ruleContent: 'TECHNICAL ASPECTS', + rulesetId: fsaeRulesetId, + createdByUserId: batman.userId + } + }); + + const T1Rule = await prisma.rule.create({ + data: { + ruleCode: 'T.1', + ruleContent: 'COCKPIT', + rulesetId: fsaeRulesetId, + parentRuleId: topLevelTechnical.ruleId, + createdByUserId: batman.userId + } + }); + + const T11Rule = await prisma.rule.create({ + data: { + ruleCode: 'T.1.1', + ruleContent: 'Cockpit Opening', + rulesetId: fsaeRulesetId, + parentRuleId: T1Rule.ruleId, + createdByUserId: thomasEmrax.userId + } + }); + + await prisma.rule.create({ + data: { + ruleCode: 'T.1.1.1', + ruleContent: 'The template shown below must pass through the cockpit opening', + rulesetId: fsaeRulesetId, + parentRuleId: T11Rule.ruleId, + createdByUserId: joeShmoe.userId + } + }); + + const F6Rule = await prisma.rule.create({ + data: { + ruleCode: 'F.6', + ruleContent: 'TUBE FRAMES', + rulesetId: fsaeRulesetId, + createdByUserId: joeShmoe.userId + } + }); + + // referenced by T.1.1.2 + const F64Rule = await prisma.rule.create({ + data: { + ruleCode: 'F.6.4', + ruleContent: 'Side Impact Structure', + rulesetId: fsaeRulesetId, + parentRuleId: F6Rule.ruleId, + createdByUserId: joeShmoe.userId + } + }); + + // referenced by T.1.1.2 + const F751Rule = await prisma.rule.create({ + data: { + ruleCode: 'F.7.5.1', + ruleContent: + 'Side Impact Zone - the region longitudinally forward of the Main Hoop and aft of the Front Hoop consisting of the combination of a vertical section minimum 290 mm in height from the bottom surface of the floor of the monocoque and half the horizontal floor', + rulesetId: fsaeRulesetId, + createdByUserId: joeShmoe.userId + // add image + } + }); + + const T112Rule = await prisma.rule.create({ + data: { + ruleCode: 'T.1.1.2', + ruleContent: + 'The template will be held horizontally, parallel to the ground, and inserted vertically from a height above any Primary Structure or bodywork that is between the Front Hoop and the Main Hoop until it meets the two of: ( refer to F.6.4 and F.7.5.1 )', + rulesetId: fsaeRulesetId, + parentRuleId: T11Rule.ruleId, + createdByUserId: joeShmoe.userId, + referencedRule: { + connect: [{ ruleId: F64Rule.ruleId }, { ruleId: F751Rule.ruleId }] + } + } + }); + + const T112ARule = await prisma.rule.create({ + data: { + ruleCode: 'T.1.1.2.a', + ruleContent: 'Has passed 25 mm below the lowest point of the top of the Side Impact Structure', + rulesetId: fsaeRulesetId, + parentRuleId: T112Rule.ruleId, + createdByUserId: joeShmoe.userId + } + }); + + await prisma.rule.create({ + data: { + ruleCode: 'T.1.1.2.b', + ruleContent: 'Is less than or equal to 320 mm above the lowest point inside the cockpit', + rulesetId: fsaeRulesetId, + parentRuleId: T112Rule.ruleId, + createdByUserId: joeShmoe.userId + } + }); + + const T12Rule = await prisma.rule.create({ + data: { + ruleCode: 'T.1.2', + ruleContent: 'Internal Cross Section', + rulesetId: fsaeRulesetId, + parentRuleId: T1Rule.ruleId, + createdByUserId: thomasEmrax.userId + } + }); + + const T121Rule = await prisma.rule.create({ + data: { + ruleCode: 'T.1.2.1', + ruleContent: 'Requirement:', + rulesetId: fsaeRulesetId, + parentRuleId: T12Rule.ruleId, + createdByUserId: thomasEmrax.userId + } + }); + + await prisma.rule.create({ + data: { + ruleCode: 'T.1.2.1.a', + ruleContent: 'The cockpit must have a free internal cross section', + rulesetId: fsaeRulesetId, + parentRuleId: T121Rule.ruleId, + createdByUserId: thomasEmrax.userId + } + }); + + await prisma.rule.create({ + data: { + ruleCode: 'T.1.2.1.b', + ruleContent: 'The template shown below must pass through the cockpit', + rulesetId: fsaeRulesetId, + parentRuleId: T121Rule.ruleId, + createdByUserId: thomasEmrax.userId, + imageFileIds: [] // add image here when implemented (page 56 of FSAE 2026) + } + }); + + // IC Rules (from FSAE 2026 rules) + const ICRule = await prisma.rule.create({ + data: { + ruleCode: 'IC', + ruleContent: 'INTERNAL COMBUSTION ENGINE VEHICLES', + rulesetId: fsaeRulesetId, + createdByUserId: thomasEmrax.userId + } + }); + + await prisma.rule.create({ + data: { + ruleCode: 'IC.1', + ruleContent: 'GENERAL REQUIREMENTS', + rulesetId: fsaeRulesetId, + parentRuleId: ICRule.ruleId, + createdByUserId: thomasEmrax.userId + } + }); + + const IC5Rule = await prisma.rule.create({ + data: { + ruleCode: 'IC.5', + ruleContent: 'FUEL AND FUEL SYSTEM', + rulesetId: fsaeRulesetId, + parentRuleId: ICRule.ruleId, + createdByUserId: thomasEmrax.userId + } + }); + + const IC56Rule = await prisma.rule.create({ + data: { + ruleCode: 'IC.5.6', + ruleContent: 'Venting Systems', + rulesetId: fsaeRulesetId, + parentRuleId: IC5Rule.ruleId, + createdByUserId: thomasEmrax.userId + } + }); + + await prisma.rule.create({ + data: { + ruleCode: 'IC.5.6.1', + ruleContent: + 'Venting systems for the fuel tank and fuel delivery system must not let fuel spill during hard cornering or acceleration', + rulesetId: fsaeRulesetId, + parentRuleId: IC56Rule.ruleId, + createdByUserId: thomasEmrax.userId + } + }); + + await prisma.rule.create({ + data: { + ruleCode: 'IC.5.6.2', + ruleContent: 'All fuel vent lines must have a check valve to prevent fuel leakage when the tank is inverted', + rulesetId: fsaeRulesetId, + parentRuleId: IC56Rule.ruleId, + createdByUserId: thomasEmrax.userId + } + }); + + await prisma.rule.create({ + data: { + ruleCode: 'IC.5.6.3', + ruleContent: 'All fuel vent lines must exit outside the bodywork', + rulesetId: fsaeRulesetId, + parentRuleId: IC56Rule.ruleId, + createdByUserId: thomasEmrax.userId + } + }); + + const IC81Rule = await prisma.rule.create({ + data: { + ruleCode: 'IC.8.1', + ruleContent: 'Starter Each vehicle must start the engine using an onboard starter at all times', + rulesetId: fsaeRulesetId, + parentRuleId: ICRule.ruleId, + createdByUserId: thomasEmrax.userId + } + }); + + // Chassis and Structural Rules (from FSAE 2025 rules) + const FRule = await prisma.rule.create({ + data: { + ruleCode: 'F', + ruleContent: 'CHASSIS AND STRUCTURAL', + rulesetId: fsaeRulesetId, + createdByUserId: thomasEmrax.userId + } + }); + + const F3Rule = await prisma.rule.create({ + data: { + ruleCode: 'F.3', + ruleContent: 'TUBING AND MATERIAL', + rulesetId: fsaeRulesetId, + parentRuleId: FRule.ruleId, + createdByUserId: joeShmoe.userId + } + }); + + const F34Rule = await prisma.rule.create({ + data: { + ruleCode: 'F.3.4', + ruleContent: 'Steel Tubing and Material', + rulesetId: fsaeRulesetId, + parentRuleId: F3Rule.ruleId, + createdByUserId: joeBlow.userId + } + }); + + await prisma.rule.create({ + data: { + ruleCode: 'F.3.4.1', + ruleContent: + 'Minimum Requirements for Steel Tubing. A tube must have all four minimum requirements for each Size specified:', + rulesetId: fsaeRulesetId, + parentRuleId: F34Rule.ruleId, + createdByUserId: batman.userId, + imageFileIds: [] // table FSAE 2025 page 26 + } + }); + + const F342Rule = await prisma.rule.create({ + data: { + ruleCode: 'F.3.4.2', + ruleContent: 'Properties for ANY steel material for calculations submitted in an SES must be:', + rulesetId: fsaeRulesetId, + parentRuleId: F34Rule.ruleId, + createdByUserId: thomasEmrax.userId + } + }); + + await prisma.rule.create({ + data: { + ruleCode: 'F.3.4.2.a', + ruleContent: + 'Non Welded Properties for continuous material calculations: Young’s Modulus (E) = 200 GPa (29,000 ksi) Yield Strength (Sy) = 305 MPa (44.2 ksi) Ultimate Strength (Su) = 365 MPa (52.9 ksi)', + rulesetId: fsaeRulesetId, + parentRuleId: F342Rule.ruleId, + createdByUserId: batman.userId + } + }); + + await prisma.rule.create({ + data: { + ruleCode: 'F.3.4.2.b', + ruleContent: + 'Welded Properties for discontinuous material such as joint calculations: Yield Strength (Sy) = 180 MPa (26 ksi) Ultimate Strength (Su) = 300 MPa (43.5 ksi)', + rulesetId: fsaeRulesetId, + parentRuleId: F342Rule.ruleId, + createdByUserId: batman.userId + } + }); + + const F32Rule = await prisma.rule.create({ + data: { + ruleCode: 'F.3.2', + ruleContent: 'Tubing Requirements', + rulesetId: fsaeRulesetId, + parentRuleId: F3Rule.ruleId, + createdByUserId: batman.userId + } + }); + + const F321Rule = await prisma.rule.create({ + data: { + ruleCode: 'F.3.2.1', + ruleContent: 'Requirements by Application', + rulesetId: fsaeRulesetId, + parentRuleId: F32Rule.ruleId, + createdByUserId: batman.userId + } + }); + + await prisma.rule.create({ + data: { + ruleCode: 'F.3.2.1.b', + ruleContent: 'Front Bulkhead Support Size C Yes', // info is part of first table from FSAE page 26 + rulesetId: fsaeRulesetId, + parentRuleId: F321Rule.ruleId, + createdByUserId: batman.userId + } + }); + + // Referenced later by F.5.7.1 + const F321cRule = await prisma.rule.create({ + data: { + ruleCode: 'F.3.2.1.c', + ruleContent: 'Front Hoop Size A Yes', // info is part of first table from FSAE page 26 + rulesetId: fsaeRulesetId, + parentRuleId: F321Rule.ruleId, + createdByUserId: batman.userId + } + }); + + // F siblings + const F5Rule = await prisma.rule.create({ + data: { + ruleCode: 'F.5', + ruleContent: 'CHASSIS REQUIREMENTS', + rulesetId: fsaeRulesetId, + parentRuleId: FRule.ruleId, + createdByUserId: joeShmoe.userId + } + }); + + const F57Rule = await prisma.rule.create({ + data: { + ruleCode: 'F.5.7', + ruleContent: 'Front Hoop', + rulesetId: fsaeRulesetId, + parentRuleId: F5Rule.ruleId, + createdByUserId: joeShmoe.userId + } + }); + + // Rule F.5.7.1 references F.3.2.1.c + await prisma.rule.create({ + data: { + ruleCode: 'F.5.7.1', + ruleContent: 'The Front Hoop must be constructed of closed section metal tubing meeting F.3.2.1.c', + rulesetId: fsaeRulesetId, + parentRuleId: F57Rule.ruleId, + createdByUserId: joeShmoe.userId, + referencedRule: { + connect: [{ ruleId: F321cRule.ruleId }] // Referenced rule + } + } + }); + + // Dynamic Events (from FSAE 2025 rules) + const DRule = await prisma.rule.create({ + data: { + ruleCode: 'D', + ruleContent: 'DYNAMIC EVENTS', + rulesetId: fsaeRulesetId, + createdByUserId: batman.userId + } + }); + + const D3Rule = await prisma.rule.create({ + data: { + ruleCode: 'D.3', + ruleContent: 'DRIVING', + rulesetId: fsaeRulesetId, + parentRuleId: DRule.ruleId, + createdByUserId: superman.userId + } + }); + + const D35Rule = await prisma.rule.create({ + data: { + ruleCode: 'D.3.5', + ruleContent: 'Driver Equipment', + rulesetId: fsaeRulesetId, + parentRuleId: D3Rule.ruleId, + createdByUserId: superman.userId + } + }); + + const D36Rule = await prisma.rule.create({ + data: { + ruleCode: 'D.3.6', + ruleContent: + 'Starting Auxiliary batteries must not be used once a vehicle has moved to the starting line of any event. See IC.8.1', + rulesetId: fsaeRulesetId, + parentRuleId: D3Rule.ruleId, + createdByUserId: batman.userId, + referencedRule: { + connect: [{ ruleId: IC81Rule.ruleId }] + } + } + }); + + const D351Rule = await prisma.rule.create({ + data: { + ruleCode: 'D.3.5.1', + ruleContent: 'All Driver Equipment and Harness must be worn by the driver anytime in the cockpit with:', + rulesetId: fsaeRulesetId, + parentRuleId: D35Rule.ruleId, + createdByUserId: batman.userId + } + }); + + await prisma.rule.create({ + data: { + ruleCode: 'D.3.5.1.a', + ruleContent: '(IC) Engine running or (EV) Tractive System Active', + rulesetId: fsaeRulesetId, + parentRuleId: D351Rule.ruleId, + createdByUserId: batman.userId + } + }); + + await prisma.rule.create({ + data: { + ruleCode: 'D.3.5.1.b', + ruleContent: 'Anytime between starting a Dynamic run and finishing or abandoning that Dynamic run', + rulesetId: fsaeRulesetId, + parentRuleId: D351Rule.ruleId, + createdByUserId: batman.userId + } + }); + + // Technical Requirements from FHE 2026 + const TRule = await prisma.rule.create({ + data: { + ruleCode: 'T', + ruleContent: 'PART T - GENERAL TECHNICAL REQUIREMENTS', + rulesetId: fheRulesetId, + createdByUserId: batman.userId + } + }); + + const T2Rule = await prisma.rule.create({ + data: { + ruleCode: 'T2', + ruleContent: 'ARTICLE T2 - GENERAL DESIGN REQUIREMENTS', + rulesetId: fheRulesetId, + createdByUserId: batman.userId, + parentRuleId: TRule.ruleId + } + }); + + const T21Rule = await prisma.rule.create({ + data: { + ruleCode: 'T2.1', + ruleContent: 'Vehicle Configuration', + rulesetId: fheRulesetId, + createdByUserId: batman.userId, + parentRuleId: T2Rule.ruleId + } + }); + + const T211Rule = await prisma.rule.create({ + data: { + ruleCode: 'T2.1.1', + ruleContent: + 'The vehicle must be open-wheeled and open-cockpit (a formula style body) with four (4) wheels that are not in a straight line.', + rulesetId: fheRulesetId, + parentRuleId: T21Rule.ruleId, + createdByUserId: thomasEmrax.userId + } + }); + + const T22Rule = await prisma.rule.create({ + data: { + ruleCode: 'T2.2', + ruleContent: + 'Bodywork There must be no openings through the bodywork into the driver compartment from the front of the vehicle back to the roll bar main hoop or firewall other than that required for the cockpit opening. Minimal openings around the front suspension components are allowed.', + rulesetId: fheRulesetId, + createdByUserId: batman.userId, + parentRuleId: T2Rule.ruleId + } + }); + + await prisma.rule.create({ + data: { + ruleCode: 'T2.3', + ruleContent: + 'Wheelbase The car must have a wheelbase of at least 1524 mm. The wheelbase is measured from the center of ground contact of the front and rear tires with the wheels pointed straight ahead.', + rulesetId: fheRulesetId, + createdByUserId: batman.userId, + parentRuleId: T2Rule.ruleId + } + }); + + const T3Rule = await prisma.rule.create({ + data: { + ruleCode: 'T3', + ruleContent: 'ARTICLE T3 - SAFETY REQUIREMENTS', + rulesetId: fheRulesetId, + createdByUserId: batman.userId, + parentRuleId: TRule.ruleId + } + }); + + const T33Rule = await prisma.rule.create({ + data: { + ruleCode: 'T3.3', + ruleContent: 'Minimum Material Requirements', + rulesetId: fheRulesetId, + createdByUserId: batman.userId, + parentRuleId: T3Rule.ruleId + } + }); + + const T332Rule = await prisma.rule.create({ + data: { + ruleCode: 'T3.3.2', + ruleContent: + 'When a cutout, or a hole greater in diameter than 3/16 inch (4 mm), is made in a regulated tube, e.g. to mount the safety harness or suspension and steering components, in order to regain the baseline, cold rolled strength of the original tubing, the tubing must be reinforced by the use of a welded insert or other reinforcement. The welded strength figures given above must be used for the additional material. And the details, including dimensioned drawings, must be included in the SES.', + rulesetId: fheRulesetId, + createdByUserId: batman.userId, + parentRuleId: T33Rule.ruleId + } + }); + + const T331Rule = await prisma.rule.create({ + data: { + ruleCode: 'T3.3.1', + ruleContent: + 'Baseline Steel Material The Primary Structure of the car must be constructed of: Either: Round, mild or alloy, steel tubing (minimum 0.1% carbon) of the minimum dimensions specified in Table 4 . Or: Approved alternatives per Rules T3.3, T3.3.2, T3.5 and T3.6.', + rulesetId: fheRulesetId, + createdByUserId: batman.userId, + parentRuleId: T33Rule.ruleId, + referencedRule: { + connect: [{ ruleId: T33Rule.ruleId }, { ruleId: T332Rule.ruleId }] // add other references later + } + } + }); + + const T312Rule = await prisma.rule.create({ + data: { + ruleCode: 'T3.12', + ruleContent: 'Main Hoop Bracing', + rulesetId: fheRulesetId, + createdByUserId: batman.userId, + parentRuleId: T3Rule.ruleId + } + }); + + // T3.12.1 references T3.3.1 + await prisma.rule.create({ + data: { + ruleCode: 'T3.12.1', + ruleContent: 'Main Hoop braces must be constructed of closed section steel tubing per Rule T3.3.1.', + rulesetId: fheRulesetId, + createdByUserId: batman.userId, + parentRuleId: T312Rule.ruleId, + referencedRule: { + connect: [{ ruleId: T331Rule.ruleId }] // Referenced rule + } + } + }); + + // Assign the leaf rule T.1.1.2.a to the husky team along with its full chain of + // ancestors (T -> T.1 -> T.1.1 -> T.1.1.2). Simulates how the assign-rules page automatically completes this full chain. + for (const rule of [topLevelTechnical, T1Rule, T11Rule, T112Rule, T112ARule]) { + await RulesService.toggleRuleTeam(rule.ruleId, huskyTeamId, batman, organization); + } + + // Assign D.3.6 to Husky along with ancestors (D -> D.3 -> D.3.6) + for (const rule of [DRule, D3Rule, D36Rule]) { + await RulesService.toggleRuleTeam(rule.ruleId, huskyTeamId, batman, organization); + } + await RulesService.createProjectRule(batman, organization, D36Rule.ruleId, projectId); + + // Assign IC.8.1 to Husky (IC -> IC.8.1) + // IC.8.1 referenced by D.3.6 above to test in-project referenced rule + await RulesService.toggleRuleTeam(ICRule.ruleId, huskyTeamId, batman, organization); + await RulesService.toggleRuleTeam(IC81Rule.ruleId, huskyTeamId, batman, organization); + await RulesService.createProjectRule(batman, organization, IC81Rule.ruleId, projectId); + + // Add the leaf rule to the bodywork project and mark it as passed for that project. + const bodyworkT112AProjectRule = await RulesService.createProjectRule(batman, organization, T112ARule.ruleId, projectId); + await RulesService.setProjectRuleStatus(batman, organization, bodyworkT112AProjectRule.projectRuleId, RuleStatus.PASS); + + // Assign FHE T2.1.1 and T2.2 to Husky along with their shared ancestors (T -> T2 -> T2.1 -> T2.1.1, T2 -> T2.2) + for (const rule of [TRule, T2Rule, T21Rule, T211Rule, T22Rule]) { + await RulesService.toggleRuleTeam(rule.ruleId, huskyTeamId, batman, organization); + } + await RulesService.createProjectRule(batman, organization, T211Rule.ruleId, projectId); + await RulesService.createProjectRule(batman, organization, T22Rule.ruleId, projectId); +}; + +export const ruleSeedData = { + rulesetTypeFHE, + rulesetTypeFSAE, + rulesetFSAE, + rulesetFHE, + projectRule1, + projectRule2 +}; diff --git a/src/backend/src/prisma/seed.ts b/src/backend/src/prisma/seed.ts index 6499dae007..59864ae6a4 100644 --- a/src/backend/src/prisma/seed.ts +++ b/src/backend/src/prisma/seed.ts @@ -27,6 +27,12 @@ import { SponsorProcess } from './seed/sponsor.process.js'; import { PartProcess } from './seed/parts.process.js'; import { OrganizationContentProcess } from './seed/organization-content.process.js'; import { GraphProcess } from './seed/graphs.process.js'; +import { ExecutiveSummaryProcess } from './seed/executive-summary.process.js'; +import { CompetitionPerformanceProcess } from './seed/competition-performance.process.js'; +import { CompetitionDocumentsSummaryProcess } from './seed/competition-documents-summary.process.js'; +import { RecruitmentCycleProcess } from './seed/recruitment-cycle.process.js'; +import { RecruitmentDivisionCountProcess } from './seed/recruitment-division-count.process.js'; +import { RulesProcess } from './seed/rules.process.js'; const prisma = new PrismaClient(); @@ -52,7 +58,13 @@ await new SeedRunner() new PartProcess(), new SponsorProcess(), new OrganizationContentProcess(), - new GraphProcess() + new GraphProcess(), + new ExecutiveSummaryProcess(), + new CompetitionPerformanceProcess(), + new CompetitionDocumentsSummaryProcess(), + new RecruitmentCycleProcess(), + new RecruitmentDivisionCountProcess(), + new RulesProcess() ) .run(); diff --git a/src/backend/src/prisma/seed/competition-documents-summary.process.ts b/src/backend/src/prisma/seed/competition-documents-summary.process.ts new file mode 100644 index 0000000000..64cd6c0f1a --- /dev/null +++ b/src/backend/src/prisma/seed/competition-documents-summary.process.ts @@ -0,0 +1,43 @@ +import { Competition_Documents_Summary } from '@prisma/client'; +import { SeedProcess } from '../processes/seed-process.js'; +import { ExecutiveSummaryOutput, ExecutiveSummaryProcess } from './executive-summary.process.js'; +import { + competitionDocumentsSummaryCreateInput, + DOCUMENTS_SUMMARY_CHANCE, + planCompetitionDocumentsSummary +} from '../factories/competition-documents-summary.factory.js'; + +export type CompetitionDocumentsSummaryOutput = { + competitionDocumentsSummaries: Competition_Documents_Summary[]; +}; + +export class CompetitionDocumentsSummaryProcess extends SeedProcess< + ExecutiveSummaryOutput, + CompetitionDocumentsSummaryOutput +> { + dependencies() { + return [ExecutiveSummaryProcess]; + } + + async run({ executiveSummaries }: ExecutiveSummaryOutput): Promise { + const now = new Date(); + + const planned = executiveSummaries + .filter(() => this.faker.datatype.boolean({ probability: DOCUMENTS_SUMMARY_CHANCE })) + .map((executiveSummary) => ({ + executiveSummaryId: executiveSummary.executiveSummaryId, + // A sync can only have happened between the summary's own creation and now. + planned: planCompetitionDocumentsSummary(this.faker, { from: executiveSummary.dateCreated, to: now }) + })); + + const competitionDocumentsSummaries = await Promise.all( + planned.map((p) => + this.prisma.competition_Documents_Summary.create({ + data: competitionDocumentsSummaryCreateInput(p.executiveSummaryId, p.planned) + }) + ) + ); + + return { competitionDocumentsSummaries }; + } +} diff --git a/src/backend/src/prisma/seed/competition-performance.process.ts b/src/backend/src/prisma/seed/competition-performance.process.ts new file mode 100644 index 0000000000..324ee7e423 --- /dev/null +++ b/src/backend/src/prisma/seed/competition-performance.process.ts @@ -0,0 +1,40 @@ +import { Competition_Performance } from '@prisma/client'; +import { SeedProcess } from '../processes/seed-process.js'; +import { ExecutiveSummaryOutput, ExecutiveSummaryProcess } from './executive-summary.process.js'; +import { + ALL_COMPETITIONS, + COMPETITION_PERFORMANCE_CHANCE, + competitionPerformanceCreateInput, + planCompetitionPerformance +} from '../factories/competition-performance.factory.js'; + +export type CompetitionPerformanceOutput = { + competitionPerformances: Competition_Performance[]; +}; + +export class CompetitionPerformanceProcess extends SeedProcess { + dependencies() { + return [ExecutiveSummaryProcess]; + } + + async run({ executiveSummaries }: ExecutiveSummaryOutput): Promise { + const planned = executiveSummaries.flatMap((executiveSummary) => + ALL_COMPETITIONS.filter(() => this.faker.datatype.boolean({ probability: COMPETITION_PERFORMANCE_CHANCE })).map( + (competition) => ({ + executiveSummaryId: executiveSummary.executiveSummaryId, + planned: planCompetitionPerformance(this.faker, competition) + }) + ) + ); + + const competitionPerformances = await Promise.all( + planned.map((p) => + this.prisma.competition_Performance.create({ + data: competitionPerformanceCreateInput(p.executiveSummaryId, p.planned) + }) + ) + ); + + return { competitionPerformances }; + } +} diff --git a/src/backend/src/prisma/seed/event.process.ts b/src/backend/src/prisma/seed/event.process.ts index f120b46799..be75021844 100644 --- a/src/backend/src/prisma/seed/event.process.ts +++ b/src/backend/src/prisma/seed/event.process.ts @@ -11,7 +11,6 @@ import { DAYS_AFTER_NO_EVENT, documentCreateInput, eventCreateInput, - generateAttendeeCount, generateConflictStatus, generateEventCount, generateEventDateCreated, @@ -21,6 +20,7 @@ import { generateInitialDateOffset, generateLocation, generateQuestionDocumentLink, + generateRosterAttendeeCount, generateScheduleSlotCount, generateScheduleSlotTimes, generateZoomLink, @@ -65,6 +65,19 @@ export class EventProcess extends SeedProcess> const creators = [...leadership, ...heads, ...admins, ...appAdmins]; const allUsers = [...members, ...leadership, ...heads, ...admins, ...appAdmins]; + // Preload each team's roster (members + leads + head) once so meeting-attendance seeding can + // draw attendees from the real roster without an extra query per meeting. + const teamRosters = await this.prisma.team.findMany({ + where: { teamId: { in: teams.map((team) => team.teamId) } }, + select: { teamId: true, headId: true, members: { select: { userId: true } }, leads: { select: { userId: true } } } + }); + const rosterIdsByTeamId = new Map( + teamRosters.map((team) => [ + team.teamId, + [team.headId, ...team.members.map((m) => m.userId), ...team.leads.map((l) => l.userId)] + ]) + ); + const now = new Date(); const BATCH_SIZE = 20; @@ -79,6 +92,7 @@ export class EventProcess extends SeedProcess> creators, allUsers, teams, + rosterIdsByTeamId, eventTypes, now ); @@ -96,6 +110,7 @@ export class EventProcess extends SeedProcess> creators: UsersOutput['leadership'], allUsers: UsersOutput['members'], teams: TeamOutput['teams'], + rosterIdsByTeamId: Map, eventTypes: ConfigDataOutput['eventTypes'], now: Date ) { @@ -190,19 +205,26 @@ export class EventProcess extends SeedProcess> if (shouldCreateMeetingAttendance(this.faker)) { const team = this.faker.helpers.arrayElement(teams); - const attendeeCount = generateAttendeeCount(this.faker, allUsers.length); - const attendees = this.faker.helpers.arrayElements(allUsers, attendeeCount); - await this.prisma.meeting_Attendance.create({ - data: meetingAttendanceCreateInput( - this.faker, - organizationId, - team.teamId, - creator.userId, - attendees.map((u) => u.userId), - initialDateScheduled - ) - }); + // Draw attendees from the team's own roster (members + leads + head) so the + // attendance-percentage statistics graphs report meaningful values. + const rosterIds = rosterIdsByTeamId.get(team.teamId) ?? []; + + if (rosterIds.length > 0) { + const attendeeCount = generateRosterAttendeeCount(this.faker, rosterIds.length); + const attendees = this.faker.helpers.arrayElements(rosterIds, Math.min(attendeeCount, rosterIds.length)); + + await this.prisma.meeting_Attendance.create({ + data: meetingAttendanceCreateInput( + this.faker, + organizationId, + team.teamId, + creator.userId, + attendees, + initialDateScheduled + ) + }); + } } } } diff --git a/src/backend/src/prisma/seed/executive-summary.process.ts b/src/backend/src/prisma/seed/executive-summary.process.ts new file mode 100644 index 0000000000..769f83dec8 --- /dev/null +++ b/src/backend/src/prisma/seed/executive-summary.process.ts @@ -0,0 +1,82 @@ +import { Executive_Summary } from '@prisma/client'; +import { SeedProcess } from '../processes/seed-process.js'; +import { UsersOutput, UsersProcess } from './user.process.js'; +import { CarProcess } from './car.process.js'; +import { CarOutput, FullUser } from '../context.js'; +import { + EXECUTIVE_SUMMARY_DELETED_CHANCE, + executiveSummaryCreateInput, + generateNotes +} from '../factories/executive-summary.factory.js'; + +type ExecutiveSummaryInput = UsersOutput & CarOutput; + +export type ExecutiveSummaryOutput = { + executiveSummaries: Executive_Summary[]; +}; + +export class ExecutiveSummaryProcess extends SeedProcess { + dependencies() { + return [UsersProcess, CarProcess]; + } + + async run({ admins, heads, leadership, cars }: ExecutiveSummaryInput): Promise { + const now = new Date(); + + const leadershipPool: FullUser[] = [...admins, ...heads, ...leadership]; + if (leadershipPool.length === 0) { + throw new Error('ExecutiveSummaryProcess requires at least one leadership-level user.'); + } + + // Each car represents one season, so every car gets exactly one executive summary. + const planned = cars.map(({ car, dateRange }) => { + const seasonStartDate = dateRange.start; + const seasonEndDate = dateRange.end; + // A season only gets its write-up once it has ended - if it hasn't (the newest, ongoing + // season), the summary is dated "now" rather than a future date past the report itself. + const dateCreated = + seasonEndDate.getTime() <= now.getTime() ? this.faker.date.between({ from: seasonEndDate, to: now }) : now; + + const isDeleted = this.faker.datatype.boolean({ probability: EXECUTIVE_SUMMARY_DELETED_CHANCE }); + + return { + carId: car.carId, + seasonStartDate, + seasonEndDate, + dateCreated, + userCreatedId: this.faker.helpers.arrayElement(leadershipPool).userId, + notes: { + goals: generateNotes(this.faker), + winsAndImprovements: generateNotes(this.faker), + budgetNotes: generateNotes(this.faker), + recruitmentNotes: generateNotes(this.faker) + }, + // A soft-deleted row's dateDeleted must fall between its own creation and now. + deleted: isDeleted + ? { + dateDeleted: this.faker.date.between({ from: dateCreated, to: now }), + deletedByUserId: this.faker.helpers.arrayElement(leadershipPool).userId + } + : undefined + }; + }); + + const executiveSummaries = await Promise.all( + planned.map((p) => + this.prisma.executive_Summary.create({ + data: executiveSummaryCreateInput( + p.carId, + p.seasonStartDate, + p.seasonEndDate, + p.notes, + p.userCreatedId, + p.dateCreated, + p.deleted + ) + }) + ) + ); + + return { executiveSummaries }; + } +} diff --git a/src/backend/src/prisma/seed/recruitment-cycle.process.ts b/src/backend/src/prisma/seed/recruitment-cycle.process.ts new file mode 100644 index 0000000000..a2ede95e67 --- /dev/null +++ b/src/backend/src/prisma/seed/recruitment-cycle.process.ts @@ -0,0 +1,38 @@ +import { Recruitment_Cycle } from '@prisma/client'; +import { SeedProcess } from '../processes/seed-process.js'; +import { ExecutiveSummaryOutput, ExecutiveSummaryProcess } from './executive-summary.process.js'; +import { + ALL_TERMS, + planRecruitmentCycle, + RECRUITMENT_CYCLE_CHANCE, + recruitmentCycleCreateInput +} from '../factories/recruitment-cycle.factory.js'; + +export type RecruitmentCycleOutput = { + recruitmentCycles: Recruitment_Cycle[]; +}; + +export class RecruitmentCycleProcess extends SeedProcess { + dependencies() { + return [ExecutiveSummaryProcess]; + } + + async run({ executiveSummaries }: ExecutiveSummaryOutput): Promise { + const planned = executiveSummaries.flatMap((executiveSummary) => + ALL_TERMS.filter(() => this.faker.datatype.boolean({ probability: RECRUITMENT_CYCLE_CHANCE })).map((term) => ({ + executiveSummaryId: executiveSummary.executiveSummaryId, + planned: planRecruitmentCycle(this.faker, term) + })) + ); + + const recruitmentCycles = await Promise.all( + planned.map((p) => + this.prisma.recruitment_Cycle.create({ + data: recruitmentCycleCreateInput(p.executiveSummaryId, p.planned) + }) + ) + ); + + return { recruitmentCycles }; + } +} diff --git a/src/backend/src/prisma/seed/recruitment-division-count.process.ts b/src/backend/src/prisma/seed/recruitment-division-count.process.ts new file mode 100644 index 0000000000..5391739776 --- /dev/null +++ b/src/backend/src/prisma/seed/recruitment-division-count.process.ts @@ -0,0 +1,52 @@ +import { Recruitment_Division_Count } from '@prisma/client'; +import { SeedProcess } from '../processes/seed-process.js'; +import { RecruitmentCycleOutput, RecruitmentCycleProcess } from './recruitment-cycle.process.js'; +import { ConfigDataOutput, ConfigDataProcess } from './config-data.process.js'; +import { + recruitmentDivisionCountCreateInput, + returningMembersForDivision, + splitIntoParts +} from '../factories/recruitment-division-count.factory.js'; + +type RecruitmentDivisionCountInput = RecruitmentCycleOutput & ConfigDataOutput; + +export type RecruitmentDivisionCountOutput = { + recruitmentDivisionCounts: Recruitment_Division_Count[]; +}; + +export class RecruitmentDivisionCountProcess extends SeedProcess< + RecruitmentDivisionCountInput, + RecruitmentDivisionCountOutput +> { + dependencies() { + return [RecruitmentCycleProcess, ConfigDataProcess]; + } + + async run({ recruitmentCycles, teamTypes }: RecruitmentDivisionCountInput): Promise { + if (teamTypes.length === 0) { + throw new Error('RecruitmentDivisionCountProcess requires at least one team type.'); + } + + const planned = recruitmentCycles.flatMap((cycle) => { + // New members are split across every division so they sum back to the cycle's own + // onboarded count, rather than each division inventing an unrelated number. + const newMemberShares = splitIntoParts(this.faker, cycle.onboarded ?? 0, teamTypes.length); + return teamTypes.map((teamType, i) => ({ + recruitmentCycleId: cycle.recruitmentCycleId, + teamTypeId: teamType.teamTypeId, + newMembers: newMemberShares[i], + returningMembers: returningMembersForDivision(this.faker) + })); + }); + + const recruitmentDivisionCounts = await Promise.all( + planned.map((p) => + this.prisma.recruitment_Division_Count.create({ + data: recruitmentDivisionCountCreateInput(p.recruitmentCycleId, p.teamTypeId, p.newMembers, p.returningMembers) + }) + ) + ); + + return { recruitmentDivisionCounts }; + } +} diff --git a/src/backend/src/prisma/seed/rules.process.ts b/src/backend/src/prisma/seed/rules.process.ts new file mode 100644 index 0000000000..8d6a5888c2 --- /dev/null +++ b/src/backend/src/prisma/seed/rules.process.ts @@ -0,0 +1,93 @@ +/* + * 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 { Ruleset, Ruleset_Type } from '@prisma/client'; +import { User } from 'shared'; +import { SeedProcess } from '../processes/seed-process.js'; +import { CarOutput } from '../context.js'; +import { OrganizationOutput, OrganizationProcess } from './organization.process.js'; +import { UsersOutput, UsersProcess } from './user.process.js'; +import { CarProcess } from './car.process.js'; +import { ProjectOutput, ProjectProcess } from './project.process.js'; +import { ruleSeedData, seedFsaeRules } from '../seed-data/rules.seed.js'; + +type RulesInput = OrganizationOutput & UsersOutput & CarOutput & ProjectOutput; + +export type RulesOutput = { + rulesetTypes: Ruleset_Type[]; + rulesets: Ruleset[]; +}; + +export class RulesProcess extends SeedProcess { + dependencies() { + return [OrganizationProcess, UsersProcess, CarProcess, ProjectProcess]; + } + + async run({ + organization, + appAdmins, + admins, + heads, + leadership, + members, + currentYearCar, + projects + }: RulesInput): Promise { + const { organizationId } = organization; + + const author = appAdmins[0] ?? admins[0] ?? heads[0]; + if (!author) throw new Error('RulesProcess requires an app admin/admin/head to author rules.'); + + const creatorPool: User[] = [...leadership, ...heads, ...admins, ...appAdmins, ...members]; + if (creatorPool.length === 0) throw new Error('RulesProcess requires at least one user to credit as a rule creator.'); + + const creatorAt = (index: number): User => creatorPool[index % creatorPool.length]; + + const primaryCarId = currentYearCar.car.carId; + + const target = projects.find( + ({ project }) => project.carId === primaryCarId && project.teams.some((team) => !team.dateArchived) + ); + if (!target) throw new Error('RulesProcess requires a current year car project with at least one non-archived team.'); + + const targetProjectId = target.project.projectId; + const targetTeamId = target.project.teams.find((team) => !team.dateArchived)!.teamId; + + const [fsaeRulesetType, fheRulesetType] = await Promise.all([ + this.prisma.ruleset_Type.create({ data: ruleSeedData.rulesetTypeFSAE(author.userId, organizationId) }), + this.prisma.ruleset_Type.create({ data: ruleSeedData.rulesetTypeFHE(author.userId, organizationId) }) + ]); + + const [rulesetFSAE, rulesetFHE] = await Promise.all([ + this.prisma.ruleset.create({ + data: ruleSeedData.rulesetFSAE(primaryCarId, author.userId, fsaeRulesetType.rulesetTypeId) + }), + this.prisma.ruleset.create({ + data: ruleSeedData.rulesetFHE(primaryCarId, author.userId, fheRulesetType.rulesetTypeId) + }) + ]); + + await seedFsaeRules( + this.prisma, + rulesetFSAE.rulesetId, + rulesetFHE.rulesetId, + { + batman: author, + thomasEmrax: creatorAt(0), + joeShmoe: creatorAt(1), + joeBlow: creatorAt(2), + superman: creatorAt(3) + }, + organization, + targetProjectId, + targetTeamId + ); + + return { + rulesetTypes: [fsaeRulesetType, fheRulesetType], + rulesets: [rulesetFSAE, rulesetFHE] + }; + } +} diff --git a/src/backend/src/routes/agent.routes.ts b/src/backend/src/routes/agent.routes.ts index 537f78168a..45b90af117 100644 --- a/src/backend/src/routes/agent.routes.ts +++ b/src/backend/src/routes/agent.routes.ts @@ -1,13 +1,28 @@ import express from 'express'; -import { query } from 'express-validator'; +import { body, query } from 'express-validator'; import AgentController from '../controllers/agent.controllers.js'; import { readOnlyGuard, requireApiToken } from '../utils/mcp-auth.utils.js'; -import { isDate, validateInputs } from '../utils/validation.utils.js'; +import { isDate, nonEmptyString, validateInputs } from '../utils/validation.utils.js'; const agentRouter = express.Router(); // this router authenticates with per-user API tokens rather than the session cookie, and is read only +// apart from the admin only routes registered above readOnlyGuard agentRouter.use(requireApiToken); + +// the admin check lives in the service, and this deliberately has no MCP tool so it is only reachable through the API +agentRouter.post( + '/slack/messages', + // public (C) or private (G) channel ids only, so this cannot be used to DM users + body('channelId') + .isString() + .matches(/^[CG][A-Z0-9]{8,}$/) + .withMessage('channelId must be a slack channel id, e.g. C0123456789'), + nonEmptyString(body('message').trim()).isLength({ max: 4000 }), + validateInputs, + AgentController.sendSlackMessage +); + agentRouter.use(readOnlyGuard); agentRouter.get('/health', AgentController.healthCheck); diff --git a/src/backend/src/routes/rules.routes.ts b/src/backend/src/routes/rules.routes.ts new file mode 100644 index 0000000000..417dc87763 --- /dev/null +++ b/src/backend/src/routes/rules.routes.ts @@ -0,0 +1,141 @@ +import express from 'express'; +import RulesController from '../controllers/rules.controllers.js'; +import { nonEmptyString, validateInputs } from '../utils/validation.utils.js'; +import { body, query } from 'express-validator'; +import { MAX_FILE_SIZE, RuleStatus } from 'shared'; +import multer, { memoryStorage } from 'multer'; + +const rulesRouter = express.Router(); + +rulesRouter.get( + '/rulesetType/:rulesetTypeId/active', + query('carNumber').optional().isInt(), + validateInputs, + RulesController.getActiveRuleset +); +rulesRouter.post( + '/rule/create', + nonEmptyString(body('ruleCode')), + body('ruleContent').isString(), + 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', + body('ruleContent').isString(), + nonEmptyString(body('ruleCode').optional()), + body('imageFileIds').optional().isArray(), + body('imageFileIds.*').optional().isString(), + body('parentRuleId').optional().isString(), + validateInputs, + RulesController.editRule +); +rulesRouter.post('/rule/:ruleId/delete', RulesController.deleteRule); + +rulesRouter.post( + '/rule/:ruleId/references/add', + nonEmptyString(body('referencedRuleId')), + validateInputs, + RulesController.addRuleReferences +); +rulesRouter.post( + '/rule/:ruleId/references/delete', + nonEmptyString(body('referencedRuleId')), + validateInputs, + RulesController.removeRuleReferences +); + +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.post('/ruleset/:rulesetId/delete', RulesController.deleteRuleset); +rulesRouter.post('/projectRule/:projectRuleId/delete', RulesController.deleteProjectRule); + +rulesRouter.get('/rulesets/:rulesetTypeId', RulesController.getRulesetsByRulesetType); +rulesRouter.post( + '/rule/:ruleId/setStatus', + body('status').isIn(Object.values(RuleStatus)), + validateInputs, + RulesController.setRuleStatus +); +rulesRouter.post( + '/projectRule/:projectRuleId/setStatus', + body('status').isIn(Object.values(RuleStatus)), + validateInputs, + RulesController.setProjectRuleStatus +); +rulesRouter.get( + '/rule/:ruleId/status-history', + query('projectRuleId').optional().isString(), + validateInputs, + RulesController.getRuleStatusHistory +); +rulesRouter.post('/ruleset/:rulesetId/resetStatuses', RulesController.resetRulesetStatuses); +rulesRouter.post('/ruleset/:rulesetId/project/:projectId/resetStatuses', RulesController.resetProjectRuleStatuses); + +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.post( + '/ruleset/:rulesetId/update', + body('isActive').isBoolean(), + nonEmptyString(body('name')), + validateInputs, + RulesController.updateRuleset +); +rulesRouter.get( + '/ruleset/:rulesetId/project/:projectId/rules/unassigned', + RulesController.getUnassignedRulesForProjectRuleset +); + +rulesRouter.get('/ruleset/:rulesetId/project/:projectId/rules', RulesController.getProjectRules); + +rulesRouter.get('/:ruleId/subrules', RulesController.getChildRules); +rulesRouter.get('/:rulesetId/parentRules', RulesController.getTopLevelRules); +rulesRouter.get('/:rulesetId/allRules', RulesController.getAllRulesForRuleset); +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' + body('firstRulePage').optional().isInt({ min: 1 }), + 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/attendance.services.ts b/src/backend/src/services/attendance.services.ts index 596dedc3f1..e6906b3461 100644 --- a/src/backend/src/services/attendance.services.ts +++ b/src/backend/src/services/attendance.services.ts @@ -18,6 +18,7 @@ import { sendMessage } from '../integrations/slack.js'; import { userHasPermission } from '../utils/users.utils.js'; +import { calculateTeamMemberAttendancePercent } from '../utils/attendance.utils.js'; export default class AttendanceService { static async takeAttendance( @@ -149,15 +150,8 @@ export default class AttendanceService { if (!attendance || attendance.closedAt) return; - const teamMemberIds = new Set([ - ...attendance.team.members.map((m) => m.userId), - ...attendance.team.leads.map((l) => l.userId), - attendance.team.headId - ]); - const attendeeIds = new Set(attendance.attendees.map((a) => a.userId)); const attendeesCount = attendance.attendees.length; - const teamMemberAttendees = [...teamMemberIds].filter((id) => attendeeIds.has(id)).length; - const teamMemberPercent = teamMemberIds.size > 0 ? (teamMemberAttendees / teamMemberIds.size) * 100 : 0; + const teamMemberPercent = calculateTeamMemberAttendancePercent(attendance.team, attendance.attendees); const closedMessage = `Attendance is now closed. ${attendeesCount} attended (${teamMemberPercent.toFixed(1)}% of team).`; await editMessage(attendance.slackChannelId, attendance.slackMessageTimestamp, closedMessage); diff --git a/src/backend/src/services/rules.services.ts b/src/backend/src/services/rules.services.ts new file mode 100644 index 0000000000..b5ce006cd7 --- /dev/null +++ b/src/backend/src/services/rules.services.ts @@ -0,0 +1,2140 @@ +import { Organization, Rule } from '@prisma/client'; +import { + isAdmin, + isLeadership, + ProjectRule, + RulesetType, + notGuest, + User, + Rule as SharedRule, + isHead, + Ruleset, + RuleStatus, + RuleStatusHistoryEntry, + RuleStatusRollup, + RuleStatusUpdate +} from 'shared'; +import prisma from '../prisma/prisma.js'; +import { + AccessDeniedAdminOnlyException, + AccessDeniedGuestException, + AccessDeniedException, + DeletedException, + HttpException, + InvalidOrganizationException, + NotFoundException +} from '../utils/errors.utils.js'; +import { userHasPermission } from '../utils/users.utils.js'; +import { isUserPartOfTeams } from '../utils/teams.utils.js'; +import { + getProjectRuleQueryArgs, + getRulesetQueryArgs, + getRulePreviewQueryArgs, + getRulesetTypeQueryArgs, + getRuleStatusHistoryQueryArgs +} from '../prisma-query-args/rules.query-args.js'; +import { getTeamPreviewQueryArgs } from '../prisma-query-args/teams.query-args.js'; +import { + ruleTransformer, + projectRuleTransformer, + rulesetTransformer, + rulesetTypeTransformer, + ruleStatusHistoryTransformer +} 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 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, carNumber?: number) { + 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); + } + + 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, ...(carId && { carId }) }, + ...getRulesetQueryArgs() + }); + + if (!activeRuleset) { + throw new NotFoundException('Active Ruleset for given Ruleset Type', rulesetTypeId); + } + + return rulesetTransformer(activeRuleset); + } + + /** + * Throws if a rule with the given code already exists in the given ruleset + * @param rulesetId The ruleset to check for an existing rule code + * @param ruleCode The trimmed rule code to check + */ + private static async assertRuleCodeAvailable(rulesetId: string, ruleCode: string) { + 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`); + } + } + + /** + * Compute a rule's status given the statuses of its children. + * @param childStatuses the statuses of the child rules + * @returns status of the parent rule based on its children's statuses + */ + private static computeRolledUpStatus(childStatuses: RuleStatus[]): RuleStatus { + // If any children FAIL, the parent fails. + if (childStatuses.includes(RuleStatus.FAIL)) return RuleStatus.FAIL; + // If no children FAIL, and any children are PENDING, the parent is PENDING. + if (childStatuses.includes(RuleStatus.PENDING)) return RuleStatus.PENDING; + // If all children PASS, the parent passes. + return RuleStatus.PASS; + } + + /** + * Recomputes and saves ruleId's status, then repeats up the parent chain. + * @param ruleId the rule to recompute the status for + * @returns exactly every rule whose status was recalculated, nearest first + */ + private static async recalculateRuleStatusChain(ruleId: string | null): Promise { + if (!ruleId) return []; + + const children = await prisma.rule.findMany({ + where: { parentRuleId: ruleId, dateDeleted: null }, + select: { status: true } + }); + + // a rule with no remaining children has no rolled-up status to derive; reset it to Pending + // and keep walking up so its own parent's rollup reflects the change + const status = + children.length === 0 + ? RuleStatus.PENDING + : RulesService.computeRolledUpStatus(children.map((child) => child.status as RuleStatus)); + + const { parentRuleId } = await prisma.rule.update({ + where: { ruleId }, + data: { status }, + select: { parentRuleId: true } + }); + + return [{ ruleId, parentRuleId, status }, ...(await RulesService.recalculateRuleStatusChain(parentRuleId))]; + } + + /** + * Same as recalculateRuleStatusChain, but scoped to one project's assigned rules. + * Recomputes and saves the status of the project rule for the given ruleId, then repeats up the parent chain. + * @param projectId the project to scope the status recomputation to + * @param ruleId the rule to recompute the status for + * @returns the updated status of the project rule + */ + private static async recalculateProjectRuleStatusChain(projectId: string, ruleId: string | null): Promise { + if (!ruleId) return; + + const projectRule = await prisma.project_Rule.findFirst({ + where: { projectId, ruleId, dateDeleted: null }, + select: { projectRuleId: true } + }); + + if (!projectRule) return; + + const children = await prisma.project_Rule.findMany({ + where: { projectId, dateDeleted: null, rule: { parentRuleId: ruleId, dateDeleted: null } }, + select: { status: true } + }); + + // a project rule with no remaining children has no rolled-up status to derive; reset it to Pending + // and keep walking up so its own parent's rollup reflects the change + const status = + children.length === 0 + ? RuleStatus.PENDING + : RulesService.computeRolledUpStatus(children.map((child) => child.status as RuleStatus)); + + await prisma.project_Rule.update({ + where: { projectRuleId: projectRule.projectRuleId }, + data: { status } + }); + + const rule = await prisma.rule.findUnique({ where: { ruleId }, select: { parentRuleId: true } }); + await RulesService.recalculateProjectRuleStatusChain(projectId, rule?.parentRuleId ?? null); + } + + /** + * 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 (leadership and above) + if (!(await userHasPermission(user.userId, organization.organizationId, isLeadership))) { + throw new AccessDeniedException('Only leadership 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'); + } + + ruleCode = ruleCode.trim(); + + if (ruleCode === '') { + throw new HttpException(400, 'Rule code cannot be empty'); + } + + // Check for duplicate rule code within the same ruleset + await RulesService.assertRuleCodeAvailable(rulesetId, ruleCode); + + // 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() + }); + + // a fresh child defaults to PENDING, which can flip the parent chain's statuses (e.g. PASS -> PENDING) + await RulesService.recalculateRuleStatusChain(parentRuleId ?? null); + + return ruleTransformer(rule); + } + + /** + * Creates new ruleset type with the given information. + * Only admin/app admins can create ruleset types through admin tools + * @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, isAdmin))) + throw new AccessDeniedAdminOnlyException('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, isLeadership))) { + throw new AccessDeniedException('Only leadership and above can 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'); + + const deletedRuleIds: string[] = []; + + const affectedProjects = 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 + } + }); + + deletedRuleIds.push(currRuleId); + }; + + await deleteParentChildReferencing(ruleId); + + // A deleted rule must not stay assigned to any project. Left behind it is invisible in the + // project's rules (those are filtered to undeleted rules) while still counting as a child, + // which blocks its parent from being removed or having its status set. + const assignedProjectRules = await tx.project_Rule.findMany({ + where: { ruleId: { in: deletedRuleIds }, dateDeleted: null }, + select: { projectId: true } + }); + + await tx.project_Rule.updateMany({ + where: { ruleId: { in: deletedRuleIds }, dateDeleted: null }, + data: { dateDeleted: new Date(), deletedByUserId: deleter.userId } + }); + + return [...new Set(assignedProjectRules.map((projectRule) => projectRule.projectId))]; + }); + + // this rule (and its descendants) are gone, so its old parent chain may update their statuses + await RulesService.recalculateRuleStatusChain(rule.parentRuleId); + + for (const projectId of affectedProjects) { + // delete rule removes project rules with the same logic as the remove project rule modal + // ancestor chain gets the same childless-ancestor prune as deleteProjectRule + const { projectRuleIds: orphanedAncestorIds, survivingAncestorRuleId } = + await RulesService.findChildlessAncestorProjectRules(projectId, rule.parentRuleId, []); + + await prisma.project_Rule.updateMany({ + where: { projectRuleId: { in: orphanedAncestorIds } }, + data: { dateDeleted: new Date(), deletedByUserId: deleter.userId } + }); + + // the parent chain within this project recalculates from the surviving ancestor + await RulesService.recalculateProjectRuleStatusChain(projectId, survivingAncestorRuleId); + } + + 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, + teams: { select: { teamId: 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, teams: { select: { teamId: 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); + + // A rule can only be assigned to a project if the rule is on one of that project's teams. + // This should never be reached since the frontend only shows unassigned rules that are assigned to the project's teams + const projectTeamIds = new Set(project.teams.map((team) => team.teamId)); + if (!rule.teams.some((team) => projectTeamIds.has(team.teamId))) { + throw new HttpException(400, "Rule must be on one of the project's teams to be assigned to it"); + } + + // Checks if this rule is already actively assigned to this project. + // A soft-deleted project rule from previous unassignment would be revived instead of creating a new one + const existingProjectRule = await prisma.project_Rule.findFirst({ + where: { ruleId, projectId, dateDeleted: null } + }); + + if (existingProjectRule) { + throw new HttpException(400, 'This rule is already associated with the project'); + } + + // ensure we assign all ancestors of a rule to the project + const ancestorIds: string[] = []; + const visited = new Set([ruleId]); + let currentParentId = rule.parentRuleId; + + while (currentParentId && !visited.has(currentParentId)) { + visited.add(currentParentId); + const parent = await prisma.rule.findUnique({ + where: { ruleId: currentParentId }, + select: { parentRuleId: true, dateDeleted: true, teams: { select: { teamId: true } } } + }); + // rule only displays if the full chain to a top-level rule exists, so a missing or deleted + // ancestor means this rule would not display - do not assign it OR its ancestors to the project + if (!parent) throw new NotFoundException('Rule', currentParentId); + if (parent.dateDeleted) throw new DeletedException('Rule', currentParentId); + // ancestors must also be on one of the project's teams otherwise the chain to the top-level rule would break + if (!parent.teams.some((team) => projectTeamIds.has(team.teamId))) { + throw new HttpException(400, "Parent rules must be on one of the project's teams to be assigned to it"); + } + ancestorIds.push(currentParentId); + currentParentId = parent.parentRuleId; + } + + // skip ancestors already assigned to this project + const existingAncestors = await prisma.project_Rule.findMany({ + where: { projectId, ruleId: { in: ancestorIds }, dateDeleted: null }, + select: { ruleId: true } + }); + const existingAncestorIds = new Set(existingAncestors.map((projectRule) => projectRule.ruleId)); + const ancestorsToCreate = ancestorIds.filter((id) => !existingAncestorIds.has(id)); + + // create (or revive) all project rules. (ruleId, projectId) is unique so + // soft deleted rules from previous team unassignment would be revived instead of colliding on insert + const reviveOrCreate = (targetRuleId: string) => + // upsert - updates existing record, or inserts if it doesn't exist + prisma.project_Rule.upsert({ + where: { ruleId_projectId: { ruleId: targetRuleId, projectId } }, + create: { + ruleId: targetRuleId, + projectId, + createdByUserId: submitter.userId + }, + update: { + dateDeleted: null, + deletedByUserId: null, + createdByUserId: submitter.userId + } + }); + + await prisma.$transaction([...ancestorsToCreate.map(reviveOrCreate), reviveOrCreate(ruleId)]); + + // new (or revived) project rule leaf defaults to PENDING so parent statuses need to be recomputed + await RulesService.recalculateProjectRuleStatusChain(projectId, rule.parentRuleId); + + // return only original project rule being assigned (leaf rule) + const projectRule = await prisma.project_Rule.findUnique({ + where: { ruleId_projectId: { ruleId, projectId } }, + ...getProjectRuleQueryArgs() + }); + + if (!projectRule) { + throw new NotFoundException('Project Rule', ruleId); + } + + return projectRuleTransformer(projectRule); + } + + /** + * Edits a rule with the given id + * @param submitter a user who is making this request + * @param ruleContent the rule content to edit + * @param ruleId The rule ID being edited + * @param ruleCode The rule code to update (optional, keeps existing if not provided) + * @param imageFileIds The image files to update (optional, keeps existing if not provided) + * @param parentRuleId The parent rule ID to update + * @param organization the organization the rule belongs to + * @returns the edited rule + */ + static async editRule( + submitter: User, + ruleContent: string, + ruleId: string, + ruleCode: string | undefined, + imageFileIds: string[] | undefined, + organization: Organization, + parentRuleId?: string + ) { + if (!(await userHasPermission(submitter.userId, organization.organizationId, isLeadership))) + throw new AccessDeniedException('Only leadership and above can 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 (ruleCode !== undefined) { + ruleCode = ruleCode.trim(); + + if (ruleCode === '') { + throw new HttpException(400, 'Rule code cannot be empty'); + } + + if (ruleCode !== currentRule.ruleCode) { + await RulesService.assertRuleCodeAvailable(currentRule.rulesetId, ruleCode); + } + } + + 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 !== currentRule.rulesetId) { + throw new HttpException(400, 'Parent rule must be in the same ruleset'); + } + } + + 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() + }); + + // reparenting moves this rule out of the old parent chain and into the new one, so recompute statuses for both parent chains + if (parentRuleId && parentRuleId !== currentRule.parentRuleId) { + await RulesService.recalculateRuleStatusChain(currentRule.parentRuleId); + await RulesService.recalculateRuleStatusChain(parentRuleId); + } + + return ruleTransformer(updatedRule); + } + + /** + * Adds a referenced rule to an existing rule + * @param submitter the user making the request + * @param ruleId the rule receiving the reference + * @param referencedRuleId the rule ID to add as a reference + * @param organization the organization the rule belongs to + * @returns the updated rule + */ + static async addRuleReferences(submitter: User, ruleId: string, referencedRuleId: string, organization: Organization) { + if (!(await userHasPermission(submitter.userId, organization.organizationId, isLeadership))) + throw new AccessDeniedException('Only leadership and above can edit a rule'); + + const rule = await prisma.rule.findUnique({ + where: { ruleId }, + include: { + ruleset: { + include: { + car: { + include: { + wbsElement: true + } + } + } + } + } + }); + + if (!rule) throw new NotFoundException('Rule', ruleId); + if (rule.dateDeleted) throw new DeletedException('Rule', ruleId); + if (rule.ruleset?.car?.wbsElement?.organizationId !== organization.organizationId) + throw new InvalidOrganizationException('Rule'); + + if (referencedRuleId === ruleId) { + throw new HttpException(400, 'A rule cannot reference itself'); + } + + // Referenced rules must be in the same ruleset, which guarantees same org + const referencedRule = await prisma.rule.findUnique({ + where: { ruleId: referencedRuleId, rulesetId: rule.rulesetId } + }); + + if (!referencedRule) throw new NotFoundException('Referenced Rule', referencedRuleId); + if (referencedRule.dateDeleted) throw new DeletedException('Referenced Rule', referencedRuleId); + + const updatedRule = await prisma.rule.update({ + where: { ruleId }, + data: { + referencedRule: { + connect: { ruleId: referencedRuleId } + }, + dateUpdated: new Date(), + updatedByUserId: submitter.userId + }, + ...getRulePreviewQueryArgs() + }); + + return ruleTransformer(updatedRule); + } + + /** + * Removes a referenced rule from an existing rule + * @param submitter the user making the request + * @param ruleId the rule losing the reference + * @param referencedRuleId the rule ID to remove from the references + * @param organization the organization the rule belongs to + * @returns the updated rule + */ + static async removeRuleReferences(submitter: User, ruleId: string, referencedRuleId: string, organization: Organization) { + if (!(await userHasPermission(submitter.userId, organization.organizationId, isLeadership))) + throw new AccessDeniedException('Only leadership and above can edit a rule'); + + const rule = await prisma.rule.findUnique({ + where: { ruleId }, + include: { + ruleset: { + include: { + car: { + include: { + wbsElement: true + } + } + } + } + } + }); + + if (!rule) throw new NotFoundException('Rule', ruleId); + if (rule.dateDeleted) throw new DeletedException('Rule', ruleId); + if (rule.ruleset?.car?.wbsElement?.organizationId !== organization.organizationId) + throw new InvalidOrganizationException('Rule'); + + const referencedRule = await prisma.rule.findUnique({ + where: { ruleId: referencedRuleId } + }); + + if (!referencedRule) throw new NotFoundException('Referenced Rule', referencedRuleId); + if (referencedRule.dateDeleted) throw new DeletedException('Referenced Rule', referencedRuleId); + + const updatedRule = await prisma.rule.update({ + where: { ruleId }, + data: { + referencedRule: { + disconnect: { ruleId: referencedRuleId } + }, + 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); + + // admins can delete any ruleset; leadership and heads can only delete a ruleset they created themselves + const isCreator = deleterId === ruleset.createdByUserId; + const hasPermission = + (await userHasPermission(deleterId, organizationId, isAdmin)) || + (isCreator && (await userHasPermission(deleterId, organizationId, isLeadership))); + + if (!hasPermission) { + throw new AccessDeniedException('You do not have permissions to delete this 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: { dateDeleted: new Date(), deletedBy: { connect: { userId: deleterId } }, active: false }, + ...getRulesetQueryArgs() + }); + + return rulesetTransformer(deletedRuleset); + } + + static async getAllRulesetTypes(user: User, organization: Organization, carId?: string): Promise { + if (!(await userHasPermission(user.userId, organization.organizationId, notGuest))) { + throw new AccessDeniedGuestException('view ruleset types'); + } + + const rulesets = await prisma.ruleset_Type.findMany({ + where: { + organizationId: organization.organizationId, + deletedByUserId: null + }, + ...getRulesetTypeQueryArgs(carId) + }); + return rulesets.map(rulesetTypeTransformer); + } + + /** + * Gets a ruleset type for a given ruleset type ID + * @param user the user requesting the ruleset type + * @param rulesetTypeId id of ruleset type + * @param organizationId id of organization + * @param carId optional id of the car to scope revision file counts to + * @returns ruleset type associated with provided ruleset type ID + */ + static async getRulesetType( + user: User, + rulesetTypeId: string, + organizationId: string, + carId?: string + ): Promise { + if (!(await userHasPermission(user.userId, organizationId, notGuest))) { + throw new AccessDeniedGuestException('view ruleset types'); + } + + const rulesetType = await prisma.ruleset_Type.findUnique({ + where: { + rulesetTypeId, + organizationId, + deletedBy: null + }, + ...getRulesetTypeQueryArgs(carId) + }); + + 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 user the user requesting the rulesets + * @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( + user: User, + rulesetTypeId: string, + organizationId: string, + carId?: string + ): Promise { + if (!(await userHasPermission(user.userId, organizationId, notGuest))) { + throw new AccessDeniedGuestException('view rulesets'); + } + + const rulesets = await prisma.ruleset.findMany({ + where: { + rulesetTypeId, + deletedByUserId: null, + rulesetType: { + organizationId + }, + ...(carId && { carId }) + }, + orderBy: { + dateCreated: 'desc' + }, + ...getRulesetQueryArgs() + }); + + return rulesets.map(rulesetTransformer); + } + + /** + * Sets a rule's general-view status. This status is independent of any project. + * It is unaffected by the status of the rule in any project it's assigned to. + * Only leadership and above can update the general-view status. + * @param submitter the user updating the status + * @param organization the organization of the rule + * @param ruleId the id of the rule to update + * @param status the new status of the rule + * @returns the updated rule, plus every ancestor whose status was recalculated + */ + static async setRuleStatus( + submitter: User, + organization: Organization, + ruleId: string, + status: RuleStatus + ): Promise { + if (!(await userHasPermission(submitter.userId, organization.organizationId, isLeadership))) { + throw new AccessDeniedException('You do not have permissions to update rule status'); + } + + const rule = await prisma.rule.findUnique({ + where: { ruleId }, + include: { ruleset: { include: { car: { include: { wbsElement: true } } } } } + }); + + if (!rule) { + throw new NotFoundException('Rule', ruleId); + } + + if (rule.dateDeleted) { + throw new DeletedException('Rule', ruleId); + } + + if (rule.ruleset.car.wbsElement.organizationId !== organization.organizationId) { + throw new InvalidOrganizationException('Rule'); + } + + const childRuleCount = await prisma.rule.count({ + where: { parentRuleId: ruleId, dateDeleted: null } + }); + + if (childRuleCount > 0) { + throw new HttpException(400, 'Only child rule statuses can be updated directly.'); + } + + // only PASS/FAIL are tracked in history; PENDING does not create an entry + if (status !== RuleStatus.PENDING) { + await prisma.rule_Status_History.create({ + data: { ruleId, status, updatedByUserId: submitter.userId } + }); + } + + const updatedRule = await prisma.rule.update({ + where: { ruleId }, + data: + status === RuleStatus.PENDING + ? { status, statusUpdatedByUserId: null, statusUpdatedAt: null } + : { status, statusUpdatedByUserId: submitter.userId, statusUpdatedAt: new Date() }, + ...getRulePreviewQueryArgs() + }); + + // updating a rules status may update the status of its parent chain, so recalculate the parent chain's statuses + const ancestors = await RulesService.recalculateRuleStatusChain(rule.parentRuleId); + + return { rule: ruleTransformer(updatedRule), ancestors }; + } + + /** + * Sets a rule's status within a single project. This status is local to that project. + * It does not affect the rule's general-view status, or its status in any other project. + * Leadership and above can update it anywhere; members can only update it for a project whose team they're on. + * @param submitter the user updating the status + * @param organization the organization of the project rule + * @param projectRuleId the id of the project rule to update + * @param status the new status of the rule in this project + * @returns the project rule with updated status + */ + static async setProjectRuleStatus( + submitter: User, + organization: Organization, + projectRuleId: string, + status: RuleStatus + ): Promise { + const projectRule = await prisma.project_Rule.findUnique({ + where: { projectRuleId }, + include: { + project: { include: { wbsElement: true, teams: getTeamPreviewQueryArgs(organization.organizationId) } }, + rule: { include: { ruleset: { include: { car: { include: { wbsElement: true } } } } } } + } + }); + + if (!projectRule) { + throw new NotFoundException('Project Rule', projectRuleId); + } + + if (projectRule.dateDeleted) { + throw new DeletedException('Project Rule', projectRuleId); + } + + if ( + projectRule.project.wbsElement.organizationId !== organization.organizationId || + projectRule.rule.ruleset.car.wbsElement.organizationId !== organization.organizationId + ) { + throw new InvalidOrganizationException('Project Rule'); + } + + const hasOrgWidePermission = await userHasPermission(submitter.userId, organization.organizationId, isLeadership); + if (!hasOrgWidePermission && !isUserPartOfTeams(projectRule.project.teams, submitter)) { + throw new AccessDeniedException('You do not have permissions to update rule status for this project'); + } + + const childProjectRuleCount = await prisma.project_Rule.count({ + where: { + projectId: projectRule.projectId, + dateDeleted: null, + rule: { parentRuleId: projectRule.ruleId, dateDeleted: null } + } + }); + + if (childProjectRuleCount > 0) { + throw new HttpException(400, 'Only child rule statuses can be updated directly.'); + } + + // only PASS/FAIL are tracked in history; PENDING does not create an entry + if (status !== RuleStatus.PENDING) { + await prisma.rule_Status_History.create({ + data: { ruleId: projectRule.ruleId, projectRuleId, status, updatedByUserId: submitter.userId } + }); + } + + const updatedProjectRule = await prisma.project_Rule.update({ + where: { projectRuleId }, + data: + status === RuleStatus.PENDING + ? { status, statusUpdatedByUserId: null, statusUpdatedAt: null } + : { status, statusUpdatedByUserId: submitter.userId, statusUpdatedAt: new Date() }, + ...getProjectRuleQueryArgs() + }); + + // updating a project rule's status may update the status of its parent chain, so recalculate the parent chain's statuses + await RulesService.recalculateProjectRuleStatusChain(projectRule.projectId, projectRule.rule.parentRuleId); + + return projectRuleTransformer(updatedProjectRule); + } + + /** + * Resets every rule's general-view status back to Pending, for a whole ruleset. + * Does not affect any rule's status within a project. Never creates history entries, + * since reverting to PENDING is not tracked. Only heads and above can do this. + * @param submitter the user resetting the statuses + * @param organization the organization of the ruleset + * @param rulesetId the id of the ruleset to reset + * @returns the number of rules that were reset + */ + static async resetRulesetStatuses(submitter: User, organization: Organization, rulesetId: string): Promise { + if (!(await userHasPermission(submitter.userId, organization.organizationId, isHead))) { + throw new AccessDeniedException('You do not have permissions to reset rule status'); + } + + 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 result = await prisma.rule.updateMany({ + where: { rulesetId, dateDeleted: null, status: { not: RuleStatus.PENDING } }, + data: { status: RuleStatus.PENDING, statusUpdatedByUserId: null, statusUpdatedAt: null } + }); + + return result.count; + } + + /** + * Resets every project rule's status back to Pending, for a single project, scoped to a + * single ruleset (a project can have rules from multiple ruleset types). Does not affect + * any rule's general-view status, or its status in any other project. Never creates history + * entries, since reverting to PENDING is not tracked. Allowed for leadership and up. + * @param submitter the user resetting the statuses + * @param organization the organization of the project and ruleset + * @param rulesetId the ruleset to scope the reset to + * @param projectId the project whose rules should be reset + * @returns the number of project rules that were reset + */ + static async resetProjectRuleStatuses( + submitter: User, + organization: Organization, + rulesetId: string, + projectId: string + ): Promise { + if (!(await userHasPermission(submitter.userId, organization.organizationId, isLeadership))) { + throw new AccessDeniedException('You do not have permissions to reset project rule status'); + } + + 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 result = await prisma.project_Rule.updateMany({ + where: { + projectId, + dateDeleted: null, + rule: { rulesetId, dateDeleted: null }, + status: { not: RuleStatus.PENDING } + }, + data: { status: RuleStatus.PENDING, statusUpdatedByUserId: null, statusUpdatedAt: null } + }); + + return result.count; + } + + /** + * Gets the full status history for a rule, every time status was marked PASS or FAIL. + * Reverting to PENDING does not create history. + * @param user a user who is requesting the status history + * @param organization the organization of the rule + * @param ruleId the id of the rule to get history for + * @param projectRuleId if provided, scopes the history to just this project rule instead of every context the rule appears in + * @returns the rule's status history, most recent first + */ + static async getRuleStatusHistory( + user: User, + organization: Organization, + ruleId: string, + projectRuleId?: string + ): Promise { + if (!(await userHasPermission(user.userId, organization.organizationId, notGuest))) { + throw new AccessDeniedGuestException('view rule status history'); + } + + const rule = await prisma.rule.findUnique({ + where: { ruleId }, + include: { ruleset: { include: { car: { include: { wbsElement: true } } } } } + }); + + if (!rule) { + throw new NotFoundException('Rule', ruleId); + } + + if (rule.ruleset.car.wbsElement.organizationId !== organization.organizationId) { + throw new InvalidOrganizationException('Rule'); + } + + const history = await prisma.rule_Status_History.findMany({ + where: { ruleId, ...(projectRuleId && { projectRuleId }) }, + orderBy: { dateCreated: 'desc' }, + ...getRuleStatusHistoryQueryArgs() + }); + + return history.map(ruleStatusHistoryTransformer); + } + + /** + * 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 leadership and above + if (!(await userHasPermission(user.userId, org.organizationId, isLeadership))) { + throw new AccessDeniedException('Only leadership and above can assign rules to teams'); + } + + // 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 { + // teams the rule still belongs to once this team assignment is removed + const remainingTeamIds = rule.teams + .filter((currTeam) => currTeam.teamId !== teamId) + .map((currTeam) => currTeam.teamId); + + // Disconnect the team and soft delete its project rules, ensure we never leave + // the rule unassigned from the team while its project rules stay active + await prisma.$transaction(async (tx) => { + await tx.rule.update({ + where: { ruleId: rule.ruleId }, + data: { + teams: { + disconnect: { + teamId + } + } + } + }); + // Since a project can belong to multiple teams, only soft delete the project rule + // when the project shares no remaining team with the rule. + await tx.project_Rule.updateMany({ + where: { + ruleId: rule.ruleId, + dateDeleted: null, + project: { + teams: { + some: { teamId }, // project has the team where the assignment is being removed + none: { teamId: { in: remainingTeamIds } } // project has no remaining teams that the rule is still assigned to + } + } + }, + data: { + dateDeleted: new Date(), + deletedByUserId: user.userId + } + }); + }); + } + // 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 + } + } + }); + + 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 }, + ...getRulesetTypeQueryArgs() + }); + + 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 }, + ...getRulesetTypeQueryArgs() + }); + + if (!deletedRule) { + throw new NotFoundException('Ruleset Type', id); + } + + return rulesetTypeTransformer(deletedRule); + } + + /** + * Deletes a project rule and its associated rule status changes. + * Walks up the rule tree finding the ancestors that would be left with no children in this project + * once the given rules are unassigned from it. Shared logic for deleteProjectRule and deleteRule. + * + * @param projectId the project being pruned + * @param startParentRuleId the parent of the rule being unassigned + * @param removedRuleIds rules the caller is about to unassign, which must not count as remaining + * children even though they are still assigned in the database + * @returns the project rules to unassign, and the first ancestor that survives the prune + */ + private static async findChildlessAncestorProjectRules( + projectId: string, + startParentRuleId: string | null, + removedRuleIds: string[] + ): Promise<{ projectRuleIds: string[]; survivingAncestorRuleId: string | null }> { + const projectRuleIds: string[] = []; + const removed = new Set(removedRuleIds); + let currentParentRuleId = startParentRuleId; + + while (currentParentRuleId) { + // If the ancestor still has children in this project, it and everything above it survive + const children = await prisma.project_Rule.findMany({ + where: { projectId, dateDeleted: null, rule: { parentRuleId: currentParentRuleId, dateDeleted: null } }, + select: { ruleId: true } + }); + if (children.some((child) => !removed.has(child.ruleId))) break; + + // If the ancestor isn't assigned to this project, there is nothing to remove + const parentProjectRule = await prisma.project_Rule.findUnique({ + where: { ruleId_projectId: { ruleId: currentParentRuleId, projectId } }, + include: { rule: true } + }); + if (!parentProjectRule || parentProjectRule.dateDeleted) break; + + projectRuleIds.push(parentProjectRule.projectRuleId); + // this ancestor is being removed too, so it cannot hold up its own parent + removed.add(currentParentRuleId); + currentParentRuleId = parentProjectRule.rule.parentRuleId; + } + + return { projectRuleIds, survivingAncestorRuleId: currentParentRuleId }; + } + + /** + * Deletes a project rule, and any ancestors it leaves with no children in that project. + * + * @param projectRuleId The ID of the project rule to delete + * @param deleter The user deleting the project rule + * @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 AccessDeniedException('You do not have permissions to 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 { projectId } = projectRule; + + const childProjectRule = await prisma.project_Rule.findFirst({ + where: { projectId, dateDeleted: null, rule: { parentRuleId: projectRule.rule.ruleId, dateDeleted: null } } + }); + if (childProjectRule) { + throw new HttpException(400, 'Cannot delete a project rule that has children assigned to this project'); + } + + // The ancestors this removal will orphan, and the first ancestor that survives it + const { projectRuleIds: orphanedAncestorIds, survivingAncestorRuleId } = + await RulesService.findChildlessAncestorProjectRules(projectId, projectRule.rule.parentRuleId, [ + projectRule.rule.ruleId + ]); + + // project can never be left holding an ancestor with nothing under it + const [deletedProjectRule] = await prisma.$transaction([ + prisma.project_Rule.update({ + where: { projectRuleId }, + data: { + dateDeleted: new Date(), + deletedByUserId: deleter.userId + }, + ...getProjectRuleQueryArgs() + }), + prisma.project_Rule.updateMany({ + where: { projectRuleId: { in: orphanedAncestorIds } }, + data: { dateDeleted: new Date(), deletedByUserId: deleter.userId } + }) + ]); + + // the deleted project rule, and any ancestors removed along with it, + // no longer count towards the surviving ancestors' status calculations + await RulesService.recalculateProjectRuleStatusChain(projectId, survivingAncestorRuleId); + + 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, + carId: rulesetExists.carId, + rulesetType: { + rulesetTypeId: rulesetExists.rulesetTypeId, + organizationId + }, + deletedByUserId: null + } + }); + + if (activeRuleset) { + throw new HttpException(400, 'There is already an active ruleset for this ruleset type and car'); + } + } + 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 user the user requesting the child rules + * @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(user: User, ruleId: string, organization: Organization): Promise { + if (!(await userHasPermission(user.userId, organization.organizationId, notGuest))) { + throw new AccessDeniedGuestException('view rules'); + } + + // 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 + }, + orderBy: { ruleCode: 'asc' }, + ...getRulePreviewQueryArgs() + }); + + return subRules.map((rule) => ruleTransformer(rule)); + } + + /** + * Gets rules assignable to a project that are not already assigned to it. + * A project can belong to multiple teams, so rules from all of its teams are shown. + * @param user the user requesting the unassigned rules + * @param rulesetId ruleset the rules are in + * @param projectId the project the rules would be assigned to + * @param organizationId the organization id + * @returns the rules on one of the project's teams that are not already actively assigned to this project + */ + static async getUnassignedRulesForProjectRuleset( + user: User, + rulesetId: string, + projectId: string, + organizationId: string + ) { + if (!(await userHasPermission(user.userId, organizationId, notGuest))) { + throw new AccessDeniedGuestException('view unassigned rules'); + } + + 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 project = await prisma.project.findUnique({ + where: { projectId }, + select: { + wbsElement: { select: { organizationId: true } }, + teams: { select: { teamId: true } } + } + }); + + if (!project) { + throw new NotFoundException('Project', projectId); + } + + if (project.wbsElement.organizationId !== organizationId) { + throw new InvalidOrganizationException('Project'); + } + + const projectTeamIds = project.teams.map((team) => team.teamId); + + const rules = await prisma.rule.findMany({ + where: { + rulesetId, + // rule is on at least one of the project's teams + teams: { + some: { + teamId: { in: projectTeamIds }, + organizationId + } + }, + // only hide it from a project that already has it actively assigned + projects: { + none: { projectId, dateDeleted: null } + }, + deletedByUserId: null + }, + ...getRulePreviewQueryArgs(), + orderBy: { + ruleCode: 'asc' + } + }); + return rules.map(ruleTransformer); + } + + /** + * Gets all rules associated with a specific project and ruleset + * @param user the user requesting the project rules + * @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( + user: User, + rulesetId: string, + projectId: string, + organization: Organization + ): Promise { + if (!(await userHasPermission(user.userId, organization.organizationId, notGuest))) { + throw new AccessDeniedGuestException('view project rules'); + } + + 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 + }, + orderBy: { rule: { ruleCode: 'asc' } }, + ...getProjectRuleQueryArgs() + }); + + return projectRules.map(projectRuleTransformer); + } + + /** + * Gets all rules with no parent id + * @param user the user requesting the top-level rules + * @param rulesetId id of ruleset + * @returns an array of rules with no parent Id + */ + static async getTopLevelRules(user: User, rulesetId: string, organizationId: string) { + if (!(await userHasPermission(user.userId, organizationId, notGuest))) { + throw new AccessDeniedGuestException('view rules'); + } + + 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 + }, + orderBy: { ruleCode: 'asc' }, + ...getRulePreviewQueryArgs() + }); + + return rules.map((rule) => ruleTransformer(rule)); + } + + /** + * Gets every rule in a ruleset in a single query instead of walking it level by level. + * @param user the user requesting the rules + * @param rulesetId id of ruleset + * @param organizationId the organization the ruleset belongs to + * @returns a flat array of every rule in the ruleset + */ + static async getAllRulesForRuleset(user: User, rulesetId: string, organizationId: string): Promise { + if (!(await userHasPermission(user.userId, organizationId, notGuest))) { + throw new AccessDeniedGuestException('view rules'); + } + + 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 + }, + orderBy: { ruleCode: 'asc' }, + ...getRulePreviewQueryArgs() + }); + + return rules.map((rule) => ruleTransformer(rule)); + } + + /** + * 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) + * @param firstRulePage 1-indexed page rules start on; pages before this are skipped instead of parsed + * @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', + firstRulePage?: number + ): 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, firstRulePage); + 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/services/slack.services.ts b/src/backend/src/services/slack.services.ts index bec6e9dfb1..7a41e759a1 100644 --- a/src/backend/src/services/slack.services.ts +++ b/src/backend/src/services/slack.services.ts @@ -1,9 +1,11 @@ -import { getChannelName, getUserName } from '../integrations/slack.js'; +import { Organization } from '@prisma/client'; +import { getChannelInfo, getChannelName, getUserName, getWorkspaceId, postMessageToChannel } from '../integrations/slack.js'; import AnnouncementService from './announcement.services.js'; import { Announcement, ReimbursementStatusType } from 'shared'; import prisma from '../prisma/prisma.js'; import { blockToMentionedUsers, blockToString } from '../utils/slack.utils.js'; import { + AccessDeniedAdminOnlyException, AccessDeniedException, HttpException, InvalidOrganizationException, @@ -14,7 +16,8 @@ import ChangeRequestsService from './change-requests.services.js'; import TeamsService from './teams.services.js'; import { userTransformer } from '../transformers/user.transformer.js'; import { getUserQueryArgs } from '../prisma-query-args/user.query-args.js'; -import { User } from 'shared'; +import { isAdmin, User } from 'shared'; +import { userHasPermission } from '../utils/users.utils.js'; /** * Represents a slack event for a message in a channel. @@ -411,6 +414,8 @@ export default class SlackServices { case 'message_deleted': //delete the message using the client_msg_id eventMessage = (event as SlackDeletedMessage).previous_message; + // bot and API posted messages have no client_msg_id, so they were never stored as announcements + if (!eventMessage.client_msg_id) return; return AnnouncementService.deleteAnnouncement(eventMessage.client_msg_id, organizationId); case 'message_changed': eventMessage = (event as SlackUpdatedMessage).message; @@ -490,4 +495,47 @@ export default class SlackServices { organizationId ); } + + /** + * Sends a plain text message from the bot to a slack channel on behalf of an admin. + * Slack's control characters are escaped so the message is posted as written, meaning it + * cannot ping @channel/@here, mention users, or embed formatted links. + * + * @param userId the id of the user sending the message + * @param organization the organization the user is sending the message from + * @param channelId the id of the slack channel to send the message to + * @param message the text of the message + * @returns the id and name of the channel and the timestamp of the sent message + * @throws if the user is not an admin, the organization is not linked to the bot's workspace, or the channel is missing, archived, or does not contain the bot + */ + static async sendMessageToChannel( + userId: string, + organization: Organization, + channelId: string, + message: string + ): Promise<{ channelId: string; channelName: string; ts: string }> { + if (!(await userHasPermission(userId, organization.organizationId, isAdmin))) { + throw new AccessDeniedAdminOnlyException('send slack messages'); + } + + // the bot lives in a single workspace, so only the organization linked to it may post there + if (!organization.slackWorkspaceId || organization.slackWorkspaceId !== (await getWorkspaceId())) { + throw new AccessDeniedException("this organization is not linked to the bot's slack workspace"); + } + + const channel = await getChannelInfo(channelId); + if (!channel) throw new NotFoundException('Slack Channel', channelId); + if (channel.isArchived) throw new HttpException(400, `Slack channel #${channel.name} is archived`); + if (!channel.isMember) { + throw new HttpException( + 400, + `The FinishLine bot is not a member of #${channel.name}, add it to the channel before sending messages there` + ); + } + + const escapedMessage = message.replace(/&/g, '&').replace(//g, '>'); + const { ts } = await postMessageToChannel(channelId, escapedMessage); + + return { channelId, channelName: channel.name, ts }; + } } diff --git a/src/backend/src/services/statistics.services.ts b/src/backend/src/services/statistics.services.ts index 5c3db7c109..864ab3ce71 100644 --- a/src/backend/src/services/statistics.services.ts +++ b/src/backend/src/services/statistics.services.ts @@ -9,7 +9,16 @@ import { } from '../prisma-query-args/statistics.query-args.js'; import { userHasPermissionNew } from '../utils/users.utils.js'; import { AccessDeniedException, HttpException } from '../utils/errors.utils.js'; -import { Graph, GraphCollection, GraphData, isSubset, isUnderWordCount, Permission, User } from 'shared'; +import { + Graph, + GraphCollection, + GraphData, + isAttendanceGraphType, + isSubset, + isUnderWordCount, + Permission, + User +} from 'shared'; import { getGraphCollectionAndVerifyPermissions, getGraphData } from '../utils/statistics.utils.js'; import { graphCollectionTransformer } from '../transformers/statistics-graph-collection.transformer.js'; @@ -47,6 +56,12 @@ export default class StatisticsService { throw new AccessDeniedException('You do not have permission to create a graph'); } + // attendance graphs are always averaged per session and aren't segmented by car + if (isAttendanceGraphType(graphType)) { + measure = Measure.AVG; + carIds = []; + } + if (startDate && endDate) { if (startDate.getTime() >= endDate.getTime()) { throw new HttpException(400, 'End date must be after start date'); @@ -155,6 +170,12 @@ export default class StatisticsService { throw new AccessDeniedException('You do not have permission to edit a graph'); } + // attendance graphs are always averaged per session and aren't segmented by car + if (isAttendanceGraphType(graphType)) { + measure = Measure.AVG; + carIds = []; + } + const graph = await prisma.graph.findUnique({ where: { id: graphId diff --git a/src/backend/src/services/users.services.ts b/src/backend/src/services/users.services.ts index 3faaf6509d..334756962f 100644 --- a/src/backend/src/services/users.services.ts +++ b/src/backend/src/services/users.services.ts @@ -269,7 +269,7 @@ export default class UsersService { // if not in database, create user in database if (!user) { const emailId = payload['email']!.includes('@husky.neu.edu') ? payload['email']!.split('@')[0] : null; - const organization = await prisma.organization.findFirst(); + const organization = await prisma.organization.findFirst({ where: { dateDeleted: null } }); const firstName = payload['given_name'] ?? payload['email']!.split('@')[0]; // Defaults to id of email const lastName = payload['family_name'] ?? ''; // Defaults to no last name @@ -278,41 +278,39 @@ export default class UsersService { ? payload['email'].replace(/@husky\.neu\.edu/i, '@northeastern.edu') : payload['email']; - const createdUser = await prisma.user.create({ - data: { - firstName, - lastName, - googleAuthId: userId, - email: nonHuskyEmail, - emailId, - userSettings: { create: {} } - }, - include: { - organizations: true, - userSettings: true - } - }); - user = createdUser; - - if (organization) { - await prisma.organization.update({ - where: { organizationId: organization.organizationId }, - data: { - users: { - connect: { - userId: createdUser.userId - } - } - } - }); - await prisma.role.create({ + // the organization is connected as part of the create rather than in a follow up update, so + // that the user we return already lists it. Connecting afterwards leaves this object stale + // with an empty organizations array, and the client then has no organization to send back to + // us on its next request + user = await prisma.$transaction(async (tx) => { + const createdUser = await tx.user.create({ data: { - userId: createdUser.userId, - organizationId: organization!.organizationId, - roleType: RoleEnum.GUEST + firstName, + lastName, + googleAuthId: userId, + email: nonHuskyEmail, + emailId, + userSettings: { create: {} }, + ...(organization && { organizations: { connect: { organizationId: organization.organizationId } } }) + }, + include: { + organizations: true, + userSettings: true } }); - } + + if (organization) { + await tx.role.create({ + data: { + userId: createdUser.userId, + organizationId: organization.organizationId, + roleType: RoleEnum.GUEST + } + }); + } + + return createdUser; + }); } // register a login diff --git a/src/backend/src/transformers/attendance.transformer.ts b/src/backend/src/transformers/attendance.transformer.ts index c9ca5edbcc..dbd8671133 100644 --- a/src/backend/src/transformers/attendance.transformer.ts +++ b/src/backend/src/transformers/attendance.transformer.ts @@ -5,18 +5,11 @@ import { MeetingAttendanceWithAttendeesQueryArgs } from '../prisma-query-args/attendance.query-args.js'; import { userTransformer } from './user.transformer.js'; +import { calculateTeamMemberAttendancePercent } from '../utils/attendance.utils.js'; export const meetingAttendanceTransformer = ( attendance: Prisma.Meeting_AttendanceGetPayload ): MeetingAttendance => { - const teamMemberIds = new Set([ - ...attendance.team.members.map((m) => m.userId), - ...attendance.team.leads.map((l) => l.userId), - attendance.team.headId - ]); - const attendeeIds = new Set(attendance.attendees.map((a) => a.userId)); - const teamMemberAttendees = [...teamMemberIds].filter((id) => attendeeIds.has(id)).length; - return { meetingAttendanceId: attendance.meetingAttendanceId, teamId: attendance.teamId, @@ -25,21 +18,13 @@ export const meetingAttendanceTransformer = ( openedAt: attendance.openedAt, closedAt: attendance.closedAt ?? undefined, attendeesCount: attendance.attendees.length, - teamMemberAttendancePercent: teamMemberIds.size > 0 ? (teamMemberAttendees / teamMemberIds.size) * 100 : 0 + teamMemberAttendancePercent: calculateTeamMemberAttendancePercent(attendance.team, attendance.attendees) }; }; export const meetingAttendanceWithAttendeesTransformer = ( attendance: Prisma.Meeting_AttendanceGetPayload ): MeetingAttendanceWithAttendees => { - const teamMemberIds = new Set([ - ...attendance.team.members.map((m) => m.userId), - ...attendance.team.leads.map((l) => l.userId), - attendance.team.headId - ]); - const attendeeIds = new Set(attendance.attendees.map((a) => a.userId)); - const teamMemberAttendees = [...teamMemberIds].filter((id) => attendeeIds.has(id)).length; - return { meetingAttendanceId: attendance.meetingAttendanceId, teamId: attendance.teamId, @@ -48,7 +33,7 @@ export const meetingAttendanceWithAttendeesTransformer = ( openedAt: attendance.openedAt, closedAt: attendance.closedAt ?? undefined, attendeesCount: attendance.attendees.length, - teamMemberAttendancePercent: teamMemberIds.size > 0 ? (teamMemberAttendees / teamMemberIds.size) * 100 : 0, + teamMemberAttendancePercent: calculateTeamMemberAttendancePercent(attendance.team, attendance.attendees), attendees: attendance.attendees.map(userTransformer) }; }; diff --git a/src/backend/src/transformers/rules.transformer.ts b/src/backend/src/transformers/rules.transformer.ts new file mode 100644 index 0000000000..9d3f0a2fbe --- /dev/null +++ b/src/backend/src/transformers/rules.transformer.ts @@ -0,0 +1,117 @@ +import { Prisma } from '@prisma/client'; +import { Rule, ProjectRule, Ruleset, RulesetType, RuleStatus, RuleStatusHistoryEntry } from 'shared'; +import { + RulesetQueryArgs, + RulePreviewQueryArgs, + ProjectRuleQueryArgs, + RuleStatusHistoryQueryArgs +} from '../prisma-query-args/rules.query-args.js'; + +export const ruleTransformer = (rule: Prisma.RuleGetPayload): Rule => { + return { + ruleId: rule.ruleId, + ruleCode: rule.ruleCode, + ruleContent: rule.ruleContent, + imageFileIds: rule.imageFileIds, + parentRule: rule.parentRule + ? { + ruleId: rule.parentRule.ruleId, + ruleCode: rule.parentRule.ruleCode + } + : undefined, + subRuleIds: rule.subRules.map((subRule) => subRule.ruleId), + referencedRules: rule.referencedRule.map((ref) => ({ ruleId: ref.ruleId, ruleCode: ref.ruleCode })), + teams: rule.teams?.map((team) => ({ + teamId: team.teamId, + teamName: team.teamName + })), + projects: rule.projects?.map((projectRule) => ({ + projectId: projectRule.project.projectId, + projectName: projectRule.project.wbsElement.name, + teamIds: projectRule.project.teams.map((team) => team.teamId), + projectRuleId: projectRule.projectRuleId, + status: projectRule.status as RuleStatus, + statusUpdatedBy: projectRule.statusUpdatedBy + ? { + firstName: projectRule.statusUpdatedBy.firstName, + lastName: projectRule.statusUpdatedBy.lastName + } + : undefined, + statusUpdatedAt: projectRule.statusUpdatedAt ?? undefined, + hasStatusHistory: projectRule._count.statusHistory > 0 + })), + status: rule.status as RuleStatus, + statusUpdatedBy: rule.statusUpdatedBy + ? { + firstName: rule.statusUpdatedBy.firstName, + lastName: rule.statusUpdatedBy.lastName + } + : undefined, + statusUpdatedAt: rule.statusUpdatedAt ?? undefined, + hasStatusHistory: rule._count.statusHistory > 0 + }; +}; + +export const projectRuleTransformer = (projectRule: Prisma.Project_RuleGetPayload): ProjectRule => { + return { + projectRuleId: projectRule.projectRuleId, + rule: ruleTransformer(projectRule.rule), + projectId: projectRule.projectId, + status: projectRule.status as RuleStatus, + statusUpdatedBy: projectRule.statusUpdatedBy + ? { + firstName: projectRule.statusUpdatedBy.firstName, + lastName: projectRule.statusUpdatedBy.lastName + } + : undefined, + statusUpdatedAt: projectRule.statusUpdatedAt ?? undefined, + hasStatusHistory: projectRule._count.statusHistory > 0 + }; +}; + +export const ruleStatusHistoryTransformer = ( + entry: Prisma.Rule_Status_HistoryGetPayload +): RuleStatusHistoryEntry => { + return { + status: entry.status as RuleStatus, + updatedBy: { + firstName: entry.updatedBy.firstName, + lastName: entry.updatedBy.lastName + }, + updatedAt: entry.dateCreated, + projectName: entry.projectRule?.project.wbsElement.name + }; +}; + +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, + createdByUserId: ruleset.createdByUserId, + 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/attendance.utils.ts b/src/backend/src/utils/attendance.utils.ts new file mode 100644 index 0000000000..53c4579054 --- /dev/null +++ b/src/backend/src/utils/attendance.utils.ts @@ -0,0 +1,24 @@ +/* + * This file is part of NER's FinishLine and licensed under GNU AGPLv3. + * See the LICENSE file in the repository root folder for details. + */ + +interface TeamRoster { + headId: string; + members: { userId: string }[]; + leads: { userId: string }[]; +} + +/** + * Calculates what percentage of a team's roster (members, leads, and head) attended a meeting. + * + * @param team the team whose roster defines the denominator + * @param attendees the users who attended the meeting + * @returns the attendance percentage (0 - 100), or 0 if the team has no roster + */ +export const calculateTeamMemberAttendancePercent = (team: TeamRoster, attendees: { userId: string }[]): number => { + const teamMemberIds = new Set([...team.members.map((m) => m.userId), ...team.leads.map((l) => l.userId), team.headId]); + const attendeeIds = new Set(attendees.map((a) => a.userId)); + const teamMemberAttendees = [...teamMemberIds].filter((id) => attendeeIds.has(id)).length; + return teamMemberIds.size > 0 ? (teamMemberAttendees / teamMemberIds.size) * 100 : 0; +}; diff --git a/src/backend/src/utils/errors.utils.ts b/src/backend/src/utils/errors.utils.ts index a83c036a1b..59f2a2de48 100644 --- a/src/backend/src/utils/errors.utils.ts +++ b/src/backend/src/utils/errors.utils.ts @@ -208,6 +208,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' @@ -220,4 +227,5 @@ export type ExceptionObjectNames = | 'Task Label' | 'Notification Channel' | 'Dashboard' - | 'Team Join Request'; + | 'Team Join Request' + | 'Slack Channel'; diff --git a/src/backend/src/utils/parse.utils.ts b/src/backend/src/utils/parse.utils.ts new file mode 100644 index 0000000000..2e3772e873 --- /dev/null +++ b/src/backend/src/utils/parse.utils.ts @@ -0,0 +1,426 @@ +import pdf from 'pdf-parse-new'; + +export interface ParsedRule { + ruleCode: string; + ruleContent: string; + parentRuleCode?: string; +} + +const defaultPageRender = pdf.DEFAULT_OPTIONS.pagerender!; + +/** + * Skip text extraction for pages before firstRulePage. + * Useful for skipping TOC and other beginning content. + * + * @param firstRulePage page number to start parsing rules from (1-indexed) + */ +export const makePageRenderer = (firstRulePage?: number) => { + if (!firstRulePage || firstRulePage <= 1) return defaultPageRender; + return (pageData: { pageNumber: number }) => { + if (pageData.pageNumber < firstRulePage) return ''; + return defaultPageRender(pageData); + }; +}; + +export const parseRulesFromPdf = async ( + buffer: Buffer, + parserType: 'FSAE' | 'FHE', + firstRulePage?: number +): Promise => { + const options = { + // max page number to parse, 0 = all pages + max: 0, + // errors: 0, warnings: 1, infos: 5 + verbosityLevel: 0 as const, + pagerender: makePageRenderer(firstRulePage) + }; + 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'`); +}; + +/** + * Checks whether a line is only a page number (e.g. "7" or "Page 7 of 143"). + * Safe to skip without risking dropping rule content. + * @param line line to check + */ +export const isPageNumberLine = (line: string): boolean => /^\d+$/.test(line); + +/** + * Removes "Page X of Y" wherever it occurs in a line + * @param line line to strip + * @returns the line with any "Page X of Y" occurrences removed and whitespace collapsed + */ +export const stripPageNumberPhrase = (line: string): string => + line + .replace(/Page\s+\d+\s+of\s+\d+/gi, '') + .replace(/\s+/g, ' ') + .trim(); + +// clean up whitespace and newlines in rule content +export const normalizeContent = (text: string): string => text.replace(/\s+/g, ' ').trim(); + +/** + * Extracts lettered sub-rules from rule content (a, b, c, etc.) when sub-rule starts on its own line. + * "EV.5.2 Main text\na. 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 + */ +export const extractSubRules = (ruleCode: string, content: string): ParsedRule[] => { + // "a." style marks a subrule only at a line start or after a colon + // "(a)" and "a)" styles mark a subrule if following any whitespace + const letterPattern = /(?:(?<=^|:\s)([a-z])\.|(?<=^|\s)\(([a-z])\)|(?<=^|\s)([a-z])\))\s+/gm; + const matches = [...content.matchAll(letterPattern)]; + + if (matches.length === 0) { + // no subrules found, return original rule + return [ + { + ruleCode, + ruleContent: normalizeContent(content), + parentRuleCode: findParentRuleCode(ruleCode) + } + ]; + } + const subRules: ParsedRule[] = []; + + // Extract the main rule content (everything before the first lettered item) + const firstMatchIndex = matches[0].index!; + const mainContent = normalizeContent(content.substring(0, firstMatchIndex)); + + // add main rule + subRules.push({ + ruleCode, + ruleContent: mainContent, + parentRuleCode: findParentRuleCode(ruleCode) + }); + + // Extract lettered sub-rules + for (let i = 0; i < matches.length; i++) { + const [, dotLetter, parenLetter, bareLetter] = matches[i]; + const letter = dotLetter ?? parenLetter ?? bareLetter; + 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 = normalizeContent(content.substring(startIndex, endIndex)); + 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 + */ +export 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 + */ +export 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; + }); +}; + +/** + * Shared line-by-line parsing loop used by FSAE and FHE parsers. + * Parsers differ in how rule code is recognized and logic for orphaned parent ref fixes. + * @param text full extracted PDF text to parse + * @param parseRuleNumber recognizes whether a line starts a new rule (FSAE- or FHE-specific) + * @param fixOrphanedRules re-parents rules whose inferred parent code doesn't exist (FSAE- or FHE-specific) + * @returns array of parsed rules + */ +const parseRules = ( + text: string, + parseRuleNumber: (line: string) => ParsedRule | null, + fixOrphanedRules: (rules: ParsedRule[]) => ParsedRule[] +): ParsedRule[] => { + const rules: ParsedRule[] = []; + const lines = text.split('\n'); + + let currentRule: { code: string; text: string } | null = null; + let unparsedText = ''; + let unparsedCount = 0; + + const saveCurrentRule = () => { + if (!currentRule) return; + const parsedRules = extractSubRules(currentRule.code, currentRule.text); + rules.push(...parsedRules); + }; + + // Text encountered with no rule open yet would otherwise disappear (e.g. before the first + // recognized rule) - keep it as its own top-level rule under an abstract code instead of dropping it. + const saveUnparsed = () => { + if (!unparsedText.trim()) return; + unparsedCount += 1; + rules.push({ + ruleCode: `UNPARSED.${unparsedCount}`, + ruleContent: unparsedText.trim(), + parentRuleCode: undefined + }); + unparsedText = ''; + }; + + for (const line of lines) { + const trimmedLine = line.trim(); + if (!trimmedLine) continue; + + // Remove "Page X of Y", then if nothing real remains or just a page number left, skip entire line + const cleanedLine = stripPageNumberPhrase(trimmedLine); + if (!cleanedLine || isPageNumberLine(cleanedLine)) continue; + + // Check if this line starts a new rule + const rule = parseRuleNumber(cleanedLine); + if (rule) { + saveCurrentRule(); + saveUnparsed(); + currentRule = { + code: rule.ruleCode, + text: rule.ruleContent + }; + } else if (currentRule) { + currentRule.text += '\n' + cleanedLine; // else append to existing rule + } else { + unparsedText += (unparsedText ? ' ' : '') + cleanedLine; + } + } + saveCurrentRule(); + saveUnparsed(); + + const fixedRules = fixOrphanedRules(rules); + return handleDuplicateCodes(fixedRules); +}; + +/**************** FSAE ****************/ + +export const parseFSAERules = (text: string): ParsedRule[] => parseRules(text, parseRuleNumberFSAE, fixOrphanedRulesFSAE); + +/** + * 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 + */ +export 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*(.+)$/; + // Match a rule code alone on its own line, with body text starting on the next line + const bareCodePattern = /^([A-Z]{1,4}(?:\.[\d]+)+)$/; + + const match = line.match(rulePattern) || line.match(sectionPattern); + if (match) { + const cleanContent = match[2].replace(/\.{5,}/g, '.....'); + return { + ruleCode: match[1], + ruleContent: cleanContent + }; + } + + const bareMatch = line.match(bareCodePattern); + if (bareMatch) { + return { + ruleCode: bareMatch[1], + ruleContent: '' + }; + } + + 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 + * @param rules array of parsed rules + * @returns rules with corrected parent references + */ +export 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 *****************/ + +export const parseFHERules = (text: string): ParsedRule[] => parseRules(text, parseRuleNumberFHE, fixOrphanedRulesFHE); + +/** + * 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 + */ +export 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+(.+)$/; + // Match FHE rule codes with no leading digit, like "EV5.6" (e.g. Electric Vehicle sections) + const plainLetterPattern = /^([A-Z]{1,4}\d+(?:\.\d+)*)\s+(.+)$/; + // Match a rule code alone on its own line, with body text starting on the next line + const bareCodePattern = /^(\d+[A-Z]+\d+(?:\.\d+)*|[A-Z]{1,4}\d+(?:\.\d+)*)$/; + + // "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) || line.match(plainLetterPattern); + if (match) { + return { + ruleCode: match[1], + ruleContent: match[2] + }; + } + + const bareMatch = line.match(bareCodePattern); + if (bareMatch) { + return { + ruleCode: bareMatch[1], + ruleContent: '' + }; + } + + 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 + */ +export 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/src/utils/slack.utils.ts b/src/backend/src/utils/slack.utils.ts index d0ca639677..44c0dfe710 100644 --- a/src/backend/src/utils/slack.utils.ts +++ b/src/backend/src/utils/slack.utils.ts @@ -321,9 +321,19 @@ export const sendSlackEventConfirmNotification = async ( ) => { const isProduction = process.env.NODE_ENV === 'production'; if (!isProduction && !DEV_TESTING_OVERRIDE) return; // don't send msgs unless in prod - const msg = remindUnconfirmed - ? `REMINDER: Please fill out your availability for ${eventName} in project ${projectName}!` - : `You have been invited to ${eventName} in project ${projectName}!`; + const projectNameNotEmpty = projectName && projectName.trim() !== ''; + + let msg = ''; + if (projectNameNotEmpty && remindUnconfirmed) { + msg = `REMINDER: Please fill out your availability for ${eventName} in project ${projectName}!`; + } else if (remindUnconfirmed) { + msg = `REMINDER: Please fill out your availability for ${eventName}!`; + } else if (projectNameNotEmpty) { + msg = `You have been invited to ${eventName} in project ${projectName}!`; + } else { + msg = `You have been invited to ${eventName}!`; + } + const fullLink = isProduction ? `https://finishlinebyner.com/calendar/event/${eventId}` : `http://localhost:3000/calendar/event/${eventId}`; @@ -455,12 +465,17 @@ export const sendSlackEventNotifications = async ( const notifications: { channelId: string; ts: string }[] = []; const mentionPrefix = buildSlackMentionPrefix(options.mention ?? SlackMentionType.USER, options.memberSlackIds ?? []); + const projectNameNotEmpty = projectName && projectName.trim() !== ''; let message; - if (workPackageName) { + if (workPackageName && projectNameNotEmpty) { message = `${mentionPrefix}:spiral_calendar_pad: ${event.title} for *${workPackageName}* is being scheduled by ${submitter.firstName} ${submitter.lastName} in project ${projectName}`; - } else { + } else if (workPackageName) { + message = `${mentionPrefix}:spiral_calendar_pad: ${event.title} for *${workPackageName}* is being scheduled by ${submitter.firstName} ${submitter.lastName}`; + } else if (projectNameNotEmpty) { message = `${mentionPrefix}:spiral_calendar_pad: ${event.title} is being scheduled by ${submitter.firstName} ${submitter.lastName} in project ${projectName}`; + } else { + message = `${mentionPrefix}:spiral_calendar_pad: ${event.title} is being scheduled by ${submitter.firstName} ${submitter.lastName}`; } const completion: Promise[] = teams.map(async (team) => { diff --git a/src/backend/src/utils/statistics.utils.ts b/src/backend/src/utils/statistics.utils.ts index 92ab34332d..edb1093bcd 100644 --- a/src/backend/src/utils/statistics.utils.ts +++ b/src/backend/src/utils/statistics.utils.ts @@ -4,6 +4,7 @@ import prisma from '../prisma/prisma.js'; import { getGraphCollectionQueryArgs } from '../prisma-query-args/statistics.query-args.js'; import { AccessDeniedException, DeletedException, InvalidOrganizationException, NotFoundException } from './errors.utils.js'; import { userHasPermissionNew } from './users.utils.js'; +import { calculateTeamMemberAttendancePercent } from './attendance.utils.js'; interface CarSegmentedData { carIds: string[]; @@ -698,6 +699,122 @@ const getGraphDataForChangeRequestByStatus = async ( return data; }; +const getMeetingAttendanceDateWhereInput = ( + startDate: Date | null, + endDate: Date | null +): { closedAt: { not: null }; openedAt?: { gte?: Date; lte?: Date } } => { + const where: { closedAt: { not: null }; openedAt?: { gte?: Date; lte?: Date } } = { + closedAt: { not: null } + }; + + if (startDate) { + where.openedAt = { gte: startDate }; + } + + if (endDate) { + where.openedAt = { ...where.openedAt, lte: endDate }; + } + + return where; +}; + +const getGraphDataForAttendanceByTeam = async ( + _measure: Measure, + organizationId: string, + startDate: Date | null, + endDate: Date | null, + _params: { carIds: string[] } +): Promise => { + const teams = await prisma.team.findMany({ + where: { organizationId, dateArchived: null }, + include: { + members: { select: { userId: true } }, + leads: { select: { userId: true } }, + meetingAttendances: { + where: getMeetingAttendanceDateWhereInput(startDate, endDate), + include: { attendees: { select: { userId: true } } } + } + } + }); + + const data: GraphData = { + tipLabel: '% Attendance', + // teams with no sessions in range are excluded so they aren't confused with 0% attendance + values: teams.flatMap((team) => { + const numSessions = team.meetingAttendances.length; + + if (numSessions === 0) return []; + + const totalPercent = team.meetingAttendances.reduce((prev, session) => { + return prev + calculateTeamMemberAttendancePercent(team, session.attendees); + }, 0); + + return [ + { + value: totalPercent / numSessions, + label: team.teamName + } + ]; + }) + }; + + return data; +}; + +const getGraphDataForAttendanceByDivision = async ( + _measure: Measure, + organizationId: string, + startDate: Date | null, + endDate: Date | null, + _params: { carIds: string[] } +): Promise => { + const divisions = await prisma.team_Type.findMany({ + where: { organizationId, dateDeleted: null }, + include: { + teams: { + where: { organizationId, dateArchived: null }, + include: { + members: { select: { userId: true } }, + leads: { select: { userId: true } }, + meetingAttendances: { + where: getMeetingAttendanceDateWhereInput(startDate, endDate), + include: { attendees: { select: { userId: true } } } + } + } + } + } + }); + + const data: GraphData = { + tipLabel: '% Attendance', + // divisions with no sessions in range are excluded so they aren't confused with 0% attendance + values: divisions.flatMap((division) => { + let numSessions = 0; + + const totalPercent = division.teams.reduce((prev, team) => { + return ( + prev + + team.meetingAttendances.reduce((prev, session) => { + numSessions++; + return prev + calculateTeamMemberAttendancePercent(team, session.attendees); + }, 0) + ); + }, 0); + + if (numSessions === 0) return []; + + return [ + { + value: totalPercent / numSessions, + label: division.name + } + ]; + }) + }; + + return data; +}; + export const getGraphData = ( graphType: Graph_Type, measure: Measure, @@ -741,6 +858,10 @@ export const getGraphData = ( return getGraphDataForProjectBudgetVsReimbursedAmount(organizationId, startDate, endDate, params); case Graph_Type.CHANGE_REQUESTS_BY_STATUS: return getGraphDataForChangeRequestByStatus(organizationId, startDate, endDate, params).then((val) => [val]); + case Graph_Type.ATTENDANCE_BY_TEAM: + return getGraphDataForAttendanceByTeam(measure, organizationId, startDate, endDate, params).then((val) => [val]); + case Graph_Type.ATTENDANCE_BY_DIVISION: + return getGraphDataForAttendanceByDivision(measure, organizationId, startDate, endDate, params).then((val) => [val]); } }; @@ -790,5 +911,9 @@ export const getAxisLabels = (graphType: Graph_Type): { x: string; y: string } = return { x: 'Project', y: 'Dollars' }; case Graph_Type.CHANGE_REQUESTS_BY_STATUS: return { x: 'Status', y: '# Change Requests' }; + case Graph_Type.ATTENDANCE_BY_TEAM: + return { x: 'Team', y: '% Attendance' }; + case Graph_Type.ATTENDANCE_BY_DIVISION: + return { x: 'Division', y: '% Attendance' }; } }; diff --git a/src/backend/tests/integration/slackMessages.test.ts b/src/backend/tests/integration/slackMessages.test.ts index ad85a59426..4745b43149 100644 --- a/src/backend/tests/integration/slackMessages.test.ts +++ b/src/backend/tests/integration/slackMessages.test.ts @@ -10,7 +10,9 @@ import { } from '../test-data/users.test-data.js'; import * as apiFunctions from '../../src/integrations/slack.js'; import AnnouncementService from '../../src/services/announcement.services.js'; -import slackServices from '../../src/services/slack.services.js'; +import slackServices, { SlackMessage } from '../../src/services/slack.services.js'; +import SlackController from '../../src/controllers/slack.controllers.js'; +import { NotFoundException } from '../../src/utils/errors.utils.js'; import { vi } from 'vitest'; import prisma from '../../src/prisma/prisma.js'; @@ -19,7 +21,8 @@ vi.mock('../../src/integrations/slack', async (importOriginal) => { ...(await importOriginal()), getUserName: vi.fn(), getChannelName: vi.fn(), - getUsersInChannel: vi.fn() + getUsersInChannel: vi.fn(), + getWorkspaceId: vi.fn() }; }); @@ -396,4 +399,50 @@ describe('Slack message tests', () => { expect(createSpy).toBeCalledTimes(0); expect(announcement).toBeUndefined(); }); + + it('Ignores a deleted message that has no client_msg_id', async () => { + vi.mocked(apiFunctions.getChannelName).mockReturnValue(Promise.resolve('Slack Channel Name')); + + const deleteSpy = vi.spyOn(AnnouncementService, 'deleteAnnouncement'); + // bot and API posted messages come through without a client_msg_id + const botMessage: Partial = createSlackMessageEvent('channel id', '1', 'user name', 'id_1', []); + delete botMessage.client_msg_id; + + const announcement = await slackServices.processMessageSent( + { + type: 'message', + subtype: 'message_deleted', + channel: 'channel id', + event_ts: '1', + channel_type: 'channel', + previous_message: botMessage + }, + orgId + ); + expect(deleteSpy).toBeCalledTimes(0); + expect(announcement).toBeUndefined(); + }); + + it('Catches a failure processing a message event instead of letting it escape', async () => { + vi.mocked(apiFunctions.getChannelName).mockReturnValue(Promise.resolve('Slack Channel Name')); + vi.mocked(apiFunctions.getWorkspaceId).mockReturnValue(Promise.resolve('T0WORKSPACE')); + await prisma.organization.update({ where: { organizationId: orgId }, data: { slackWorkspaceId: 'T0WORKSPACE' } }); + + const deleteSpy = vi.spyOn(AnnouncementService, 'deleteAnnouncement'); + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + // deleting a message that was never stored as an announcement makes deleteAnnouncement throw + await expect( + SlackController.processMessageEvent({ + type: 'message', + subtype: 'message_deleted', + channel: 'channel id', + event_ts: '1', + channel_type: 'channel', + previous_message: createSlackMessageEvent('channel id', '1', 'user name', 'never_an_announcement', []) + }) + ).resolves.toBeUndefined(); + expect(deleteSpy).toBeCalledWith('never_an_announcement', orgId); + expect(logSpy).toBeCalledWith(expect.any(NotFoundException)); + }); }); diff --git a/src/backend/tests/test-data/change-requests.test-data.ts b/src/backend/tests/test-data/change-requests.test-data.ts index 0dabe3a821..3ce46c1e23 100644 --- a/src/backend/tests/test-data/change-requests.test-data.ts +++ b/src/backend/tests/test-data/change-requests.test-data.ts @@ -19,7 +19,10 @@ export const prismaChangeRequest1: PrismaChangeRequest = { reviewerId: null, reviewNotes: null, dateDeleted: null, - deletedByUserId: null + deletedByUserId: null, + snapshotBlockingCount: null, + snapshotStartDate: null, + snapshotDuration: null }; export const sharedChangeRequest: SharedChangeRequest = { diff --git a/src/backend/tests/test-utils.ts b/src/backend/tests/test-utils.ts index c90ffabb4d..749456772b 100644 --- a/src/backend/tests/test-utils.ts +++ b/src/backend/tests/test-utils.ts @@ -116,6 +116,11 @@ export const resetUsers = async () => { await prisma.part_Review.deleteMany(); await prisma.part_Submission.deleteMany(); await prisma.part.deleteMany(); + await prisma.rule_Status_History.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(); @@ -134,6 +139,9 @@ export const resetUsers = async () => { await prisma.reimbursement.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_Label.deleteMany(); await prisma.task.deleteMany(); diff --git a/src/backend/tests/unit/parse.utils.test.ts b/src/backend/tests/unit/parse.utils.test.ts new file mode 100644 index 0000000000..74cdf3cc79 --- /dev/null +++ b/src/backend/tests/unit/parse.utils.test.ts @@ -0,0 +1,607 @@ +import { vi } from 'vitest'; +import { + ParsedRule, + isPageNumberLine, + stripPageNumberPhrase, + normalizeContent, + findParentRuleCode, + handleDuplicateCodes, + extractSubRules, + parseRuleNumberFSAE, + fixOrphanedRulesFSAE, + parseFSAERules, + parseRuleNumberFHE, + fixOrphanedRulesFHE, + parseFHERules, + makePageRenderer, + parseRulesFromPdf +} from '../../src/utils/parse.utils.js'; + +describe('Parse Utils Tests', () => { + describe('isPageNumberLine', () => { + it('matches a bare page number', () => { + expect(isPageNumberLine('7')).toBe(true); + expect(isPageNumberLine('143')).toBe(true); + }); + + it('does not match a real rule line', () => { + expect(isPageNumberLine('T.1.1 Some requirement text')).toBe(false); + }); + + it('does not match a line that merely contains a number', () => { + expect(isPageNumberLine('See page 7 for details')).toBe(false); + }); + }); + + describe('stripPageNumberPhrase', () => { + it('removes "Page X of Y" when it is the whole line', () => { + expect(stripPageNumberPhrase('Page 7 of 143')).toBe(''); + }); + + it('matches case-insensitively', () => { + expect(stripPageNumberPhrase('page 7 of 143')).toBe(''); + }); + + it('removes "Page X of Y" embedded in a longer line, keeping the real content on both sides', () => { + const line = + 'Violations on Intent The violation of the intent of a rule will be considered a violation of the rule itself Formula SAE® Rules 2026 © 2025 SAE International Page 8 of 145 Version 1.0 10 Sept 2025'; + expect(stripPageNumberPhrase(line)).toBe( + 'Violations on Intent The violation of the intent of a rule will be considered a violation of the rule itself Formula SAE® Rules 2026 © 2025 SAE International Version 1.0 10 Sept 2025' + ); + }); + + it('leaves a line unchanged when it does not contain the phrase', () => { + expect(stripPageNumberPhrase('GR.1.1 Cars must have a roll bar.')).toBe('GR.1.1 Cars must have a roll bar.'); + }); + }); + + describe('normalizeContent', () => { + it('collapses embedded newlines and repeated whitespace into single spaces', () => { + expect(normalizeContent('Main text\na. Sub-rule\n\nb. Another')).toBe('Main text a. Sub-rule b. Another'); + }); + + it('trims leading and trailing whitespace', () => { + expect(normalizeContent(' \n padded content \n ')).toBe('padded content'); + }); + }); + + describe('findParentRuleCode', () => { + it('strips the last segment for a nested code', () => { + expect(findParentRuleCode('EV.5.2.2')).toBe('EV.5.2'); + }); + + it('returns undefined for a top-level code', () => { + expect(findParentRuleCode('GR')).toBeUndefined(); + }); + }); + + describe('handleDuplicateCodes', () => { + it('leaves rules with unique codes unchanged', () => { + const input: ParsedRule[] = [ + { ruleCode: 'GR.1', ruleContent: 'First' }, + { ruleCode: 'GR.2', ruleContent: 'Second' } + ]; + expect(handleDuplicateCodes(input)).toEqual(input); + }); + + it('suffixes a duplicate code and remaps children pointing at the original code', () => { + const input: ParsedRule[] = [ + { ruleCode: 'GR.1', ruleContent: 'First occurrence' }, + { ruleCode: 'GR.1', ruleContent: 'Second occurrence (duplicate)' }, + { ruleCode: 'GR.1.a', ruleContent: 'Child', parentRuleCode: 'GR.1' } + ]; + const result = handleDuplicateCodes(input); + expect(result[0].ruleCode).toBe('GR.1'); + expect(result[1].ruleCode).toBe('GR.1.duplicate'); + expect(result[2].parentRuleCode).toBe('GR.1.duplicate'); + }); + + it('suffixes a third occurrence with an incrementing number', () => { + const input: ParsedRule[] = [ + { ruleCode: 'GR.1', ruleContent: 'First' }, + { ruleCode: 'GR.1', ruleContent: 'Second' }, + { ruleCode: 'GR.1', ruleContent: 'Third' } + ]; + const result = handleDuplicateCodes(input); + expect(result.map((r) => r.ruleCode)).toEqual(['GR.1', 'GR.1.duplicate', 'GR.1.duplicate2']); + }); + }); + + describe('extractSubRules', () => { + it('returns the original rule unsplit when there are no lettered items', () => { + const result = extractSubRules('T.1', 'Just some plain rule text.'); + expect(result).toEqual([{ ruleCode: 'T.1', ruleContent: 'Just some plain rule text.', parentRuleCode: 'T' }]); + }); + + it('splits lettered sub-rules that each start their own line', () => { + const content = 'Main text\na. First sub-rule\nb. Second sub-rule'; + const result = extractSubRules('EV.5.2', content); + expect(result).toEqual([ + { ruleCode: 'EV.5.2', ruleContent: 'Main text', parentRuleCode: 'EV.5' }, + { ruleCode: 'EV.5.2.a', ruleContent: 'First sub-rule', parentRuleCode: 'EV.5.2' }, + { ruleCode: 'EV.5.2.b', ruleContent: 'Second sub-rule', parentRuleCode: 'EV.5.2' } + ]); + }); + + it('splits a lettered item that immediately follows a list-introducing colon on the same line', () => { + const content = 'Requirements: a. First item\nb. Second item'; + const result = extractSubRules('T.1', content); + expect(result.map((r) => [r.ruleCode, r.ruleContent])).toEqual([ + ['T.1', 'Requirements:'], + ['T.1.a', 'First item'], + ['T.1.b', 'Second item'] + ]); + }); + + it('splits a lettered item starting at the very beginning of the content, with no intro text', () => { + const content = 'a. First item\nb. Second item'; + const result = extractSubRules('T.1', content); + expect(result.map((r) => [r.ruleCode, r.ruleContent])).toEqual([ + ['T.1', ''], + ['T.1.a', 'First item'], + ['T.1.b', 'Second item'] + ]); + }); + + it('does not split on a cross-reference like "p. 10" mid-sentence', () => { + const content = 'See p. 10 for the full diagram.'; + const result = extractSubRules('T.1', content); + expect(result).toEqual([{ ruleCode: 'T.1', ruleContent: content, parentRuleCode: 'T' }]); + }); + + it('does not split on an abbreviation like "fig. b." mid-sentence', () => { + const content = 'Refer to fig. b. above for clarification.'; + const result = extractSubRules('T.1', content); + expect(result).toEqual([{ ruleCode: 'T.1', ruleContent: content, parentRuleCode: 'T' }]); + }); + + it('splits lettered sub-rules for the "(a)" format', () => { + const content = 'Main text\n(a) First sub-rule\n(b) Second sub-rule'; + const result = extractSubRules('EV.5.2', content); + expect(result).toEqual([ + { ruleCode: 'EV.5.2', ruleContent: 'Main text', parentRuleCode: 'EV.5' }, + { ruleCode: 'EV.5.2.a', ruleContent: 'First sub-rule', parentRuleCode: 'EV.5.2' }, + { ruleCode: 'EV.5.2.b', ruleContent: 'Second sub-rule', parentRuleCode: 'EV.5.2' } + ]); + }); + + it('splits lettered sub-rules "(a)" item after colon', () => { + const content = 'Requirements: (a) First item\n(b) Second item'; + const result = extractSubRules('T.1', content); + expect(result.map((r) => [r.ruleCode, r.ruleContent])).toEqual([ + ['T.1', 'Requirements:'], + ['T.1.a', 'First item'], + ['T.1.b', 'Second item'] + ]); + }); + + it('splits lettered sub-rules for "a)" format', () => { + const content = 'Main text\na) First sub-rule\nb) Second sub-rule'; + const result = extractSubRules('EV.5.2', content); + expect(result).toEqual([ + { ruleCode: 'EV.5.2', ruleContent: 'Main text', parentRuleCode: 'EV.5' }, + { ruleCode: 'EV.5.2.a', ruleContent: 'First sub-rule', parentRuleCode: 'EV.5.2' }, + { ruleCode: 'EV.5.2.b', ruleContent: 'Second sub-rule', parentRuleCode: 'EV.5.2' } + ]); + }); + + it('splits inline subrules "a) ... OR b) ..."', () => { + const content = 'then either: a) First option OR b) Second option'; + const result = extractSubRules('EV.6.4', content); + expect(result.map((r) => [r.ruleCode, r.ruleContent])).toEqual([ + ['EV.6.4', 'then either:'], + ['EV.6.4.a', 'First option OR'], + ['EV.6.4.b', 'Second option'] + ]); + }); + + it('splits inline subrules "(a) ... or (b) ..."', () => { + const content = 'then either (a) First option or (b) Second option'; + const result = extractSubRules('EV.6.4', content); + expect(result.map((r) => [r.ruleCode, r.ruleContent])).toEqual([ + ['EV.6.4', 'then either'], + ['EV.6.4.a', 'First option or'], + ['EV.6.4.b', 'Second option'] + ]); + }); + + it('does not split on a bare-paren marker embedded mid-word, like "cab)"', () => { + const content = 'The cab) is not a valid marker.'; + const result = extractSubRules('T.1', content); + expect(result).toEqual([{ ruleCode: 'T.1', ruleContent: content, parentRuleCode: 'T' }]); + }); + + it('parses the real FHE EV fuse-protection rule with inline "either: a) ... OR b) ..." and a trailing Note', () => { + const content = + 'then either: a) Each cell must be protected with a fuse or fusible link OR b) Manufacturer’s documentation must be provided that certifies that it is acceptable to use this number of single cells.'; + const result = extractSubRules('EV2.6.4', content); + expect(result.map((r) => r.ruleCode)).toEqual(['EV2.6.4', 'EV2.6.4.a', 'EV2.6.4.b']); + expect(result[0].ruleContent).toBe('then either:'); + expect(result[1].ruleContent).toMatch(/^Each cell must be protected with a fuse or fusible link OR/); + expect(result[2].ruleContent).toMatch(/^Manufacturer’s documentation must be provided/); + }); + }); + + // FSAE Testing + describe('parseRuleNumberFSAE', () => { + it('matches a rule code followed by inline content', () => { + expect(parseRuleNumberFSAE('GR.1.1 Cars must have a roll bar.')).toEqual({ + ruleCode: 'GR.1.1', + ruleContent: 'Cars must have a roll bar.' + }); + }); + + it('matches a section header', () => { + expect(parseRuleNumberFSAE('GR - GENERAL REGULATIONS')).toEqual({ + ruleCode: 'GR', + ruleContent: 'GENERAL REGULATIONS' + }); + }); + + it('matches a bare rule code with no inline content, returning empty content', () => { + expect(parseRuleNumberFSAE('GR.1.1')).toEqual({ ruleCode: 'GR.1.1', ruleContent: '' }); + }); + + it('collapses runs of 5+ dots down to exactly 5', () => { + const result = parseRuleNumberFSAE('GR.1.1 See table.......... 12'); + expect(result?.ruleContent).toBe('See table..... 12'); + }); + + it('returns null for a line that does not look like a rule', () => { + expect(parseRuleNumberFSAE('This is just ordinary prose.')).toBeNull(); + }); + }); + + describe('fixOrphanedRulesFSAE', () => { + it('leaves a rule unchanged when its parent exists', () => { + const input: ParsedRule[] = [ + { ruleCode: 'GR.1', ruleContent: 'Parent' }, + { ruleCode: 'GR.1.1', ruleContent: 'Child', parentRuleCode: 'GR.1' } + ]; + expect(fixOrphanedRulesFSAE(input)).toEqual(input); + }); + + it('walks up to the nearest existing ancestor when the direct parent is missing', () => { + const input: ParsedRule[] = [ + { ruleCode: 'D', ruleContent: 'Top level' }, + { ruleCode: 'D.8.1.2', ruleContent: 'Deep child', parentRuleCode: 'D.8.1' } + ]; + const result = fixOrphanedRulesFSAE(input); + expect(result[1].parentRuleCode).toBe('D'); + }); + + it('becomes top-level when no ancestor exists at all', () => { + const input: ParsedRule[] = [{ ruleCode: 'D.8.1.2', ruleContent: 'Orphan', parentRuleCode: 'D.8.1' }]; + const result = fixOrphanedRulesFSAE(input); + expect(result[0].parentRuleCode).toBeUndefined(); + }); + }); + + describe('parseRuleNumberFHE', () => { + it('matches a digit-prefixed rule code', () => { + expect(parseRuleNumberFHE('1T3.17.1 Battery enclosures must be sealed.')).toEqual({ + ruleCode: '1T3.17.1', + ruleContent: 'Battery enclosures must be sealed.' + }); + }); + + it('matches a plain letter+digit rule code with no leading digit', () => { + expect(parseRuleNumberFHE('EV5.6 Accumulator systems must address stack arrangement.')).toEqual({ + ruleCode: 'EV5.6', + ruleContent: 'Accumulator systems must address stack arrangement.' + }); + }); + + it('matches a PART header and strips the "PART" keyword from the code', () => { + expect(parseRuleNumberFHE('PART A1 - ADMINISTRATIVE REGULATIONS')).toEqual({ + ruleCode: 'A1', + ruleContent: 'ADMINISTRATIVE REGULATIONS' + }); + }); + + it('matches an ARTICLE header and strips the "ARTICLE" keyword from the code', () => { + expect(parseRuleNumberFHE('ARTICLE A11 FORMULA HYBRID + ELECTRIC OVERVIEW')).toEqual({ + ruleCode: 'A11', + ruleContent: 'FORMULA HYBRID + ELECTRIC OVERVIEW' + }); + }); + + it('matches a bare rule code with no inline content, returning empty content', () => { + expect(parseRuleNumberFHE('EV5.6')).toEqual({ ruleCode: 'EV5.6', ruleContent: '' }); + expect(parseRuleNumberFHE('1T3.17.1')).toEqual({ ruleCode: '1T3.17.1', ruleContent: '' }); + }); + + it('returns null for a line that does not look like a rule', () => { + expect(parseRuleNumberFHE('This is just ordinary prose.')).toBeNull(); + }); + }); + + describe('fixOrphanedRulesFHE', () => { + it('leaves a rule unchanged when its parent exists', () => { + const input: ParsedRule[] = [ + { ruleCode: '1T3', ruleContent: 'Parent' }, + { ruleCode: '1T3.17', ruleContent: 'Child', parentRuleCode: '1T3' } + ]; + expect(fixOrphanedRulesFHE(input)).toEqual(input); + }); + + it('falls back to the article-format parent (1A11 -> A11) when the digit-prefixed parent is missing', () => { + const input: ParsedRule[] = [ + { ruleCode: 'A11', ruleContent: 'Article A11 overview' }, + { ruleCode: '1A11.1', ruleContent: 'Sub-rule', parentRuleCode: '1A11' } + ]; + const result = fixOrphanedRulesFHE(input); + expect(result[1].parentRuleCode).toBe('A11'); + }); + + it('strips a leading digit from an intermediate ancestor found while walking up the hierarchy', () => { + const input: ParsedRule[] = [ + { ruleCode: 'A5', ruleContent: 'Article A5 overview' }, + { ruleCode: '1A5.1.2', ruleContent: 'Deep sub-rule', parentRuleCode: '1A5.1' } + ]; + const result = fixOrphanedRulesFHE(input); + expect(result[1].parentRuleCode).toBe('A5'); + }); + + it('becomes top-level when no ancestor exists at all', () => { + const input: ParsedRule[] = [{ ruleCode: '1A5.1', ruleContent: 'Orphan', parentRuleCode: '1A5' }]; + const result = fixOrphanedRulesFHE(input); + expect(result[0].parentRuleCode).toBeUndefined(); + }); + }); + + describe('parseFSAERules', () => { + it('parses multiple top-level and nested rules from multi-line text', () => { + const text = ['T - TECHNICAL ASPECTS', 'T.1 COCKPIT', 'T.1.1 Cockpit Opening'].join('\n'); + const result = parseFSAERules(text); + expect(result.map((r) => [r.ruleCode, r.ruleContent])).toEqual([ + ['T', 'TECHNICAL ASPECTS'], + ['T.1', 'COCKPIT'], + ['T.1.1', 'Cockpit Opening'] + ]); + }); + + it('opens a new rule from a bare code line instead of merging it into the previous rule', () => { + const text = ['GR.1.1 First rule content.', 'GR.1.2', 'Body text starting on the next line.'].join('\n'); + const result = parseFSAERules(text); + expect(result.map((r) => [r.ruleCode, r.ruleContent])).toEqual([ + ['GR.1.1', 'First rule content.'], + ['GR.1.2', 'Body text starting on the next line.'] + ]); + }); + + it('captures text before the first recognized rule as an UNPARSED rule instead of dropping it', () => { + const text = ['Cover page filler text.', 'More preamble.', 'GR.1.1 The real first rule.'].join('\n'); + const result = parseFSAERules(text); + expect(result[0]).toEqual({ + ruleCode: 'UNPARSED.1', + ruleContent: 'Cover page filler text. More preamble.', + parentRuleCode: undefined + }); + expect(result[1].ruleCode).toBe('GR.1.1'); + }); + + it('skips bare page number lines without dropping surrounding content', () => { + const text = ['GR.1.1 First rule.', '7', 'Page 7 of 143', 'GR.1.2 Second rule.'].join('\n'); + const result = parseFSAERules(text); + expect(result.map((r) => r.ruleCode)).toEqual(['GR.1.1', 'GR.1.2']); + }); + }); + + describe('parseFHERules', () => { + it('recognizes a plain letter+digit code (EV5.6) as its own rule instead of merging into the enclosing ARTICLE', () => { + const text = [ + 'ARTICLE EV1 POUCH TYPE LITHIUM-ION CELLS', + 'Important Note: Designing an accumulator system utilizing pouch cells is a substantial undertaking.', + 'EV5.6 Accumulator systems using pouch cells must address stack arrangement.', + 'EV5.7 Teams must provide details of the design in their ESF1 and ESF2 submissions.' + ].join('\n'); + const result = parseFHERules(text); + expect(result.map((r) => r.ruleCode)).toEqual(['EV1', 'EV5.6', 'EV5.7']); + expect(result[1].ruleContent).toBe('Accumulator systems using pouch cells must address stack arrangement.'); + expect(result[2].ruleContent).toBe('Teams must provide details of the design in their ESF1 and ESF2 submissions.'); + }); + + it('opens a new rule from a bare code line instead of merging it into the previous rule', () => { + const text = ['1T3.17.1 First rule content.', '1T3.17.2', 'Body text starting on the next line.'].join('\n'); + const result = parseFHERules(text); + expect(result.map((r) => [r.ruleCode, r.ruleContent])).toEqual([ + ['1T3.17.1', 'First rule content.'], + ['1T3.17.2', 'Body text starting on the next line.'] + ]); + }); + + it('captures text before the first recognized rule as an UNPARSED rule instead of dropping it', () => { + const text = ['2026 Formula Hybrid + Electric Rules', 'Table of contents filler.', '1T3.1 The real first rule.'].join( + '\n' + ); + const result = parseFHERules(text); + expect(result[0]).toEqual({ + ruleCode: 'UNPARSED.1', + ruleContent: '2026 Formula Hybrid + Electric Rules Table of contents filler.', + parentRuleCode: undefined + }); + expect(result[1].ruleCode).toBe('1T3.1'); + }); + }); +}); + +// mocks pdf-parse-new, used to test parsing logic without real PDF files +vi.mock('pdf-parse-new', () => { + // loop over pages 1..N, call options.pagerender for each one and join with '\n\n'. + const fn: any = async (_buffer: Buffer, options: any) => { + const pages = (globalThis as any).__testPages as string[]; + let text = ''; + for (let i = 1; i <= pages.length; i++) { + text += `\n\n${await options.pagerender({ pageNumber: i })}`; + } + return { text, numpages: pages.length, numrender: pages.length, info: null, metadata: null }; + }; + // mimics the library's default per-page text extractor + fn.DEFAULT_OPTIONS = { + pagerender: async (pageData: any) => { + const pages = (globalThis as any).__testPages as string[]; + return pages[pageData.pageNumber - 1] ?? ''; + } + }; + return { default: fn }; +}); + +describe('Parsing pdf Tests', () => { + describe('makePageRenderer', () => { + beforeEach(() => { + (globalThis as any).__testPages = ['page one text', 'page two text', 'page three text']; + }); + + it('delegates to the default renderer for every page when firstRulePage is not given', async () => { + expect(await makePageRenderer(undefined)({ pageNumber: 1 })).toBe('page one text'); + expect(await makePageRenderer(undefined)({ pageNumber: 3 })).toBe('page three text'); + }); + + it('returns empty text for pages before firstRulePage and delegates from firstRulePage onward', async () => { + expect(await makePageRenderer(3)({ pageNumber: 1 })).toBe(''); + expect(await makePageRenderer(3)({ pageNumber: 2 })).toBe(''); + expect(await makePageRenderer(3)({ pageNumber: 3 })).toBe('page three text'); + }); + }); + + describe('parseRulesFromPdf', () => { + it('dispatches to the FSAE parser for parserType "FSAE"', async () => { + (globalThis as any).__testPages = ['GR.1.1 Cars must have a roll bar.']; + const result = await parseRulesFromPdf(Buffer.from(''), 'FSAE'); + expect(result).toEqual([{ ruleCode: 'GR.1.1', ruleContent: 'Cars must have a roll bar.', parentRuleCode: undefined }]); + }); + + it('dispatches to the FHE parser for parserType "FHE"', async () => { + (globalThis as any).__testPages = ['EV5.6 Accumulator systems must address stack arrangement.']; + const result = await parseRulesFromPdf(Buffer.from(''), 'FHE'); + expect(result).toEqual([ + { ruleCode: 'EV5.6', ruleContent: 'Accumulator systems must address stack arrangement.', parentRuleCode: undefined } + ]); + }); + + it('throws for an unrecognized parser type', async () => { + (globalThis as any).__testPages = ['GR.1.1 Cars must have a roll bar.']; + await expect(parseRulesFromPdf(Buffer.from(''), 'INVALID' as unknown as 'FSAE')).rejects.toThrow( + "Invalid parser type: INVALID. Must be 'FSAE' or 'FHE'" + ); + }); + + it('skips pages before firstRulePage entirely', async () => { + (globalThis as any).__testPages = ['TABLE OF CONTENTS\nGR.1.1 ..... 5', 'GR.1.1 Cars must have a roll bar.']; + const result = await parseRulesFromPdf(Buffer.from(''), 'FSAE', 2); + expect(result).toEqual([{ ruleCode: 'GR.1.1', ruleContent: 'Cars must have a roll bar.', parentRuleCode: undefined }]); + }); + + it('real 2026 FSAE rules with subrules', async () => { + (globalThis as any).__testPages = [ + '2026 FSAE Rules\nTABLE OF CONTENTS', + [ + 'PS.3.2 Penalty Detail', + 'PS.3.2.1 Late Submissions get a point penalty as shown in Table PS-2, subject to official discretion', + 'PS.3.2.2 Additional penalties will apply if Not Submitted, subject to official discretion', + 'PS.3.2.3 Penalties up to and including Removal of Team Entry may apply based on document reviews,', + 'subject to official discretion', + 'PS.3.3 Removal of Team Entry', + 'PS.3.3.1 The organizer may remove the team entry when a:' + ].join('\n'), + [ + 'a. Grounds for Removal document is Not Submitted in 24 hours or less after the deadline.', + 'Removals will occur after each Document Submission deadline', + 'b. Team does not respond to Reviewer requests or organizer communications', + 'PS.3.3.2 When a team entry will be removed:', + 'a. The team will be notified prior to cancelling registration', + 'b. No refund of entry fees will be given' + ].join('\n') + ]; + const result = await parseRulesFromPdf(Buffer.from(''), 'FSAE', 2); + expect(result.map((r) => [r.ruleCode, r.ruleContent])).toEqual([ + ['PS.3.2', 'Penalty Detail'], + ['PS.3.2.1', 'Late Submissions get a point penalty as shown in Table PS-2, subject to official discretion'], + ['PS.3.2.2', 'Additional penalties will apply if Not Submitted, subject to official discretion'], + [ + 'PS.3.2.3', + 'Penalties up to and including Removal of Team Entry may apply based on document reviews, subject to official discretion' + ], + ['PS.3.3', 'Removal of Team Entry'], + ['PS.3.3.1', 'The organizer may remove the team entry when a:'], + [ + 'PS.3.3.1.a', + 'Grounds for Removal document is Not Submitted in 24 hours or less after the deadline. Removals will occur after each Document Submission deadline' + ], + ['PS.3.3.1.b', 'Team does not respond to Reviewer requests or organizer communications'], + ['PS.3.3.2', 'When a team entry will be removed:'], + ['PS.3.3.2.a', 'The team will be notified prior to cancelling registration'], + ['PS.3.3.2.b', 'No refund of entry fees will be given'] + ]); + }); + + it('real 2026 FHE rules with a page boundary', async () => { + (globalThis as any).__testPages = [ + '2026 Formula Hybrid + Electric Rules\nTABLE OF CONTENTS', + [ + 'A2.3.2 Teams planning to enter a vehicle in the HIP category will initially register as a Hybrid. To', + 'change to the HIP category, the team must submit a request to the organizers in writing before', + 'the start of the design event.', + 'Note: The advantages of entering as an HIP are:', + '(a) Receive a full technical inspection of the vehicle and electrical drive systems.', + '(b) Participate in all the competition events. (Provided tech inspection is passed).', + '(c) Receive feedback from the design judges.', + 'Note: Teams can maximize the benefits of an HIP entry by including the full-hybrid', + 'designs in their document submissions and design event presentations, as well as', + 'including the full multi-year program in their Project Management materials.', + '(d) When the vehicle is completed and entered as a hybrid, in a subsequent competition, it is', + 'considered an all-new vehicle, and not a second-year entry.', + '2026 Formula Hybrid + Electric Rules – Rev. 1 3 September 11, 2025' + ].join('\n'), + [ + 'A2.4 Static Events Only (SEO)', + 'A2.4.1 SEO is a category that may only be declared after arrival at the competition. All teams must', + 'initially register as either Hybrid/HIP or Electric.', + 'A2.4.2 A team may declare themselves as SEO and participate in the design2 and other static events', + 'even if the vehicle is in an unfinished state.', + '(a) An SEO vehicle may not participate in any of the dynamic events.', + '(b) An SEO vehicle may continue the technical inspection process, but will be given a lower', + 'priority than the non-SEO teams.', + 'A2.4.3 An SEO declaration must be submitted in writing to the organizers before the scheduled start of', + 'the design events.' + ].join('\n') + ]; + const result = await parseRulesFromPdf(Buffer.from(''), 'FHE', 2); + expect(result.map((r) => [r.ruleCode, r.ruleContent])).toEqual([ + [ + 'A2.3.2', + 'Teams planning to enter a vehicle in the HIP category will initially register as a Hybrid. To change to the HIP category, the team must submit a request to the organizers in writing before the start of the design event. Note: The advantages of entering as an HIP are:' + ], + ['A2.3.2.a', 'Receive a full technical inspection of the vehicle and electrical drive systems.'], + ['A2.3.2.b', 'Participate in all the competition events. (Provided tech inspection is passed).'], + [ + 'A2.3.2.c', + 'Receive feedback from the design judges. Note: Teams can maximize the benefits of an HIP entry by including the full-hybrid designs in their document submissions and design event presentations, as well as including the full multi-year program in their Project Management materials.' + ], + [ + 'A2.3.2.d', + 'When the vehicle is completed and entered as a hybrid, in a subsequent competition, it is considered an all-new vehicle, and not a second-year entry. 2026 Formula Hybrid + Electric Rules – Rev. 1 3 September 11, 2025' + ], + ['A2.4', 'Static Events Only (SEO)'], + [ + 'A2.4.1', + 'SEO is a category that may only be declared after arrival at the competition. All teams must initially register as either Hybrid/HIP or Electric.' + ], + [ + 'A2.4.2', + 'A team may declare themselves as SEO and participate in the design2 and other static events even if the vehicle is in an unfinished state.' + ], + ['A2.4.2.a', 'An SEO vehicle may not participate in any of the dynamic events.'], + [ + 'A2.4.2.b', + 'An SEO vehicle may continue the technical inspection process, but will be given a lower priority than the non-SEO teams.' + ], + [ + 'A2.4.3', + 'An SEO declaration must be submitted in writing to the organizers before the scheduled start of the design events.' + ] + ]); + }); + }); +}); diff --git a/src/backend/tests/unit/rule.test.ts b/src/backend/tests/unit/rule.test.ts new file mode 100644 index 0000000000..14a35de482 --- /dev/null +++ b/src/backend/tests/unit/rule.test.ts @@ -0,0 +1,3433 @@ +import RulesService from '../../src/services/rules.services'; +import { Organization, User, Project, Car, Ruleset_Type, Ruleset, Team } from '@prisma/client'; +import { + supermanAdmin, + financeMember, + wonderwomanGuest, + batmanAppAdmin, + aquamanLeadership, + greenlanternHead, + 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'; +import ProjectsService from '../../src/services/projects.services'; +import { RuleStatus } from 'shared'; + +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.referencedRules).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] + ); + + const referencedIds = rule3.referencedRules.map((ref) => ref.ruleId); + expect(referencedIds).toHaveLength(2); + expect(referencedIds).toContain(rule1.ruleId); + expect(referencedIds).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 leadership and above can create rules') + ); + }); + + it('fails when a member tries to create a rule', async () => { + const member = await createTestUser(financeMember, orgId); + await expect(RulesService.createRule(member, 'T.1.1', 'Some rule', rulesetId, organization)).rejects.toThrow( + new AccessDeniedException('Only leadership 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 rule code is blank', async () => { + await expect(RulesService.createRule(batman, '', 'Some rule', rulesetId, organization)).rejects.toThrow( + new HttpException(400, 'Rule code cannot be empty') + ); + }); + + it('fails when rule code is whitespace only', async () => { + await expect(RulesService.createRule(batman, ' ', 'Some rule', rulesetId, organization)).rejects.toThrow( + new HttpException(400, 'Rule code cannot be empty') + ); + }); + + 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')); + }); + + // this is allowed but with warning in case a parent code needs to be updated which won't break all existing children + it('allows a child rule code that does not start with the parent rule code', async () => { + const parentRule = await RulesService.createRule(batman, 'T.1', 'Parent', rulesetId, organization); + + const childRule = await RulesService.createRule(superman, 'TH.1', 'Child', rulesetId, organization, parentRule.ruleId); + + expect(childRule.ruleCode).toBe('TH.1'); + expect(childRule.parentRule?.ruleId).toBe(parentRule.ruleId); + }); + + it('successfully creates a rule with blank content', async () => { + const rule = await RulesService.createRule(batman, 'T.9.1', '', rulesetId, organization); + expect(rule.ruleContent).toBe(''); + }); + + 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 leadership and above to create rules', async () => { + await RulesService.createRule(aquaman, 'T.1.1', 'Leadership created rule', rulesetId, organization); + await RulesService.createRule(superman, 'T.1.2', 'Admin created rule', rulesetId, organization); + }); + + describe('Create ruleset', () => { + it('successful create ruleset', async () => { + const ruleset = await RulesService.createRuleset( + superman, + organization, + 'ruleset name', + rulesetType.rulesetTypeId, + 0, + false, + 'fileId' + ); + + expect(ruleset.name).toEqual('ruleset name'); + }); + it('Create ruleset fails when submitters is not leadership', async () => { + await expect( + async () => + await RulesService.createRuleset( + wonderwoman, + organization, + 'ruleset name', + rulesetType.rulesetTypeId, + 0, + false, + 'fileId' + ) + ).rejects.toThrow(new AccessDeniedException('only leadership and above can create ruleset!')); + }); + it('Create ruleset fails when given bad ruleset id', async () => { + await expect( + async () => + await RulesService.createRuleset(superman, organization, 'ruleset name', 'bad ruleset type', 0, false, 'fileId') + ).rejects.toThrow(new NotFoundException('Ruleset Type', 'bad ruleset type')); + }); + it('Create ruleset fails when given bad car number', async () => { + await expect( + async () => + await RulesService.createRuleset( + superman, + organization, + 'ruleset name', + rulesetType.rulesetTypeId, + 12312312, + false, + 'fileId' + ) + ).rejects.toThrow(new NotFoundException('Car', 12312312)); + }); + }); + }); + + describe('Complex Rule Scenarios', () => { + it('creates a hierarchical rule structure', async () => { + const root = await RulesService.createRule(batman, 'T.1', 'Technical Rules', rulesetId, organization); + + const child1 = await RulesService.createRule( + batman, + 'T.1.1', + 'Vehicle Requirements', + rulesetId, + organization, + root.ruleId + ); + + const child2 = await RulesService.createRule( + batman, + 'T.1.2', + 'Safety Requirements', + rulesetId, + organization, + root.ruleId + ); + + const grandchild1 = await RulesService.createRule( + superman, + 'T.1.1.1', + 'Wheels', + rulesetId, + organization, + child1.ruleId + ); + + expect(root.parentRule).toBeUndefined(); + expect(child1.parentRule?.ruleId).toBe(root.ruleId); + expect(child2.parentRule?.ruleId).toBe(root.ruleId); + expect(grandchild1.parentRule?.ruleId).toBe(child1.ruleId); + }); + + it('creates rules with cross-references', async () => { + const wheelRule = await RulesService.createRule(batman, 'T.1.1', 'Wheel specifications', rulesetId, organization); + + const brakeRule = await RulesService.createRule(batman, 'T.1.2', 'Brake specifications', rulesetId, organization); + + const brakingSystemRule = await RulesService.createRule( + superman, + 'T.2.1', + 'Braking system must comply with T.1.1 and T.1.2', + rulesetId, + organization, + undefined, + [wheelRule.ruleId, brakeRule.ruleId] + ); + + const wheelRuleFromDb = await prisma.rule.findUnique({ + where: { ruleId: wheelRule.ruleId }, + include: { referencedBy: true } + }); + + expect(wheelRuleFromDb?.referencedBy.some((r) => r.ruleId === brakingSystemRule.ruleId)).toBe(true); + }); + }); + + describe('Create Project Rule', () => { + it('Creates project rules for all ancestors when assigning deep child rule', async () => { + const topLevelRule = await RulesService.createRule(batman, 'T.1', 'Technical Rules', rulesetId, organization); + const child = await RulesService.createRule( + batman, + 'T.1.1', + 'Vehicle Requirements', + rulesetId, + organization, + topLevelRule.ruleId + ); + const grandchild = await RulesService.createRule(batman, 'T.1.1.1', 'Wheels', rulesetId, organization, child.ruleId); + + const teamType = await createTestTeamType('technical', orgId); + const team = await createTestTeam(batman.userId, teamType.teamTypeId, orgId); + // every rule in the ancestor chain must be on the project's team for the leaf to be assignable + await RulesService.toggleRuleTeam(topLevelRule.ruleId, team.teamId, batman, organization); + await RulesService.toggleRuleTeam(child.ruleId, team.teamId, batman, organization); + + await RulesService.toggleRuleTeam(grandchild.ruleId, team.teamId, batman, organization); + + const project = await createTestProject(aquaman, orgId, team.teamId, carId); + + await RulesService.createProjectRule(aquaman, organization, grandchild.ruleId, project.projectId); + + const projectRules = await RulesService.getProjectRules(aquaman, rulesetId, project.projectId, organization); + const assignedRuleIds = projectRules.map((pr) => pr.rule.ruleId); + expect(assignedRuleIds).toHaveLength(3); // grandchild, child, topLevelRule + expect(assignedRuleIds).toEqual(expect.arrayContaining([topLevelRule.ruleId, child.ruleId, grandchild.ruleId])); + }); + + it('Creates project rules for shared ancestors when adding a sibling child rule', async () => { + const topLevelRule = await RulesService.createRule(batman, 'T.1', 'Technical Rules', rulesetId, organization); + const child = await RulesService.createRule( + batman, + 'T.1.1', + 'Vehicle Requirements', + rulesetId, + organization, + topLevelRule.ruleId + ); + const grandchild1 = await RulesService.createRule(batman, 'T.1.1.1', 'Wheels', rulesetId, organization, child.ruleId); + const grandchild2 = await RulesService.createRule(batman, 'T.1.1.2', 'Brakes', rulesetId, organization, child.ruleId); + + const teamType = await createTestTeamType('technical', orgId); + const team = await createTestTeam(batman.userId, teamType.teamTypeId, orgId); + + // every rule in the ancestor chain must be on the project's team for the leaves to be assignable + await RulesService.toggleRuleTeam(topLevelRule.ruleId, team.teamId, batman, organization); + await RulesService.toggleRuleTeam(child.ruleId, team.teamId, batman, organization); + + await RulesService.toggleRuleTeam(grandchild1.ruleId, team.teamId, batman, organization); + await RulesService.toggleRuleTeam(grandchild2.ruleId, team.teamId, batman, organization); + + const project = await createTestProject(aquaman, orgId, team.teamId, carId); + + await RulesService.createProjectRule(aquaman, organization, grandchild1.ruleId, project.projectId); + // adding sibling must not error or duplicate the already-present parent/root rules + await RulesService.createProjectRule(aquaman, organization, grandchild2.ruleId, project.projectId); + + const projectRules = await RulesService.getProjectRules(aquaman, rulesetId, project.projectId, organization); + const assignedRuleIds = projectRules.map((pr) => pr.rule.ruleId); + expect(assignedRuleIds).toHaveLength(4); // grandchild1, grandchild2, child, topLevelRule + expect(assignedRuleIds).toEqual( + expect.arrayContaining([topLevelRule.ruleId, child.ruleId, grandchild1.ruleId, grandchild2.ruleId]) + ); + }); + + it('Creating project rule does not assign descendants of the selected rule', async () => { + const topLevelRule = await RulesService.createRule(batman, 'T.1', 'Technical Rules', rulesetId, organization); + const child = await RulesService.createRule( + batman, + 'T.1.1', + 'Vehicle Requirements', + rulesetId, + organization, + topLevelRule.ruleId + ); + await RulesService.createRule(batman, 'T.1.1.1', 'Wheels', rulesetId, organization, child.ruleId); + + const teamType = await createTestTeamType('technical', orgId); + const team = await createTestTeam(batman.userId, teamType.teamTypeId, orgId); + + await RulesService.toggleRuleTeam(topLevelRule.ruleId, team.teamId, batman, organization); + await RulesService.toggleRuleTeam(child.ruleId, team.teamId, batman, organization); + + const project = await createTestProject(aquaman, orgId, team.teamId, carId); + + await RulesService.createProjectRule(aquaman, organization, child.ruleId, project.projectId); + + const projectRules = await RulesService.getProjectRules(aquaman, rulesetId, project.projectId, organization); + const assignedRuleIds = projectRules.map((pr) => pr.rule.ruleId); + expect(assignedRuleIds).toHaveLength(2); // child and topLevelRule, not grandchild + expect(assignedRuleIds).toEqual(expect.arrayContaining([topLevelRule.ruleId, child.ruleId])); + }); + + it('Creating project rule is refused entirely when an ancestor has been deleted', async () => { + const topLevelRule = await RulesService.createRule(batman, 'T.1', 'Technical Rules', rulesetId, organization); + const child = await RulesService.createRule( + batman, + 'T.1.1', + 'Vehicle Requirements', + rulesetId, + organization, + topLevelRule.ruleId + ); + const grandchild = await RulesService.createRule(batman, 'T.1.1.1', 'Wheels', rulesetId, organization, child.ruleId); + + const teamType = await createTestTeamType('technical', orgId); + const team = await createTestTeam(batman.userId, teamType.teamTypeId, orgId); + await RulesService.toggleRuleTeam(grandchild.ruleId, team.teamId, batman, organization); + + // soft-delete an ancestor so the chain to the top-level rule is broken + await prisma.rule.update({ + where: { ruleId: child.ruleId }, + data: { dateDeleted: new Date(), deletedBy: { connect: { userId: batman.userId } } } + }); + + const project = await createTestProject(aquaman, orgId, team.teamId, carId); + + // a broken chain means the grandchild could never display, so no rules are assigned to the project and an error is thrown + await expect( + async () => await RulesService.createProjectRule(aquaman, organization, grandchild.ruleId, project.projectId) + ).rejects.toThrow(new DeletedException('Rule', child.ruleId)); + + // nothing should have been assigned (not the grandchild, the deleted parent, or the root) + const projectRules = await RulesService.getProjectRules(aquaman, rulesetId, project.projectId, organization); + expect(projectRules).toHaveLength(0); + + const grandchildProjectRule = await prisma.project_Rule.findUnique({ + where: { ruleId_projectId: { ruleId: grandchild.ruleId, projectId: project.projectId } } + }); + expect(grandchildProjectRule).toBeNull(); + }); + + it('throws when the rule is already associated with the project', async () => { + const rule = await RulesService.createRule(batman, 'T.1', 'Technical Rules', rulesetId, organization); + + const teamType = await createTestTeamType('technical', orgId); + const team = await createTestTeam(batman.userId, teamType.teamTypeId, orgId); + await RulesService.toggleRuleTeam(rule.ruleId, team.teamId, batman, organization); + + const project = await createTestProject(aquaman, orgId, team.teamId, carId); + + await RulesService.createProjectRule(aquaman, organization, rule.ruleId, project.projectId); + + await expect( + async () => await RulesService.createProjectRule(aquaman, organization, rule.ruleId, project.projectId) + ).rejects.toThrow(new HttpException(400, 'This rule is already associated with the project')); + }); + + it('throws when a guest tries to assign a rule to a project', async () => { + const rule = await RulesService.createRule(batman, 'T.1', 'Technical Rules', rulesetId, organization); + const project = await createTestProject(aquaman, orgId, undefined, carId); + + await expect( + async () => await RulesService.createProjectRule(wonderwoman, organization, rule.ruleId, project.projectId) + ).rejects.toThrow(AccessDeniedException); + }); + + it('throws when the rule does not exist', async () => { + const project = await createTestProject(aquaman, orgId, undefined, carId); + + await expect( + async () => await RulesService.createProjectRule(aquaman, organization, 'fake-rule-id', project.projectId) + ).rejects.toThrow(new NotFoundException('Rule', 'fake-rule-id')); + }); + + it('throws when the project does not exist', async () => { + const rule = await RulesService.createRule(batman, 'T.1', 'Technical Rules', rulesetId, organization); + + await expect( + async () => await RulesService.createProjectRule(aquaman, organization, rule.ruleId, 'fake-project-id') + ).rejects.toThrow(new NotFoundException('Project', 'fake-project-id')); + }); + + it('throws when the rule is not on any of the project`s teams', async () => { + // rule has no team, but the project belongs to a team, so they share no team + const rule = await RulesService.createRule(batman, 'T.1', 'Technical Rules', rulesetId, organization); + + const teamType = await createTestTeamType('technical', orgId); + const team = await createTestTeam(batman.userId, teamType.teamTypeId, orgId); + const project = await createTestProject(aquaman, orgId, team.teamId, carId); + + await expect( + async () => await RulesService.createProjectRule(aquaman, organization, rule.ruleId, project.projectId) + ).rejects.toThrow(new HttpException(400, "Rule must be on one of the project's teams to be assigned to it")); + }); + }); + + describe('Get rulesets by ruleset type', () => { + it('Successful get rulesets by ruleset types', async () => { + const rulesets = await RulesService.getRulesetsByRulesetType(aquaman, 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); + expect(rulesets[0].createdByUserId).toBe(batman.userId); + }); + + 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(aquaman, 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(aquaman, rulesetType.rulesetTypeId, orgId); + expect(rulesets.length).toBe(2); + 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(aquaman, rulesetType.rulesetTypeId, orgId); + expect(allRulesets.length).toBe(2); + + // 1 ruleset when filtered to the original car + const originalCarRulesets = await RulesService.getRulesetsByRulesetType( + aquaman, + rulesetType.rulesetTypeId, + orgId, + carId + ); + expect(originalCarRulesets.length).toBe(1); + expect(originalCarRulesets[0].rulesetId).toBe(rulesetId); + + const otherCarRulesets = await RulesService.getRulesetsByRulesetType( + aquaman, + 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', () => { + 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(aquaman, 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(aquaman, 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(aquaman, 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(aquaman, 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(aquaman, '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(aquaman, 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(aquaman, otherParentRule.ruleId, organization) + ).rejects.toThrow(new InvalidOrganizationException('Rule')); + }); + }); + describe('Update ruleset status', () => { + it('update ruleset status - successful', async () => { + const ruleset1 = await RulesService.updateRuleset(batman, orgId, rulesetId, 'name1', false); + expect(ruleset1.active).toBe(false); + expect(ruleset1.name).toBe('name1'); + const ruleset2 = await RulesService.updateRuleset(batman, orgId, rulesetId, 'name2', true); + expect(ruleset2.active).toBe(true); + expect(ruleset2.name).toBe('name2'); + }); + it('update ruleset status on deleted ruleset fails', async () => { + // Deactivate the ruleset before deleting + await prisma.ruleset.update({ + where: { rulesetId }, + data: { active: false } + }); + await RulesService.deleteRuleset(rulesetId, batman.userId, orgId); + await expect(async () => await RulesService.updateRuleset(batman, orgId, rulesetId, 'name', false)).rejects.toThrow( + new NotFoundException('Ruleset', rulesetId) + ); + }); + it('update active ruleset successful with active ruleset in different type', async () => { + const ruleset2 = await RulesService.createRuleset( + superman, + organization, + 'ruleset name', + (await RulesService.createRulesetType(batman, 'ruleset type 2', organization)).rulesetTypeId, + 0, + false, + 'fileId' + ); + await RulesService.updateRuleset(batman, orgId, ruleset2.rulesetId, 'name', false); + const ruleset = await RulesService.updateRuleset(batman, orgId, rulesetId, 'name', true); + expect(ruleset.active).toBe(true); + }); + it('update ruleset status fails with wrong org', async () => { + const wrongOrg = await prisma.organization.create({ + data: { + name: 'wrong org', + userCreatedId: batman.userId, + description: 'desc', + applicationLink: '1' + } + }); + + const wrongOrgCar = await prisma.car.create({ + data: { + wbsElement: { + create: { + name: 'wrong org car', + carNumber: 0, + projectNumber: 0, + workPackageNumber: 0, + organizationId: wrongOrg.organizationId + } + } + } + }); + + const wrongOrgRulesetType = await prisma.ruleset_Type.create({ + data: { + name: 'ruleset type 2', + createdBy: { connect: { userId: batman.userId } }, + organization: { connect: { organizationId: wrongOrg.organizationId } } + } + }); + + const wrongOrgRuleset = await prisma.ruleset.create({ + data: { + fileId: 'fileId', + name: 'ruleset name', + active: false, + rulesetType: { connect: { rulesetTypeId: wrongOrgRulesetType.rulesetTypeId } }, + car: { connect: { carId: wrongOrgCar.carId } }, + createdBy: { connect: { userId: batman.userId } } + } + }); + + await expect( + async () => await RulesService.updateRuleset(batman, orgId, wrongOrgRuleset.rulesetId, 'name', false) + ).rejects.toThrow(new NotFoundException('Ruleset', wrongOrgRuleset.rulesetId)); + }); + it('update ruleset status - fails non leadership', async () => { + await expect( + async () => await RulesService.updateRuleset(wonderwoman, orgId, rulesetId, 'name', false) + ).rejects.toThrow(new AccessDeniedException('You do not have permissions to update ruleset status')); + }); + it('update ruleset status - fails if 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, + '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 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); + }); + }); +}); + +describe('Rule Tests', () => { + let organization: Organization; + let orgId: string; + let otherOrg: Organization; + let admin: User; + let head: 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); + head = await createTestUser(greenlanternHead, 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 } } + } + }); + + const referencedRule = await prisma.rule.create({ + data: { + ruleCode: 'B2', + ruleContent: 'Rule content for B2', + imageFileIds: [], + dateCreated: new Date(), + ruleset: { connect: { rulesetId: ruleset1.rulesetId } }, + createdBy: { connect: { userId: admin.userId } } + } + }); + + const referencingRule = await prisma.rule.create({ + data: { + ruleCode: 'A2', + ruleContent: 'This rule references B2', + imageFileIds: [], + dateCreated: new Date(), + ruleset: { connect: { rulesetId: ruleset1.rulesetId } }, + createdBy: { connect: { userId: admin.userId } }, + referencedRule: { connect: { ruleId: referencedRule.ruleId } } + } + }); + + return { ruleset1, ruleset2, topLevelRule, leafRule1, leafRule2, referencedRule, referencingRule }; + }; + + describe('Create Ruleset Type', () => { + it('Fails if user is not an admin', async () => { + await expect(async () => await RulesService.createRulesetType(guest, 'FSAE', organization)).rejects.toThrow( + new AccessDeniedAdminOnlyException('create ruleset types') + ); + }); + + it('Fails if user is leadership but not an admin', async () => { + await expect(async () => await RulesService.createRulesetType(head, 'FSAE', organization)).rejects.toThrow( + new AccessDeniedAdminOnlyException('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); + // rule and project must share a team for the rule to be assignable + const project = await createTestProject(admin, orgId, testTeam.teamId, car.carId, car.wbsElement.carNumber); + await RulesService.toggleRuleTeam(topLevelRule.ruleId, testTeam.teamId, admin, organization); + 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.status).toBe(RuleStatus.PENDING); + }); + it('Creates a project rule successfully for a leaf rule', async () => { + const car = await createUniqueCar(orgId); + const { leafRule1, topLevelRule } = await setupRules(car); + // every rule in the ancestor chain must be on the project's team for the leaf to be assignable + const project = await createTestProject(admin, orgId, testTeam.teamId, car.carId, car.wbsElement.carNumber); + await RulesService.toggleRuleTeam(topLevelRule.ruleId, testTeam.teamId, admin, organization); + await RulesService.toggleRuleTeam(leafRule1.ruleId, testTeam.teamId, admin, organization); + 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.status).toBe(RuleStatus.PENDING); + }); + 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, topLevelRule } = await setupRules(car); + // every rule in the chain must be on the project's team + const project = await createTestProject(admin, orgId, testTeam.teamId, car.carId, car.wbsElement.carNumber); + await RulesService.toggleRuleTeam(topLevelRule.ruleId, testTeam.teamId, admin, organization); + await RulesService.toggleRuleTeam(leafRule1.ruleId, testTeam.teamId, admin, organization); + await RulesService.createProjectRule(admin, organization, leafRule1.ruleId, project.projectId); + await expect(RulesService.createProjectRule(admin, organization, leafRule1.ruleId, project.projectId)).rejects.toThrow( + new HttpException(400, 'This rule is already associated with the project') + ); + }); + + // Setting Rule Status (general view) + it('Marks a rule Pass in the general view and records who updated it', async () => { + const car = await createUniqueCar(orgId); + const { leafRule1 } = await setupRules(car); + + const { rule: updatedRule } = await RulesService.setRuleStatus(admin, organization, leafRule1.ruleId, RuleStatus.PASS); + + expect(updatedRule.ruleId).toBe(leafRule1.ruleId); + expect(updatedRule.status).toBe(RuleStatus.PASS); + expect(updatedRule.statusUpdatedBy?.firstName).toBe(admin.firstName); + expect(updatedRule.statusUpdatedBy?.lastName).toBe(admin.lastName); + expect(updatedRule.statusUpdatedAt).toBeInstanceOf(Date); + }); + + it('Marks a rule back to Pending in the general view and clears who/when', async () => { + const car = await createUniqueCar(orgId); + const { leafRule1 } = await setupRules(car); + + await RulesService.setRuleStatus(admin, organization, leafRule1.ruleId, RuleStatus.FAIL); + const { rule: updatedRule } = await RulesService.setRuleStatus( + admin, + organization, + leafRule1.ruleId, + RuleStatus.PENDING + ); + + expect(updatedRule.status).toBe(RuleStatus.PENDING); + expect(updatedRule.statusUpdatedBy).toBeUndefined(); + expect(updatedRule.statusUpdatedAt).toBeUndefined(); + }); + + it('Returns every ancestor recalculated by the status rollup', async () => { + const car = await createUniqueCar(orgId); + const { ruleset1 } = await setupRules(car); + + const grandparentRule = await RulesService.createRule( + admin, + 'G', + 'Grandparent Rule', + ruleset1.rulesetId, + organization + ); + const parentRule = await RulesService.createRule( + admin, + 'G.1', + 'Parent Rule', + ruleset1.rulesetId, + organization, + grandparentRule.ruleId + ); + const childRule = await RulesService.createRule( + admin, + 'G.1.1', + 'Child Rule', + ruleset1.rulesetId, + organization, + parentRule.ruleId + ); + + // each rule is its parent's only child, so FAIL rolls all the way up the chain + const { ancestors } = await RulesService.setRuleStatus(admin, organization, childRule.ruleId, RuleStatus.FAIL); + expect(ancestors).toEqual([ + { ruleId: parentRule.ruleId, parentRuleId: grandparentRule.ruleId, status: RuleStatus.FAIL }, + { ruleId: grandparentRule.ruleId, parentRuleId: null, status: RuleStatus.FAIL } + ]); + }); + + it('Returns no ancestors when the rule set is top level', async () => { + const car = await createUniqueCar(orgId); + const { referencedRule } = await setupRules(car); + + // referencedRule has no parent, so there is no chain to roll up + const { ancestors } = await RulesService.setRuleStatus(admin, organization, referencedRule.ruleId, RuleStatus.PASS); + expect(ancestors).toEqual([]); + }); + + it('Set rule status fails if user does not have permission', async () => { + const car = await createUniqueCar(orgId); + const { leafRule1 } = await setupRules(car); + + await expect( + async () => await RulesService.setRuleStatus(nonLeadership, organization, leafRule1.ruleId, RuleStatus.PASS) + ).rejects.toThrow(new AccessDeniedException('You do not have permissions to update rule status')); + }); + + it('Set rule status fails if the rule has sub-rules', async () => { + const car = await createUniqueCar(orgId); + const { topLevelRule } = await setupRules(car); + + await expect( + async () => await RulesService.setRuleStatus(admin, organization, topLevelRule.ruleId, RuleStatus.PASS) + ).rejects.toThrow(new HttpException(400, 'Only child rule statuses can be updated directly.')); + }); + + // Setting Project Rule Status (per-project view) + it('Marks a rule Pass within a project and records who updated it', async () => { + const car = await createUniqueCar(orgId); + const { topLevelRule } = await setupRules(car); + const project = await createTestProject(admin, orgId, testTeam.teamId, car.carId, car.wbsElement.carNumber); + await RulesService.toggleRuleTeam(topLevelRule.ruleId, testTeam.teamId, admin, organization); + const projectRule = await RulesService.createProjectRule(admin, organization, topLevelRule.ruleId, project.projectId); + + const updatedProjectRule = await RulesService.setProjectRuleStatus( + admin, + organization, + projectRule.projectRuleId, + RuleStatus.PASS + ); + + expect(updatedProjectRule.projectRuleId).toBe(projectRule.projectRuleId); + expect(updatedProjectRule.status).toBe(RuleStatus.PASS); + expect(updatedProjectRule.statusUpdatedBy?.firstName).toBe(admin.firstName); + expect(updatedProjectRule.statusUpdatedBy?.lastName).toBe(admin.lastName); + expect(updatedProjectRule.statusUpdatedAt).toBeInstanceOf(Date); + }); + + it('Set project rule status fails if a member is not on the project team', async () => { + const car = await createUniqueCar(orgId); + const { topLevelRule } = await setupRules(car); + const project = await createTestProject(admin, orgId, testTeam.teamId, car.carId, car.wbsElement.carNumber); + await RulesService.toggleRuleTeam(topLevelRule.ruleId, testTeam.teamId, admin, organization); + const projectRule = await RulesService.createProjectRule(admin, organization, topLevelRule.ruleId, project.projectId); + + await expect( + async () => + await RulesService.setProjectRuleStatus(nonLeadership, organization, projectRule.projectRuleId, RuleStatus.PASS) + ).rejects.toThrow(new AccessDeniedException('You do not have permissions to update rule status for this project')); + }); + + it('Set project rule status fails if the rule has sub-rules assigned to the project', async () => { + const car = await createUniqueCar(orgId); + const { topLevelRule, leafRule1 } = await setupRules(car); + const project = await createTestProject(admin, orgId, testTeam.teamId, car.carId, car.wbsElement.carNumber); + await RulesService.toggleRuleTeam(topLevelRule.ruleId, testTeam.teamId, admin, organization); + await RulesService.toggleRuleTeam(leafRule1.ruleId, testTeam.teamId, admin, organization); + // creating leafRule1's project rule also assigns topLevelRule as its ancestor + await RulesService.createProjectRule(admin, organization, leafRule1.ruleId, project.projectId); + const topLevelProjectRule = await prisma.project_Rule.findUniqueOrThrow({ + where: { ruleId_projectId: { ruleId: topLevelRule.ruleId, projectId: project.projectId } } + }); + + await expect( + async () => + await RulesService.setProjectRuleStatus(admin, organization, topLevelProjectRule.projectRuleId, RuleStatus.PASS) + ).rejects.toThrow(new HttpException(400, 'Only child rule statuses can be updated directly.')); + }); + + it('A rule status in one project is independent of its general-view status and its status in other projects', async () => { + const car = await createUniqueCar(orgId); + const { topLevelRule, leafRule1, ruleset1 } = await setupRules(car); + await RulesService.toggleRuleTeam(topLevelRule.ruleId, testTeam.teamId, admin, organization); + await RulesService.toggleRuleTeam(leafRule1.ruleId, testTeam.teamId, admin, organization); + const project = await createTestProject(admin, orgId, testTeam.teamId, car.carId, car.wbsElement.carNumber, 1); + const project2 = await createTestProject(admin, orgId, testTeam.teamId, car.carId, car.wbsElement.carNumber, 2); + const projectRule1 = await RulesService.createProjectRule(admin, organization, leafRule1.ruleId, project.projectId); + const projectRule2 = await RulesService.createProjectRule(admin, organization, leafRule1.ruleId, project2.projectId); + + await RulesService.setProjectRuleStatus(admin, organization, projectRule1.projectRuleId, RuleStatus.PASS); + await RulesService.setRuleStatus(admin, organization, leafRule1.ruleId, RuleStatus.FAIL); + + const projectRules2 = await RulesService.getProjectRules(admin, ruleset1.rulesetId, project2.projectId, organization); + const rule2Entry = projectRules2.find((pr) => pr.projectRuleId === projectRule2.projectRuleId); + + expect(rule2Entry?.status).toBe(RuleStatus.PENDING); + expect(rule2Entry?.rule.status).toBe(RuleStatus.FAIL); + }); + + it('Deleting a rule leaves its parent chain stale once the parent becomes childless', async () => { + const car = await createUniqueCar(orgId); + const { ruleset1 } = await setupRules(car); + + const grandparentRule = await RulesService.createRule( + admin, + 'G', + 'Grandparent Rule', + ruleset1.rulesetId, + organization + ); + const parentRule = await RulesService.createRule( + admin, + 'G.1', + 'Parent Rule', + ruleset1.rulesetId, + organization, + grandparentRule.ruleId + ); + const childRule = await RulesService.createRule( + admin, + 'G.1.1', + 'Child Rule', + ruleset1.rulesetId, + organization, + parentRule.ruleId + ); + + // childRule is parentRule's only child, and parentRule is grandparentRule's only child, + // so FAIL rolls all the way up the chain + await RulesService.setRuleStatus(admin, organization, childRule.ruleId, RuleStatus.FAIL); + + const rulesBeforeDelete = await RulesService.getAllRulesForRuleset( + admin, + ruleset1.rulesetId, + organization.organizationId + ); + expect(rulesBeforeDelete.find((r) => r.ruleId === parentRule.ruleId)?.status).toBe(RuleStatus.FAIL); + expect(rulesBeforeDelete.find((r) => r.ruleId === grandparentRule.ruleId)?.status).toBe(RuleStatus.FAIL); + + // deleting parentRule's last remaining child leaves parentRule childless; its rolled-up + // status should reset to Pending, and that change should keep propagating up to grandparentRule + await RulesService.deleteRule(childRule.ruleId, admin, organization); + + const rulesAfterDelete = await RulesService.getAllRulesForRuleset( + admin, + ruleset1.rulesetId, + organization.organizationId + ); + const updatedParent = rulesAfterDelete.find((r) => r.ruleId === parentRule.ruleId); + const updatedGrandparent = rulesAfterDelete.find((r) => r.ruleId === grandparentRule.ruleId); + + expect(updatedParent?.status).toBe(RuleStatus.PENDING); + expect(updatedGrandparent?.status).toBe(RuleStatus.PENDING); + }); + + it('A rule that gains then loses a child returns to Pending without extra status history entries', async () => { + const car = await createUniqueCar(orgId); + const { ruleset1 } = await setupRules(car); + + const rule = await RulesService.createRule( + admin, + 'H', + 'Rule that will gain a child', + ruleset1.rulesetId, + organization + ); + + // rule starts as a leaf, so it can be marked FAIL directly; this is the only direct write + await RulesService.setRuleStatus(admin, organization, rule.ruleId, RuleStatus.FAIL); + + // giving it a child rolls rule's status to Pending, since the new child defaults to Pending + const childRule = await RulesService.createRule( + admin, + 'H.1', + 'New child rule', + ruleset1.rulesetId, + organization, + rule.ruleId + ); + + // marking the child Pass rolls parent rule up to Pass too + await RulesService.setRuleStatus(admin, organization, childRule.ruleId, RuleStatus.PASS); + const rules = await RulesService.getAllRulesForRuleset(admin, ruleset1.rulesetId, organization.organizationId); + const parentRule = rules.find((r) => r.ruleId === rule.ruleId); + expect(parentRule!.status).toBe(RuleStatus.PASS); + + // rule still has history from its FAIL write back when it was a leaf + // the frontend relies on subRuleIds being non-empty (i.e. isLeaf being false) + // to hide the status-history tooltip for a rule that is currently a parent + expect(parentRule!.hasStatusHistory).toBe(true); + expect(parentRule!.subRuleIds.length).toBeGreaterThan(0); + + // deleting the only child makes rule a leaf again, so it should reset to Pending + await RulesService.deleteRule(childRule.ruleId, admin, organization); + + const allRules = await RulesService.getAllRulesForRuleset(admin, ruleset1.rulesetId, organization.organizationId); + const updatedRule = allRules.find((r) => r.ruleId === rule.ruleId); + + expect(updatedRule?.status).toBe(RuleStatus.PENDING); + + // the Pending -> Pass -> Pending transitions caused by gaining and losing a child are + // rollups, not direct writes, so only the original direct FAIL write is recorded + const historyCount = await prisma.rule_Status_History.count({ where: { ruleId: rule.ruleId } }); + expect(historyCount).toBe(1); + }); + + it('Deleting a project rule removes the ancestors it leaves childless in the project', async () => { + const car = await createUniqueCar(orgId); + const { ruleset1 } = await setupRules(car); + + const grandparentRule = await RulesService.createRule( + admin, + 'G', + 'Grandparent Rule', + ruleset1.rulesetId, + organization + ); + const parentRule = await RulesService.createRule( + admin, + 'G.1', + 'Parent Rule', + ruleset1.rulesetId, + organization, + grandparentRule.ruleId + ); + const childRule = await RulesService.createRule( + admin, + 'G.1.1', + 'Child Rule', + ruleset1.rulesetId, + organization, + parentRule.ruleId + ); + await RulesService.toggleRuleTeam(grandparentRule.ruleId, testTeam.teamId, admin, organization); + await RulesService.toggleRuleTeam(parentRule.ruleId, testTeam.teamId, admin, organization); + await RulesService.toggleRuleTeam(childRule.ruleId, testTeam.teamId, admin, organization); + + const project = await createTestProject(admin, orgId, testTeam.teamId, car.carId, car.wbsElement.carNumber); + // creating childRule's project rule also assigns parentRule and grandparentRule as ancestors + const childProjectRule = await RulesService.createProjectRule( + admin, + organization, + childRule.ruleId, + project.projectId + ); + + // childRule is parentRule's only child assigned to the project, and parentRule is grandparentRule's + // only child assigned to the project, so FAIL rolls all the way up the chain + await RulesService.setProjectRuleStatus(admin, organization, childProjectRule.projectRuleId, RuleStatus.FAIL); + + const projectRulesBeforeDelete = await RulesService.getProjectRules( + admin, + ruleset1.rulesetId, + project.projectId, + organization + ); + expect(projectRulesBeforeDelete.find((pr) => pr.rule.ruleId === parentRule.ruleId)?.status).toBe(RuleStatus.FAIL); + expect(projectRulesBeforeDelete.find((pr) => pr.rule.ruleId === grandparentRule.ruleId)?.status).toBe(RuleStatus.FAIL); + + // deleting childRule's project rule leaves parentRule childless in this project, and removing + // parentRule in turn leaves grandparentRule childless, so the whole chain is unassigned + await RulesService.deleteProjectRule(childProjectRule.projectRuleId, admin, organization); + + const projectRulesAfterDelete = await RulesService.getProjectRules( + admin, + ruleset1.rulesetId, + project.projectId, + organization + ); + + expect(projectRulesAfterDelete.find((pr) => pr.rule.ruleId === childRule.ruleId)).toBeUndefined(); + expect(projectRulesAfterDelete.find((pr) => pr.rule.ruleId === parentRule.ruleId)).toBeUndefined(); + expect(projectRulesAfterDelete.find((pr) => pr.rule.ruleId === grandparentRule.ruleId)).toBeUndefined(); + }); + + it('Deleting a project rule recalculates the status of an ancestor that keeps other children', async () => { + const car = await createUniqueCar(orgId); + const { ruleset1 } = await setupRules(car); + + // G keeps two branches: G.1 -> G.1.1, and the leaf G.2. Deleting G.1.1 removes the G.1 branch + // but leaves G assigned, so G's status has to be rolled up again from G.2 alone + const grandparentRule = await RulesService.createRule( + admin, + 'G', + 'Grandparent Rule', + ruleset1.rulesetId, + organization + ); + const parentRule = await RulesService.createRule( + admin, + 'G.1', + 'Parent Rule', + ruleset1.rulesetId, + organization, + grandparentRule.ruleId + ); + const childRule = await RulesService.createRule( + admin, + 'G.1.1', + 'Child Rule', + ruleset1.rulesetId, + organization, + parentRule.ruleId + ); + const siblingRule = await RulesService.createRule( + admin, + 'G.2', + 'Sibling Rule', + ruleset1.rulesetId, + organization, + grandparentRule.ruleId + ); + await RulesService.toggleRuleTeam(grandparentRule.ruleId, testTeam.teamId, admin, organization); + await RulesService.toggleRuleTeam(parentRule.ruleId, testTeam.teamId, admin, organization); + await RulesService.toggleRuleTeam(childRule.ruleId, testTeam.teamId, admin, organization); + await RulesService.toggleRuleTeam(siblingRule.ruleId, testTeam.teamId, admin, organization); + + const project = await createTestProject(admin, orgId, testTeam.teamId, car.carId, car.wbsElement.carNumber); + const childProjectRule = await RulesService.createProjectRule( + admin, + organization, + childRule.ruleId, + project.projectId + ); + // grandparentRule is already assigned as childRule's ancestor, so this only adds the sibling + await RulesService.createProjectRule(admin, organization, siblingRule.ruleId, project.projectId); + + // failing G.1.1 fails G.1, and a failing child makes G fail too even though G.2 is still Pending + await RulesService.setProjectRuleStatus(admin, organization, childProjectRule.projectRuleId, RuleStatus.FAIL); + + const projectRulesBeforeDelete = await RulesService.getProjectRules( + admin, + ruleset1.rulesetId, + project.projectId, + organization + ); + expect(projectRulesBeforeDelete.find((pr) => pr.rule.ruleId === parentRule.ruleId)?.status).toBe(RuleStatus.FAIL); + expect(projectRulesBeforeDelete.find((pr) => pr.rule.ruleId === grandparentRule.ruleId)?.status).toBe(RuleStatus.FAIL); + + await RulesService.deleteProjectRule(childProjectRule.projectRuleId, admin, organization); + + const projectRulesAfterDelete = await RulesService.getProjectRules( + admin, + ruleset1.rulesetId, + project.projectId, + organization + ); + + // the G.1 branch is gone, but G survives on G.2 and now rolls up that lone Pending child + expect(projectRulesAfterDelete.find((pr) => pr.rule.ruleId === childRule.ruleId)).toBeUndefined(); + expect(projectRulesAfterDelete.find((pr) => pr.rule.ruleId === parentRule.ruleId)).toBeUndefined(); + expect(projectRulesAfterDelete.find((pr) => pr.rule.ruleId === siblingRule.ruleId)?.status).toBe(RuleStatus.PENDING); + expect(projectRulesAfterDelete.find((pr) => pr.rule.ruleId === grandparentRule.ruleId)?.status).toBe( + RuleStatus.PENDING + ); + }); + + it('Deleting a rule that is assigned to a project unassigns it and recalculates the project chain', async () => { + const car = await createUniqueCar(orgId); + const { ruleset1 } = await setupRules(car); + + const parentRule = await RulesService.createRule(admin, 'G', 'Parent Rule', ruleset1.rulesetId, organization); + const childRule = await RulesService.createRule( + admin, + 'G.1', + 'Child Rule', + ruleset1.rulesetId, + organization, + parentRule.ruleId + ); + await RulesService.toggleRuleTeam(parentRule.ruleId, testTeam.teamId, admin, organization); + await RulesService.toggleRuleTeam(childRule.ruleId, testTeam.teamId, admin, organization); + + const project = await createTestProject(admin, orgId, testTeam.teamId, car.carId, car.wbsElement.carNumber); + // creating childRule's project rule also assigns parentRule as its ancestor + const childProjectRule = await RulesService.createProjectRule( + admin, + organization, + childRule.ruleId, + project.projectId + ); + + // childRule is parentRule's only child in the project, so FAIL rolls up to parentRule + await RulesService.setProjectRuleStatus(admin, organization, childProjectRule.projectRuleId, RuleStatus.FAIL); + + const projectRulesBeforeDelete = await RulesService.getProjectRules( + admin, + ruleset1.rulesetId, + project.projectId, + organization + ); + expect(projectRulesBeforeDelete.find((pr) => pr.rule.ruleId === parentRule.ruleId)?.status).toBe(RuleStatus.FAIL); + + // deleting the rule itself, not just its project assignment + await RulesService.deleteRule(childRule.ruleId, admin, organization); + + const projectRulesAfterDelete = await RulesService.getProjectRules( + admin, + ruleset1.rulesetId, + project.projectId, + organization + ); + + // delete rule removes project rules with the same logic as the remove project rule modal + expect(projectRulesAfterDelete.find((pr) => pr.rule.ruleId === childRule.ruleId)).toBeUndefined(); + expect(projectRulesAfterDelete.find((pr) => pr.rule.ruleId === parentRule.ruleId)).toBeUndefined(); + + // child and parent ancestors with no remaining children are soft-deleted + const deletedChildProjectRule = await prisma.project_Rule.findUnique({ + where: { projectRuleId: childProjectRule.projectRuleId } + }); + expect(deletedChildProjectRule?.dateDeleted).not.toBeNull(); + expect(deletedChildProjectRule?.deletedByUserId).toBe(admin.userId); + + const deletedParentProjectRule = await prisma.project_Rule.findUnique({ + where: { ruleId_projectId: { ruleId: parentRule.ruleId, projectId: project.projectId } } + }); + expect(deletedParentProjectRule?.dateDeleted).not.toBeNull(); + expect(deletedParentProjectRule?.deletedByUserId).toBe(admin.userId); + }); + + it('Deleting a rule leaves ancestors assigned when they still have other children in the project', async () => { + const car = await createUniqueCar(orgId); + const { ruleset1 } = await setupRules(car); + + // G keeps two children in the project, so deleting G.1 must not unassign G + const parentRule = await RulesService.createRule(admin, 'G', 'Parent Rule', ruleset1.rulesetId, organization); + const childRule = await RulesService.createRule( + admin, + 'G.1', + 'Child Rule', + ruleset1.rulesetId, + organization, + parentRule.ruleId + ); + const siblingRule = await RulesService.createRule( + admin, + 'G.2', + 'Sibling Rule', + ruleset1.rulesetId, + organization, + parentRule.ruleId + ); + for (const rule of [parentRule, childRule, siblingRule]) { + await RulesService.toggleRuleTeam(rule.ruleId, testTeam.teamId, admin, organization); + } + + const project = await createTestProject(admin, orgId, testTeam.teamId, car.carId, car.wbsElement.carNumber); + await RulesService.createProjectRule(admin, organization, childRule.ruleId, project.projectId); + const siblingProjectRule = await RulesService.createProjectRule( + admin, + organization, + siblingRule.ruleId, + project.projectId + ); + + await RulesService.setProjectRuleStatus(admin, organization, siblingProjectRule.projectRuleId, RuleStatus.PASS); + + await RulesService.deleteRule(childRule.ruleId, admin, organization); + + const projectRulesAfterDelete = await RulesService.getProjectRules( + admin, + ruleset1.rulesetId, + project.projectId, + organization + ); + + expect(projectRulesAfterDelete.find((pr) => pr.rule.ruleId === childRule.ruleId)).toBeUndefined(); + // G survives on G.2 and now rolls up that lone passing child + expect(projectRulesAfterDelete.find((pr) => pr.rule.ruleId === siblingRule.ruleId)?.status).toBe(RuleStatus.PASS); + expect(projectRulesAfterDelete.find((pr) => pr.rule.ruleId === parentRule.ruleId)?.status).toBe(RuleStatus.PASS); + }); + }); + + describe('Reset ruleset statuses', () => { + it('Resets every rule status in the ruleset to Pending and clears who/when', async () => { + const car = await createUniqueCar(orgId); + const { ruleset1, topLevelRule, leafRule1 } = await setupRules(car); + + // topLevelRule has sub-rules, so its status can't be set directly; setting leafRule1 + // rolls up to also mark topLevelRule non-Pending + await RulesService.setRuleStatus(admin, organization, leafRule1.ruleId, RuleStatus.FAIL); + + const count = await RulesService.resetRulesetStatuses(admin, organization, ruleset1.rulesetId); + + expect(count).toBe(2); + + const rules = await RulesService.getAllRulesForRuleset(admin, ruleset1.rulesetId, organization.organizationId); + const updatedTopLevel = rules.find((r) => r.ruleId === topLevelRule.ruleId); + const updatedLeaf = rules.find((r) => r.ruleId === leafRule1.ruleId); + + expect(updatedTopLevel?.status).toBe(RuleStatus.PENDING); + expect(updatedTopLevel?.statusUpdatedBy).toBeUndefined(); + expect(updatedTopLevel?.statusUpdatedAt).toBeUndefined(); + expect(updatedLeaf?.status).toBe(RuleStatus.PENDING); + }); + + it('Reset does not create Rule_Status_History entries', async () => { + const car = await createUniqueCar(orgId); + const { ruleset1, leafRule1 } = await setupRules(car); + + await RulesService.setRuleStatus(admin, organization, leafRule1.ruleId, RuleStatus.PASS); + const countBefore = await prisma.rule_Status_History.count(); + + await RulesService.resetRulesetStatuses(admin, organization, ruleset1.rulesetId); + const countAfter = await prisma.rule_Status_History.count(); + + expect(countAfter).toBe(countBefore); + }); + + it('Reset status fails if user does not have permission', async () => { + const car = await createUniqueCar(orgId); + const { ruleset1 } = await setupRules(car); + + await expect( + async () => await RulesService.resetRulesetStatuses(nonLeadership, organization, ruleset1.rulesetId) + ).rejects.toThrow(new AccessDeniedException('You do not have permissions to reset rule status')); + }); + + it('Reset status succeeds for a head (non-admin) user', async () => { + const car = await createUniqueCar(orgId); + const { ruleset1, leafRule1 } = await setupRules(car); + + await RulesService.setRuleStatus(admin, organization, leafRule1.ruleId, RuleStatus.PASS); + + const count = await RulesService.resetRulesetStatuses(head, organization, ruleset1.rulesetId); + + expect(count).toBe(1); + }); + + it('Reset status only affects the given ruleset', async () => { + const car = await createUniqueCar(orgId); + const { ruleset1, ruleset2, leafRule1 } = await setupRules(car); + + const otherRule = await prisma.rule.create({ + data: { + ruleCode: 'X', + ruleContent: 'Rule in a different ruleset', + ruleset: { connect: { rulesetId: ruleset2.rulesetId } }, + createdBy: { connect: { userId: admin.userId } } + } + }); + + await RulesService.setRuleStatus(admin, organization, leafRule1.ruleId, RuleStatus.PASS); + await RulesService.setRuleStatus(admin, organization, otherRule.ruleId, RuleStatus.PASS); + + await RulesService.resetRulesetStatuses(admin, organization, ruleset1.rulesetId); + + const rules = await RulesService.getAllRulesForRuleset(admin, ruleset2.rulesetId, organization.organizationId); + const untouchedRule = rules.find((r) => r.ruleId === otherRule.ruleId); + + expect(untouchedRule?.status).toBe(RuleStatus.PASS); + }); + + it('Reset status when there is nothing to reset', async () => { + const car = await createUniqueCar(orgId); + const { ruleset1 } = await setupRules(car); + + const count = await RulesService.resetRulesetStatuses(admin, organization, ruleset1.rulesetId); + + expect(count).toBe(0); + }); + }); + + describe('Reset project rule statuses', () => { + it('Resets every project rule status for the project+ruleset', async () => { + const car = await createUniqueCar(orgId); + const { ruleset1, topLevelRule } = await setupRules(car); + const project = await createTestProject(admin, orgId, testTeam.teamId, car.carId, car.wbsElement.carNumber); + await RulesService.toggleRuleTeam(topLevelRule.ruleId, testTeam.teamId, admin, organization); + const projectRule = await RulesService.createProjectRule(admin, organization, topLevelRule.ruleId, project.projectId); + + await RulesService.setProjectRuleStatus(admin, organization, projectRule.projectRuleId, RuleStatus.PASS); + + const count = await RulesService.resetProjectRuleStatuses(admin, organization, ruleset1.rulesetId, project.projectId); + + expect(count).toBe(1); + + const projectRules = await RulesService.getProjectRules(admin, ruleset1.rulesetId, project.projectId, organization); + const updated = projectRules.find((pr) => pr.projectRuleId === projectRule.projectRuleId); + + expect(updated?.status).toBe(RuleStatus.PENDING); + expect(updated?.statusUpdatedBy).toBeUndefined(); + expect(updated?.statusUpdatedAt).toBeUndefined(); + }); + + it('Reset project status count leaves out project rules whose rule was deleted', async () => { + const car = await createUniqueCar(orgId); + const { ruleset1, topLevelRule } = await setupRules(car); + const project = await createTestProject(admin, orgId, testTeam.teamId, car.carId, car.wbsElement.carNumber); + + const otherTopLevelRule = await RulesService.createRule(admin, 'Z', 'Other Rule', ruleset1.rulesetId, organization); + await RulesService.toggleRuleTeam(topLevelRule.ruleId, testTeam.teamId, admin, organization); + await RulesService.toggleRuleTeam(otherTopLevelRule.ruleId, testTeam.teamId, admin, organization); + + const projectRule = await RulesService.createProjectRule(admin, organization, topLevelRule.ruleId, project.projectId); + const otherProjectRule = await RulesService.createProjectRule( + admin, + organization, + otherTopLevelRule.ruleId, + project.projectId + ); + + await RulesService.setProjectRuleStatus(admin, organization, projectRule.projectRuleId, RuleStatus.PASS); + await RulesService.setProjectRuleStatus(admin, organization, otherProjectRule.projectRuleId, RuleStatus.PASS); + + await prisma.rule.update({ + where: { ruleId: otherTopLevelRule.ruleId }, + data: { dateDeleted: new Date(), deletedByUserId: admin.userId } + }); + + const count = await RulesService.resetProjectRuleStatuses(admin, organization, ruleset1.rulesetId, project.projectId); + + // only the rule the project actually displays is counted + expect(count).toBe(1); + + const projectRules = await RulesService.getProjectRules(admin, ruleset1.rulesetId, project.projectId, organization); + expect(projectRules.find((pr) => pr.projectRuleId === projectRule.projectRuleId)?.status).toBe(RuleStatus.PENDING); + }); + + it('Reset project status does not create Rule_Status_History entries', async () => { + const car = await createUniqueCar(orgId); + const { ruleset1, topLevelRule } = await setupRules(car); + const project = await createTestProject(admin, orgId, testTeam.teamId, car.carId, car.wbsElement.carNumber); + await RulesService.toggleRuleTeam(topLevelRule.ruleId, testTeam.teamId, admin, organization); + const projectRule = await RulesService.createProjectRule(admin, organization, topLevelRule.ruleId, project.projectId); + + await RulesService.setProjectRuleStatus(admin, organization, projectRule.projectRuleId, RuleStatus.PASS); + const countBefore = await prisma.rule_Status_History.count(); + + await RulesService.resetProjectRuleStatuses(admin, organization, ruleset1.rulesetId, project.projectId); + const countAfter = await prisma.rule_Status_History.count(); + + expect(countAfter).toBe(countBefore); + }); + + it('Reset project status fails if user does not have permission', async () => { + const car = await createUniqueCar(orgId); + const { ruleset1, topLevelRule } = await setupRules(car); + const project = await createTestProject(admin, orgId, testTeam.teamId, car.carId, car.wbsElement.carNumber); + await RulesService.toggleRuleTeam(topLevelRule.ruleId, testTeam.teamId, admin, organization); + await RulesService.createProjectRule(admin, organization, topLevelRule.ruleId, project.projectId); + + await expect( + async () => + await RulesService.resetProjectRuleStatuses(nonLeadership, organization, ruleset1.rulesetId, project.projectId) + ).rejects.toThrow(new AccessDeniedException('You do not have permissions to reset project rule status')); + }); + + it('Reset project status only affects the given project', async () => { + const car = await createUniqueCar(orgId); + const { ruleset1, topLevelRule } = await setupRules(car); + await RulesService.toggleRuleTeam(topLevelRule.ruleId, testTeam.teamId, admin, organization); + const project1 = await createTestProject(admin, orgId, testTeam.teamId, car.carId, car.wbsElement.carNumber, 1); + const project2 = await createTestProject(admin, orgId, testTeam.teamId, car.carId, car.wbsElement.carNumber, 2); + const projectRule1 = await RulesService.createProjectRule( + admin, + organization, + topLevelRule.ruleId, + project1.projectId + ); + const projectRule2 = await RulesService.createProjectRule( + admin, + organization, + topLevelRule.ruleId, + project2.projectId + ); + + await RulesService.setProjectRuleStatus(admin, organization, projectRule1.projectRuleId, RuleStatus.PASS); + await RulesService.setProjectRuleStatus(admin, organization, projectRule2.projectRuleId, RuleStatus.PASS); + + await RulesService.resetProjectRuleStatuses(admin, organization, ruleset1.rulesetId, project1.projectId); + + const project2Rules = await RulesService.getProjectRules(admin, ruleset1.rulesetId, project2.projectId, organization); + const untouched = project2Rules.find((pr) => pr.projectRuleId === projectRule2.projectRuleId); + + expect(untouched?.status).toBe(RuleStatus.PASS); + }); + + it('Reset project status only affects the given ruleset within that project', async () => { + const car = await createUniqueCar(orgId); + const { ruleset1, ruleset2, topLevelRule } = await setupRules(car); + const project = await createTestProject(admin, orgId, testTeam.teamId, car.carId, car.wbsElement.carNumber); + await RulesService.toggleRuleTeam(topLevelRule.ruleId, testTeam.teamId, admin, organization); + + const otherRule = await prisma.rule.create({ + data: { + ruleCode: 'Y', + ruleContent: 'Rule in a different ruleset, same project', + ruleset: { connect: { rulesetId: ruleset2.rulesetId } }, + createdBy: { connect: { userId: admin.userId } } + } + }); + await RulesService.toggleRuleTeam(otherRule.ruleId, testTeam.teamId, admin, organization); + + const projectRule1 = await RulesService.createProjectRule(admin, organization, topLevelRule.ruleId, project.projectId); + const projectRule2 = await RulesService.createProjectRule(admin, organization, otherRule.ruleId, project.projectId); + + await RulesService.setProjectRuleStatus(admin, organization, projectRule1.projectRuleId, RuleStatus.PASS); + await RulesService.setProjectRuleStatus(admin, organization, projectRule2.projectRuleId, RuleStatus.PASS); + + await RulesService.resetProjectRuleStatuses(admin, organization, ruleset1.rulesetId, project.projectId); + + const projectRules = await RulesService.getProjectRules(admin, ruleset2.rulesetId, project.projectId, organization); + const untouched = projectRules.find((pr) => pr.projectRuleId === projectRule2.projectRuleId); + + expect(untouched?.status).toBe(RuleStatus.PASS); + }); + + it('Set project rule status succeeds if a member is on the project team', async () => { + const car = await createUniqueCar(orgId); + const { topLevelRule } = await setupRules(car); + const memberProject = await createTestProject(admin, orgId, testTeam.teamId, car.carId, car.wbsElement.carNumber); + await RulesService.toggleRuleTeam(topLevelRule.ruleId, testTeam.teamId, admin, organization); + const projectRule = await RulesService.createProjectRule( + admin, + organization, + topLevelRule.ruleId, + memberProject.projectId + ); + await prisma.team.update({ + where: { teamId: testTeam.teamId }, + data: { members: { connect: { userId: nonLeadership.userId } } } + }); + + const updatedProjectRule = await RulesService.setProjectRuleStatus( + nonLeadership, + organization, + projectRule.projectRuleId, + RuleStatus.PASS + ); + + expect(updatedProjectRule.status).toBe(RuleStatus.PASS); + expect(updatedProjectRule.statusUpdatedBy?.firstName).toBe(nonLeadership.firstName); + expect(updatedProjectRule.statusUpdatedBy?.lastName).toBe(nonLeadership.lastName); + }); + }); + + describe('Edit Rule', () => { + it('Fails if user is not leadership or above', 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 AccessDeniedException('Only leadership and above can 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'); + }); + + it('Succeeds and edits a rule to have blank content', async () => { + const car = await createUniqueCar(orgId); + const { leafRule1 } = await setupRules(car); + const updatedRule = await RulesService.editRule( + admin, + '', + leafRule1.ruleId, + leafRule1.ruleCode, + leafRule1.imageFileIds, + organization + ); + + expect(updatedRule.ruleContent).toEqual(''); + }); + + it('Fails when new rule code is blank', async () => { + const car = await createUniqueCar(orgId); + const { leafRule1 } = await setupRules(car); + + await expect( + RulesService.editRule(admin, leafRule1.ruleContent, leafRule1.ruleId, '', leafRule1.imageFileIds, organization) + ).rejects.toThrow(new HttpException(400, 'Rule code cannot be empty')); + }); + + it('Succeeds and changes a rule code that still satisfies the parent prefix', async () => { + const car = await createUniqueCar(orgId); + const { leafRule1 } = await setupRules(car); + + const updatedRule = await RulesService.editRule( + admin, + leafRule1.ruleContent, + leafRule1.ruleId, + 'T99', + leafRule1.imageFileIds, + organization + ); + + expect(updatedRule.ruleCode).toEqual('T99'); + }); + + it('Fails when new rule code duplicates another rule in the same ruleset', async () => { + const car = await createUniqueCar(orgId); + const { leafRule1, leafRule2 } = await setupRules(car); + + await expect( + RulesService.editRule( + admin, + leafRule1.ruleContent, + leafRule1.ruleId, + leafRule2.ruleCode, + leafRule1.imageFileIds, + organization + ) + ).rejects.toThrow(new HttpException(400, `Rule with code ${leafRule2.ruleCode} already exists in this ruleset`)); + }); + + it('Fails when new rule code duplicates another rule code padded with whitespace', async () => { + const car = await createUniqueCar(orgId); + const { leafRule1, leafRule2 } = await setupRules(car); + + await expect( + RulesService.editRule( + admin, + leafRule1.ruleContent, + leafRule1.ruleId, + ` ${leafRule2.ruleCode} `, + leafRule1.imageFileIds, + organization + ) + ).rejects.toThrow(new HttpException(400, `Rule with code ${leafRule2.ruleCode} already exists in this ruleset`)); + }); + + it('Fails when parent rule is in a different ruleset', async () => { + const car = await createUniqueCar(orgId); + const { ruleset2, leafRule1 } = await setupRules(car); + + const otherRulesetRule = await prisma.rule.create({ + data: { + ruleCode: 'X1', + ruleContent: 'Rule in a different ruleset', + imageFileIds: [], + dateCreated: new Date(), + ruleset: { connect: { rulesetId: ruleset2.rulesetId } }, + createdBy: { connect: { userId: admin.userId } } + } + }); + + await expect( + RulesService.editRule( + admin, + leafRule1.ruleContent, + leafRule1.ruleId, + leafRule1.ruleCode, + leafRule1.imageFileIds, + organization, + otherRulesetRule.ruleId + ) + ).rejects.toThrow(new HttpException(400, 'Parent rule must be in the same ruleset')); + }); + + it('Allows a new rule code that does not start with the existing parent rule code', async () => { + const car = await createUniqueCar(orgId); + const { leafRule2 } = await setupRules(car); // leafRule2's parent code is 'T' + + const updatedRule = await RulesService.editRule( + admin, + leafRule2.ruleContent, + leafRule2.ruleId, + 'X2.1', + leafRule2.imageFileIds, + organization + ); + + expect(updatedRule.parentRule?.ruleCode).not.toEqual('X2'); + expect(updatedRule.ruleCode).toEqual('X2.1'); + }); + }); + + 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('You do not have permissions to delete this ruleset.')); + }); + it('Delete ruleset succeeds if a leadership user created the ruleset', async () => { + const car = await createUniqueCar(orgId); + const leadershipUser = await createTestUser(aquamanLeadership, orgId); + const ruleset = await prisma.ruleset.create({ + data: { + name: 'Leadership Ruleset', + fileId: 'leadership-created-ruleset-file', + active: false, + car: { connect: { carId: car.carId } }, + createdBy: { connect: { userId: leadershipUser.userId } }, + rulesetType: { connect: { rulesetTypeId: fsaeRulesetType.rulesetTypeId } } + } + }); + + const deleted = await RulesService.deleteRuleset( + ruleset.rulesetId, + leadershipUser.userId, + organization.organizationId + ); + + expect(deleted).toBeDefined(); + expect(deleted.rulesetId).toBe(ruleset.rulesetId); + }); + it('Delete ruleset fails if a leadership user did not create the ruleset', async () => { + const car = await createUniqueCar(orgId); + const leadershipUser = await createTestUser(aquamanLeadership, orgId); + const ruleset = await prisma.ruleset.create({ + data: { + name: 'Admin Created Ruleset', + fileId: 'admin-created-ruleset-file', + active: false, + car: { connect: { carId: car.carId } }, + createdBy: { connect: { userId: admin.userId } }, + rulesetType: { connect: { rulesetTypeId: fsaeRulesetType.rulesetTypeId } } + } + }); + + await expect( + async () => await RulesService.deleteRuleset(ruleset.rulesetId, leadershipUser.userId, organization.organizationId) + ).rejects.toThrow(new AccessDeniedException('You do not have permissions to delete this 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(admin, 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(admin, 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(admin, 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); + }); + + 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', () => { + 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 AccessDeniedException('Only leadership and above can assign rules to teams')); + }); + it('Fails if user is a member', async () => { + const car = await createUniqueCar(orgId); + const { topLevelRule } = await setupRules(car); + await expect( + async () => await RulesService.toggleRuleTeam(topLevelRule.ruleId, '', nonLeadership, organization) + ).rejects.toThrow(new AccessDeniedException('Only leadership and above can assign rules to teams')); + }); + 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, topLevelRule } = await setupRules(car); + const project = await createTestProject(admin, orgId, testTeam.teamId, car.carId, car.wbsElement.carNumber); + // every rule in the chain must be on the project's team for the leaf to be assignable + await RulesService.toggleRuleTeam(topLevelRule.ruleId, testTeam.teamId, admin, organization); + await RulesService.toggleRuleTeam(leafRule1.ruleId, testTeam.teamId, admin, organization); + const projectRule = await RulesService.createProjectRule(admin, organization, leafRule1.ruleId, project.projectId); + + const deletedProjectRule = await RulesService.deleteProjectRule(projectRule.projectRuleId, admin, organization); + + expect(deletedProjectRule).toBeDefined(); + expect(deletedProjectRule.projectRuleId).toBe(projectRule.projectRuleId); + + const found = await prisma.project_Rule.findUnique({ where: { projectRuleId: projectRule.projectRuleId } }); + expect(found?.dateDeleted).toBeDefined(); + }); + it('Delete project rule fails if user does not have permission', async () => { + const car = await createUniqueCar(orgId); + const { leafRule1, topLevelRule } = await setupRules(car); + const project = await createTestProject(admin, orgId, testTeam.teamId, car.carId, car.wbsElement.carNumber); + await RulesService.toggleRuleTeam(topLevelRule.ruleId, testTeam.teamId, admin, organization); + await RulesService.toggleRuleTeam(leafRule1.ruleId, testTeam.teamId, admin, organization); + const projectRule = await RulesService.createProjectRule(admin, organization, leafRule1.ruleId, project.projectId); + + await expect( + async () => await RulesService.deleteProjectRule(projectRule.projectRuleId, nonLeadership, organization) + ).rejects.toThrow(new AccessDeniedException('You do not have permissions to delete project rules')); + }); + it('Delete project rule fails if project rule was already deleted', async () => { + const car = await createUniqueCar(orgId); + const { leafRule1, topLevelRule } = await setupRules(car); + const project = await createTestProject(admin, orgId, testTeam.teamId, car.carId, car.wbsElement.carNumber); + await RulesService.toggleRuleTeam(topLevelRule.ruleId, testTeam.teamId, admin, organization); + await RulesService.toggleRuleTeam(leafRule1.ruleId, testTeam.teamId, admin, organization); + 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')); + }); + it('Deletes an ancestor project rule when it has no remaining children in the project', async () => { + const car = await createUniqueCar(orgId); + const { leafRule1, topLevelRule } = await setupRules(car); + const project = await createTestProject(admin, orgId, testTeam.teamId, car.carId, car.wbsElement.carNumber); + await RulesService.toggleRuleTeam(topLevelRule.ruleId, testTeam.teamId, admin, organization); + await RulesService.toggleRuleTeam(leafRule1.ruleId, testTeam.teamId, admin, organization); + // creating the leaf's project rule also creates the top-level rule's project rule as an ancestor + const leafProjectRule = await RulesService.createProjectRule(admin, organization, leafRule1.ruleId, project.projectId); + const topLevelProjectRule = await prisma.project_Rule.findUniqueOrThrow({ + where: { ruleId_projectId: { ruleId: topLevelRule.ruleId, projectId: project.projectId } } + }); + expect(topLevelProjectRule.dateDeleted).toBeNull(); + + await RulesService.deleteProjectRule(leafProjectRule.projectRuleId, admin, organization); + + const foundLeaf = await prisma.project_Rule.findUniqueOrThrow({ + where: { projectRuleId: leafProjectRule.projectRuleId } + }); + expect(foundLeaf.dateDeleted).toBeDefined(); + const foundTopLevel = await prisma.project_Rule.findUniqueOrThrow({ + where: { projectRuleId: topLevelProjectRule.projectRuleId } + }); + expect(foundTopLevel.dateDeleted).toBeDefined(); + }); + it('Fails to delete a project rule that still has children assigned to the project', async () => { + const car = await createUniqueCar(orgId); + const { leafRule1, topLevelRule } = await setupRules(car); + const project = await createTestProject(admin, orgId, testTeam.teamId, car.carId, car.wbsElement.carNumber); + await RulesService.toggleRuleTeam(topLevelRule.ruleId, testTeam.teamId, admin, organization); + await RulesService.toggleRuleTeam(leafRule1.ruleId, testTeam.teamId, admin, organization); + // creating the leaf's project rule also creates the top-level rule's project rule as an ancestor + await RulesService.createProjectRule(admin, organization, leafRule1.ruleId, project.projectId); + const topLevelProjectRule = await prisma.project_Rule.findUniqueOrThrow({ + where: { ruleId_projectId: { ruleId: topLevelRule.ruleId, projectId: project.projectId } } + }); + + await expect( + async () => await RulesService.deleteProjectRule(topLevelProjectRule.projectRuleId, admin, organization) + ).rejects.toThrow(new HttpException(400, 'Cannot delete a project rule that has children assigned to this project')); + + const foundTopLevel = await prisma.project_Rule.findUniqueOrThrow({ + where: { projectRuleId: topLevelProjectRule.projectRuleId } + }); + expect(foundTopLevel.dateDeleted).toBeNull(); + }); + it('Keeps an ancestor project rule when it still has other children in the project', async () => { + const car = await createUniqueCar(orgId); + const { leafRule1, leafRule2, topLevelRule } = await setupRules(car); + const project = await createTestProject(admin, orgId, testTeam.teamId, car.carId, car.wbsElement.carNumber); + await RulesService.toggleRuleTeam(topLevelRule.ruleId, testTeam.teamId, admin, organization); + await RulesService.toggleRuleTeam(leafRule1.ruleId, testTeam.teamId, admin, organization); + await RulesService.toggleRuleTeam(leafRule2.ruleId, testTeam.teamId, admin, organization); + const leafProjectRule1 = await RulesService.createProjectRule( + admin, + organization, + leafRule1.ruleId, + project.projectId + ); + const leafProjectRule2 = await RulesService.createProjectRule( + admin, + organization, + leafRule2.ruleId, + project.projectId + ); + + await RulesService.deleteProjectRule(leafProjectRule1.projectRuleId, admin, organization); + + const foundTopLevel = await prisma.project_Rule.findUniqueOrThrow({ + where: { ruleId_projectId: { ruleId: topLevelRule.ruleId, projectId: project.projectId } } + }); + expect(foundTopLevel.dateDeleted).toBeNull(); + const foundLeaf2 = await prisma.project_Rule.findUniqueOrThrow({ + where: { projectRuleId: leafProjectRule2.projectRuleId } + }); + expect(foundLeaf2.dateDeleted).toBeNull(); + }); + it('Allows a rule to be added, deleted, re-added, and deleted again without error', async () => { + const car = await createUniqueCar(orgId); + const { leafRule1, topLevelRule } = await setupRules(car); + const project = await createTestProject(admin, orgId, testTeam.teamId, car.carId, car.wbsElement.carNumber); + await RulesService.toggleRuleTeam(topLevelRule.ruleId, testTeam.teamId, admin, organization); + await RulesService.toggleRuleTeam(leafRule1.ruleId, testTeam.teamId, admin, organization); + + const firstAdd = await RulesService.createProjectRule(admin, organization, leafRule1.ruleId, project.projectId); + await RulesService.deleteProjectRule(firstAdd.projectRuleId, admin, organization); + + const secondAdd = await RulesService.createProjectRule(admin, organization, leafRule1.ruleId, project.projectId); + const secondDelete = await RulesService.deleteProjectRule(secondAdd.projectRuleId, admin, organization); + + expect(secondDelete.projectRuleId).toBe(secondAdd.projectRuleId); + const found = await prisma.project_Rule.findUniqueOrThrow({ where: { projectRuleId: secondAdd.projectRuleId } }); + expect(found.dateDeleted).toBeDefined(); + }); + }); + + 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(admin, 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(admin, 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( + admin, + fsaeRulesetType2WithRevisionFiles.rulesetTypeId, + orgId + ); + expect(rulesets.length).toBe(1); + await RulesService.deleteRulesetType(admin, fsaeRulesetType2WithRevisionFiles.rulesetTypeId, organization); + rulesets = await RulesService.getRulesetsByRulesetType(admin, fsaeRulesetType2WithRevisionFiles.rulesetTypeId, orgId); + expect(rulesets.length).toBe(0); + }); + }); + + 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.getUnassignedRulesForProjectRuleset( + admin, + otherRuleset.rulesetId, + project.projectId, + organization.organizationId + ) + ).rejects.toThrow(InvalidOrganizationException); + }); + it('fails if project is in the wrong org', async () => { + const car = await createUniqueCar(orgId); + const { ruleset1 } = await setupRules(car); + const otherOrgProject = await createTestProject(admin, otherOrg.organizationId); + await expect( + RulesService.getUnassignedRulesForProjectRuleset( + admin, + ruleset1.rulesetId, + otherOrgProject.projectId, + organization.organizationId + ) + ).rejects.toThrow(InvalidOrganizationException); + }); + it('fails if ruleset does not exist', async () => { + await expect( + RulesService.getUnassignedRulesForProjectRuleset( + admin, + 'nonexistent-ruleset-id', + project.projectId, + organization.organizationId + ) + ).rejects.toThrow(new NotFoundException('Ruleset', 'nonexistent-ruleset-id')); + }); + it('fails if project does not exist', async () => { + const car = await createUniqueCar(orgId); + const { ruleset1 } = await setupRules(car); + await expect( + RulesService.getUnassignedRulesForProjectRuleset( + admin, + ruleset1.rulesetId, + 'fake-project-id', + organization.organizationId + ) + ).rejects.toThrow(new NotFoundException('Project', 'fake-project-id')); + }); + it("successfully returns rules on the project's teams that are not already assigned to the project", async () => { + const car = await createUniqueCar(orgId); + const { ruleset1, topLevelRule, leafRule1, leafRule2 } = await setupRules(car); + // the project belongs to the team, so rules on that team are candidates + const project = await createTestProject(admin, orgId, testTeam.teamId, car.carId, car.wbsElement.carNumber); + // add rules to the project's team + await RulesService.toggleRuleTeam(topLevelRule.ruleId, testTeam.teamId, admin, organization); + await RulesService.toggleRuleTeam(leafRule1.ruleId, testTeam.teamId, admin, organization); + // rule on the project's team that is already assigned to the project + const ruleWithProject = await prisma.rule.create({ + data: { + ruleCode: 'T.1.3', + ruleContent: 'Rule with project', + imageFileIds: [], + rulesetId: ruleset1.rulesetId, + createdByUserId: admin.userId + } + }); + await RulesService.toggleRuleTeam(ruleWithProject.ruleId, testTeam.teamId, admin, organization); + await prisma.project_Rule.create({ + data: { + projectId: project.projectId, + ruleId: ruleWithProject.ruleId, + createdByUserId: admin.userId + } + }); + const rules = await RulesService.getUnassignedRulesForProjectRuleset( + admin, + ruleset1.rulesetId, + project.projectId, + organization.organizationId + ); + expect(rules.length).toEqual(2); + expect(rules[0].ruleCode).toEqual('T'); + expect(rules[1].ruleCode).toEqual('T2'); + // leafRule2 is not on any of the project's teams so should not be returned + expect(rules.find((r) => r.ruleCode === leafRule2.ruleCode)).toBeUndefined(); + // ruleWithProject is already assigned to the project so should not be returned + expect(rules.find((r) => r.ruleCode === ruleWithProject.ruleCode)).toBeUndefined(); + }); + it('returns rules from any of the project`s teams when it belongs to multiple', async () => { + const car = await createUniqueCar(orgId); + const { ruleset1, topLevelRule, leafRule1, leafRule2 } = await setupRules(car); + + // a second team the project also belongs to + const teamType = await createTestTeamType('secondTeamType', orgId); + const secondTeam = await createTestTeam(admin.userId, teamType.teamTypeId, orgId); + + // project belongs to both testTeam and secondTeam + const project = await createTestProject(admin, orgId, testTeam.teamId, car.carId, car.wbsElement.carNumber); + await ProjectsService.setProjectTeam( + admin, + { carNumber: car.wbsElement.carNumber, projectNumber: 1, workPackageNumber: 0 }, + secondTeam.teamId, + organization + ); + + // topLevelRule on the first team, leafRule1 on the second team - both should be returned + await RulesService.toggleRuleTeam(topLevelRule.ruleId, testTeam.teamId, admin, organization); + await RulesService.toggleRuleTeam(leafRule1.ruleId, secondTeam.teamId, admin, organization); + + const rules = await RulesService.getUnassignedRulesForProjectRuleset( + admin, + ruleset1.rulesetId, + project.projectId, + organization.organizationId + ); + expect(rules.map((r) => r.ruleId)).toContain(topLevelRule.ruleId); + expect(rules.map((r) => r.ruleId)).toContain(leafRule1.ruleId); + // leafRule2 has no team so it should not be returned + expect(rules.map((r) => r.ruleId)).not.toContain(leafRule2.ruleId); + }); + it("successfully returns empty if the project's teams have no assignable rules", async () => { + const car = await createUniqueCar(orgId); + const { ruleset1 } = await setupRules(car); + const project = await createTestProject(admin, orgId, testTeam.teamId, car.carId, car.wbsElement.carNumber); + const rules = await RulesService.getUnassignedRulesForProjectRuleset( + admin, + ruleset1.rulesetId, + project.projectId, + organization.organizationId + ); + expect(rules).toEqual([]); + }); + }); + + describe('Get Project Rules', () => { + it('Successfully gets all project rules for a project', async () => { + const car = await createUniqueCar(orgId); + const { topLevelRule } = await setupRules(car); + const project = await createTestProject(admin, orgId, testTeam.teamId, car.carId, car.wbsElement.carNumber); + await RulesService.toggleRuleTeam(topLevelRule.ruleId, testTeam.teamId, admin, organization); + const projectRule = await RulesService.createProjectRule(admin, organization, topLevelRule.ruleId, project.projectId); + + const projectRules = await RulesService.getProjectRules( + admin, + 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( + admin, + 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(admin, 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(admin, '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(admin, 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(admin, topLevelRule.rulesetId, project.projectId, organization) + ).rejects.toThrow(new DeletedException('Ruleset', topLevelRule.rulesetId)); + }); + }); + + describe('Get Top Level Rules', () => { + it('Fails if user is a guest', async () => { + const car = await createUniqueCar(orgId); + const { ruleset1 } = await setupRules(car); + + await expect(RulesService.getTopLevelRules(guest, ruleset1.rulesetId, organization.organizationId)).rejects.toThrow( + new AccessDeniedGuestException('view 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(admin, ruleset1.rulesetId, organization.organizationId); + + expect(rules.length).toEqual(3); + expect(rules.map((r) => r.ruleCode).sort()).toEqual(['A2', 'B2', 'T']); + expect(rules.find((r) => r.ruleId === topLevelRule.ruleId)?.ruleCode).toEqual('T'); + }); + + 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(admin, ruleset1.rulesetId, organization.organizationId); + + expect(rules.length).toEqual(4); + expect(rules.map((r) => r.ruleCode).sort()).toEqual(['A', 'A2', 'B2', '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(admin, ruleset.rulesetId, organization.organizationId); + expect(rules.length).toEqual(0); + }); + + it('Leaves deleted children out of a parent subRuleIds', async () => { + const car = await createUniqueCar(orgId); + const { ruleset1, topLevelRule, leafRule1, leafRule2 } = await setupRules(car); + + await RulesService.deleteRule(leafRule1.ruleId, admin, organization); + + const rules = await RulesService.getTopLevelRules(admin, ruleset1.rulesetId, organization.organizationId); + const topRule = rules.find((r) => r.ruleId === topLevelRule.ruleId); + + expect(topRule?.subRuleIds).not.toContain(leafRule1.ruleId); + expect(topRule?.subRuleIds).toContain(leafRule2.ruleId); + }); + + it('Reports no sub rules once every child is deleted', async () => { + const car = await createUniqueCar(orgId); + const { ruleset1, topLevelRule, leafRule1, leafRule2 } = await setupRules(car); + + await RulesService.deleteRule(leafRule1.ruleId, admin, organization); + await RulesService.deleteRule(leafRule2.ruleId, admin, organization); + + const rules = await RulesService.getTopLevelRules(admin, ruleset1.rulesetId, organization.organizationId); + + expect(rules.find((r) => r.ruleId === topLevelRule.ruleId)?.subRuleIds).toHaveLength(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(admin, ruleset1.rulesetId, organization.organizationId); + + expect(rules.length).toEqual(3); + expect(rules.find((r) => r.ruleId === topLevelRule.ruleId)).toBeDefined(); + 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(admin, ruleset1.rulesetId, organization.organizationId); + expect(rules.find((r) => r.ruleId === topLevelRule.ruleId)).toBeUndefined(); + }); + }); + + describe('Get All Rules For Ruleset', () => { + it('Fails if user is a guest', async () => { + const car = await createUniqueCar(orgId); + const { ruleset1 } = await setupRules(car); + + await expect( + RulesService.getAllRulesForRuleset(guest, ruleset1.rulesetId, organization.organizationId) + ).rejects.toThrow(new AccessDeniedGuestException('view rules')); + }); + + it('Successfully gets every rule in a ruleset, including children', async () => { + const car = await createUniqueCar(orgId); + const { ruleset1, topLevelRule, leafRule1, leafRule2, referencedRule, referencingRule } = await setupRules(car); + + const rules = await RulesService.getAllRulesForRuleset(admin, ruleset1.rulesetId, organization.organizationId); + + expect(rules.length).toEqual(5); + const ruleIds = rules.map((r) => r.ruleId); + expect(ruleIds).toContain(topLevelRule.ruleId); + expect(ruleIds).toContain(leafRule1.ruleId); + expect(ruleIds).toContain(leafRule2.ruleId); + expect(ruleIds).toContain(referencedRule.ruleId); + expect(ruleIds).toContain(referencingRule.ruleId); + }); + + it('Returns parent/subRuleIds correctly for rules at every depth', async () => { + const car = await createUniqueCar(orgId); + const { ruleset1, topLevelRule, leafRule1, leafRule2 } = await setupRules(car); + + const rules = await RulesService.getAllRulesForRuleset(admin, ruleset1.rulesetId, organization.organizationId); + + const topRule = rules.find((r) => r.ruleId === topLevelRule.ruleId); + const leaf1 = rules.find((r) => r.ruleId === leafRule1.ruleId); + const leaf2 = rules.find((r) => r.ruleId === leafRule2.ruleId); + + expect(topRule?.parentRule).toBeUndefined(); + expect(topRule?.subRuleIds).toContain(leafRule1.ruleId); + expect(topRule?.subRuleIds).toContain(leafRule2.ruleId); + + expect(leaf1?.parentRule?.ruleId).toBe(topLevelRule.ruleId); + expect(leaf2?.parentRule?.ruleId).toBe(topLevelRule.ruleId); + }); + + it('Does not return rules from a different ruleset', async () => { + const car = await createUniqueCar(orgId); + const { ruleset1, ruleset2, topLevelRule } = await setupRules(car); + + const otherRule = await prisma.rule.create({ + data: { + ruleCode: 'Z', + ruleContent: 'Different ruleset rule', + imageFileIds: [], + ruleset: { connect: { rulesetId: ruleset2.rulesetId } }, + createdBy: { connect: { userId: admin.userId } } + } + }); + + const rules = await RulesService.getAllRulesForRuleset(admin, ruleset1.rulesetId, organization.organizationId); + + expect(rules.find((r) => r.ruleId === topLevelRule.ruleId)).toBeDefined(); + expect(rules.find((r) => r.ruleId === otherRule.ruleId)).toBeUndefined(); + }); + + it('Does not return deleted rules', async () => { + const car = await createUniqueCar(orgId); + const { ruleset1, leafRule1 } = await setupRules(car); + + await prisma.rule.update({ + where: { ruleId: leafRule1.ruleId }, + data: { dateDeleted: new Date(), deletedByUserId: admin.userId } + }); + + const rules = await RulesService.getAllRulesForRuleset(admin, ruleset1.rulesetId, organization.organizationId); + expect(rules.find((r) => r.ruleId === leafRule1.ruleId)).toBeUndefined(); + }); + + it('Returns rules ordered by ruleCode ascending', async () => { + const car = await createUniqueCar(orgId); + const { ruleset1 } = await setupRules(car); + + const rules = await RulesService.getAllRulesForRuleset(admin, ruleset1.rulesetId, organization.organizationId); + + for (let i = 0; i < rules.length - 1; i++) { + expect(rules[i].ruleCode <= rules[i + 1].ruleCode).toBe(true); + } + }); + + it('Returns an empty array when the ruleset has no rules', async () => { + const car = await createUniqueCar(orgId); + const ruleset = await prisma.ruleset.create({ + data: { + name: 'Empty Ruleset', + fileId: 'empty-ruleset-all', + active: true, + car: { connect: { carId: car.carId } }, + createdBy: { connect: { userId: admin.userId } }, + rulesetType: { connect: { rulesetTypeId: fsaeRulesetType.rulesetTypeId } } + } + }); + + const rules = await RulesService.getAllRulesForRuleset(admin, ruleset.rulesetId, organization.organizationId); + expect(rules.length).toEqual(0); + }); + + it('Fails when ruleset does not exist', async () => { + await expect( + RulesService.getAllRulesForRuleset(admin, 'fake-ruleset-id', organization.organizationId) + ).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); + + await prisma.ruleset.update({ + where: { rulesetId: ruleset1.rulesetId }, + data: { active: false } + }); + await RulesService.deleteRuleset(ruleset1.rulesetId, admin.userId, organization.organizationId); + + await expect( + RulesService.getAllRulesForRuleset(admin, ruleset1.rulesetId, organization.organizationId) + ).rejects.toThrow(new DeletedException('Ruleset', ruleset1.rulesetId)); + }); + + 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 = await prisma.ruleset.create({ + data: { + name: '2024', + fileId: 'other-fhe-2024-all-rules', + active: true, + rulesetTypeId: otherOrgRulesetType.rulesetTypeId, + carId: car.carId, + createdByUserId: admin.userId + } + }); + + await expect( + RulesService.getAllRulesForRuleset(admin, otherRuleset.rulesetId, organization.organizationId) + ).rejects.toThrow(InvalidOrganizationException); + }); + }); + + describe('Get Ruleset Type', () => { + it('Successfully gets a ruleset type by ID', async () => { + const rulesetType = await RulesService.getRulesetType( + admin, + fsaeRulesetType.rulesetTypeId, + organization.organizationId + ); + expect(rulesetType).toBeDefined(); + expect(rulesetType.rulesetTypeId).toBe(fsaeRulesetType.rulesetTypeId); + expect(rulesetType.name).toBe(fsaeRulesetType.name); + }); + }); + + describe('Referenced rule tests', () => { + it('Successfully deletes a referenced rule', async () => { + const car = await createUniqueCar(orgId); + const { referencedRule, referencingRule } = await setupRules(car); + const rule = await RulesService.removeRuleReferences( + admin, + referencingRule.ruleId, + referencedRule.ruleId, + organization + ); + expect(rule.ruleId).toBe(referencingRule.ruleId); + expect(rule.referencedRules.length).toEqual(0); + }); + + it('Successfully adds a referenced rule', async () => { + const car = await createUniqueCar(orgId); + const { topLevelRule, referencedRule } = await setupRules(car); + const rule = await RulesService.addRuleReferences(admin, topLevelRule.ruleId, referencedRule.ruleId, organization); + expect(rule.ruleId).toBe(topLevelRule.ruleId); + expect(rule.referencedRules.length).toEqual(1); + }); + + it('Successfully adds multiple referenced rules', async () => { + const car = await createUniqueCar(orgId); + const { topLevelRule, leafRule1, referencedRule } = await setupRules(car); + await RulesService.addRuleReferences(admin, topLevelRule.ruleId, referencedRule.ruleId, organization); + const rule = await RulesService.addRuleReferences(admin, topLevelRule.ruleId, leafRule1.ruleId, organization); + expect(rule.ruleId).toBe(topLevelRule.ruleId); + expect(rule.referencedRules.length).toEqual(2); + expect(rule.referencedRules.map((r) => r.ruleId)).toEqual( + expect.arrayContaining([referencedRule.ruleId, leafRule1.ruleId]) + ); + }); + + it('Fails adding referenced rule if user is not leadership or above', async () => { + const car = await createUniqueCar(orgId); + const { topLevelRule, referencedRule } = await setupRules(car); + await expect( + async () => + await RulesService.addRuleReferences(nonLeadership, topLevelRule.ruleId, referencedRule.ruleId, organization) + ).rejects.toThrow(new AccessDeniedException('Only leadership and above can edit a rule')); + }); + + it('Fails adding referenced rule if rule does not exist', async () => { + const car = await createUniqueCar(orgId); + const { referencedRule } = await setupRules(car); + await expect( + async () => await RulesService.addRuleReferences(admin, 'fake-rule-id', referencedRule.ruleId, organization) + ).rejects.toThrow(new NotFoundException('Rule', 'fake-rule-id')); + }); + + it('Fails adding referenced rule if rule is not in the correct organization', async () => { + const otherCar = await createUniqueCar(otherOrg.organizationId); + const { topLevelRule: otherOrgRule, referencedRule } = await setupRules(otherCar); + await expect( + async () => await RulesService.addRuleReferences(admin, otherOrgRule.ruleId, referencedRule.ruleId, organization) + ).rejects.toThrow(new InvalidOrganizationException('Rule')); + }); + + it('Fails adding referenced rule if rule was deleted', async () => { + const car = await createUniqueCar(orgId); + const { topLevelRule, referencedRule } = await setupRules(car); + + await prisma.rule.update({ + where: { ruleId: topLevelRule.ruleId }, + data: { dateDeleted: new Date() } + }); + await expect( + async () => await RulesService.addRuleReferences(admin, topLevelRule.ruleId, referencedRule.ruleId, organization) + ).rejects.toThrow(new DeletedException('Rule', topLevelRule.ruleId)); + }); + + it('Fails adding referenced rule if referenced rule was deleted', async () => { + const car = await createUniqueCar(orgId); + const { topLevelRule, referencedRule } = await setupRules(car); + + await prisma.rule.update({ + where: { ruleId: referencedRule.ruleId }, + data: { dateDeleted: new Date() } + }); + await expect( + async () => await RulesService.addRuleReferences(admin, topLevelRule.ruleId, referencedRule.ruleId, organization) + ).rejects.toThrow(new DeletedException('Referenced Rule', referencedRule.ruleId)); + }); + + it('Fails adding referenced rule if referenced rule does not exist', async () => { + const car = await createUniqueCar(orgId); + const { topLevelRule } = await setupRules(car); + await expect( + async () => await RulesService.addRuleReferences(admin, topLevelRule.ruleId, 'fake-rule-id', organization) + ).rejects.toThrow(new NotFoundException('Referenced Rule', 'fake-rule-id')); + }); + + it('Fails adding referenced rule if referenced rule is in a different ruleset', async () => { + const car = await createUniqueCar(orgId); + const { topLevelRule, ruleset2 } = await setupRules(car); + const otherRulesetRule = await prisma.rule.create({ + data: { + ruleCode: 'X1', + ruleContent: 'Rule in a different ruleset', + imageFileIds: [], + dateCreated: new Date(), + ruleset: { connect: { rulesetId: ruleset2.rulesetId } }, + createdBy: { connect: { userId: admin.userId } } + } + }); + await expect( + async () => await RulesService.addRuleReferences(admin, topLevelRule.ruleId, otherRulesetRule.ruleId, organization) + ).rejects.toThrow(new NotFoundException('Referenced Rule', otherRulesetRule.ruleId)); + }); + + it('Fails adding referenced rule if referencing itself', async () => { + const car = await createUniqueCar(orgId); + const { referencingRule } = await setupRules(car); + await expect( + async () => await RulesService.addRuleReferences(admin, referencingRule.ruleId, referencingRule.ruleId, organization) + ).rejects.toThrow(new HttpException(400, 'A rule cannot reference itself')); + }); + + it('Fails removing referenced rule if user is not leadership or above', async () => { + const car = await createUniqueCar(orgId); + const { referencedRule, referencingRule } = await setupRules(car); + await expect( + async () => + await RulesService.removeRuleReferences(nonLeadership, referencingRule.ruleId, referencedRule.ruleId, organization) + ).rejects.toThrow(new AccessDeniedException('Only leadership and above can edit a rule')); + }); + + it('Fails removing referenced rule if rule does not exist', async () => { + const car = await createUniqueCar(orgId); + const { referencedRule } = await setupRules(car); + await expect( + async () => await RulesService.removeRuleReferences(admin, 'fake-rule-id', referencedRule.ruleId, organization) + ).rejects.toThrow(new NotFoundException('Rule', 'fake-rule-id')); + }); + + it('Fails removing referenced rule if rule was deleted', async () => { + const car = await createUniqueCar(orgId); + const { referencedRule, referencingRule } = await setupRules(car); + + await prisma.rule.update({ + where: { ruleId: referencingRule.ruleId }, + data: { dateDeleted: new Date() } + }); + await expect( + async () => + await RulesService.removeRuleReferences(admin, referencingRule.ruleId, referencedRule.ruleId, organization) + ).rejects.toThrow(new DeletedException('Rule', referencingRule.ruleId)); + }); + + it('Fails removing referenced rule if rule is not in the correct organization', async () => { + const otherCar = await createUniqueCar(otherOrg.organizationId); + const { topLevelRule: otherOrgRule, referencedRule } = await setupRules(otherCar); + await expect( + async () => await RulesService.removeRuleReferences(admin, otherOrgRule.ruleId, referencedRule.ruleId, organization) + ).rejects.toThrow(new InvalidOrganizationException('Rule')); + }); + + it('Fails removing referenced rule if referenced rule does not exist', async () => { + const car = await createUniqueCar(orgId); + const { referencingRule } = await setupRules(car); + await expect( + async () => await RulesService.removeRuleReferences(admin, referencingRule.ruleId, 'fake-rule-id', organization) + ).rejects.toThrow(new NotFoundException('Referenced Rule', 'fake-rule-id')); + }); + + it('Fails removing referenced rule if referenced rule was deleted', async () => { + const car = await createUniqueCar(orgId); + const { referencedRule, referencingRule } = await setupRules(car); + + await prisma.rule.update({ + where: { ruleId: referencedRule.ruleId }, + data: { dateDeleted: new Date() } + }); + await expect( + async () => + await RulesService.removeRuleReferences(admin, referencingRule.ruleId, referencedRule.ruleId, organization) + ).rejects.toThrow(new DeletedException('Referenced Rule', referencedRule.ruleId)); + }); + }); +}); diff --git a/src/backend/tests/unit/slack-api-messages.test.ts b/src/backend/tests/unit/slack-api-messages.test.ts new file mode 100644 index 0000000000..c91f79a0e6 --- /dev/null +++ b/src/backend/tests/unit/slack-api-messages.test.ts @@ -0,0 +1,121 @@ +import { Organization, User } from '@prisma/client'; +import { Mock, vi } from 'vitest'; +import SlackServices from '../../src/services/slack.services.js'; +import { AccessDeniedException, HttpException, NotFoundException } from '../../src/utils/errors.utils.js'; +import { batmanAppAdmin, member, supermanAdmin } from '../test-data/users.test-data.js'; +import { createTestOrganization, createTestUser, resetUsers } from '../test-utils.js'; +import prisma from '../../src/prisma/prisma.js'; +import { getChannelInfo, getWorkspaceId, postMessageToChannel } from '../../src/integrations/slack.js'; + +vi.mock('../../src/integrations/slack.js', () => ({ + getChannelInfo: vi.fn(), + getChannelName: vi.fn(), + getUserName: vi.fn(), + getWorkspaceId: vi.fn(), + postMessageToChannel: vi.fn() +})); + +const WORKSPACE_ID = 'T0WORKSPACE'; +const CHANNEL_ID = 'C0123456789'; + +describe('Slack API Messages', () => { + let organization: Organization; + let admin: User; + + beforeEach(async () => { + vi.clearAllMocks(); + organization = await createTestOrganization(); + organization = await prisma.organization.update({ + where: { organizationId: organization.organizationId }, + data: { slackWorkspaceId: WORKSPACE_ID } + }); + admin = await createTestUser(supermanAdmin, organization.organizationId); + + (getWorkspaceId as Mock).mockResolvedValue(WORKSPACE_ID); + (getChannelInfo as Mock).mockResolvedValue({ name: 'general', isArchived: false, isMember: true }); + (postMessageToChannel as Mock).mockResolvedValue({ channelId: CHANNEL_ID, ts: '123.456' }); + }); + + afterEach(async () => { + await resetUsers(); + }); + + it('sends the message and reports where it went', async () => { + const result = await SlackServices.sendMessageToChannel(admin.userId, organization, CHANNEL_ID, 'hello team'); + + expect(postMessageToChannel).toHaveBeenCalledWith(CHANNEL_ID, 'hello team'); + expect(result).toEqual({ channelId: CHANNEL_ID, channelName: 'general', ts: '123.456' }); + }); + + it('allows app admins', async () => { + const appAdmin = await createTestUser(batmanAppAdmin, organization.organizationId); + + await SlackServices.sendMessageToChannel(appAdmin.userId, organization, CHANNEL_ID, 'hi'); + + expect(postMessageToChannel).toHaveBeenCalled(); + }); + + it('escapes slack control characters so mentions and links are not rendered', async () => { + await SlackServices.sendMessageToChannel(admin.userId, organization, CHANNEL_ID, ' a & b '); + + expect(postMessageToChannel).toHaveBeenCalledWith(CHANNEL_ID, '<!channel> a & b <https://x.com|x>'); + }); + + it('rejects non admins', async () => { + const regularMember = await createTestUser(member, organization.organizationId); + + await expect( + SlackServices.sendMessageToChannel(regularMember.userId, organization, CHANNEL_ID, 'hi') + ).rejects.toBeInstanceOf(AccessDeniedException); + expect(postMessageToChannel).not.toHaveBeenCalled(); + }); + + it("rejects organizations not linked to the bot's workspace", async () => { + (getWorkspaceId as Mock).mockResolvedValue('T0OTHER'); + + await expect(SlackServices.sendMessageToChannel(admin.userId, organization, CHANNEL_ID, 'hi')).rejects.toBeInstanceOf( + AccessDeniedException + ); + expect(postMessageToChannel).not.toHaveBeenCalled(); + }); + + it('rejects organizations with no slack workspace', async () => { + const unlinked = await prisma.organization.update({ + where: { organizationId: organization.organizationId }, + data: { slackWorkspaceId: null } + }); + + await expect(SlackServices.sendMessageToChannel(admin.userId, unlinked, CHANNEL_ID, 'hi')).rejects.toBeInstanceOf( + AccessDeniedException + ); + }); + + it('reports a channel that does not exist', async () => { + (getChannelInfo as Mock).mockResolvedValue(undefined); + + await expect(SlackServices.sendMessageToChannel(admin.userId, organization, CHANNEL_ID, 'hi')).rejects.toEqual( + new NotFoundException('Slack Channel', CHANNEL_ID) + ); + expect(postMessageToChannel).not.toHaveBeenCalled(); + }); + + it('reports an archived channel', async () => { + (getChannelInfo as Mock).mockResolvedValue({ name: 'old', isArchived: true, isMember: true }); + + await expect(SlackServices.sendMessageToChannel(admin.userId, organization, CHANNEL_ID, 'hi')).rejects.toEqual( + new HttpException(400, 'Slack channel #old is archived') + ); + }); + + it('reports a channel the bot is not in', async () => { + (getChannelInfo as Mock).mockResolvedValue({ name: 'secret', isArchived: false, isMember: false }); + + await expect(SlackServices.sendMessageToChannel(admin.userId, organization, CHANNEL_ID, 'hi')).rejects.toEqual( + new HttpException( + 400, + 'The FinishLine bot is not a member of #secret, add it to the channel before sending messages there' + ) + ); + expect(postMessageToChannel).not.toHaveBeenCalled(); + }); +}); diff --git a/src/backend/tests/unmocked/statistics.test.ts b/src/backend/tests/unmocked/statistics.test.ts index aca04c4bce..d0e1a10afe 100644 --- a/src/backend/tests/unmocked/statistics.test.ts +++ b/src/backend/tests/unmocked/statistics.test.ts @@ -1,5 +1,16 @@ import { Graph_Type, Organization, User, Graph_Display_Type, Special_Permission } from '@prisma/client'; -import { batmanAppAdmin, supermanAdmin, theVisitorGuest, wonderwomanGuest } from '../test-data/users.test-data.js'; +import { + batmanAppAdmin, + supermanAdmin, + theVisitorGuest, + wonderwomanGuest, + member, + robinMember, + cyborgMember, + greenlanternHead, + aquamanLeadership, + flashAdmin +} from '../test-data/users.test-data.js'; import { createTestCar, createTestOrganization, @@ -550,4 +561,370 @@ describe('Statistics Tests', () => { ).rejects.toThrow(new HttpException(400, 'End date must be after start date')); }); }); + + describe('Attendance Graphs', () => { + it('Create graph averages attendance percent by team per session for both sum and average measures, excluding open sessions', async () => { + const division = await createTestTeamType('aDivision', orgId); + const team = await createTestTeam(user.userId, division.teamTypeId, orgId); + const m1 = await createTestUser(member, orgId); + const m2 = await createTestUser(robinMember, orgId); + const m3 = await createTestUser(cyborgMember, orgId); + await prisma.team.update({ + where: { teamId: team.teamId }, + data: { members: { connect: [m1, m2, m3].map((m) => ({ userId: m.userId })) } } + }); + + // 50% attendance: head + m1 out of head + m1 + m2 + m3 + await prisma.meeting_Attendance.create({ + data: { + organizationId: orgId, + teamId: team.teamId, + userCreatedId: user.userId, + openedAt: new Date('2024-01-01'), + closedAt: new Date('2024-01-01T01:00:00'), + slackChannelId: 'aChannel', + slackMessageTimestamp: '1', + attendees: { connect: [user, m1].map((u) => ({ userId: u.userId })) } + } + }); + + // 100% attendance + await prisma.meeting_Attendance.create({ + data: { + organizationId: orgId, + teamId: team.teamId, + userCreatedId: user.userId, + openedAt: new Date('2024-01-08'), + closedAt: new Date('2024-01-08T01:00:00'), + slackChannelId: 'aChannel', + slackMessageTimestamp: '2', + attendees: { connect: [user, m1, m2, m3].map((u) => ({ userId: u.userId })) } + } + }); + + // still open (no closedAt) - must be excluded regardless of attendees + await prisma.meeting_Attendance.create({ + data: { + organizationId: orgId, + teamId: team.teamId, + userCreatedId: user.userId, + openedAt: new Date('2024-01-15'), + closedAt: null, + slackChannelId: 'aChannel', + slackMessageTimestamp: '3', + attendees: { connect: [{ userId: user.userId }] } + } + }); + + // closed, but outside the queried date range - must be excluded + await prisma.meeting_Attendance.create({ + data: { + organizationId: orgId, + teamId: team.teamId, + userCreatedId: user.userId, + openedAt: new Date('2020-01-01'), + closedAt: new Date('2020-01-01T01:00:00'), + slackChannelId: 'aChannel', + slackMessageTimestamp: '4', + attendees: { connect: [user, m1, m2, m3].map((u) => ({ userId: u.userId })) } + } + }); + + const sumResult = await StatisticsService.createGraph( + user, + 'New Graph', + Graph_Type.ATTENDANCE_BY_TEAM, + Measure.SUM, + Graph_Display_Type.BAR, + organization, + [], + [], + new Date('2023-12-01'), + new Date('2024-02-01') + ); + + // attendance percents are always averaged per session, so SUM matches AVG + expect(sumResult.graphData).toStrictEqual([ + { + tipLabel: '% Attendance', + values: [ + { + label: team.teamName, + value: 75 + } + ] + } + ]); + + const avgResult = await StatisticsService.createGraph( + user, + 'New Graph', + Graph_Type.ATTENDANCE_BY_TEAM, + Measure.AVG, + Graph_Display_Type.BAR, + organization, + [], + [], + new Date('2023-12-01'), + new Date('2024-02-01') + ); + + expect(avgResult.graphData).toStrictEqual([ + { + tipLabel: '% Attendance', + values: [ + { + label: team.teamName, + value: 75 + } + ] + } + ]); + }); + + it('Create graph works for getting average attendance percent by division, aggregating across teams', async () => { + const division = await createTestTeamType('aDivision', orgId); + const teamA = await createTestTeam(user.userId, division.teamTypeId, orgId); + const teamAMember1 = await createTestUser(member, orgId); + const teamAMember2 = await createTestUser(robinMember, orgId); + const teamAMember3 = await createTestUser(cyborgMember, orgId); + await prisma.team.update({ + where: { teamId: teamA.teamId }, + data: { + members: { connect: [teamAMember1, teamAMember2, teamAMember3].map((m) => ({ userId: m.userId })) } + } + }); + + const teamBHead = await createTestUser(greenlanternHead, orgId); + const teamB = await createTestTeam(teamBHead.userId, division.teamTypeId, orgId); + const teamBMember1 = await createTestUser(aquamanLeadership, orgId); + const teamBMember2 = await createTestUser(wonderwomanGuest, orgId); + const teamBMember3 = await createTestUser(flashAdmin, orgId); + await prisma.team.update({ + where: { teamId: teamB.teamId }, + data: { + members: { connect: [teamBMember1, teamBMember2, teamBMember3].map((m) => ({ userId: m.userId })) } + } + }); + + // team A: 50% attendance + await prisma.meeting_Attendance.create({ + data: { + organizationId: orgId, + teamId: teamA.teamId, + userCreatedId: user.userId, + openedAt: new Date('2024-01-01'), + closedAt: new Date('2024-01-01T01:00:00'), + slackChannelId: 'aChannel', + slackMessageTimestamp: '1', + attendees: { connect: [user, teamAMember1].map((u) => ({ userId: u.userId })) } + } + }); + + // team B: 100% attendance + await prisma.meeting_Attendance.create({ + data: { + organizationId: orgId, + teamId: teamB.teamId, + userCreatedId: teamBHead.userId, + openedAt: new Date('2024-01-08'), + closedAt: new Date('2024-01-08T01:00:00'), + slackChannelId: 'aChannel', + slackMessageTimestamp: '2', + attendees: { + connect: [teamBHead, teamBMember1, teamBMember2, teamBMember3].map((u) => ({ userId: u.userId })) + } + } + }); + + const result = await StatisticsService.createGraph( + user, + 'New Graph', + Graph_Type.ATTENDANCE_BY_DIVISION, + Measure.AVG, + Graph_Display_Type.BAR, + organization, + [], + [], + new Date('2023-12-01'), + new Date('2024-02-01') + ); + + expect(result.graphData).toStrictEqual([ + { + tipLabel: '% Attendance', + values: [ + { + label: division.name, + value: 75 + } + ] + } + ]); + }); + + it('Create graph excludes teams with no closed sessions in range from attendance by team', async () => { + const division = await createTestTeamType('aDivision', orgId); + const teamA = await createTestTeam(user.userId, division.teamTypeId, orgId); + const teamAMember = await createTestUser(member, orgId); + await prisma.team.update({ + where: { teamId: teamA.teamId }, + data: { members: { connect: [{ userId: teamAMember.userId }] } } + }); + + const teamBHead = await createTestUser(greenlanternHead, orgId); + const teamB = await createTestTeam(teamBHead.userId, division.teamTypeId, orgId); + + // team A: 50% attendance + await prisma.meeting_Attendance.create({ + data: { + organizationId: orgId, + teamId: teamA.teamId, + userCreatedId: user.userId, + openedAt: new Date('2024-01-01'), + closedAt: new Date('2024-01-01T01:00:00'), + slackChannelId: 'aChannel', + slackMessageTimestamp: '1', + attendees: { connect: [{ userId: user.userId }] } + } + }); + + // team B: closed session outside the range + await prisma.meeting_Attendance.create({ + data: { + organizationId: orgId, + teamId: teamB.teamId, + userCreatedId: teamBHead.userId, + openedAt: new Date('2020-01-01'), + closedAt: new Date('2020-01-01T01:00:00'), + slackChannelId: 'aChannel', + slackMessageTimestamp: '2', + attendees: { connect: [{ userId: teamBHead.userId }] } + } + }); + + // team B: open session inside the range + await prisma.meeting_Attendance.create({ + data: { + organizationId: orgId, + teamId: teamB.teamId, + userCreatedId: teamBHead.userId, + openedAt: new Date('2024-01-08'), + closedAt: null, + slackChannelId: 'aChannel', + slackMessageTimestamp: '3', + attendees: { connect: [{ userId: teamBHead.userId }] } + } + }); + + const result = await StatisticsService.createGraph( + user, + 'New Graph', + Graph_Type.ATTENDANCE_BY_TEAM, + Measure.AVG, + Graph_Display_Type.BAR, + organization, + [], + [], + new Date('2023-12-01'), + new Date('2024-02-01') + ); + + expect(result.graphData).toStrictEqual([ + { + tipLabel: '% Attendance', + values: [ + { + label: teamA.teamName, + value: 50 + } + ] + } + ]); + }); + + it('Create graph excludes divisions with no closed sessions in range from attendance by division', async () => { + const divisionWithSessions = await createTestTeamType('aDivision', orgId); + const divisionWithoutSessions = await createTestTeamType('bDivision', orgId); + const teamA = await createTestTeam(user.userId, divisionWithSessions.teamTypeId, orgId); + const teamBHead = await createTestUser(greenlanternHead, orgId); + await createTestTeam(teamBHead.userId, divisionWithoutSessions.teamTypeId, orgId); + + // team A: 100% attendance + await prisma.meeting_Attendance.create({ + data: { + organizationId: orgId, + teamId: teamA.teamId, + userCreatedId: user.userId, + openedAt: new Date('2024-01-01'), + closedAt: new Date('2024-01-01T01:00:00'), + slackChannelId: 'aChannel', + slackMessageTimestamp: '1', + attendees: { connect: [{ userId: user.userId }] } + } + }); + + const result = await StatisticsService.createGraph( + user, + 'New Graph', + Graph_Type.ATTENDANCE_BY_DIVISION, + Measure.AVG, + Graph_Display_Type.BAR, + organization, + [], + [], + new Date('2023-12-01'), + new Date('2024-02-01') + ); + + expect(result.graphData).toStrictEqual([ + { + tipLabel: '% Attendance', + values: [ + { + label: divisionWithSessions.name, + value: 100 + } + ] + } + ]); + }); + + it('Create graph forces average measure and no cars for attendance graphs', async () => { + const car = await createTestCar(orgId, user.userId); + + const result = await StatisticsService.createGraph( + user, + 'New Graph', + Graph_Type.ATTENDANCE_BY_TEAM, + Measure.SUM, + Graph_Display_Type.BAR, + organization, + [car.carId], + [] + ); + + expect(result.measure).toStrictEqual(Measure.AVG); + expect(result.carIds).toStrictEqual([]); + }); + + it('Edit graph forces average measure and no cars for attendance graphs', async () => { + const car = await createTestCar(orgId, user.userId); + + const result = await StatisticsService.editGraph( + user, + graph.graphId, + 'Updated Graph', + Graph_Type.ATTENDANCE_BY_DIVISION, + Measure.SUM, + Graph_Display_Type.BAR, + organization, + [car.carId], + [] + ); + + expect(result.measure).toStrictEqual(Measure.AVG); + expect(result.carIds).toStrictEqual([]); + }); + }); }); diff --git a/src/frontend/src/apis/rules.api.ts b/src/frontend/src/apis/rules.api.ts new file mode 100644 index 0000000000..dc4b09b40d --- /dev/null +++ b/src/frontend/src/apis/rules.api.ts @@ -0,0 +1,335 @@ +/* + * This file is part of NER's FinishLine and licensed under GNU AGPLv3. + * See the LICENSE file in the repository root folder for details. + */ + +import axios from '../utils/axios'; +import { + ProjectRule, + Rule as SharedRule, + RulesetType, + Ruleset, + RuleStatus, + RuleStatusHistoryEntry, + RuleStatusUpdate +} from 'shared'; +import { apiUrls } from '../utils/urls'; +import { CreateRulesetPayload, ParseRulesetPayload, CreateRulePayload } from '../hooks/rules.hooks'; +import { + projectRuleTransformer, + rulesetTransformer, + rulesetTypeTransformer, + ruleTransformer, + ruleStatusHistoryTransformer, + ruleStatusUpdateTransformer +} from './transformers/rules.transformers'; + +/** + * 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 }); +}; + +/** + * 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, carNumber?: number) => { + return axios.get(apiUrls.rulesGetActiveRuleset(rulesetTypeId), { + params: { carNumber }, + 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 rules assignable to a project, across all of its teams, that aren't already assigned + */ +export const getUnassignedRulesForRuleset = (rulesetId: string, projectId: string) => { + return axios.get(apiUrls.rulesGetUnassignedRulesForRuleset(rulesetId, projectId), { + transformResponse: (data) => JSON.parse(data).map(ruleTransformer) + }); +}; + +/** + * Creates a project rule + */ +export const createProjectRule = (ruleId: string, projectId: string) => { + return axios.post(apiUrls.rulesCreateProjectRule(), { ruleId, projectId }); +}; + +/** + * Deletes a project rule + */ +export const deleteProjectRule = (projectRuleId: string) => { + return axios.post(apiUrls.rulesDeleteProjectRule(projectRuleId)); +}; + +/** + * Sets a rule's general-view status. This status is independent of any project. + * The response goes into the rules cache instead of being refetched, + * so we convert the date to match the proper rule structure. Errors stay untransformed. + * + * @param ruleId the rule to update + * @param status the new status of the rule + * @returns the updated rule plus every ancestor whose status rolled up as a result + */ +export const setRuleStatus = (ruleId: string, status: RuleStatus) => { + return axios.post( + apiUrls.rulesSetRuleStatus(ruleId), + { status }, + { + // turn JSON strings into Date objects + transformResponse: (data) => { + const parsed = JSON.parse(data); + // error responses will not contain a rule + return parsed?.rule ? ruleStatusUpdateTransformer(parsed) : parsed; + } + } + ); +}; + +/** + * Sets a rule's status within a single project. This status is local to that project. + * @param projectRuleId the project rule to update + * @param status the new status of the rule in this project + */ +export const setProjectRuleStatus = (projectRuleId: string, status: RuleStatus) => { + return axios.post(apiUrls.rulesSetProjectRuleStatus(projectRuleId), { status }); +}; + +/** + * Gets a rule's full status history - every time it was marked PASS or FAIL. + * @param ruleId the rule to get history for + * @param projectRuleId if provided, scopes the history to just this project rule instead of every context the rule appears in + */ +export const getRuleStatusHistory = (ruleId: string, projectRuleId?: string) => { + return axios.get(apiUrls.rulesGetStatusHistory(ruleId), { + params: { projectRuleId }, + transformResponse: (data) => JSON.parse(data).map(ruleStatusHistoryTransformer) + }); +}; + +/** + * Resets every rule's general-view status back to Pending, for a whole ruleset. + * @param rulesetId the ruleset to reset + */ +export const resetRulesetStatuses = (rulesetId: string) => { + return axios.post<{ count: number }>(apiUrls.rulesResetRulesetStatuses(rulesetId)); +}; + +/** + * Resets every project rule's status back to Pending, for a single project scoped to a single ruleset. + * @param rulesetId the ruleset to scope the reset to + * @param projectId the project whose rules should be reset + */ +export const resetProjectRuleStatuses = (rulesetId: string, projectId: string) => { + return axios.post<{ count: number }>(apiUrls.rulesResetProjectRuleStatuses(rulesetId, projectId)); +}; + +/** + * Gets child rules + */ +export const getChildRules = (ruleId: string) => { + return axios.get(apiUrls.rulesChildRules(ruleId), { + transformResponse: (data) => JSON.parse(data).map(ruleTransformer) + }); +}; + +/** + * Gets top-level rules + */ +export const getTopLevelRules = (rulesetId: string) => { + return axios.get(apiUrls.rulesTopLevel(rulesetId), { + transformResponse: (data) => JSON.parse(data).map(ruleTransformer) + }); +}; + +/** + * Gets every rule in a ruleset (top-level and all descendants) in a single request + */ +export const getAllRulesForRuleset = (rulesetId: string) => { + return axios.get(apiUrls.rulesAllRules(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 and/or code + * @param ruleId - The ID of the rule to edit + * @param ruleContent - The new content for the rule + * @param ruleCode - The new code for the rule (optional, keeps existing if not provided) + * @param imageFileIds - Image file IDs for the rule + */ +export const editRule = (ruleId: string, ruleContent: string, ruleCode?: string, imageFileIds?: string[]) => { + return axios.post( + apiUrls.rulesEdit(ruleId), + { + ruleContent, + ruleCode, + ...(imageFileIds !== undefined && { imageFileIds }) + }, + { + transformResponse: (data) => { + const parsed = JSON.parse(data); + // error responses will not contain a rule + return parsed?.ruleId ? ruleTransformer(parsed) : parsed; + } + } + ); +}; + +/** + * Adds a referenced rule to a rule + * @param ruleId the rule receiving the reference + * @param referencedRuleId the rule ID to add as a reference + */ +export const addRuleReferences = (ruleId: string, referencedRuleId: string) => { + return axios.post( + apiUrls.rulesAddReferences(ruleId), + { referencedRuleId }, + { + transformResponse: (data) => { + const parsed = JSON.parse(data); + return parsed?.ruleId ? ruleTransformer(parsed) : parsed; + } + } + ); +}; + +/** + * Removes a referenced rule from a rule + * @param ruleId the rule losing the reference + * @param referencedRuleId the rule ID to remove from the references + */ +export const removeRuleReferences = (ruleId: string, referencedRuleId: string) => { + return axios.post( + apiUrls.rulesRemoveReferences(ruleId), + { referencedRuleId }, + { + transformResponse: (data) => { + const parsed = JSON.parse(data); + return parsed?.ruleId ? ruleTransformer(parsed) : parsed; + } + } + ); +}; + +/** + * Updates a ruleset's name and 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, + firstRulePage: payload.firstRulePage + }); +}; + +/** + * 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..365f432f7c --- /dev/null +++ b/src/frontend/src/apis/transformers/rules.transformers.ts @@ -0,0 +1,92 @@ +/* + * 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, RuleStatusHistoryEntry, RuleStatusUpdate } 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 || [], + referencedRules: rule.referencedRules || [], + statusUpdatedAt: rule.statusUpdatedAt ? new Date(rule.statusUpdatedAt) : undefined, + projects: rule.projects?.map((project) => ({ + ...project, + statusUpdatedAt: project.statusUpdatedAt ? new Date(project.statusUpdatedAt) : undefined + })) + }; +}; + +/** + * Transforms the result of a status write. + * + * @param update Incoming status update object + * @returns transformed status update object + */ +export const ruleStatusUpdateTransformer = (update: RuleStatusUpdate): RuleStatusUpdate => { + return { + ...update, + rule: ruleTransformer(update.rule), + ancestors: update.ancestors || [] + }; +}; + +/** + * 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), + statusUpdatedAt: projectRule.statusUpdatedAt ? new Date(projectRule.statusUpdatedAt) : undefined + }; +}; + +/** + * 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) + }; +}; + +/** + * Transforms a rule status history entry + * + * @param entry Incoming status history entry + * @returns Properly transformed status history entry. + */ +export const ruleStatusHistoryTransformer = (entry: RuleStatusHistoryEntry): RuleStatusHistoryEntry => ({ + ...entry, + updatedAt: new Date(entry.updatedAt) +}); diff --git a/src/frontend/src/app/AppAuthenticated.tsx b/src/frontend/src/app/AppAuthenticated.tsx index fd912ec143..9da175da5a 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 GlobalTasksPage from '../pages/GlobalTasksPage/GlobalTasksPage'; import Teams from '../pages/TeamsPage/Teams'; @@ -85,6 +86,7 @@ const AppAuthenticated: React.FC = ({ userId, userRole, c + diff --git a/src/frontend/src/app/AppPublic.tsx b/src/frontend/src/app/AppPublic.tsx index e9be388e0c..1b7fff4599 100644 --- a/src/frontend/src/app/AppPublic.tsx +++ b/src/frontend/src/app/AppPublic.tsx @@ -11,6 +11,7 @@ import AppAuthenticated from './AppAuthenticated'; import { useProvideThemeToggle } from '../hooks/theme.hooks'; import LoadingIndicator from '../components/LoadingIndicator'; import { useOrganization } from '../hooks/organizations.hooks'; +import ErrorPage from '../pages/ErrorPage'; const AppPublic: React.FC = () => { const auth = useAuth(); @@ -26,10 +27,16 @@ const AppPublic: React.FC = () => { theme.toggleTheme(); } - if (auth.user.organizations.length > 0) { - organization.selectOrganization(auth.user.organizations[0]); + if (auth.user.organizations.length === 0) { + // without an organization every request would be sent with an empty organizationId header, + // so say so instead of spinning forever waiting for one to be selected + return ( + + ); } + organization.selectOrganization(auth.user.organizations[0]); + if (!organization.organizationId) { return ; } diff --git a/src/frontend/src/components/NERAutocomplete.tsx b/src/frontend/src/components/NERAutocomplete.tsx index b77b45470c..3f6fedc707 100644 --- a/src/frontend/src/components/NERAutocomplete.tsx +++ b/src/frontend/src/components/NERAutocomplete.tsx @@ -30,6 +30,7 @@ interface NERAutocompleteProps { disabled?: boolean; noOptionsText?: React.ReactNode; onInputChange?: (event: React.SyntheticEvent, value: string) => void; + disablePortal?: boolean; } const NERAutocomplete: React.FC = ({ @@ -46,7 +47,8 @@ const NERAutocomplete: React.FC = ({ required = true, disabled = false, noOptionsText, - onInputChange + onInputChange, + disablePortal = true }) => { const theme = useTheme(); @@ -79,7 +81,7 @@ const NERAutocomplete: React.FC = ({ isOptionEqualToValue={(option, value) => option.id === value.id} getOptionLabel={(option) => option.label} getOptionKey={(option) => option.id} - disablePortal + disablePortal={disablePortal} id={id} onChange={onChange} onInputChange={onInputChange} diff --git a/src/frontend/src/hooks/rules.hooks.ts b/src/frontend/src/hooks/rules.hooks.ts new file mode 100644 index 0000000000..6207b21854 --- /dev/null +++ b/src/frontend/src/hooks/rules.hooks.ts @@ -0,0 +1,935 @@ +/* + * 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 { useCallback } from 'react'; +import { QueryClient, useMutation, useQuery, useQueryClient } from 'react-query'; +import { + ProjectRule, + Rule as SharedRule, + Ruleset, + RulesetType, + RuleStatus, + RuleStatusHistoryEntry, + RuleStatusUpdate +} from 'shared'; +import { + createRulesetType, + getAllRulesetTypes, + getActiveRuleset, + getProjectRules, + getUnassignedRulesForRuleset, + createProjectRule, + deleteProjectRule, + setRuleStatus, + setProjectRuleStatus, + getRuleStatusHistory, + resetRulesetStatuses, + resetProjectRuleStatuses, + getChildRules, + getTopLevelRules, + getAllRulesForRuleset, + toggleRuleTeam, + parseRuleset, + uploadRulesetFile, + getRulesetsByRulesetType, + deleteRule, + editRule, + addRuleReferences, + removeRuleReferences, + updateRuleset, + deleteRuleset, + deleteRulesetType, + createRuleset, + createRule, + getSingleRuleset, + getRulesetType +} from '../apis/rules.api'; +import { useToast } from './toasts.hooks'; +import { useGlobalCarFilter } from '../app/AppGlobalCarFilterContext'; +import { getRuleStatusConfig } from '../utils/rules.utils'; + +/** + * Hook to supply all ruleset types. + * Revision file counts are scoped to the globally selected car. + */ +export const useAllRulesetTypes = () => { + const { selectedCar } = useGlobalCarFilter(); + return useQuery( + ['rules', 'rulesetTypes', selectedCar === 'all-cars' ? 'all-cars' : selectedCar.id], + async () => { + const { data } = await getAllRulesetTypes(); + return data; + } + ); +}; + +/** + * 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, carNumber?: number) => { + return useQuery( + ['rules', 'activeRuleset', rulesetTypeId, carNumber], + async () => { + try { + const { data } = await getActiveRuleset(rulesetTypeId, carNumber); + 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 rules assignable to a project, across all of its teams, that aren't already assigned. + */ +export const useUnassignedRulesForRuleset = (rulesetId: string, projectId: string) => { + return useQuery( + ['rules', 'unassigned', rulesetId, projectId], + async () => { + const { data } = await getUnassignedRulesForRuleset(rulesetId, projectId); + return data; + }, + { enabled: !!rulesetId && !!projectId } + ); +}; + +interface CreateRulesetTypePayload { + name: string; +} + +export interface ParseRulesetPayload { + rulesetId: string; + fileId: string; + parserType: 'FSAE' | 'FHE'; + firstRulePage?: number; +} + +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[]; +} + +/** + * Writes the result of a status write straight into the cached rule lists, so a click costs no refetch. + * For example ['rules', 'children', ruleId] or ['rules', 'top-level', rulesetId]. + * Swaps in the rules this update changed and leaves the rest alone. + */ +const applyRuleStatusUpdate = (queryClient: QueryClient, rulesetId: string, { rule, ancestors }: RuleStatusUpdate) => { + // for this action an ancestor will only ever update its status field + const rolledUpStatus = new Map(ancestors.map(({ ruleId, status }) => [ruleId, status])); + + // updates only statuses that have changed for one cached array of Rule objects + const applyUpdatesTo = (key: unknown[]) => { + const cachedRules = queryClient.getQueryData(key); + // uncached list has no rows on the screen and was not loaded, so do not update + if (!cachedRules) return; + + queryClient.setQueryData( + key, + cachedRules.map((cachedRule) => { + // the rule whose status was directly updated gets a full update since + // other fields such as dateUpdated or hasStatusHistory will also change + if (cachedRule.ruleId === rule.ruleId) return rule; + + // an ancestor keeps all fields, only its status is overwritten if the status changed + // for example, if an ancestor status was FAIL and this change updated it to FAIL, it will not rerender + const newStatus = rolledUpStatus.get(cachedRule.ruleId); + if (newStatus && newStatus !== cachedRule.status) return { ...cachedRule, status: newStatus }; + + // everything else is returned as the same object, so React skips rerendering those rows + return cachedRule; + }) + ); + }; + + // a rule's row is stored in its parent's children list, this grabs the cache list the clicked rule exists in + const listForParent = (parentRuleId: string | null | undefined) => + parentRuleId ? ['rules', 'children', parentRuleId] : ['rules', 'top-level', rulesetId]; + applyUpdatesTo(listForParent(rule.parentRule?.ruleId)); + + // one cache list per ancestor, walking up the chain the server recalculated + for (const { parentRuleId } of ancestors) applyUpdatesTo(listForParent(parentRuleId)); +}; + +/** + * Hook to get all top level rules for a given ruleset. + */ +export const useGetTopLevelRules = (rulesetId: string) => { + return useQuery(['rules', 'top-level', rulesetId], async () => { + const { data } = await getTopLevelRules(rulesetId); + return data; + }); +}; + +/** + * Hook to get direct child rules for a given rule. + */ +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 + staleTime: 5 * 60 * 1000 // 5 minutes + } + ); +}; + +/** + * Hook to get a rule's full status history. Only fetched when enabled is true, such as when a modal is open. + * @param projectRuleId if provided, scopes the history to just this project rule instead of every context the rule appears in + */ +export const useRuleStatusHistory = (ruleId: string, enabled: boolean, projectRuleId?: string) => { + return useQuery( + ['rules', 'statusHistory', ruleId, projectRuleId], + async () => { + const { data } = await getRuleStatusHistory(ruleId, projectRuleId); + return data; + }, + { + enabled + } + ); +}; + +/** + * Hook to get a single ruleset by ID. + */ +export const useSingleRuleset = (rulesetId: string) => { + return useQuery( + ['rules', 'ruleset', rulesetId], + async () => { + const { data } = await getSingleRuleset(rulesetId); + return data; + }, + { enabled: !!rulesetId } + ); +}; + +/** + * 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}`); + } + } + ); +}; + +/** + * Hook to reset every rule's general-view status back to Pending, for a whole ruleset. + */ +export const useResetRulesetStatuses = (rulesetId: string) => { + const queryClient = useQueryClient(); + const toast = useToast(); + + return useMutation<{ count: number }, Error, void>( + ['rules', 'resetRulesetStatuses', rulesetId], + async () => { + const { data } = await resetRulesetStatuses(rulesetId); + return data; + }, + { + onSuccess: ({ count }) => { + queryClient.invalidateQueries(['rules']); + toast.success(`Reset ${count} rule status${count === 1 ? '' : 'es'} to Pending`); + }, + onError: (error: Error) => { + toast.error(error.message); + } + } + ); +}; + +/** + * Hook to reset every project rule's status back to Pending, for a single project scoped to a single ruleset. + */ +export const useResetProjectRuleStatuses = (rulesetId: string, projectId: string) => { + const queryClient = useQueryClient(); + const toast = useToast(); + + return useMutation<{ count: number }, Error, void>( + ['rules', 'resetProjectRuleStatuses', rulesetId, projectId], + async () => { + const { data } = await resetProjectRuleStatuses(rulesetId, projectId); + return data; + }, + { + onSuccess: ({ count }) => { + queryClient.invalidateQueries(['rules']); + toast.success(`Reset ${count} rule status${count === 1 ? '' : 'es'} to Pending`); + }, + onError: (error: Error) => { + toast.error(error.message); + } + } + ); +}; + +/** + * 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 for a given ruleset. + */ +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 multiple project rules (assign rules to a project) in bulk. + * Failures and successes are counted and reported via toast. + * + * "1 rule failed to add. 3 succeeded. T.4: This rule is already associated with the project" + * "Successfully added 5 rules" + */ +export const useBulkCreateProjectRules = (rulesetId: string, projectId: string) => { + const queryClient = useQueryClient(); + const toast = useToast(); + + return useMutation<{ successful: number; failed: number; errors: string[] }, Error, string[]>( + ['rules', 'projectRules', 'bulk-create'], + async (ruleIds) => { + const unassignedRules = queryClient.getQueryData(['rules', 'unassigned', rulesetId, projectId]); + const ruleCodesById = new Map(unassignedRules?.map((rule) => [rule.ruleId, rule.ruleCode])); + + let successful = 0; + let failed = 0; + const errors: string[] = []; + + for (const ruleId of ruleIds) { + try { + await createProjectRule(ruleId, projectId); + successful++; + } catch (error) { + failed++; + errors.push(`${ruleCodesById.get(ruleId) ?? ruleId}: ${error instanceof Error ? error.message : 'Unknown error'}`); + } + } + + return { successful, failed, errors }; + }, + { + onSuccess: (result) => { + queryClient.invalidateQueries(['rules', 'projectRules']); + queryClient.invalidateQueries(['rules', 'unassigned']); + + if (result.failed > 0) { + toast.error( + `${result.failed} rule${result.failed !== 1 ? 's' : ''} failed to add. ${ + result.successful + } succeeded. ${result.errors.join('; ')}` + ); + } else if (result.successful > 0) { + toast.success(`Successfully added ${result.successful} rule${result.successful !== 1 ? 's' : ''}`); + } + }, + onError: (error: Error) => { + toast.error(`Failed to add rules: ${error.message}`); + } + } + ); +}; + +/** + * Hook to delete multiple project rules in bulk. + * Failures and successes are counted and reported via toast. + * + * "1 rule failed to remove. 2 succeeded. T.3.4: Cannot delete a project rule that has children assigned to this project" + * "Successfully removed 3 rules" + */ +export const useBulkDeleteProjectRules = (rulesetId: string, projectId: string) => { + const queryClient = useQueryClient(); + const toast = useToast(); + + return useMutation<{ successful: number; failed: number; errors: string[] }, Error, string[]>( + ['rules', 'projectRules', 'bulk-delete'], + async (projectRuleIds) => { + const cachedProjectRules = queryClient.getQueryData(['rules', 'projectRules', rulesetId, projectId]); + const ruleCodesById = new Map(cachedProjectRules?.map((pr) => [pr.projectRuleId, pr.rule.ruleCode])); + + let successful = 0; + let failed = 0; + const errors: string[] = []; + + for (const projectRuleId of projectRuleIds) { + try { + await deleteProjectRule(projectRuleId); + successful++; + } catch (error) { + failed++; + errors.push( + `${ruleCodesById.get(projectRuleId) ?? projectRuleId}: ${ + error instanceof Error ? error.message : 'Unknown error' + }` + ); + } + } + + return { successful, failed, errors }; + }, + { + onSuccess: (result) => { + queryClient.invalidateQueries(['rules', 'projectRules', rulesetId, projectId]); + queryClient.invalidateQueries(['rules', 'unassigned']); + + if (result.failed > 0) { + toast.error( + `${result.failed} rule${result.failed !== 1 ? 's' : ''} failed to remove. ${ + result.successful + } succeeded. ${result.errors.join('; ')}` + ); + } else if (result.successful > 0) { + toast.success(`Successfully removed ${result.successful} rule${result.successful !== 1 ? 's' : ''}`); + } + }, + onError: (error: Error) => { + toast.error(`Failed to remove rules: ${error.message}`); + } + } + ); +}; + +/** + * Hook to set a rule's general-view status. This status is independent of any project. + * Applies the result to the cache directly instead of refetching, so a click costs one request. + */ +export const useSetRuleStatus = (rulesetId: string) => { + const queryClient = useQueryClient(); + const toast = useToast(); + return useMutation( + ['rules', 'setStatus'], + async ({ ruleId, status }) => { + const { data } = await setRuleStatus(ruleId, status); + return data; + }, + { + onSuccess: ({ rule, ancestors }, { ruleId }) => { + // updates in cache only the rules whose status are updated by this action, no extra refetching + applyRuleStatusUpdate(queryClient, rulesetId, { rule, ancestors }); + // only fetched while a history modal is open, refetches at most for one rule + queryClient.invalidateQueries(['rules', 'statusHistory', ruleId]); + toast.success( + `Rule status updated successfully. Rule ${rule.ruleCode} marked as ${getRuleStatusConfig(rule.status).label}.` + ); + }, + onError: (error) => { + toast.error(error.message); + } + } + ); +}; + +/** + * Hook to set a rule's status within a single project. This status is local to that project. + */ +export const useSetProjectRuleStatus = (rulesetId: string, projectId: string) => { + const queryClient = useQueryClient(); + const toast = useToast(); + return useMutation( + ['rules', 'setProjectRuleStatus'], + async ({ projectRuleId, status }) => { + const { data } = await setProjectRuleStatus(projectRuleId, status); + return data; + }, + { + onSuccess: ({ rule, status }) => { + queryClient.invalidateQueries(['rules', 'projectRules', rulesetId, projectId]); + queryClient.invalidateQueries(['rules', 'statusHistory', rule.ruleId]); + toast.success( + `Rule status updated successfully. Rule ${rule.ruleCode} marked as ${getRuleStatusConfig(status).label}` + ); + }, + onError: (error) => { + toast.error(error.message); + } + } + ); +}; + +/** + * 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) => { + const { selectedCar } = useGlobalCarFilter(); + return useQuery( + ['rulesets', rulesetTypeId, selectedCar === 'all-cars' ? 'all-cars' : selectedCar.id], + async () => { + const { data } = await getRulesetsByRulesetType(rulesetTypeId); + return data; + } + ); +}; + +/** + * React Query hook to delete a rule. + */ +export const useDeleteRule = (rulesetId: string) => { + const queryClient = useQueryClient(); + const toast = useToast(); + + return useMutation( + ['rules', 'delete'], + async ({ ruleId }) => { + await deleteRule(ruleId); + }, + { + onSuccess: (_data, { ruleId, ruleCode, totalRulesToDelete }) => { + const subRuleCount = totalRulesToDelete - 1; + toast.success( + subRuleCount > 0 + ? `Rule ${ruleCode} and ${subRuleCount} sub-rule${subRuleCount === 1 ? '' : 's'} deleted successfully` + : `Rule ${ruleCode} deleted successfully` + ); + queryClient.invalidateQueries(['rules', 'top-level', rulesetId]); + // identify which list holds the deleted rule or its parent, and only invalidate that list + queryClient + .getQueryCache() + .findAll(['rules', 'children']) + .filter((query) => + (query.state.data as SharedRule[] | undefined)?.some( + (rule) => rule.ruleId === ruleId || rule.subRuleIds.includes(ruleId) + ) + ) + .forEach((query) => queryClient.invalidateQueries(query.queryKey)); + queryClient.invalidateQueries(['rules', 'allRules', rulesetId]); + queryClient.invalidateQueries(['rules', 'ruleset', rulesetId]); + queryClient.invalidateQueries(['rulesets']); + }, + onError: (error: Error) => { + toast.error(error.message); + } + } + ); +}; + +// the edited row is stored in its parent's children list, or the top level list when it has no parent +const ruleListKey = (rulesetId: string, rule: SharedRule) => + rule.parentRule ? ['rules', 'children', rule.parentRule.ruleId] : ['rules', 'top-level', rulesetId]; + +/** + * Swaps edited rule into cache so editing does not refetch. + */ +const applyRuleUpdate = (queryClient: QueryClient, rulesetId: string, updatedRule: SharedRule) => { + const replaceIn = (key: unknown[]) => { + const cachedRules = queryClient.getQueryData(key); + // uncached list has no rows on the screen and was not loaded, so do not update + if (!cachedRules) return; + queryClient.setQueryData( + key, + cachedRules.map((rule) => (rule.ruleId === updatedRule.ruleId ? updatedRule : rule)) + ); + }; + + replaceIn(ruleListKey(rulesetId, updatedRule)); + replaceIn(['rules', 'allRules', rulesetId]); +}; + +/** + * React Query hook to edit a rule's content and/or code. + */ +export const useEditRule = (rulesetId: string) => { + const queryClient = useQueryClient(); + const toast = useToast(); + const fetchFullRuleTree = useFetchFullRuleTree(rulesetId); + + return useMutation( + ['rules', 'edit'], + async ({ ruleId, ruleContent, ruleCode }) => { + const { data } = await editRule(ruleId, ruleContent, ruleCode); + return data; + }, + { + onSuccess: (updatedRule) => { + toast.success(`Rule ${updatedRule.ruleCode} updated successfully`); + const previousRule = queryClient + .getQueryData(ruleListKey(rulesetId, updatedRule)) + ?.find((rule) => rule.ruleId === updatedRule.ruleId); + applyRuleUpdate(queryClient, rulesetId, updatedRule); + + // other rows show this rule's code in their references and as their parent's code, + // so a code change reloads the ruleset instead of only swapping this row + if (previousRule?.ruleCode !== updatedRule.ruleCode) { + Promise.all([queryClient.invalidateQueries(['rules', 'top-level', rulesetId]), fetchFullRuleTree()]).catch(() => + queryClient.invalidateQueries(['rules']) + ); + } + }, + onError: (error: Error) => { + toast.error(`Failed to update rule: ${error.message}`); + } + } + ); +}; + +/** + * React Query hook to upload an image and attach it to a rule. + */ +export const useAddRuleImage = (rulesetId: string) => { + const queryClient = useQueryClient(); + const toast = useToast(); + const { mutateAsync: uploadFile } = useUploadRulesetFile(); + + return useMutation( + ['rules', 'addImage'], + async ({ rule, file }) => { + const fileId = await uploadFile(file); + const { data } = await editRule(rule.ruleId, rule.ruleContent, rule.ruleCode, [...rule.imageFileIds, fileId]); + return data; + }, + { + onSuccess: (updatedRule) => { + toast.success(`Image added to rule ${updatedRule.ruleCode} successfully`); + applyRuleUpdate(queryClient, rulesetId, updatedRule); + }, + onError: (error: Error) => { + toast.error(error.message); + } + } + ); +}; + +/** + * React Query hook to remove an image from a rule. + */ +export const useRemoveRuleImage = (rulesetId: string) => { + const queryClient = useQueryClient(); + const toast = useToast(); + + return useMutation( + ['rules', 'removeImage'], + async ({ rule, fileId }) => { + const { data } = await editRule( + rule.ruleId, + rule.ruleContent, + rule.ruleCode, + rule.imageFileIds.filter((id) => id !== fileId) + ); + return data; + }, + { + onSuccess: (updatedRule) => { + toast.success(`Image removed from rule ${updatedRule.ruleCode} successfully`); + applyRuleUpdate(queryClient, rulesetId, updatedRule); + }, + onError: (error: Error) => { + toast.error(error.message); + } + } + ); +}; + +/** + * React Query hook to add referenced rules to a rule. + */ +export const useAddRuleReferences = (rulesetId: string) => { + const queryClient = useQueryClient(); + const toast = useToast(); + + return useMutation( + ['rules', 'addReference'], + async ({ ruleId, referencedRuleId }) => { + const { data } = await addRuleReferences(ruleId, referencedRuleId); + return data; + }, + { + onSuccess: (updatedRule, { referencedRuleCode }) => { + toast.success(`Referenced rule ${referencedRuleCode} added to rule ${updatedRule.ruleCode} successfully`); + applyRuleUpdate(queryClient, rulesetId, updatedRule); + }, + onError: (error: Error) => { + toast.error(`Failed to add referenced rule: ${error.message}`); + } + } + ); +}; + +/** + * React Query hook to remove referenced rules from a rule. + */ +export const useRemoveRuleReferences = (rulesetId: string) => { + const queryClient = useQueryClient(); + const toast = useToast(); + + return useMutation( + ['rules', 'removeReference'], + async ({ ruleId, referencedRuleId }) => { + const { data } = await removeRuleReferences(ruleId, referencedRuleId); + return data; + }, + { + onSuccess: (updatedRule, { referencedRuleCode }) => { + toast.success(`Referenced rule ${referencedRuleCode} removed from rule ${updatedRule.ruleCode} successfully`); + applyRuleUpdate(queryClient, rulesetId, updatedRule); + }, + onError: (error: Error) => { + toast.error(`Failed to remove referenced rule: ${error.message}`); + } + } + ); +}; + +/** + * React Query hook to update a ruleset. + */ +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']); + queryClient.invalidateQueries(['rules', 'rulesetTypes']); + } + } + ); +}; + +/** + * React Query hook to delete a ruleset. + */ +export const useDeleteRuleset = () => { + const queryClient = useQueryClient(); + return useMutation( + async (rulesetId: string) => { + await deleteRuleset(rulesetId); + }, + { + onSuccess: () => { + queryClient.invalidateQueries(['rulesets']); + queryClient.invalidateQueries(['rules', 'rulesetTypes']); + } + } + ); +}; + +/** + * React Query hook to delete a ruleset type. + */ +export const useDeleteRulesetType = () => { + const queryClient = useQueryClient(); + return useMutation( + async (rulesetTypeId: string) => { + await deleteRulesetType(rulesetTypeId); + }, + { + onSuccess: () => { + queryClient.invalidateQueries(['rules', 'rulesetTypes']); + } + } + ); +}; + +/** + * Hook to get a single ruleset type by ID. + */ +export const useRulesetType = (rulesetTypeId: string) => { + return useQuery(['rulesetType', rulesetTypeId], async () => { + const { data } = await getRulesetType(rulesetTypeId); + return data; + }); +}; + +/** + * Hook to create a new ruleset. + */ +export const useCreateRuleset = () => { + const queryClient = useQueryClient(); + return useMutation( + ['rulesets', 'create'], + async (payload: CreateRulesetPayload) => { + const { data } = await createRuleset(payload); + return data; + }, + { + onSuccess: () => { + queryClient.invalidateQueries(['rulesets']); + queryClient.invalidateQueries(['rules', 'rulesetTypes']); + } + } + ); +}; + +/** + * Parses an uploaded file and returns the parsed rules. + */ +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; + }); +}; + +/** + * Hook to get every rule in a ruleset (top-level and all descendants) in a single request. + */ +export const useAllRulesForRuleset = (rulesetId: string, enabled: boolean = true) => { + return useQuery( + ['rules', 'allRules', rulesetId], + async () => { + const { data } = await getAllRulesForRuleset(rulesetId); + return data; + }, + { enabled: !!rulesetId && enabled } + ); +}; + +/** + * Loads the full rule tree, for actions like "Expand All" that need every rule at once. + */ +export const useFetchFullRuleTree = (rulesetId: string) => { + const queryClient = useQueryClient(); + + return useCallback(async (): Promise => { + const allRules = await queryClient.fetchQuery(['rules', 'allRules', rulesetId], async () => { + const { data } = await getAllRulesForRuleset(rulesetId); + return data; + }); + + // Build a map of parentId -> children for all rules in this ruleset + const childrenByParentId = new Map(); + allRules.forEach((rule) => { + const parentId = rule.parentRule?.ruleId; + if (!parentId) return; + const siblings = childrenByParentId.get(parentId) ?? []; + siblings.push(rule); + childrenByParentId.set(parentId, siblings); + }); + + allRules + .filter((rule) => rule.subRuleIds.length > 0) + .forEach((rule) => { + queryClient.setQueryData(['rules', 'children', rule.ruleId], childrenByParentId.get(rule.ruleId) ?? []); + }); + + return allRules; + }, [queryClient, rulesetId]); +}; diff --git a/src/frontend/src/layouts/Sidebar/Sidebar.tsx b/src/frontend/src/layouts/Sidebar/Sidebar.tsx index 864f2f288e..431a1a3de1 100644 --- a/src/frontend/src/layouts/Sidebar/Sidebar.tsx +++ b/src/frontend/src/layouts/Sidebar/Sidebar.tsx @@ -18,6 +18,7 @@ import QuestionMarkIcon from '@mui/icons-material/QuestionMark'; import AttachMoneyIcon from '@mui/icons-material/AttachMoney'; import CalendarTodayIcon from '@mui/icons-material/CalendarToday'; import ChecklistIcon from '@mui/icons-material/Checklist'; +import WarningAmberRoundedIcon from '@mui/icons-material/WarningAmberRounded'; import NavPageLink from './NavPageLink'; import NERDrawer from '../../components/NERDrawer'; import NavUserMenu from '../PageTitle/NavUserMenu'; @@ -169,6 +170,11 @@ const Sidebar = ({ drawerOpen, setDrawerOpen, moveContent, setMoveContent }: Sid icon: , route: routes.SPONSORS }, + !onGuestHomePage && { + name: 'Rules', + icon: , + route: routes.RULES + }, { name: 'Info', icon: , diff --git a/src/frontend/src/pages/AdminToolsPage/AdminToolsProjectsConfig.tsx b/src/frontend/src/pages/AdminToolsPage/AdminToolsProjectsConfig.tsx index 82e5120070..10585ba2dc 100644 --- a/src/frontend/src/pages/AdminToolsPage/AdminToolsProjectsConfig.tsx +++ b/src/frontend/src/pages/AdminToolsPage/AdminToolsProjectsConfig.tsx @@ -13,6 +13,7 @@ import PartReviewSampleImage from './ProjectsConfig/PartReviewSampleImage'; import CommonMistakesTable from './ProjectsConfig/CommonMistakesTable'; import PartTagsTable from './ProjectsConfig/PartTagsTable'; import TaskLabelsTable from './ProjectsConfig/TaskLabelsTable'; +import RulesetTypesTable from './ProjectsConfig/RulesetTypesTable'; const AdminToolsProjectsConfig: React.FC = () => { return ( @@ -36,6 +37,10 @@ const AdminToolsProjectsConfig: React.FC = () => { Cars Config + + Rules Config + + Links Config diff --git a/src/frontend/src/pages/AdminToolsPage/ProjectsConfig/AddRulesetTypeModal.tsx b/src/frontend/src/pages/AdminToolsPage/ProjectsConfig/AddRulesetTypeModal.tsx new file mode 100644 index 0000000000..e5b20a626d --- /dev/null +++ b/src/frontend/src/pages/AdminToolsPage/ProjectsConfig/AddRulesetTypeModal.tsx @@ -0,0 +1,80 @@ +import { FormControl, FormHelperText, FormLabel } from '@mui/material'; +import * as yup from 'yup'; +import { yupResolver } from '@hookform/resolvers/yup'; +import { useForm } from 'react-hook-form'; +import NERFormModal from '../../../components/NERFormModal'; +import ReactHookTextField from '../../../components/ReactHookTextField'; +import { useToast } from '../../../hooks/toasts.hooks'; + +interface RulesetTypeFormData { + name: string; +} + +interface AddRulesetTypeModalProps { + open: boolean; + onHide: () => void; + onFormSubmit: (data: RulesetTypeFormData) => Promise; +} + +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 ( + + + Ruleset Name + + {errors.name?.message} + + + ); +}; + +export default AddRulesetTypeModal; diff --git a/src/frontend/src/pages/AdminToolsPage/ProjectsConfig/RulesetTypeDeleteModal.tsx b/src/frontend/src/pages/AdminToolsPage/ProjectsConfig/RulesetTypeDeleteModal.tsx new file mode 100644 index 0000000000..ad48f500d2 --- /dev/null +++ b/src/frontend/src/pages/AdminToolsPage/ProjectsConfig/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/AdminToolsPage/ProjectsConfig/RulesetTypesTable.tsx b/src/frontend/src/pages/AdminToolsPage/ProjectsConfig/RulesetTypesTable.tsx new file mode 100644 index 0000000000..f070e3b0ee --- /dev/null +++ b/src/frontend/src/pages/AdminToolsPage/ProjectsConfig/RulesetTypesTable.tsx @@ -0,0 +1,140 @@ +import { TableRow, TableCell, Box, IconButton } from '@mui/material'; +import LoadingIndicator from '../../../components/LoadingIndicator'; +import { datePipe } from '../../../utils/pipes'; +import ErrorPage from '../../ErrorPage'; +import { NERButton } from '../../../components/NERButton'; +import NERTable from '../../../components/NERTable'; +import { useState } from 'react'; +import { useToast } from '../../../hooks/toasts.hooks'; +import { useAllRulesetTypes, useCreateRulesetType, useDeleteRulesetType } from '../../../hooks/rules.hooks'; +import { Delete } from '@mui/icons-material'; +import RulesetTypeDeleteModal from './RulesetTypeDeleteModal'; +import AddRulesetTypeModal from './AddRulesetTypeModal'; + +const handleDeleteRulesetType = async ( + rulesetTypeId: string, + name: string, + hasRevisionFiles: boolean, + deleteRulesetTypeMutation: (rulesetTypeId: string) => Promise, + toast: ReturnType, + onSuccess: () => void +) => { + if (hasRevisionFiles) { + toast.error('Cannot delete ruleset type with existing revisions'); + return; + } + + try { + await deleteRulesetTypeMutation(rulesetTypeId); + toast.success(`Ruleset Type: ${name} deleted successfully!`); + onSuccess(); + } catch (error: unknown) { + if (error instanceof Error) { + toast.error(error.message); + } + } +}; + +interface RulesetTypeDeleteButtonProps { + rulesetTypeId: string; + name: string; + hasRevisionFiles: boolean; + deleteRulesetTypeMutation: (rulesetTypeId: string) => Promise; + toast: ReturnType; +} + +const RulesetTypeDeleteButton: React.FC = ({ + rulesetTypeId, + name, + hasRevisionFiles, + deleteRulesetTypeMutation, + toast +}) => { + const [showDeleteModal, setShowDeleteModal] = useState(false); + + const handleDeleteSubmit = () => + handleDeleteRulesetType(rulesetTypeId, name, hasRevisionFiles, deleteRulesetTypeMutation, toast, () => + setShowDeleteModal(false) + ); + + return ( + <> + setShowDeleteModal(true)}> + + + {showDeleteModal && ( + setShowDeleteModal(false)} + /> + )} + + ); +}; + +const RulesetTypesTable: React.FC = () => { + const toast = useToast(); + const { data: rulesetTypes, isLoading, isError, error } = useAllRulesetTypes(); + const { mutateAsync: createRulesetType } = useCreateRulesetType(); + const { mutateAsync: deleteRulesetType } = useDeleteRulesetType(); + const [addRulesetTypeModalShow, setAddRulesetTypeModalShow] = useState(false); + + if (isError) return ; + if (!rulesetTypes || isLoading) return ; + + const handleAddRulesetTypeConfirm = async (data: { name: string }) => { + await createRulesetType({ name: data.name }); + }; + + const handleAddRulesetTypeCancel = () => { + setAddRulesetTypeModalShow(false); + }; + + const rulesetTypeTableRows = rulesetTypes.map((rulesetType, index) => ( + + {rulesetType.name} + + {datePipe(rulesetType.lastUpdated)} + + + {rulesetType.revisionFiles.length} + + + 0} + deleteRulesetTypeMutation={deleteRulesetType} + toast={toast} + /> + + + )); + + return ( + + + + + setAddRulesetTypeModalShow(!addRulesetTypeModalShow)}> + Add Ruleset Type + + + + ); +}; + +export default RulesetTypesTable; diff --git a/src/frontend/src/pages/CalendarPage/Components/EventModal.tsx b/src/frontend/src/pages/CalendarPage/Components/EventModal.tsx index fb5b0ca417..3b85a14a61 100644 --- a/src/frontend/src/pages/CalendarPage/Components/EventModal.tsx +++ b/src/frontend/src/pages/CalendarPage/Components/EventModal.tsx @@ -419,10 +419,10 @@ const EventModal: React.FC = ({ // made it so it only fills when the field is empty, that way doesn't overwrite a link or anythingi me useEffect(() => { if (!open || isEditMode) return; - if (scheduleSettings?.personalZoomLink && !getValues('zoomLink')) { + if (scheduleSettings?.personalZoomLink && selectedEventType?.zoomLink && !getValues('zoomLink')) { setValue('zoomLink', scheduleSettings.personalZoomLink); } - }, [open, isEditMode, scheduleSettings, getValues, setValue]); + }, [open, isEditMode, scheduleSettings, getValues, setValue, selectedEventType]); const computedTitle = isEditMode ? 'Edit Event' : 'Add Event'; diff --git a/src/frontend/src/pages/CreditsPage/CreditsPage.tsx b/src/frontend/src/pages/CreditsPage/CreditsPage.tsx index cc9d537d97..619536899a 100644 --- a/src/frontend/src/pages/CreditsPage/CreditsPage.tsx +++ b/src/frontend/src/pages/CreditsPage/CreditsPage.tsx @@ -380,7 +380,8 @@ const CreditsPage: React.FC = () => { { name: 'Jasper Pinkus', color: '#276221' }, { name: 'Hamilton LaPides', color: '#55a50a' }, { name: 'Sara Johnson', color: '#ffff99' }, - { name: 'Abhishek Vijay', color: '#4287f5' } + { name: 'Abhishek Vijay', color: '#4287f5' }, + { name: 'Kidus Desalegn', color: '#B027F5' } ]; const snark = ['Add your name!', "Shouldn't you do it yourself?", 'Seriously', 'go', 'do', 'it']; diff --git a/src/frontend/src/pages/GanttPage/GanttChart/GanttChart.tsx b/src/frontend/src/pages/GanttPage/GanttChart/GanttChart.tsx index c45eb0ec24..052e8446af 100644 --- a/src/frontend/src/pages/GanttPage/GanttChart/GanttChart.tsx +++ b/src/frontend/src/pages/GanttPage/GanttChart/GanttChart.tsx @@ -12,6 +12,7 @@ import { eachDayOfInterval, isMonday, differenceInDays } from 'date-fns'; import { getMonday } from '../../../utils/datetime.utils'; import { toDateString } from 'shared'; import { GANTT_CHART_CELL_SIZE, GANTT_CHART_GAP_SIZE } from '../../../utils/gantt.utils'; +import { useCallback, useState } from 'react'; export interface GanttEditability { highlightTaskComparator: HighlightTaskComparator; highlightSubtaskComparator: HighlightTaskComparator; @@ -41,6 +42,15 @@ const GanttChart = ({ startDate, endDate, collections, editability }: Gant const daysIntoWeek = differenceInDays(today, getMonday(today)); const dailyOffset = daysIntoWeek * (parseFloat(GANTT_CHART_CELL_SIZE) / 7); + const [expanded, setExpanded] = useState>(new Set()); + const toggleExpanded = useCallback((id: string) => { + setExpanded((prev) => { + const next = new Set(prev); + next.has(id) ? next.delete(id) : next.add(id); + return next; + }); + }, []); + return ( ({ startDate, endDate, collections, editability }: Gant endDate={endDate} collection={collection} editability={editability} + toggleExpanded={toggleExpanded} + expanded={expanded} + key={collection.id} /> ) : ( <> diff --git a/src/frontend/src/pages/GanttPage/GanttChart/GanttChartCollectionSection.tsx b/src/frontend/src/pages/GanttPage/GanttChart/GanttChartCollectionSection.tsx index 5e72c48475..d2dd9e13ba 100644 --- a/src/frontend/src/pages/GanttPage/GanttChart/GanttChartCollectionSection.tsx +++ b/src/frontend/src/pages/GanttPage/GanttChart/GanttChartCollectionSection.tsx @@ -14,13 +14,17 @@ interface GanttChartCollectionSectionProps { endDate: Date; collection: GanttCollection; editability?: GanttEditability; + toggleExpanded: (id: string) => void; + expanded: Set; } const GanttChartCollectionSection = ({ startDate, endDate, collection, - editability + editability, + toggleExpanded, + expanded }: GanttChartCollectionSectionProps) => { const theme = useTheme(); const [isEditMode, setIsEditMode] = useState(false); @@ -94,6 +98,8 @@ const GanttChartCollectionSection = ({ onAddTaskPressed={editability?.onNewSubTaskPressed ?? ignore} highlightSubtaskComparator={editability?.highlightSubtaskComparator ?? ignoreBool} highlightTaskComparator={editability?.highlightTaskComparator ?? ignoreBool} + toggleExpanded={toggleExpanded} + expanded={expanded} /> diff --git a/src/frontend/src/pages/GanttPage/GanttChart/GanttChartComponents/GanttTaskBar/GanttTaskBar.tsx b/src/frontend/src/pages/GanttPage/GanttChart/GanttChartComponents/GanttTaskBar/GanttTaskBar.tsx index 7b281698a1..625495236a 100644 --- a/src/frontend/src/pages/GanttPage/GanttChart/GanttChartComponents/GanttTaskBar/GanttTaskBar.tsx +++ b/src/frontend/src/pages/GanttPage/GanttChart/GanttChartComponents/GanttTaskBar/GanttTaskBar.tsx @@ -12,8 +12,6 @@ import { OnMouseOverOptions, RequestEventChange } from '../../../../../utils/gantt.utils'; -import { getMonday } from '../../../../../utils/datetime.utils'; -import { toDateString } from 'shared'; interface GanttTaskBarProps { days: Date[]; @@ -27,6 +25,11 @@ interface GanttTaskBarProps { highlightTaskComparator: HighlightTaskComparator; highlightSubtaskComparator: HighlightTaskComparator; onToggle?: () => void; + toggleExpanded: (id: string) => void; + isExpanded: boolean; + expanded: Set; + getStartCol: (start: Date) => number; + getEndCol: (end: Date) => number; } const GanttTaskBar = ({ @@ -40,21 +43,13 @@ const GanttTaskBar = ({ onAddTaskPressed, highlightSubtaskComparator, highlightTaskComparator, - onToggle + onToggle, + toggleExpanded, + isExpanded, + expanded, + getStartCol, + getEndCol }: GanttTaskBarProps) => { - const getStartCol = (start: Date) => { - const startCol = days.findIndex((day) => toDateString(day) === toDateString(getMonday(start))) + 1; - return startCol; - }; - - const getEndCol = (end: Date) => { - const endCol = - days.findIndex((day) => toDateString(day) === toDateString(getMonday(end))) === -1 - ? days.length + 1 - : days.findIndex((day) => toDateString(day) === toDateString(getMonday(end))) + 2; - return endCol; - }; - return (
{isEditMode ? ( @@ -79,6 +74,9 @@ const GanttTaskBar = ({ highlightSubtaskComparator={highlightSubtaskComparator} highlightTaskComparator={highlightTaskComparator} onToggle={onToggle} + toggleExpanded={toggleExpanded} + isExpanded={isExpanded} + expanded={expanded} /> )}
diff --git a/src/frontend/src/pages/GanttPage/GanttChart/GanttChartComponents/GanttTaskBar/GanttTaskBarDisplay.tsx b/src/frontend/src/pages/GanttPage/GanttChart/GanttChartComponents/GanttTaskBar/GanttTaskBarDisplay.tsx index c9ae3b426c..6109869cf8 100644 --- a/src/frontend/src/pages/GanttPage/GanttChart/GanttChartComponents/GanttTaskBar/GanttTaskBarDisplay.tsx +++ b/src/frontend/src/pages/GanttPage/GanttChart/GanttChartComponents/GanttTaskBar/GanttTaskBarDisplay.tsx @@ -29,7 +29,7 @@ interface GanttTaskBarDisplayProps { handleOnMouseLeave: () => void; onShowChildrenToggle: () => void; highlightedChange?: RequestEventChange; - showChildren?: boolean; + isExpanded: boolean; getStartCol: (start: Date) => number; getEndCol: (end: Date) => number; highlightTaskComparator: HighlightTaskComparator; @@ -42,7 +42,7 @@ const GanttTaskBarDisplay = ({ handleOnMouseOver, handleOnMouseLeave, onShowChildrenToggle, - showChildren, + isExpanded, highlightedChange, getStartCol, getEndCol, @@ -182,7 +182,7 @@ const GanttTaskBarDisplay = ({ }} sx={{ marginRight: '-15px', marginLeft: '-5px' }} > - {showChildren ? ( + {isExpanded ? ( ) : ( diff --git a/src/frontend/src/pages/GanttPage/GanttChart/GanttChartComponents/GanttTaskBar/GanttTaskBarView.tsx b/src/frontend/src/pages/GanttPage/GanttChart/GanttChartComponents/GanttTaskBar/GanttTaskBarView.tsx index 05cba3506e..6772eea425 100644 --- a/src/frontend/src/pages/GanttPage/GanttChart/GanttChartComponents/GanttTaskBar/GanttTaskBarView.tsx +++ b/src/frontend/src/pages/GanttPage/GanttChart/GanttChartComponents/GanttTaskBar/GanttTaskBarView.tsx @@ -7,7 +7,9 @@ import { import { Collapse } from '@mui/material'; import GanttTaskBar from './GanttTaskBar'; import GanttTaskBarDisplay from './GanttTaskBarDisplay'; -import { useState } from 'react'; +import React from 'react'; + +const noop = () => {}; interface GanttTaskBarViewProps { days: Date[]; @@ -21,6 +23,9 @@ interface GanttTaskBarViewProps { highlightTaskComparator: HighlightTaskComparator; highlightSubtaskComparator: HighlightTaskComparator; onToggle?: () => void; + toggleExpanded: (id: string) => void; + isExpanded: boolean; + expanded: Set; } const GanttTaskBarView = ({ @@ -34,12 +39,13 @@ const GanttTaskBarView = ({ onAddTaskPressed, highlightSubtaskComparator, highlightTaskComparator, - onToggle + onToggle, + toggleExpanded, + isExpanded, + expanded }: GanttTaskBarViewProps) => { - const [showChildren, setShowChildren] = useState(false); - const handleToggle = () => { - setShowChildren((prev) => !prev); + toggleExpanded(task.id); }; return ( @@ -49,7 +55,7 @@ const GanttTaskBarView = ({ task={task} handleOnMouseOver={handleOnMouseOver} handleOnMouseLeave={handleOnMouseLeave} - showChildren={showChildren} + isExpanded={isExpanded} onShowChildrenToggle={handleToggle} highlightedChange={highlightedChange} getStartCol={getStartCol} @@ -58,14 +64,14 @@ const GanttTaskBarView = ({ highlightTaskComparator={highlightTaskComparator} /> - + {task.children.map((child) => ( {}} + createChange={noop} handleOnMouseOver={handleOnMouseOver} handleOnMouseLeave={handleOnMouseLeave} highlightedChange={highlightedChange} @@ -73,6 +79,11 @@ const GanttTaskBarView = ({ highlightSubtaskComparator={highlightSubtaskComparator} highlightTaskComparator={highlightTaskComparator} onToggle={onToggle} + toggleExpanded={toggleExpanded} + isExpanded={expanded.has(child.id)} + expanded={expanded} + getStartCol={getStartCol} + getEndCol={getEndCol} /> ))} @@ -80,4 +91,4 @@ const GanttTaskBarView = ({ ); }; -export default GanttTaskBarView; +export default React.memo(GanttTaskBarView) as typeof GanttTaskBarView; diff --git a/src/frontend/src/pages/GanttPage/GanttChart/GanttChartSection.tsx b/src/frontend/src/pages/GanttPage/GanttChart/GanttChartSection.tsx index e4222aeb08..8defaaf036 100644 --- a/src/frontend/src/pages/GanttPage/GanttChart/GanttChartSection.tsx +++ b/src/frontend/src/pages/GanttPage/GanttChart/GanttChartSection.tsx @@ -12,10 +12,12 @@ import { RequestEventChange } from '../../../utils/gantt.utils'; import { Box } from '@mui/material'; -import { MutableRefObject, useCallback, useEffect, useRef, useState } from 'react'; +import { MutableRefObject, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import GanttTaskBar from './GanttChartComponents/GanttTaskBar/GanttTaskBar'; import GanttToolTip from './GanttChartComponents/GanttToolTip'; import { ArcherContainer, ArcherContainerRef } from 'react-archer'; +import { toDateString } from 'shared'; +import { getMonday } from '../../../utils/datetime.utils'; interface GanttChartSectionProps { start: Date; @@ -27,6 +29,8 @@ interface GanttChartSectionProps { onAddTaskPressed: (parentTask: GanttTask) => void; highlightTaskComparator: HighlightTaskComparator; highlightSubtaskComparator: HighlightTaskComparator; + toggleExpanded: (id: string) => void; + expanded: Set; } interface GanttTooltipLayerProps { @@ -66,9 +70,30 @@ const GanttChartSection = ({ highlightedChange, onAddTaskPressed, highlightSubtaskComparator, - highlightTaskComparator + highlightTaskComparator, + toggleExpanded, + expanded }: GanttChartSectionProps) => { - const days = eachDayOfInterval({ start, end }).filter((day) => isMonday(day)); + const days = useMemo(() => eachDayOfInterval({ start, end }).filter((day) => isMonday(day)), [start, end]); + + const dayColIndex = useMemo(() => { + const m = new Map(); + days.forEach((day, i) => m.set(toDateString(day), i)); + return m; + }, [days]); + + const getStartCol = useCallback( + (start: Date) => (dayColIndex.get(toDateString(getMonday(start))) ?? -1) + 1, + [dayColIndex] + ); + + const getEndCol = useCallback( + (end: Date) => { + const idx = dayColIndex.get(toDateString(getMonday(end))); + return idx === undefined ? days.length + 1 : idx + 2; + }, + [dayColIndex, days.length] + ); const treeContainerRef = useRef(null); const archerContainerRef = useRef(null); @@ -80,16 +105,15 @@ const GanttChartSection = ({ const node = treeContainerRef.current; if (!node) return; - let frame: number; + let timeout: ReturnType; const observer = new ResizeObserver(() => { - // rAF here avoids "ResizeObserver loop" warnings and batches rapid-fire events - cancelAnimationFrame(frame); - frame = requestAnimationFrame(() => onToggle()); + clearTimeout(timeout); + timeout = setTimeout(onToggle, 250); }); observer.observe(node); return () => { - cancelAnimationFrame(frame); + clearTimeout(timeout); observer.disconnect(); }; }, [onToggle]); @@ -134,6 +158,11 @@ const GanttChartSection = ({ highlightSubtaskComparator={highlightSubtaskComparator} highlightTaskComparator={highlightTaskComparator} onToggle={onToggle} + toggleExpanded={toggleExpanded} + isExpanded={expanded.has(task.id)} + expanded={expanded} + getStartCol={getStartCol} + getEndCol={getEndCol} /> ); diff --git a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectRules/AddProjectRuleModal.tsx b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectRules/AddProjectRuleModal.tsx new file mode 100644 index 0000000000..b163223e86 --- /dev/null +++ b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectRules/AddProjectRuleModal.tsx @@ -0,0 +1,268 @@ +/* + * 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, + FormLabel, + Select, + MenuItem, + SelectChangeEvent, + IconButton, + useTheme +} from '@mui/material'; +import RemoveCircleOutlineIcon from '@mui/icons-material/RemoveCircleOutline'; +import { Rule } from 'shared'; +import NERModal from '../../../../components/NERModal'; +import { useUnassignedRulesForRuleset } from '../../../../hooks/rules.hooks'; + +interface AddRuleModalProps { + open: boolean; + onHide: () => void; + rulesetId: string; + projectId: string; + teamNames: string[]; + onSubmit: (ruleIds: string[]) => void; +} + +const AddRuleModal = ({ open, onHide, rulesetId, projectId, teamNames, onSubmit }: AddRuleModalProps) => { + const theme = useTheme(); + + const [selectedRuleIds, setSelectedRuleIds] = useState([]); // Leaf-rule selections that will be submitted + const [path, setPath] = useState([]); // In-progress path (rule ids from top level down); only holds non-leaf rules + const { data: unassignedRules, isLoading, isError } = useUnassignedRulesForRuleset(rulesetId, projectId); + + const addableRules = useMemo(() => unassignedRules ?? [], [unassignedRules]); // rules that can still be added + const rulesById = useMemo( + () => new Map(addableRules.map((rule: Rule) => [rule.ruleId, rule] as [string, Rule])), + [addableRules] + ); + const addableRuleIds = useMemo(() => new Set(addableRules.map((rule: Rule) => rule.ruleId)), [addableRules]); + + // Children of a rule that are still addable + const addableChildrenOf = (ruleId: string) => addableRules.filter((rule: Rule) => rule.parentRule?.ruleId === ruleId); + // A rule is a selectable leaf when it has no addable children to drill into + const isLeaf = (rule: Rule) => addableChildrenOf(rule.ruleId).length === 0; + // used to sort rules in the dropdowns by their rule code + const byRuleCode = (a: Rule, b: Rule) => a.ruleCode.localeCompare(b.ruleCode, undefined, { numeric: true }); + + // Recursively checks if a rule has any unselected leaf descendants. + const hasUnselectedLeaf = (ruleId: string, selected: string[]): boolean => { + const rule = rulesById.get(ruleId); + if (!rule) return false; + const addableChildren = addableChildrenOf(ruleId); + if (addableChildren.length === 0) return !selected.includes(ruleId); + return addableChildren.some((child: Rule) => hasUnselectedLeaf(child.ruleId, selected)); + }; + + // Finds children of the given parent that still have a leaf left to assign + // Or if parentId is null finds the top-level rules of each addable branch (has some unassigned leaf rule) + const optionsForParent = (parentId: string | null, selected: string[] = selectedRuleIds): Rule[] => + addableRules + .filter((rule: Rule) => + parentId === null + ? !rule.parentRule || !addableRuleIds.has(rule.parentRule.ruleId) + : rule.parentRule?.ruleId === parentId + ) + .filter((rule: Rule) => hasUnselectedLeaf(rule.ruleId, selected)) + .sort(byRuleCode); + + // One dropdown per level: the top-level rules, then the children of each chosen rule, recursively. + // The first dropdown will always be the top level options, each selection adds a dropdown for its children, until a leaf is chosen. + const buildLevels = (): { options: Rule[]; value: string }[] => { + // Tracks the dropdowns to show, starting with the top-level rules and adding a level for each selected rule in the path. + const result: { options: Rule[]; value: string }[] = [{ options: optionsForParent(null), value: path[0] ?? '' }]; + // Each rule already chosen in the path adds one more dropdown beneath it, listing that rule's children. + path.forEach((ruleId, index) => { + result.push({ options: optionsForParent(ruleId), value: path[index + 1] ?? '' }); + }); + return result; + }; + const levels = buildLevels(); + + // If an addable leaf exists anywhere + const hasAddableRules = optionsForParent(null).length > 0; + + const handleLevelChange = (levelIndex: number, event: SelectChangeEvent) => { + const ruleId = event.target.value; + const rule = rulesById.get(ruleId); + if (!rule) return; + + if (isLeaf(rule)) { + // Add the leaf to the selected list if it isn't already there + const updatedSelected = selectedRuleIds.includes(ruleId) ? selectedRuleIds : [...selectedRuleIds, ruleId]; + setSelectedRuleIds(updatedSelected); + // Keep the path up to the parent of the leaf so siblings can be added if any exist + // Collapse levels whose options are now exhausted (no unselected leaves left) + let newPath = path.slice(0, levelIndex); + while (newPath.length > 0 && optionsForParent(newPath[newPath.length - 1], updatedSelected).length === 0) { + newPath = newPath.slice(0, -1); + } + setPath(newPath); + } else { + setPath([...path.slice(0, levelIndex), 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([]); + setPath([]); // reset dropdowns + }; + + // Get rule display name + const getRuleName = (ruleId: string): string => rulesById.get(ruleId)?.ruleCode ?? ruleId; + + const sectionHeaderStyle = { + display: 'block', + fontWeight: 'bold', + color: '#ef4345', + textDecoration: 'underline', + fontSize: '1rem', + textUnderlineOffset: '5px', + marginBottom: '10px' + }; + + // Selected rule row styling + const selectedRuleStyles = { + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + backgroundColor: theme.palette.action.hover, + borderRadius: '8px', + px: 2.5, + py: 1.5, + mb: 1.5 + }; + + return ( + + + {isError ? ( + Failed to load rules + ) : isLoading ? ( + + + + ) : // Empty state when there are no rules assigned or nothing addable is left + addableRules.length === 0 || (!hasAddableRules && selectedRuleIds.length === 0) ? ( + + {teamNames.length > 0 + ? `No unassigned rules available for the ${teamNames.join(', ')} team${teamNames.length === 1 ? '' : 's'}.` + : 'No unassigned rules available for this project.'} + + ) : ( + + {/* Selected Rules */} + {selectedRuleIds.length > 0 && ( + + Selected Rule Codes + {selectedRuleIds.map((ruleId) => ( + + + handleRemoveRule(ruleId)} + sx={{ color: theme.palette.text.primary, p: 0.5, mr: 1 }} + > + + + {getRuleName(ruleId)} + + + ))} + + )} + + {/* Continuous dropdowns: start at a top-level rule and pick sub-rules until reaching a leaf */} + {hasAddableRules ? ( + + Rule Code + + {/* Non-leaf options are flagged with a › to show the ability to continue selecting */} + {levels.map((level, levelIndex) => ( + + + + ))} + + + ) : ( + // Every addable leaf has been selected this session, but the Selected Rules list + // above stays visible with save enabled so the user can submit what they picked + + {teamNames.length > 0 + ? `No more unassigned rules available for the ${teamNames.join(', ')} team${teamNames.length === 1 ? '' : 's'}.` + : 'No more unassigned rules available for this project.'} + + )} + + )} + + + ); +}; + +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..5daa8e7f73 --- /dev/null +++ b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectRules/ProjectRulesTab.tsx @@ -0,0 +1,459 @@ +/* + * 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, + Tab, + Tabs as MuiTabs, + Table, + TableBody, + TableContainer, + Paper, + useTheme, + IconButton, + Tooltip +} from '@mui/material'; +import { isHead, Project, ProjectRule, Rule, RuleStatus, isLeadership } from 'shared'; +import LoadingIndicator from '../../../../components/LoadingIndicator'; +import ErrorPage from '../../../ErrorPage'; +import RuleRow from '../../../RulesPage/RuleRow'; +import RuleContent from '../../../RulesPage/components/RuleContent'; +import RuleStatusHistoryModal from '../../../RulesPage/components/RuleStatusHistoryModal'; +import ResetStatusesModal from '../../../RulesPage/components/ResetStatusesModal'; +import { useRuleTreeNavigation } from '../../../RulesPage/useRuleTreeNavigation'; +import AddRuleModal from './AddProjectRuleModal'; +import RemoveRuleModal from './RemoveProjectRuleModal'; +import { + useAllRulesetTypes, + useActiveRuleset, + useProjectRules, + useSetProjectRuleStatus, + useBulkCreateProjectRules, + useResetProjectRuleStatuses, + useBulkDeleteProjectRules +} from '../../../../hooks/rules.hooks'; +import { useCurrentUser } from '../../../../hooks/users.hooks'; +import { useToast } from '../../../../hooks/toasts.hooks'; +import { InfoOutlined } from '@mui/icons-material'; +import { useHistory } from 'react-router-dom'; +import { routes } from '../../../../utils/routes'; +import RuleStatusTag from '../../../RulesPage/components/RuleStatusTag'; +import { NERButton } from '../../../../components/NERButton'; +import { compareRuleCodes } from '../../../../utils/rules.utils'; +import { isUserOnTeam } from '../../../../utils/teams.utils'; + +interface ProjectRulesTabProps { + project: Project; +} + +export const ProjectRulesTab = ({ project }: ProjectRulesTabProps) => { + const toast = useToast(); + const theme = useTheme(); + const history = useHistory(); + const user = useCurrentUser(); + + const [selectedRulesetTypeIndex, setSelectedRulesetTypeIndex] = useState(0); + const [addRuleModalOpen, setAddRuleModalOpen] = useState(false); + const [removeRuleModalOpen, setRemoveRuleModalOpen] = useState(false); + const [historyModalProjectRule, setHistoryModalProjectRule] = useState(null); + const [showResetModal, setShowResetModal] = 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, scoped to this project's own car + const { data: activeRuleset, isLoading: activeRulesetLoading } = useActiveRuleset( + selectedRulesetType?.rulesetTypeId || '', + project.wbsNum.carNumber + ); + + // Fetch project rules for the active ruleset + const { + data: projectRules, + isLoading: projectRulesLoading, + isError: projectRulesError + } = useProjectRules(activeRuleset?.rulesetId || '', project.id); + + // Mutations + const { mutateAsync: setStatusMutation } = useSetProjectRuleStatus(activeRuleset?.rulesetId || '', project.id); + + const { mutate: addProjectRules, isLoading: isCreating } = useBulkCreateProjectRules( + activeRuleset?.rulesetId || '', + project.id + ); + + const { mutate: removeProjectRules, isLoading: isDeleting } = useBulkDeleteProjectRules( + activeRuleset?.rulesetId || '', + project.id + ); + + const { mutateAsync: resetProjectRuleStatuses, isLoading: isResetting } = useResetProjectRuleStatuses( + activeRuleset?.rulesetId || '', + project.id + ); + + // First team's ID, used only to pre-select a team tab on the assign-rules deep link + const teamId = project.teams[0]?.teamId || ''; + const teamNames = project.teams.map((team) => team.teamName); + + // leadership can update status anywhere; members can only update it for projects whose team they're on + const canUpdateStatus = isLeadership(user.role) || project.teams.some((team) => isUserOnTeam(team, user)); + + // Convert project rules to rules for display, merging in each rule's local status for this project + // Sorted by rule code so both top-level rows and their children render in stable numeric order + const projectRuleList = useMemo(() => { + if (!projectRules) return []; + return projectRules + .map((pr) => ({ + ...pr.rule, + status: pr.status, + statusUpdatedBy: pr.statusUpdatedBy, + statusUpdatedAt: pr.statusUpdatedAt, + // history modal for PASS/FAIL only scoped to this project + hasStatusHistory: pr.hasStatusHistory + })) + .sort(compareRuleCodes); + }, [projectRules]); + + // Get top-level rules (rules without a parent) + const topLevelRules = useMemo(() => { + return projectRuleList.filter((rule) => !rule.parentRule); + }, [projectRuleList]); + + // all referenced rules are shown, only referenced rules that exist in this project are clickable + const projectRuleIds = useMemo(() => new Set(projectRuleList.map((r) => r.ruleId)), [projectRuleList]); + + // controlled expansion + click-to-navigate + const { expandedIds, toggleExpand, navigateToRule, expandAll, collapseAll, areAllExpanded } = + useRuleTreeNavigation(projectRuleList); + + // projectRuleList flattens project rules and drops projectRuleId, so connect a rule row back to its project rule + const projectRuleByRuleId = useMemo(() => new Map((projectRules ?? []).map((pr) => [pr.rule.ruleId, pr])), [projectRules]); + + // Handle opening the status history modal, scoped to this project + const handleHistoryClick = (rule: Rule) => { + const projectRule = projectRuleByRuleId.get(rule.ruleId); + if (projectRule) { + setHistoryModalProjectRule(projectRule); + } + }; + + // status writes are local to this project + const handleSetStatus = async (rule: Rule, status: RuleStatus) => { + const projectRule = projectRuleByRuleId.get(rule.ruleId); + if (!projectRule) { + toast.error(`Rule ${rule.ruleCode} is no longer assigned to this project.`); + return; + } + await setStatusMutation({ projectRuleId: projectRule.projectRuleId, status }); + }; + + // Handle resetting all of this project's statuses (for the active ruleset) back to Pending + const handleResetStatuses = async () => { + await resetProjectRuleStatuses(); + setShowResetModal(false); + // close any open history modal since statuses are changing + setHistoryModalProjectRule(null); + }; + + // Handle tab change + const handleTabChange = (_event: React.SyntheticEvent, newValue: number) => { + setSelectedRulesetTypeIndex(newValue); + }; + + if (rulesetTypesError) { + return ; + } + + if (rulesetTypesLoading) { + return ; + } + + // No ruleset types + if (!rulesetTypes || rulesetTypes.length === 0) { + return ( + + + No ruleset types configured for this organization. + + + ); + } + + // Check if we have no active ruleset + const hasNoActiveRuleset = !activeRulesetLoading && !activeRuleset; + + // Right content for rule rows - status badge. Leaf rules show Pass/Fail checkboxes; + // parents show an aggregated, read-only status. + const renderRightContent = (rule: Rule) => { + const isLeafRule = !projectRuleList.some((r) => r.parentRule?.ruleId === rule.ruleId); + + return ( + + ); + }; + + const backgroundColor = theme.palette.background.default; + const tableBackgroundColor = theme.palette.background.paper; + const tableTextColor = theme.palette.text.primary; + const tableHoverColor = theme.palette.action.hover; + + return ( + + {/* Ruleset Type Tabs */} + + + {rulesetTypes.map((rulesetType, idx) => ( + + ))} + + {activeRuleset && ( + + + {areAllExpanded ? 'Collapse All' : 'Expand All'} + + {isLeadership(user.role) && ( + setShowResetModal(true)}> + Reset Status + + )} + + )} + + + {/* Active ruleset name for this ruleset type */} + {activeRuleset && ( + + {activeRuleset.name} + + )} + + {/* Rules Content */} + {projectRulesError ? ( + Failed to load rules + ) : activeRulesetLoading || projectRulesLoading ? ( + + + + ) : hasNoActiveRuleset ? ( + + + No active ruleset configured for this ruleset type. + + + ) : topLevelRules.length === 0 ? ( + + + No rules assigned to this project yet. + + + ) : ( + + + + + {topLevelRules.map((rule) => ( + ( + projectRuleIds.has(id)} + /> + )} + rightContent={renderRightContent} + backgroundColor={tableBackgroundColor} + textColor={tableTextColor} + hoverColor={tableHoverColor} + rowHeight="40px" + verticalPadding="8px" + indentRow + /> + ))} + +
+
+
+ )} + + {/* Add Rule Button */} + + + {isLeadership(user.role) && ( + + + {/* Assign Rules Tooltip */} + 0 + ? `Assign rules to the ${teamNames.join(', ')} team${ + teamNames.length === 1 ? '' : 's' + } to add them to this project` + : 'Add a team to this project to assign rules' + } + arrow + slotProps={{ tooltip: { sx: { textAlign: 'center' } } }} + > + e.stopPropagation()} + sx={{ + padding: '5px', + color: 'text.secondary' + }} + > + + + + {/* Assign Rules Button */} + + activeRuleset && + history.push( + `${routes.RULESET_EDIT.replace(':rulesetId', activeRuleset.rulesetId)}/assign-rules${ + teamId ? `?teamId=${teamId}` : '' + }` + ) + } + > + Assign Rules + + + {/* Add Rule Button */} + setAddRuleModalOpen(true)} + disabled={teamNames.length === 0 || hasNoActiveRuleset} + > + Add Rule + + {/* Remove Rule Button */} + {isHead(user.role) && projectRuleList.length > 0 && ( + setRemoveRuleModalOpen(true)}> + Remove Rule + + )} + + )} + + + {/* Status History Modal, scoped to this project */} + {historyModalProjectRule && ( + setHistoryModalProjectRule(null)} + rule={historyModalProjectRule.rule} + projectRuleId={historyModalProjectRule.projectRuleId} + /> + )} + + {/* Reset Statuses Modal, scoped to this project + active ruleset */} + {showResetModal && activeRuleset && ( + setShowResetModal(false)} + onReset={handleResetStatuses} + /> + )} + + {/* Add Rule Modal */} + {activeRuleset && ( + setAddRuleModalOpen(false)} + rulesetId={activeRuleset.rulesetId} + projectId={project.id} + teamNames={project.teams.map((team) => team.teamName)} + onSubmit={addProjectRules} + /> + )} + + {/* Remove Rule Modal */} + {activeRuleset && projectRules && ( + setRemoveRuleModalOpen(false)} + projectRules={projectRules} + projectName={project.name} + onSubmit={removeProjectRules} + /> + )} + + {(isCreating || isResetting || isDeleting) && ( + + + + )} + + ); +}; diff --git a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectRules/RemoveProjectRuleModal.tsx b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectRules/RemoveProjectRuleModal.tsx new file mode 100644 index 0000000000..8b010aa39a --- /dev/null +++ b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectRules/RemoveProjectRuleModal.tsx @@ -0,0 +1,181 @@ +/* + * 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 { useMemo, useState } from 'react'; +import { + Box, + Typography, + FormControl, + FormLabel, + Select, + MenuItem, + SelectChangeEvent, + IconButton, + useTheme +} from '@mui/material'; +import RemoveCircleOutlineIcon from '@mui/icons-material/RemoveCircleOutline'; +import { ProjectRule } from 'shared'; +import NERModal from '../../../../components/NERModal'; + +interface RemoveRuleModalProps { + open: boolean; + onHide: () => void; + projectRules: ProjectRule[]; + projectName: string; + onSubmit: (projectRuleIds: string[]) => void; +} + +const RemoveRuleModal = ({ open, onHide, projectRules, projectName, onSubmit }: RemoveRuleModalProps) => { + const theme = useTheme(); + + const [selectedProjectRuleIds, setSelectedProjectRuleIds] = useState([]); + + // Only leaf rules (rules with no children currently assigned to this project) can be removed directly. + // Ancestor rules are auto-managed by the backend when their leaf descendants are added/removed. + const removableProjectRules = useMemo(() => { + const parentRuleIds = new Set(projectRules.map((pr) => pr.rule.parentRule?.ruleId).filter((id): id is string => !!id)); + return projectRules.filter((pr) => !parentRuleIds.has(pr.rule.ruleId)); + }, [projectRules]); + + const projectRulesById = useMemo( + () => new Map(removableProjectRules.map((pr) => [pr.projectRuleId, pr] as [string, ProjectRule])), + [removableProjectRules] + ); + + const availableOptions = removableProjectRules + .filter((pr) => !selectedProjectRuleIds.includes(pr.projectRuleId)) + .sort((a, b) => a.rule.ruleCode.localeCompare(b.rule.ruleCode, undefined, { numeric: true })); + + const handleSelectChange = (event: SelectChangeEvent) => { + const projectRuleId = event.target.value; + if (!projectRuleId) return; + setSelectedProjectRuleIds((prev) => [...prev, projectRuleId]); + }; + + const handleRemoveSelection = (projectRuleId: string) => { + setSelectedProjectRuleIds((prev) => prev.filter((id) => id !== projectRuleId)); + }; + + const resetForm = () => { + setSelectedProjectRuleIds([]); + }; + + const handleSubmit = () => { + onSubmit(selectedProjectRuleIds); + resetForm(); + onHide(); + }; + + const handleClose = () => { + resetForm(); + onHide(); + }; + + const sectionHeaderStyle = { + display: 'block', + fontWeight: 'bold', + color: '#ef4345', + textDecoration: 'underline', + fontSize: '1rem', + textUnderlineOffset: '5px', + marginBottom: '10px' + }; + + 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 ( + + + {removableProjectRules.length === 0 ? ( + + No project rules available to remove for the {projectName} project. + + ) : ( + + {selectedProjectRuleIds.length > 0 && ( + + Selected Rule Codes + {selectedProjectRuleIds.map((projectRuleId) => ( + + + handleRemoveSelection(projectRuleId)} + sx={{ color: theme.palette.text.primary, p: 0.5, mr: 1 }} + > + + + + {projectRulesById.get(projectRuleId)?.rule.ruleCode ?? projectRuleId} + + + + ))} + + )} + + {availableOptions.length > 0 ? ( + + Rule Code + + + + + ) : ( + selectedProjectRuleIds.length > 0 && ( + + No more project rules available to remove for the {projectName} project. + + ) + )} + + )} + + + ); +}; + +export default RemoveRuleModal; diff --git a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectViewContainer.tsx b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectViewContainer.tsx index 459b6ead03..75ad43aaf8 100644 --- a/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectViewContainer.tsx +++ b/src/frontend/src/pages/ProjectDetailPage/ProjectViewContainer/ProjectViewContainer.tsx @@ -34,6 +34,7 @@ import PartsReviewPage from './PartReview/PartsReviewPage'; import ActionsMenu from '../../../components/ActionsMenu'; import ProjectSpendingHistory from '../../ProjectPage/ProjectSpendingHistory'; import { useMyTeamAsHead } from '../../../hooks/teams.hooks'; +import { ProjectRulesTab } from './ProjectRules/ProjectRulesTab'; interface ProjectViewContainerProps { project: Project; @@ -214,7 +215,8 @@ const ProjectViewContainer: React.FC = ({ project, en { tabUrlValue: 'gantt', tabName: 'Gantt' }, { tabUrlValue: 'change-requests', tabName: 'Change Requests' }, { tabUrlValue: 'parts-review', tabName: 'Parts Review' }, - { tabUrlValue: 'spending', tabName: 'Budget' } + { tabUrlValue: 'spending', tabName: 'Budget' }, + { tabUrlValue: 'rules', tabName: 'Rules' } ]} baseUrl={`${routes.PROJECTS}/${wbsNum}`} defaultTab="overview" @@ -237,8 +239,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..9bc5b9b6ad --- /dev/null +++ b/src/frontend/src/pages/RulesPage/AssignRulesTab.tsx @@ -0,0 +1,440 @@ +/* + * 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, useRef } from 'react'; +import { Rule, TeamPreview } from 'shared'; +import { useAllTeams } from '../../hooks/teams.hooks'; +import LoadingIndicator from '../../components/LoadingIndicator'; +import ErrorPage from '../ErrorPage'; +import { useHistory, useLocation, useParams } from 'react-router-dom'; +import { routes } from '../../utils/routes'; +import { useToast } from '../../hooks/toasts.hooks'; +import { NERButton } from '../../components/NERButton'; +import NERModal from '../../components/NERModal'; +import WarningIcon from '@mui/icons-material/Warning'; +import RuleRow from './RuleRow'; +import { useBulkToggleRuleTeam, useGetTopLevelRules, useFetchFullRuleTree } from '../../hooks/rules.hooks'; +import { compareRuleCodes } from '../../utils/rules.utils'; +import { getAncestorIds, getRuleAndDescendantIds, getDescendantLeafRules } from '../../utils/rules.utils'; + +/* + * Props for the team row. + */ +interface TeamRowProps { + team: TeamPreview; + backgroundColor: string; + hoverColor: string; + onClick: () => void; +} + +/** + * Row component for displaying a team in the teams table. + */ +const TeamRow: React.FC = ({ team, backgroundColor, hoverColor, onClick }) => { + const theme = useTheme(); + return ( + + + {team.teamName} + + + ); +}; + +/** + * Tab component for assigning rules to teams. + * Displays teams and rules side-by-side for selection. + */ +const AssignRulesTab: React.FC = () => { + const theme = useTheme(); + const history = useHistory(); + const { rulesetId } = useParams<{ rulesetId: string }>(); + const location = useLocation(); + const toast = useToast(); + const [selectedTeamId, setSelectedTeamId] = useState(null); + const [assignments, setAssignments] = useState>(new Set()); + const [originalAssignments, setOriginalAssignments] = useState>(new Set()); + // unassign impact for the amount of project rules that would be deleted by this action, used to warn the user before saving + const [pendingToggles, setPendingToggles] = useState | null>(null); + const [unassignImpact, setUnassignImpact] = useState<{ ruleCount: number; projectCount: number }>({ + ruleCount: 0, + projectCount: 0 + }); + + const { data: teams, isLoading: teamsLoading, isError: teamsError, error: teamsErrorData } = useAllTeams(); + const { mutate: bulkToggle, isLoading: isSaving } = useBulkToggleRuleTeam(); + + // Top-level rules render immediately; subrules are fetched as rows are expanded + const { + data: topLevelRules, + isError: isTopLevelRulesError, + error: topLevelRulesError, + isLoading: isTopLevelRulesLoading + } = useGetTopLevelRules(rulesetId); + + // Loaded fresh on every mount so it reflects any edits just made + const fetchFullRuleTree = useFetchFullRuleTree(rulesetId); + const [rules, setRules] = useState([]); + + useEffect(() => { + let cancelled = false; + fetchFullRuleTree().then((loadedRules) => { + if (!cancelled) setRules(loadedRules); + }); + return () => { + cancelled = true; + }; + }, [fetchFullRuleTree]); + + // background refetches ensure current toggles remain + const hasPendingChangesRef = useRef(false); + + // Reload when rule data changes, unless the user has unsaved toggles pending + useEffect(() => { + if (!teams || teams.length === 0) return; + + if (!hasPendingChangesRef.current) { + const initialAssignments = new Set(); + rules.forEach((rule) => { + rule.teams?.forEach((team) => { + initialAssignments.add(`${team.teamId}:${rule.ruleId}`); + }); + }); + + setOriginalAssignments(initialAssignments); + setAssignments(new Set(initialAssignments)); + } + + if (!selectedTeamId) { + const teamIdParam = new URLSearchParams(location.search).get('teamId'); + if (teamIdParam && teams.some((team) => team.teamId === teamIdParam)) { + setSelectedTeamId(teamIdParam); + } + } + }, [rules, teams, location.search, selectedTeamId]); + + const handleTeamSelect = (teamId: string) => setSelectedTeamId(teamId); + + const isRuleAssigned = (ruleId: string) => { + if (!selectedTeamId) return false; + return assignments.has(`${selectedTeamId}:${ruleId}`); + }; + + // A rule is considered selected when all of its leaf rules are assigned to the current team. + const isRuleSelected = (rule: Rule) => { + const leafIds = getDescendantLeafRules(rule, rules).map((leaf) => leaf.ruleId); + return leafIds.length > 0 && leafIds.every((id) => isRuleAssigned(id)); + }; + + // Shared highlight logic for both the team rows and rule rows. + const rowBackgroundColor = (isSelected: boolean) => (isSelected ? '#b36b6b' : theme.palette.grey[500]); + const rowHoverColor = (isSelected: boolean) => (isSelected ? '#a05858' : theme.palette.grey[700]); + + 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; + } + + // Assign the clicked rule and all of its descendants, not just leaves, so + // every rule in the subtree gets its own team relation + const subtreeIds = getRuleAndDescendantIds(ruleId, rules); + if (subtreeIds.length === 0) { + return; + } + + const newAssignments = new Set(assignments); + const teamAssignmentKey = (id: string) => `${selectedTeamId}:${id}`; // needed since the same rule can be assigned to multiple teams + const allSelected = subtreeIds.every((id) => newAssignments.has(teamAssignmentKey(id))); + + if (allSelected) { + // Unassign the rule and its descendants + subtreeIds.forEach((id) => newAssignments.delete(teamAssignmentKey(id))); + + // Drop each parent that no longer has ANY assigned descendant. + for (const ancestorId of getAncestorIds(ruleId, rules)) { + const hasAssignedDescendant = getRuleAndDescendantIds(ancestorId, rules).some( + (id) => id !== ancestorId && newAssignments.has(teamAssignmentKey(id)) + ); + // Stop at the first ancestor still holding an assigned leaf rule, since every + // higher ancestor shares that descendant and must remain assigned. + if (hasAssignedDescendant) { + break; + } + newAssignments.delete(teamAssignmentKey(ancestorId)); + } + } else { + // Assign the rule, its descendants, and all of its ancestors so there is + // always a path to the top-level rule + subtreeIds.forEach((id) => newAssignments.add(teamAssignmentKey(id))); + getAncestorIds(ruleId, rules).forEach((id) => newAssignments.add(teamAssignmentKey(id))); + } + + hasPendingChangesRef.current = true; + setAssignments(newAssignments); + }; + + // Counts the amount of rules and projects that would lose their project rule + // assignments when the given team-unassignments are saved + const getUnassignImpact = (removedKeys: string[]): { ruleCount: number; projectCount: number } => { + const affectedRuleIds = new Set(); + const affectedProjectIds = new Set(); + + removedKeys.forEach((key) => { + const [teamId, ruleId] = key.split(':'); + const rule = rules.find((r) => r.ruleId === ruleId); + // teams the rule will remain on after this save + const remainingTeamIds = [...assignments] + .filter((assignmentKey) => assignmentKey.endsWith(`:${ruleId}`)) + .map((assignmentKey) => assignmentKey.split(':')[0]); + + rule?.projects?.forEach((project) => { + if (!project.teamIds.includes(teamId)) return; + // A project can belong to multiple teams + // Therefore a project only loses the rule if the rule no longer shares ANY team with it + const sharesRemainingTeam = project.teamIds.some((id) => remainingTeamIds.includes(id)); + if (!sharesRemainingTeam) { + affectedRuleIds.add(ruleId); + affectedProjectIds.add(project.projectId); + } + }); + }); + + return { ruleCount: affectedRuleIds.size, projectCount: affectedProjectIds.size }; + }; + + const executeToggles = (toggles: Array<{ ruleId: string; teamId: string }>) => { + bulkToggle(toggles, { + onSuccess: () => { + hasPendingChangesRef.current = false; + history.push(routes.RULESET_EDIT.replace(':rulesetId', rulesetId)); + }, + onSettled: () => { + setPendingToggles(null); + } + }); + }; + + 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 = [...toAdd, ...toRemove].map((key) => { + const [teamId, ruleId] = key.split(':'); + return { ruleId, teamId }; + }); + + // Warn before saving if unassignments would remove existing project assignments + const impact = getUnassignImpact(toRemove); + if (impact.ruleCount > 0) { + setUnassignImpact(impact); + setPendingToggles(toggles); + return; + } + + executeToggles(toggles); + }; + + const handleConfirmSave = () => { + if (pendingToggles) { + executeToggles(pendingToggles); + } + }; + + if (teamsError) { + return ; + } + + if (isTopLevelRulesError) { + return ; + } + + if (teamsLoading || isTopLevelRulesLoading || !topLevelRules) { + return ; + } + + const sortedTopLevelRules = [...topLevelRules].sort(compareRuleCodes); + + return ( + + + {/* Teams Column */} + + + Teams: + + + + + {teams?.map((team) => ( + handleTeamSelect(team.teamId)} + /> + ))} + +
+
+
+ + {/* Rules Column */} + + + Rules: + + + + + {sortedTopLevelRules.map((rule) => ( + rowBackgroundColor(isRuleSelected(r))} + hoverColor={(r) => rowHoverColor(isRuleSelected(r))} + textColor={theme.palette.common.black} + 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'} + + + + + setPendingToggles(null)} + title="Confirm Unassignment" + cancelText="Cancel" + submitText="Save" + onSubmit={handleConfirmSave} + disabled={isSaving} + > + + + + This will remove {unassignImpact.ruleCount} rule{unassignImpact.ruleCount === 1 ? '' : 's'} from{' '} + {unassignImpact.projectCount} project{unassignImpact.projectCount === 1 ? '' : 's'}. + + + + + ); +}; + +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..a12689b1a9 --- /dev/null +++ b/src/frontend/src/pages/RulesPage/RuleRow.tsx @@ -0,0 +1,364 @@ +/* + * 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, Typography } from '@mui/material'; +import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { Rule } from 'shared'; +import ChevronRightIcon from '@mui/icons-material/ChevronRight'; +import { useGetChildRules } from '../../hooks/rules.hooks'; +import { compareRuleCodes } from '../../utils/rules.utils'; + +// how many children of an expanded rule are mounted at a time +const RULE_PAGE_SIZE = 20; +const EMPTY_SUB_RULES: Rule[] = []; + +interface RuleRowProps { + rule: Rule; + allRules?: Rule[]; + level?: number; + leftContent?: ( + rule: Rule, + level: number, + isExpanded: boolean, + hasSubRules: boolean, + toggleExpand: () => void + ) => React.ReactNode; + middleContent?: (rule: Rule, level: number) => React.ReactNode; + rightContent: (rule: Rule, level: number) => React.ReactNode; + backgroundColor: string | ((rule: Rule) => string); + textColor: string | ((rule: Rule) => string); + hoverColor: string | ((rule: Rule) => string); + onRowClick?: (rule: Rule) => void; + rowHeight?: string; + verticalPadding?: string; + horizontalPadding?: string; + leftWidth?: string; + middleWidth?: string; + rightWidth?: string; + initiallyExpanded?: boolean; + // When true, the entire rule is shifted right per child depth + indentRow?: boolean; + // Amount of indentation per child depth when indentRow is enabled + indentWidth?: number; + // If a rule's code/name should span the entire row - used for team view header rows + fullWidthCode?: (rule: Rule) => boolean; + // Optional controlled expansion, otherwise each row manages its own open/closed state + expandedIds?: Set; + onToggleExpand?: (ruleId: string) => void; + // When true, renders rows incrementally instead of all at once + windowChildren?: 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 = '10%', + middleWidth = '80%', + rightWidth = '10%', + initiallyExpanded = false, + indentRow = false, + indentWidth = 10, + fullWidthCode, + expandedIds, + onToggleExpand, + windowChildren = false +}) => { + const [localExpanded, setLocalExpanded] = useState(initiallyExpanded); + // Controlled by the parent when `expandedIds` is provided, otherwise from this row's own state + const isExpanded = expandedIds ? expandedIds.has(rule.ruleId) : localExpanded; + + // a parent rule whose sub rules aren't in the set (e.g. rule T.1 was assigned to a project but T.1.1 wasn't) + // will render as a leaf rule but with no expand dropdown + const providedSubRules = useMemo( + () => (allRules ? allRules.filter((r) => rule.subRuleIds.includes(r.ruleId)) : null), + [allRules, rule.subRuleIds] + ); + const hasSubRules = providedSubRules ? providedSubRules.length > 0 : rule.subRuleIds.length > 0; + + // Lazy load if allRules not provided + const { + data: fetchedSubRules = EMPTY_SUB_RULES, + isLoading, + isError, + error + } = useGetChildRules(rule.ruleId, !allRules && isExpanded && hasSubRules); + + // Use allRules if provided, otherwise use fetched. + // Sorted by rule code so children render in a stable numeric order (e.g. F.2 before F.10) + // Collapsed rows are skipped and render none of their children + const subRules = useMemo(() => { + if (!isExpanded || !hasSubRules) return EMPTY_SUB_RULES; + return [...(providedSubRules ?? fetchedSubRules)].sort(compareRuleCodes); + }, [isExpanded, hasSubRules, providedSubRules, fetchedSubRules]); + + // Only the first RULE_PAGE_SIZE children are mounted, rendering continues as rows scroll into view + const [visibleCount, setVisibleCount] = useState(RULE_PAGE_SIZE); + const observerRef = useRef(null); + + const loadMoreWhenVisible = useCallback((node: HTMLDivElement | null) => { + observerRef.current?.disconnect(); // protects against collapsed/expanded row changes + if (!node) return; + // tells when an element enters or leaves visible area + observerRef.current = new IntersectionObserver( + (entries) => { + // if we hit the invisible marker, load RULE_PAGE_SIZE more rules, invisible marker will move down automatically + if (entries[0].isIntersecting) setVisibleCount((count) => count + RULE_PAGE_SIZE); + }, + { rootMargin: '400px' } // treat viewport as extra large so next rules are rendered before they are reached + ); + observerRef.current.observe(node); + }, []); + + // disconnect if a row unmounts, say from a row collapsing + useEffect(() => () => observerRef.current?.disconnect(), []); + + // Start fresh when this row collapses or its child count changes + useEffect(() => { + setVisibleCount(RULE_PAGE_SIZE); + }, [isExpanded, subRules.length]); + + const visibleSubRules = windowChildren ? subRules.slice(0, visibleCount) : subRules; + const hasHiddenSubRules = windowChildren && visibleCount < subRules.length; + + 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 = useCallback(() => { + if (!hasSubRules) return; + if (onToggleExpand) { + onToggleExpand(rule.ruleId); + } else { + setLocalExpanded((prev) => !prev); + } + }, [hasSubRules, onToggleExpand, rule.ruleId]); + + const handleChevronClick = (e: React.MouseEvent) => { + e.stopPropagation(); + toggleExpand(); + }; + + const handleRowClick = () => { + if (onRowClick) { + onRowClick(rule); + } else if (hasSubRules) { + toggleExpand(); + } + }; + + const rowIsClickable = Boolean(onRowClick) || hasSubRules; + + const commonCellStyles = { + fontSize: '16px', + padding: `${verticalPadding} ${horizontalPadding}`, + backgroundColor: 'inherit', + borderBottom: 'none', + height: rowHeight + }; + + const cardRadius = 8; + const cardCellBg = indentRow ? { backgroundColor: bgColor } : {}; + const cardCellClass = indentRow ? 'rule-card-cell' : undefined; + // Indent left edge of rule with transparent left border + const leftInset = indentRow ? level * indentWidth : 0; + const leftCellRadius = indentRow + ? { + borderTopLeftRadius: `${leftInset + cardRadius}px ${cardRadius}px`, + borderBottomLeftRadius: `${leftInset + cardRadius}px ${cardRadius}px` + } + : {}; + const rightCellRadius = indentRow + ? { borderTopRightRadius: `${cardRadius}px`, borderBottomRightRadius: `${cardRadius}px` } + : {}; + + const defaultLeftContent = ( + + {hasSubRules && ( + + )} + {rule.ruleCode} + + ); + + return ( + <> + + {fullWidthCode && fullWidthCode(rule) ? ( + + {leftContent ? leftContent(rule, level, isExpanded, hasSubRules, toggleExpand) : defaultLeftContent} + + ) : ( + <> + + {leftContent ? leftContent(rule, level, isExpanded, hasSubRules, toggleExpand) : defaultLeftContent} + + + {middleContent + ? middleContent(rule, level) + : rule.ruleContent && {rule.ruleContent}} + + + {rightContent(rule, level)} + + + )} + + {isExpanded && hasSubRules && isError && ( + + + + {`Failed to load sub-rules${error?.message ? `: ${error.message}` : ''}`} + + + + )} + {isExpanded && + hasSubRules && + visibleSubRules.map((subRule) => ( + + ))} + {hasHiddenSubRules && ( + + +
+ + + )} + + ); +}; + +export default memo(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..6d44288c59 --- /dev/null +++ b/src/frontend/src/pages/RulesPage/Rules.tsx @@ -0,0 +1,34 @@ +// switch route page for rules +import { Redirect, Route, Switch } from 'react-router-dom'; +import { isGuest } from 'shared'; +import { routes } from '../../utils/routes'; +import { useCurrentUser } from '../../hooks/users.hooks'; +import RulesetTypePage from './RulesetTypePage'; +import RulesetPage from './RulesetPage'; +import RulesetEditPage from './RulesetEditPage'; +import RulesetViewPage from './RulesetViewPage'; + +const RulesPage: React.FC = () => { + const user = useCurrentUser(); + + if (isGuest(user.role)) { + return ( + + ); + } + + 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..97ea584638 --- /dev/null +++ b/src/frontend/src/pages/RulesPage/RulesetEditPage.tsx @@ -0,0 +1,598 @@ +/* + * 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, CircularProgress, Paper, Table, TableBody, TableContainer, useTheme } from '@mui/material'; +import { useCallback, useMemo, useRef, useState } from 'react'; +import { Redirect, 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 ErrorPage from '../ErrorPage'; +import LoadingIndicator from '../../components/LoadingIndicator'; +import AddRuleModal from './components/AddRuleModal'; +import AddReferencedRuleModal from './components/AddReferencedRuleModal'; +import AddImageModal from './components/AddImageModal'; +import RemoveReferencedRuleModal from './components/RemoveReferencedRuleModal'; +import RemoveImageModal from './components/RemoveImageModal'; +import { AddRuleBox } from './components/AddRuleBox'; +import AssignRulesTab from './AssignRulesTab'; +import { NERButton } from '../../components/NERButton'; +import DeleteRuleModal from './components/DeleteRuleModal'; +import MismatchedRuleCodeModal from './components/MismatchedRuleCodeModal'; +import { + useDeleteRule, + useEditRule, + useRemoveRuleReferences, + useRemoveRuleImage, + useSingleRuleset, + useAllRulesForRuleset, + useGetTopLevelRules, + useFetchFullRuleTree +} from '../../hooks/rules.hooks'; +import { countRulesToDelete, compareRuleCodes } from '../../utils/rules.utils'; +import { Rule, isLeadership } from 'shared'; +import { useToast } from '../../hooks/toasts.hooks'; +import { useCurrentUser } from '../../hooks/users.hooks'; +import { useRuleTreeNavigation } from './useRuleTreeNavigation'; +import { RuleActionsCell, RuleBodyCell, RuleCodeCell, RuleDraft } from './components/RuleEditCells'; + +/** + * RulesetEditPage component for displaying and editing ruleset rules. + */ +const RulesetEditPage: React.FC = () => { + const { rulesetId } = useParams<{ rulesetId: string }>(); + const user = useCurrentUser(); + const [tabValue, setTabValue] = useState(0); + const defaultTab = 'edit-rules'; + + const [addMenuAnchorEl, setAddMenuAnchorEl] = useState(null); + const [activeRule, setActiveRule] = useState(null); + + const [showAddRuleModal, setShowAddRuleModal] = useState(false); + const [showAddReferencedRuleModal, setShowAddReferencedRuleModal] = useState(false); + const [showAddImageModal, setShowAddImageModal] = useState(false); + + const [referenceToRemove, setReferenceToRemove] = useState<{ + rule: Rule; + referencedRule: { ruleId: string; ruleCode: string }; + } | null>(null); + const [imageToRemove, setImageToRemove] = useState<{ rule: Rule; fileId: string } | null>(null); + + // Delete modal state, the count comes from the full tree since deleting cascades to every descendant + const [ruleToDelete, setRuleToDelete] = useState<{ rule: Rule; totalRulesToDelete: number } | null>(null); + + // Editing state, only the row being edited lives in state. + // The drafts live in a ref so a keystroke doesn't rerender every rule row. + const [editingRuleId, setEditingRuleId] = useState(null); + const draftRef = useRef({ ruleCode: '', ruleContent: '' }); + + const editingRuleIdRef = useRef(editingRuleId); + editingRuleIdRef.current = editingRuleId; + + // covers loading the tree for the code checks as well as the save itself, so Save can't be clicked twice + const [isSavingEdit, setIsSavingEdit] = useState(false); + + // Editing rule code warnings + const [pendingCodeWarnings, setPendingCodeWarnings] = useState(null); + + // work that blocks the page (loading the full tree, expanding every rule) shows a spinner by the tabs + const [isFetchingTree, setIsFetchingTree] = useState(false); + + const theme = useTheme(); + // useToast hands back a new object every render, so the latest one is kept in a ref to keep the callbacks stable. + const toast = useToast(); + const toastRef = useRef(toast); + toastRef.current = toast; + + const { + data: ruleset, + isError: isRulesetError, + error: rulesetError, + isLoading: isRulesetLoading + } = useSingleRuleset(rulesetId!); + + // Edit Rules table only needs top-level rules to render immediately; subrules are fetched lazily as rows expand + const { + data: topLevelRules, + isError: isTopLevelRulesError, + error: topLevelRulesError, + isLoading: isTopLevelRulesLoading + } = useGetTopLevelRules(rulesetId!); + + // Only the modals that browse the whole ruleset need every rule, so this stays unfetched until one opens + const { + data: allRules, + isError: isAllRulesError, + isLoading: isAllRulesLoading + } = useAllRulesForRuleset(rulesetId!, showAddReferencedRuleModal || showAddImageModal); + + const { mutateAsync: deleteRuleMutation } = useDeleteRule(rulesetId!); + const { mutateAsync: editRuleMutation } = useEditRule(rulesetId!); + const { mutateAsync: removeRuleReferencesMutation } = useRemoveRuleReferences(rulesetId!); + const { mutateAsync: removeRuleImageMutation } = useRemoveRuleImage(rulesetId!); + + // Expand All and the rule code checks need the whole tree, so load it on demand rather than up front + const fetchFullRuleTree = useFetchFullRuleTree(rulesetId!); + + const loadFullTree = useCallback(async () => { + setIsFetchingTree(true); + try { + return await fetchFullRuleTree(); + } finally { + setIsFetchingTree(false); + } + }, [fetchFullRuleTree]); + + const { expandedIds, toggleExpand, expandAll, collapseAll, areAllExpanded, isLoadingFullTree } = useRuleTreeNavigation( + topLevelRules ?? [], + fetchFullRuleTree + ); + + const handleToggleExpandAll = useCallback(async () => { + if (areAllExpanded) { + collapseAll(); + return; + } + + setIsFetchingTree(true); + await new Promise((resolve) => setTimeout(resolve, 0)); + try { + await expandAll(); + } catch (err) { + if (err instanceof Error) toastRef.current.error(`Failed to load rules: ${err.message}`); + } finally { + setTimeout(() => setIsFetchingTree(false), 0); + } + }, [areAllExpanded, collapseAll, expandAll]); + + // the top-level "Add Rule" button creates a rule with no parent, same modal as the row-level "+" menu + const handleAddRule = useCallback(() => { + setActiveRule(null); + setShowAddRuleModal(true); + }, []); + + const handleOpenAddMenu = useCallback((rule: Rule, anchorEl: HTMLElement) => { + setActiveRule(rule); + // clicking the same row's add button again closes the menu + setAddMenuAnchorEl((previousAnchorEl) => (previousAnchorEl === anchorEl ? null : anchorEl)); + }, []); + + const handleCloseAddMenu = useCallback(() => setAddMenuAnchorEl(null), []); + + const handleAddRuleFromMenu = useCallback(() => { + setShowAddRuleModal(true); + handleCloseAddMenu(); + }, [handleCloseAddMenu]); + + const handleAddReferencedRuleFromMenu = useCallback(() => { + setShowAddReferencedRuleModal(true); + handleCloseAddMenu(); + }, [handleCloseAddMenu]); + + const handleAddImageFromMenu = useCallback(() => { + setShowAddImageModal(true); + handleCloseAddMenu(); + }, [handleCloseAddMenu]); + + const handleRemoveReference = useCallback((rule: Rule, referencedRuleId: string) => { + const referencedRule = rule.referencedRules.find((r) => r.ruleId === referencedRuleId); + if (referencedRule) { + setReferenceToRemove({ rule, referencedRule }); + } + }, []); + + const handleRemoveReferenceCancel = useCallback(() => setReferenceToRemove(null), []); + + const handleConfirmRemoveReference = useCallback(async () => { + if (!referenceToRemove) return; + + try { + await removeRuleReferencesMutation({ + ruleId: referenceToRemove.rule.ruleId, + referencedRuleId: referenceToRemove.referencedRule.ruleId, + referencedRuleCode: referenceToRemove.referencedRule.ruleCode + }); + setReferenceToRemove(null); + } catch { + // the modal stays open so the removal can be retried + } + }, [referenceToRemove, removeRuleReferencesMutation]); + + const handleRemoveImage = useCallback((rule: Rule, fileId: string) => setImageToRemove({ rule, fileId }), []); + + const handleRemoveImageCancel = useCallback(() => setImageToRemove(null), []); + + const handleConfirmRemoveImage = useCallback(async () => { + if (!imageToRemove) return; + + try { + await removeRuleImageMutation({ rule: imageToRemove.rule, fileId: imageToRemove.fileId }); + setImageToRemove(null); + } catch { + // the modal stays open so the removal can be retried + } + }, [imageToRemove, removeRuleImageMutation]); + + const handleRemoveRule = useCallback( + async (rule: Rule) => { + // deleting a rule deletes its whole subtree, which only the full tree can count + try { + const allRulesInRuleset = await loadFullTree(); + setRuleToDelete({ rule, totalRulesToDelete: countRulesToDelete(rule, allRulesInRuleset) }); + } catch (err) { + if (err instanceof Error) toastRef.current.error(`Failed to load rules: ${err.message}`); + } + }, + [loadFullTree] + ); + + const handleDeleteCancel = useCallback(() => setRuleToDelete(null), []); + + const handleDeleteConfirm = useCallback(async () => { + if (!ruleToDelete) return; + + try { + await deleteRuleMutation({ + ruleId: ruleToDelete.rule.ruleId, + ruleCode: ruleToDelete.rule.ruleCode, + totalRulesToDelete: ruleToDelete.totalRulesToDelete + }); + setRuleToDelete(null); + } catch { + // the modal stays open so the delete can be retried + } + }, [deleteRuleMutation, ruleToDelete]); + + const handleEditRule = useCallback((rule: Rule) => { + draftRef.current = { ruleCode: rule.ruleCode, ruleContent: rule.ruleContent }; + setEditingRuleId(rule.ruleId); + }, []); + + const handleCancelEdit = useCallback(() => setEditingRuleId(null), []); + + const performSaveEdit = useCallback( + async (ruleId: string, draft: RuleDraft) => { + try { + await editRuleMutation({ ruleId, ruleContent: draft.ruleContent, ruleCode: draft.ruleCode.trim() }); + // another row may have started editing while this save was in flight, so leave it open + setEditingRuleId((currentlyEditing) => (currentlyEditing === ruleId ? null : currentlyEditing)); + } catch { + // the row stays in edit mode with the typed text intact + } + }, + [editRuleMutation] + ); + + const saveEdit = useCallback( + async (rule: Rule) => { + const draft = { ...draftRef.current }; + const trimmedCode = draft.ruleCode.trim(); + + if (!trimmedCode) { + toastRef.current.error('Rule code cannot be empty'); + return; + } + + // an unchanged code can't collide or strand child codes, so content only edits skip the tree entirely + if (trimmedCode === rule.ruleCode) { + await performSaveEdit(rule.ruleId, draft); + return; + } + + let allRulesInRuleset: Rule[]; + try { + allRulesInRuleset = await loadFullTree(); + } catch (err) { + // the row stays in edit mode so the save can be retried + if (err instanceof Error) toastRef.current.error(`Failed to load rules: ${err.message}`); + return; + } + + if (editingRuleIdRef.current !== rule.ruleId) return; + + // the row's copy can be out of date, such as its parent's code after the parent was renamed + const currentRule = allRulesInRuleset.find((r) => r.ruleId === rule.ruleId) ?? rule; + + // a duplicate code cannot be saved, so reject it before any of the warnings below + if (allRulesInRuleset.some((r) => r.ruleId !== currentRule.ruleId && r.ruleCode === trimmedCode)) { + toastRef.current.error(`Rule with code ${trimmedCode} already exists in this ruleset`); + return; + } + + const warnings: string[] = []; + + if (currentRule.parentRule && !trimmedCode.startsWith(currentRule.parentRule.ruleCode)) { + warnings.push(`This code doesn't start with its parent rule's code: ${currentRule.parentRule.ruleCode}.`); + } + + const affectedCount = countRulesToDelete(currentRule, allRulesInRuleset) - 1; + if (affectedCount > 0) { + warnings.push( + `This rule has ${affectedCount} child rule${affectedCount === 1 ? '' : 's'} whose code${ + affectedCount === 1 ? '' : 's' + } will not update with the new prefix.` + ); + } + + if (warnings.length > 0) { + setPendingCodeWarnings(warnings); + return; + } + + await performSaveEdit(currentRule.ruleId, draft); + }, + [loadFullTree, performSaveEdit] + ); + + const handleSaveEdit = useCallback( + async (rule: Rule) => { + setIsSavingEdit(true); + try { + await saveEdit(rule); + } finally { + setIsSavingEdit(false); + } + }, + [saveEdit] + ); + + const handleConfirmCodeWarning = useCallback(async () => { + setPendingCodeWarnings(null); + if (!editingRuleId) return; + setIsSavingEdit(true); + try { + await performSaveEdit(editingRuleId, { ...draftRef.current }); + } finally { + setIsSavingEdit(false); + } + }, [editingRuleId, performSaveEdit]); + + const handleCancelCodeWarning = useCallback(() => setPendingCodeWarnings(null), []); + + const renderLeftContent = useCallback( + (currentRule: Rule, level: number, isExpanded: boolean, hasSubRules: boolean, toggleRuleExpand: () => void) => ( + + ), + [editingRuleId] + ); + + const renderMiddleContent = useCallback( + (currentRule: Rule, level: number) => ( + + ), + [editingRuleId, handleRemoveImage, handleRemoveReference] + ); + + const renderRightContent = useCallback( + (currentRule: Rule) => ( + + ), + [editingRuleId, isSavingEdit, handleCancelEdit, handleEditRule, handleOpenAddMenu, handleRemoveRule, handleSaveEdit] + ); + + const getRowBackgroundColor = useCallback( + (currentRule: Rule) => (editingRuleId === currentRule.ruleId ? theme.palette.grey[400] : theme.palette.grey[500]), + [editingRuleId, theme] + ); + + // Sort top-level rules by rule code for stable numeric order + const sortedTopLevelRules = useMemo(() => [...(topLevelRules ?? [])].sort(compareRuleCodes), [topLevelRules]); + + const tabs = [ + { tabUrlValue: 'edit-rules', tabName: 'Edit Rules' }, + { tabUrlValue: 'assign-rules', tabName: 'Assign Rules' } + ]; + + if (isRulesetError) { + return ; + } + + if (isTopLevelRulesError) { + return ; + } + + if (isRulesetLoading || isTopLevelRulesLoading || !ruleset || !topLevelRules) { + return ; + } + + // creating, editing, deleting, and assigning rules all require leadership and above + // if the user is not leadership, redirect them to the view page for this ruleset + if (!isLeadership(user.role)) { + return ; + } + + return ( + + + {tabValue === 0 && ( + + {(isLoadingFullTree || isFetchingTree) && } + + {areAllExpanded ? 'Collapse All' : 'Expand All'} + + + )} + + } + > + + {tabValue === 0 ? ( + + + + + {sortedTopLevelRules.map((rule) => ( + + ))} + +
+
+ + + + setShowAddRuleModal(false)} + rulesetId={rulesetId} + initialParentRuleId={activeRule?.ruleId} + parentRuleCode={activeRule?.ruleCode} + /> + + setShowAddReferencedRuleModal(false)} + ruleId={activeRule?.ruleId ?? null} + rulesetId={rulesetId} + allRules={allRules ?? []} + isAllRulesLoading={isAllRulesLoading} + isAllRulesError={isAllRulesError} + /> + + setShowAddImageModal(false)} + ruleId={activeRule?.ruleId ?? null} + rulesetId={rulesetId} + allRules={allRules ?? []} + isAllRulesLoading={isAllRulesLoading} + isAllRulesError={isAllRulesError} + /> + + + + {referenceToRemove && ( + + )} + + {imageToRemove && ( + + )} + + {ruleToDelete && ( + + )} + + + + + + Add Rule + + + + + ) : ( + + )} +
+
+ ); +}; + +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..de5fa64a89 --- /dev/null +++ b/src/frontend/src/pages/RulesPage/RulesetPage.tsx @@ -0,0 +1,147 @@ +/* + * 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 { isLeadership } from 'shared'; +import { useToast } from '../../hooks/toasts.hooks'; +import { useCreateRuleset, useDeleteRuleset, useParseRuleset } from '../../hooks/rules.hooks'; +import { useCurrentUser } from '../../hooks/users.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 for uploading and listing rulesets. + */ +const RulesetPage: React.FC = () => { + const { rulesetTypeId } = useParams<{ rulesetTypeId: string }>(); + const user = useCurrentUser(); + + 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; + firstRulePage?: number; + }) => { + 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', + firstRulePage: data.firstRulePage + }); + toast.success(`Successfully parsed ${parsedRules.length} rules!`); + } catch (e) { + if (createdRulesetId) { + try { + await deleteRuleset(createdRulesetId); + toast.error('Parsing failed. Ruleset has been removed. ' + (e instanceof Error ? e.message : 'Unknown error')); + } catch (deleteError) { + toast.error('Error during cleanup: ' + (deleteError instanceof Error ? deleteError.message : 'Unknown error')); + } + } else { + toast.error('Error creating ruleset: ' + (e instanceof Error ? e.message : 'Unknown error')); + } + } + }; + + if (isError) return ; + if (isLoading) return ; + + return ( + <> + + + + + + + ({ + borderBottom: `2px solid ${theme.palette.divider}`, + mb: 2 + })} + /> + + {/* Add New File Button */} + {isLeadership(user.role) && ( + 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..7533820c7e --- /dev/null +++ b/src/frontend/src/pages/RulesPage/RulesetTypePage.tsx @@ -0,0 +1,13 @@ +import React from 'react'; +import PageLayout from '../../components/PageLayout'; +import RulesetTypeTable from './components/RulesetTypeTable'; + +const RulesetTypePage: React.FC = () => { + return ( + + + + ); +}; + +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..6674f736ff --- /dev/null +++ b/src/frontend/src/pages/RulesPage/RulesetViewPage.tsx @@ -0,0 +1,190 @@ +import React, { useState } from 'react'; +import { isHead } from 'shared'; +import FullPageTabs from '../../components/FullPageTabs'; +import PageLayout from '../../components/PageLayout'; +import { NERButton } from '../../components/NERButton'; +import { routes } from '../../utils/routes'; +import { Box } from '@mui/system'; +import { CircularProgress } from '@mui/material'; +import { useParams } from 'react-router-dom'; +import ErrorPage from '../ErrorPage'; +import LoadingIndicator from '../../components/LoadingIndicator'; +import RulesetGeneralView from './components/RulesetGeneralView'; +import RulesetTeamView from './components/RulesetTeamView'; +import ResetStatusesModal from './components/ResetStatusesModal'; +import { + useSingleRuleset, + useAllRulesForRuleset, + useGetTopLevelRules, + useFetchFullRuleTree, + useResetRulesetStatuses +} from '../../hooks/rules.hooks'; +import { useCurrentUser } from '../../hooks/users.hooks'; +import { useRuleTreeNavigation } from './useRuleTreeNavigation'; +import { useTeamRuleOrganization } from './useTeamRuleOrganization'; + +const RulesetViewPage = () => { + const user = useCurrentUser(); + const [tabIndex, setTabIndex] = useState(0); + const [showResetModal, setShowResetModal] = useState(false); + // bumped after a reset to force RulesetGeneralView to remount, clearing any open popover/history modal + const [resetNonce, setResetNonce] = useState(0); + const tabs = [ + { tabUrlValue: 'generalView', tabName: 'General View' }, + { tabUrlValue: 'teamView', tabName: 'Team View' } + ]; + + const { rulesetId } = useParams<{ rulesetId: string }>(); + + const { mutateAsync: resetRulesetStatuses, isLoading: isResetting } = useResetRulesetStatuses(rulesetId!); + + const { + data: ruleset, + isError: isRulesetError, + error: rulesetError, + isLoading: isRulesetLoading + } = useSingleRuleset(rulesetId!); + + // General View only needs top-level rules on first render; subrules fetched as dropdowns are expanded + const { + data: topLevelRules, + isError: isTopLevelRulesError, + error: topLevelRulesError, + isLoading: isTopLevelRulesLoading + } = useGetTopLevelRules(rulesetId!); + + const { + data: allRules, + isError: isRulesError, + error: rulesError, + isLoading: isRulesLoading + } = useAllRulesForRuleset(rulesetId!, tabIndex === 1); + + // Expand All needs the whole tree, load it on click instead of on page load + const fetchFullRuleTree = useFetchFullRuleTree(rulesetId!); + + const { expandedIds, toggleExpand, navigateToRule, expandAll, collapseAll, areAllExpanded, isLoadingFullTree } = + useRuleTreeNavigation(topLevelRules ?? [], fetchFullRuleTree); + + const { + topLevelItems: teamTopLevelItems, + rowsById: teamRowsById, + actualRuleIds: teamActualRuleIds + } = useTeamRuleOrganization(allRules ?? []); + + const { + expandedIds: teamExpandedIds, + toggleExpand: teamToggleExpand, + expandAll: teamExpandAll, + collapseAll: teamCollapseAll, + areAllExpanded: teamAreAllExpanded + } = useRuleTreeNavigation(teamRowsById); + + if (isRulesetError) { + return ; + } + + if (isTopLevelRulesError) { + return ; + } + + if (tabIndex === 1 && isRulesError) { + return ; + } + + if (isRulesetLoading || isTopLevelRulesLoading) { + return ; + } + + if (!ruleset || !topLevelRules) { + return ; + } + + return ( + + + + + + {tabIndex === 0 && ( + + {isLoadingFullTree && } + + {areAllExpanded ? 'Collapse All' : 'Expand All'} + + {isHead(user.role) && ( + setShowResetModal(true)}> + Reset Status + + )} + + )} + {tabIndex === 1 && ( + + {isRulesLoading && } + + {teamAreAllExpanded ? 'Collapse All' : 'Expand All'} + + + )} + + } + > + + {tabIndex === 0 ? ( + + ) : isRulesLoading || !allRules ? ( + + ) : ( + + )} + + + + {showResetModal && ( + setShowResetModal(false)} + onReset={async () => { + await resetRulesetStatuses(); + setShowResetModal(false); + setResetNonce((n) => n + 1); + }} + /> + )} + + ); +}; + +export default RulesetViewPage; diff --git a/src/frontend/src/pages/RulesPage/components/AddImageModal.tsx b/src/frontend/src/pages/RulesPage/components/AddImageModal.tsx new file mode 100644 index 0000000000..31b5f168d7 --- /dev/null +++ b/src/frontend/src/pages/RulesPage/components/AddImageModal.tsx @@ -0,0 +1,115 @@ +/* + * 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, CircularProgress, Typography } from '@mui/material'; +import { useState } from 'react'; +import { FileUpload } from '@mui/icons-material'; +import { MAX_FILE_SIZE, Rule } from 'shared'; +import NERModal from '../../../components/NERModal'; +import { useToast } from '../../../hooks/toasts.hooks'; +import { useAddRuleImage } from '../../../hooks/rules.hooks'; + +interface AddImageModalProps { + open: boolean; + onClose: () => void; + ruleId: string | null; + rulesetId: string; + allRules: Rule[]; + isAllRulesLoading: boolean; + isAllRulesError: boolean; +} + +const isImage = (fileName: string) => { + const extension = fileName.split('.').pop()?.toLowerCase(); + return extension === 'png' || extension === 'jpg' || extension === 'jpeg'; +}; + +const AddImageModal: React.FC = ({ + open, + onClose, + ruleId, + rulesetId, + allRules, + isAllRulesLoading, + isAllRulesError +}) => { + const toast = useToast(); + const [file, setFile] = useState(null); + const [isSubmitting, setIsSubmitting] = useState(false); + const { mutateAsync: addRuleImage } = useAddRuleImage(rulesetId); + + const activeRule = ruleId ? allRules.find((r) => r.ruleId === ruleId) : undefined; + + const handleFileSelect = (e: React.ChangeEvent) => { + if (!e.target.files || !e.target.files[0]) { + return; + } + + const [selectedFile] = e.target.files; + + if (!isImage(selectedFile.name)) { + toast.error('File must be a PNG or JPEG image'); + return; + } + + if (selectedFile.size > MAX_FILE_SIZE) { + toast.error(`File exceeds the maximum size limit of ${MAX_FILE_SIZE / (1024 * 1024)} MB`); + return; + } + + setFile(selectedFile); + }; + + const handleClose = () => { + setFile(null); + onClose(); + }; + + const handleSubmit = async () => { + if (!file || !activeRule) return; + + setIsSubmitting(true); + try { + await addRuleImage({ rule: activeRule, file }); + handleClose(); + } catch { + // the modal stays open so the upload can be retried + } finally { + setIsSubmitting(false); + } + }; + + return ( + + + {isAllRulesLoading ? ( + + + + ) : isAllRulesError ? ( + Failed to load rules. Please close and try again. + ) : ( + + {file && {file.name}} + + + )} + + + ); +}; + +export default AddImageModal; 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..367ec223d5 --- /dev/null +++ b/src/frontend/src/pages/RulesPage/components/AddNewFileModal.tsx @@ -0,0 +1,340 @@ +import NERFormModal from '../../../components/NERFormModal'; +import { useForm, Controller } from 'react-hook-form'; +import { + Box, + FormControl, + TextField, + Typography, + FormLabel, + FormHelperText, + Button, + Select, + MenuItem, + Accordion, + AccordionSummary, + AccordionDetails +} from '@mui/material'; +import { 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 ExpandMoreIcon from '@mui/icons-material/ExpandMore'; +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'; + firstRulePage?: number; +} + +interface ButtonGroupProps { + options: string[]; + value: string; + onChange: (value: string) => any; +} + +const sectionHeaderStyle = { + display: 'block', + 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'), + firstRulePage: yup.number().min(1, 'Minimum is page 1').integer('Must be a whole number').optional() +}); + +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 [additionalOptionsOpen, setAdditionalOptionsOpen] = 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: undefined, + parserType: 'FSAE', + firstRulePage: undefined + } + }); + + 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(); + setAdditionalOptionsOpen(false); + onHide(); + }; + + const handleReset = () => { + setFile(null); + reset(); + setAdditionalOptionsOpen(false); + }; + + return ( + + + + + {/* File Upload */} + + Upload Ruleset File + + {file && {file.name}} + {uploading && Uploading...} + + + {errors.fileId?.message} + + + {/* Car */} + + Car + {carsLoading ? ( + Loading cars... + ) : carsError ? ( + Failed to load cars + ) : ( + ( + // a min width keeps the empty state from collapsing before a car is picked + + )} + /> + )} + {errors.carNumber?.message} + + + {/* Parser Type */} + + Parser Type + ( + onChange(val as 'FSAE' | 'FHE')} /> + )} + /> + {errors.parserType?.message} + + + + {/* Ruleset Name */} + + Name Ruleset File + ( + + )} + /> + {errors.name?.message} + + + {/* Additional Options */} + setAdditionalOptionsOpen(isExpanded)} + disableGutters + elevation={0} + square + sx={{ + backgroundColor: 'transparent', + backgroundImage: 'none', + '&:before': { display: 'none' } + }} + > + } + sx={{ + flexDirection: 'row-reverse', + gap: 1, + minHeight: 'unset', + px: 0, + '& .MuiAccordionSummary-content': { my: 0 } + }} + > + + Additional Options + + + + {/* First Rule Page */} + + First Page # + ( + onChange(e.target.value === '' ? undefined : Number(e.target.value))} + slotProps={{ htmlInput: { min: 1 } }} + error={!!errors.firstRulePage} + sx={{ width: 160 }} + /> + )} + /> + + {errors.firstRulePage?.message ?? 'Optional, skips earlier pages'} + + + + + + + + ); +}; + +export default AddNewFileModal; diff --git a/src/frontend/src/pages/RulesPage/components/AddReferencedRuleModal.tsx b/src/frontend/src/pages/RulesPage/components/AddReferencedRuleModal.tsx new file mode 100644 index 0000000000..4dffbca6e7 --- /dev/null +++ b/src/frontend/src/pages/RulesPage/components/AddReferencedRuleModal.tsx @@ -0,0 +1,121 @@ +/* + * 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, CircularProgress, FormLabel, Typography } from '@mui/material'; +import { useMemo, useState } from 'react'; +import { Rule } from 'shared'; +import NERModal from '../../../components/NERModal'; +import NERAutocomplete from '../../../components/NERAutocomplete'; +import { useAddRuleReferences } from '../../../hooks/rules.hooks'; + +interface AddReferencedRuleModalProps { + open: boolean; + onClose: () => void; + // The rule receiving a reference, whose "+" menu was used to open this modal + ruleId: string | null; + rulesetId: string; + allRules: Rule[]; + isAllRulesLoading: boolean; + isAllRulesError: boolean; +} + +type RuleOption = { label: string; id: string }; + +const sectionHeaderStyle = { + display: 'block', + fontWeight: 'bold', + color: '#ef4345', + textDecoration: 'underline', + fontSize: '1rem', + textUnderlineOffset: '5px', + marginBottom: '10px' +}; + +/** + * Modal for attaching an existing rule as a referenced rule to the currently-edited rule + */ +const AddReferencedRuleModal: React.FC = ({ + open, + onClose, + ruleId, + rulesetId, + allRules, + isAllRulesLoading, + isAllRulesError +}) => { + const [selected, setSelected] = useState(null); + const { mutateAsync: addReferences, isLoading } = useAddRuleReferences(rulesetId); + + const activeRule = ruleId ? allRules.find((r) => r.ruleId === ruleId) : undefined; + + // Can attach any rule in the ruleset except the active rule itself and its already-referenced rules + const options = useMemo(() => { + const excluded = new Set([ + ...(activeRule ? [activeRule.ruleId] : []), + ...(activeRule?.referencedRules ?? []).map((ref) => ref.ruleId) + ]); + return allRules + .filter((r) => !excluded.has(r.ruleId)) + .sort((a, b) => a.ruleCode.localeCompare(b.ruleCode, undefined, { numeric: true })) + .map((r) => ({ label: r.ruleCode, id: r.ruleId })); + }, [allRules, activeRule]); + + // once loading finishes with no error, a missing activeRule means the ruleId doesn't exist, so there's nothing to show + if (!isAllRulesLoading && !isAllRulesError && !activeRule) return null; + + const handleClose = () => { + setSelected(null); + onClose(); + }; + + const handleSubmit = async () => { + if (!ruleId || !selected) return; + try { + await addReferences({ ruleId, referencedRuleId: selected.id, referencedRuleCode: selected.label }); + handleClose(); + } catch { + // the modal stays open so the reference can be retried + } + }; + + return ( + + + {isAllRulesLoading ? ( + + + + ) : isAllRulesError ? ( + Failed to load rules. Please close and try again. + ) : ( + + Referenced Rule Code + setSelected(value)} + size="small" + placeholder="Search for an existing rule code" + filterSelectedOptions + disablePortal={false} + sx={{ backgroundColor: 'transparent' }} + /> + + )} + + + ); +}; + +export default AddReferencedRuleModal; 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..5f3c39a3a8 --- /dev/null +++ b/src/frontend/src/pages/RulesPage/components/AddRuleBox.tsx @@ -0,0 +1,71 @@ +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; + onAddReferencedRule: () => void; + onAddImage: () => void; +}; + +export const AddRuleBox: React.FC = ({ + open, + anchorEl, + onClose, + onAddRule, + onAddReferencedRule, + onAddImage +}) => { + const theme = useTheme(); + + const optionButtonSx = { + borderRadius: 0, + backgroundColor: 'transparent', + color: theme.palette.common.white, + lineHeight: 1.1, + justifyContent: 'flex-end', + '&:hover': { backgroundColor: 'rgba(255,255,255,0.12)' } + }; + + return ( + + + + Add Rule + + + Add Referenced Rule + + + Add Image + + + + ); +}; 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..7a0dcfa8ae --- /dev/null +++ b/src/frontend/src/pages/RulesPage/components/AddRuleModal.tsx @@ -0,0 +1,151 @@ +import { useState, useEffect } from 'react'; +import { Box, Typography, TextField, FormControl, FormLabel, FormHelperText } 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; + parentRuleCode?: string; +} + +interface FormData { + ruleCode: string; + ruleContent?: string; +} + +const schema = yup.object().shape({ + ruleCode: yup + .string() + .required('Rule Code is required') + .test('not-blank', 'Rule Code is required', (value) => !!value?.trim()), + ruleContent: yup.string() +}); + +const sectionHeaderStyle = { + display: 'block', + fontWeight: 'bold', + color: '#ef4345', + textDecoration: 'underline', + fontSize: '1rem', + textUnderlineOffset: '5px', + marginBottom: '10px' +}; + +const AddRuleModal: React.FC = ({ open, onClose, rulesetId, initialParentRuleId, parentRuleCode }) => { + 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, + watch, + formState: { errors } + } = useForm({ + resolver: yupResolver(schema), + defaultValues: { + ruleCode: '', + ruleContent: '' + } + }); + + const watchedRuleCode = watch('ruleCode'); + const showPrefixWarning = !!parentRuleCode && !!watchedRuleCode && !watchedRuleCode.startsWith(parentRuleCode); + + // Prefill code field with the parent's code when modal opens + useEffect(() => { + if (open) { + setSelectedReferenceHierarchy([]); + reset({ + ruleCode: parentRuleCode ? `${parentRuleCode}.` : '', + ruleContent: '' + }); + } + }, [open, initialParentRuleId, parentRuleCode, reset]); + + const onSubmit = async (data: FormData) => { + const referencedRules = + selectedReferenceHierarchy.length > 0 ? [selectedReferenceHierarchy[selectedReferenceHierarchy.length - 1]] : []; + + // only resets the draft on success + await createRule({ + ruleCode: data.ruleCode, + ruleContent: data.ruleContent ?? '', + rulesetId, + parentRuleId: initialParentRuleId, + referencedRules, + imageFileIds: [] + }); + + toast.success('Rule created successfully'); + handleClose(); + }; + + const handleClose = () => { + setSelectedReferenceHierarchy([]); + onClose(); + }; + + return ( + { + reset(); + setSelectedReferenceHierarchy([]); + }} + title="Add Rule" + handleUseFormSubmit={handleSubmit} + onFormSubmit={onSubmit} + formId="add-rule-form" + showCloseButton + > + + {/* Rule Code */} + + Rule Code* + ( + + )} + /> + {errors.ruleCode?.message} + {showPrefixWarning && ( + + + Note: Rule Code should start with '{parentRuleCode}' to match parent code + + + )} + + + {/* Rule Content */} + + Rule Content + ( + + )} + /> + {errors.ruleContent?.message} + + + + ); +}; + +export default AddRuleModal; 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..cdff343f81 --- /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/ImagePreviewModal.tsx b/src/frontend/src/pages/RulesPage/components/ImagePreviewModal.tsx new file mode 100644 index 0000000000..9f834ff0dd --- /dev/null +++ b/src/frontend/src/pages/RulesPage/components/ImagePreviewModal.tsx @@ -0,0 +1,29 @@ +/* + * 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 } from '@mui/material'; +import NERModal from '../../../components/NERModal'; + +interface ImagePreviewModalProps { + open: boolean; + imageUrl: string; + title: string; + onClose: () => void; +} + +const ImagePreviewModal: React.FC = ({ open, imageUrl, title, onClose }) => { + return ( + + + + ); +}; + +export default ImagePreviewModal; diff --git a/src/frontend/src/pages/RulesPage/components/MismatchedRuleCodeModal.tsx b/src/frontend/src/pages/RulesPage/components/MismatchedRuleCodeModal.tsx new file mode 100644 index 0000000000..e287634edd --- /dev/null +++ b/src/frontend/src/pages/RulesPage/components/MismatchedRuleCodeModal.tsx @@ -0,0 +1,44 @@ +/* + * 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 WarningIcon from '@mui/icons-material/Warning'; +import NERModal from '../../../components/NERModal'; + +interface MismatchedRuleCodeModalProps { + open: boolean; + onHide: () => void; + onConfirm: () => void; + messages: string[]; +} + +/** + * Lists the warnings for a rule code change before it saves, such as the code not starting with its parent's + * code or child codes not updating with the new prefix. Neither blocks the save, the user can submit anyway. + */ +const MismatchedRuleCodeModal = ({ open, onHide, onConfirm, messages }: MismatchedRuleCodeModalProps) => { + return ( + + + {messages.map((message, index) => ( + + + {message} + + ))} + + + ); +}; + +export default MismatchedRuleCodeModal; diff --git a/src/frontend/src/pages/RulesPage/components/RemoveImageModal.tsx b/src/frontend/src/pages/RulesPage/components/RemoveImageModal.tsx new file mode 100644 index 0000000000..e6808d834e --- /dev/null +++ b/src/frontend/src/pages/RulesPage/components/RemoveImageModal.tsx @@ -0,0 +1,37 @@ +/* + * 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 NERModal from '../../../components/NERModal'; + +interface RemoveImageModalProps { + open: boolean; + onHide: () => void; + onConfirm: () => void; + ruleCode: string; + imageNumber: number; +} + +const RemoveImageModal = ({ open, onHide, onConfirm, ruleCode, imageNumber }: RemoveImageModalProps) => { + return ( + + + + Remove Image {imageNumber} from {ruleCode} + + + + ); +}; + +export default RemoveImageModal; diff --git a/src/frontend/src/pages/RulesPage/components/RemoveReferencedRuleModal.tsx b/src/frontend/src/pages/RulesPage/components/RemoveReferencedRuleModal.tsx new file mode 100644 index 0000000000..7ea51634fe --- /dev/null +++ b/src/frontend/src/pages/RulesPage/components/RemoveReferencedRuleModal.tsx @@ -0,0 +1,38 @@ +/* + * 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 NERModal from '../../../components/NERModal'; + +interface RemoveReferencedRuleModalProps { + open: boolean; + onHide: () => void; + onConfirm: () => void; + rule: Rule; // The rule the reference is being removed from + referencedRule: { ruleId: string; ruleCode: string }; +} + +const RemoveReferencedRuleModal = ({ open, onHide, onConfirm, rule, referencedRule }: RemoveReferencedRuleModalProps) => { + return ( + + + + Remove referenced rule {referencedRule.ruleCode} from {rule.ruleCode} + + + + ); +}; + +export default RemoveReferencedRuleModal; diff --git a/src/frontend/src/pages/RulesPage/components/ResetStatusesModal.tsx b/src/frontend/src/pages/RulesPage/components/ResetStatusesModal.tsx new file mode 100644 index 0000000000..d23ed894d4 --- /dev/null +++ b/src/frontend/src/pages/RulesPage/components/ResetStatusesModal.tsx @@ -0,0 +1,34 @@ +/* + * 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 { Typography } from '@mui/material'; +import NERModal from '../../../components/NERModal'; + +interface ResetStatusesModalProps { + // e.g. "the Mock FSAE ruleset" or "the Bodywork project's Mock FSAE rules" + scopeDescription: string; + onReset: () => void; + onHide: () => void; + disabled?: boolean; +} + +const ResetStatusesModal: React.FC = ({ scopeDescription, onReset, onHide, disabled }) => { + return ( + + Are you sure you want to reset all rule statuses + to PENDING for {scopeDescription}? + + ); +}; + +export default ResetStatusesModal; diff --git a/src/frontend/src/pages/RulesPage/components/RuleContent.tsx b/src/frontend/src/pages/RulesPage/components/RuleContent.tsx new file mode 100644 index 0000000000..95efe0eda8 --- /dev/null +++ b/src/frontend/src/pages/RulesPage/components/RuleContent.tsx @@ -0,0 +1,188 @@ +/* + * 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, CircularProgress, IconButton, Tooltip, useTheme } from '@mui/material'; +import { Close, ErrorOutline } from '@mui/icons-material'; +import { memo, useState } from 'react'; +import { Rule } from 'shared'; +import { useGetImageUrls } from '../../../hooks/onboarding.hook'; +import ImagePreviewModal from './ImagePreviewModal'; + +interface RuleContentProps { + rule: Rule; + color?: string; + // if set, clicking an interactive referenced code navigates to it + onReferenceClick?: (ruleId: string) => void; + // if set, referenced rule code turns red on hover and clicking it initiates removal process (for edit view) + onReferenceRemove?: (ruleId: string) => void; + // sets selected referenced rules as interactable, when omitted every reference is interactive + // used in project view since only references in that project will be clickable + isReferenceInteractive?: (ruleId: string) => boolean; + // in edit view, a delete icon is shown on each image thumbnail to remove it + onImageRemove?: (fileId: string) => void; +} + +interface RuleImagesProps { + rule: Rule; + onImageRemove?: (fileId: string) => void; +} + +/** + * Fetches and renders a rule's image attachments + */ +const RuleImages: React.FC = ({ rule, onImageRemove }) => { + const theme = useTheme(); + const [previewIndex, setPreviewIndex] = useState(null); + + const { + data: imageUrls, + isLoading: isImagesLoading, + isError: isImagesError + } = useGetImageUrls(rule.imageFileIds.map((fileId) => ({ objectId: fileId, imageFileId: fileId }))); + + if (isImagesLoading) { + return ( + + + + ); + } + + if (isImagesError) { + return ( + + + e.stopPropagation()} sx={{ padding: '2px', color: 'error.main' }}> + + + + + ); + } + + if (!imageUrls || imageUrls.length === 0) { + return null; + } + + return ( + <> + + {imageUrls.map( + (image, index) => + image.url && ( + + { + e.stopPropagation(); + setPreviewIndex(index); + }} + sx={{ maxWidth: 300, maxHeight: 300, borderRadius: 1, cursor: 'pointer', display: 'block' }} + /> + {onImageRemove && ( + { + e.stopPropagation(); + onImageRemove(image.id); + }} + sx={{ + position: 'absolute', + top: -8, + right: -8, + padding: '2px', + backgroundColor: theme.palette.grey[700], + '&:hover': { backgroundColor: theme.palette.primary.main } + }} + > + + + )} + + ) + )} + + {previewIndex !== null && imageUrls[previewIndex]?.url && ( + setPreviewIndex(null)} + /> + )} + + ); +}; + +/** + * Renders a rule's content followed by a bracketed list of its referenced rule codes. + */ +const RuleContent: React.FC = ({ + rule, + color, + onReferenceClick, + onReferenceRemove, + isReferenceInteractive, + onImageRemove +}) => { + const theme = useTheme(); + + const { referencedRules } = rule; + const hasImages = rule.imageFileIds.length > 0; + + const handleReferenceClick = (referencedRuleId: string) => (e: React.MouseEvent) => { + e.stopPropagation(); + if (onReferenceClick) { + onReferenceClick(referencedRuleId); + } else if (onReferenceRemove) { + onReferenceRemove(referencedRuleId); + } + }; + + // determines if interaction is possible for this view + const interactionEnabled = Boolean(onReferenceClick || onReferenceRemove); + // determines if this specific referenced rule is interactable + const isReferenceInteractable = (ruleId: string) => + interactionEnabled && (isReferenceInteractive ? isReferenceInteractive(ruleId) : true); + + return ( + + + {rule.ruleContent} + {referencedRules.length > 0 && ( + + {' [ '} + {referencedRules.map((ref, index) => { + const interactive = isReferenceInteractable(ref.ruleId); + return ( + + {index > 0 && ', '} + + {ref.ruleCode} + + + ); + })} + {' ]'} + + )} + + {hasImages && } + + ); +}; + +export default memo(RuleContent); diff --git a/src/frontend/src/pages/RulesPage/components/RuleEditCells.tsx b/src/frontend/src/pages/RulesPage/components/RuleEditCells.tsx new file mode 100644 index 0000000000..f09bd2ca3f --- /dev/null +++ b/src/frontend/src/pages/RulesPage/components/RuleEditCells.tsx @@ -0,0 +1,238 @@ +/* + * 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, TextField, Theme, useTheme } from '@mui/material'; +import ChevronRightIcon from '@mui/icons-material/ChevronRight'; +import { memo, MutableRefObject } from 'react'; +import { Rule } from 'shared'; +import RuleActions from '../RuleActions'; +import RuleContent from './RuleContent'; +import NERFailButton from '../../../components/NERFailButton'; +import NERSuccessButton from '../../../components/NERSuccessButton'; + +// how far a rule steps right per level of depth +const RULE_INDENT_PX = 12; +// when deep rules stop stepping right so content stays readable +const MAX_INDENT_LEVEL = 4; +const indentFor = (level: number) => `${Math.min(level, MAX_INDENT_LEVEL) * RULE_INDENT_PX}px`; + +// the code and content being edited, kept in a ref so typing does not rerender +export interface RuleDraft { + ruleCode: string; + ruleContent: string; +} + +const editFieldStyles = (theme: Theme) => ({ + '& .MuiOutlinedInput-root': { + backgroundColor: theme.palette.grey[100], + color: theme.palette.common.black, + '& fieldset': { + borderColor: '#dd514c' + }, + '&:hover fieldset': { + borderColor: '#dd514c' + }, + '&.Mui-focused fieldset': { + borderColor: '#dd514c' + } + } +}); + +interface RuleCodeCellProps { + rule: Rule; + level: number; + isExpanded: boolean; + hasSubRules: boolean; + onToggleExpand: () => void; + isEditing: boolean; + draftRef: MutableRefObject; +} + +/** + * Left cell of a rule row: the expand chevron and the rule code, swapped for an input while editing. + */ +const RuleCodeCellComponent: React.FC = ({ + rule, + level, + isExpanded, + hasSubRules, + onToggleExpand, + isEditing, + draftRef +}) => { + const theme = useTheme(); + + return ( + + {hasSubRules && ( + { + e.stopPropagation(); + onToggleExpand(); + }} + sx={{ + fontSize: '20px', + flexShrink: 0, + color: theme.palette.common.black, + transform: isExpanded ? 'rotate(90deg)' : 'rotate(0deg)', + transition: 'transform 0.2s', + cursor: 'pointer', + '&:hover': { + backgroundColor: 'rgba(0, 0, 0, 0.04)', + borderRadius: '50%' + } + }} + /> + )} + {isEditing ? ( + { + draftRef.current.ruleCode = e.target.value; + }} + onClick={(e) => e.stopPropagation()} + variant="outlined" + size="small" + autoFocus + sx={{ flex: 1, minWidth: 0, ...editFieldStyles(theme) }} + /> + ) : ( + // a rule code always stays on one line, the column widens and pushes the content across instead + {rule.ruleCode} + )} + + ); +}; + +interface RuleBodyCellProps { + rule: Rule; + level: number; + isEditing: boolean; + draftRef: MutableRefObject; + onReferenceRemove: (rule: Rule, referencedRuleId: string) => void; + onImageRemove: (rule: Rule, fileId: string) => void; +} + +/** + * Middle cell of a rule row: the rule's content and attachments, swapped for an input while editing. + */ +const RuleBodyCellComponent: React.FC = ({ + rule, + level, + isEditing, + draftRef, + onReferenceRemove, + onImageRemove +}) => { + const theme = useTheme(); + + return ( + + {isEditing ? ( + { + draftRef.current.ruleContent = e.target.value; + }} + onClick={(e) => e.stopPropagation()} + variant="outlined" + size="small" + sx={editFieldStyles(theme)} + /> + ) : ( + (rule.ruleContent || rule.referencedRules.length > 0) && ( + onReferenceRemove(rule, referencedRuleId)} + onImageRemove={(fileId) => onImageRemove(rule, fileId)} + /> + ) + )} + + ); +}; + +interface RuleActionsCellProps { + rule: Rule; + isEditing: boolean; + isSaving: boolean; + onAdd: (rule: Rule, anchorEl: HTMLElement) => void; + onRemove: (rule: Rule) => void; + onEdit: (rule: Rule) => void; + onSave: (rule: Rule) => void; + onCancel: () => void; +} + +/** + * Right cell of a rule row: the add, remove and edit actions, swapped for Cancel and Save while editing. + */ +const RuleActionsCellComponent: React.FC = ({ + rule, + isEditing, + isSaving, + onAdd, + onRemove, + onEdit, + onSave, + onCancel +}) => { + const theme = useTheme(); + + return ( + + {isEditing ? ( + <> + { + e.stopPropagation(); + onCancel(); + }} + sx={{ whiteSpace: 'nowrap', flexShrink: 0 }} + > + Cancel + + { + e.stopPropagation(); + onSave(rule); + }} + disabled={isSaving} + sx={{ whiteSpace: 'nowrap', flexShrink: 0 }} + > + Save + + + ) : ( + onAdd(rule, anchorEl)} + onRemove={() => onRemove(rule)} + onEdit={() => onEdit(rule)} + iconColor={theme.palette.common.black} + /> + )} + + ); +}; + +// memo so that starting or ending an edit, which rerenders every mounted row, +// doesn't rebuild the code, content and actions of the rows that didn't change +export const RuleCodeCell = memo(RuleCodeCellComponent); +export const RuleBodyCell = memo(RuleBodyCellComponent); +export const RuleActionsCell = memo(RuleActionsCellComponent); diff --git a/src/frontend/src/pages/RulesPage/components/RuleStatusHistoryModal.tsx b/src/frontend/src/pages/RulesPage/components/RuleStatusHistoryModal.tsx new file mode 100644 index 0000000000..54829c2a98 --- /dev/null +++ b/src/frontend/src/pages/RulesPage/components/RuleStatusHistoryModal.tsx @@ -0,0 +1,57 @@ +/* + * 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, formatTimestamp } from 'shared'; +import NERModal from '../../../components/NERModal'; +import ErrorPage from '../../ErrorPage'; +import LoadingIndicator from '../../../components/LoadingIndicator'; +import { getRuleStatusConfig } from '../../../utils/rules.utils'; +import { useRuleStatusHistory } from '../../../hooks/rules.hooks'; + +interface RuleStatusHistoryModalProps { + open: boolean; + onClose: () => void; + rule: Rule; + // if provided, scopes history to just this project; otherwise shows every context the rule appears in + projectRuleId?: string; +} + +/** + * Full status history for a rule: every time status was marked PASS or FAIL, most recent first. + * Reverting to PENDING doesn't add an entry. + */ +const RuleStatusHistoryModal: React.FC = ({ open, onClose, rule, projectRuleId }) => { + const { data: entries, isLoading, isError, error } = useRuleStatusHistory(rule.ruleId, open, projectRuleId); + + if (isLoading) return ; + if (isError) return ; + + return ( + + {!entries || entries.length === 0 ? ( + No status history yet. + ) : ( + + {entries.map((entry, index) => { + const { color } = getRuleStatusConfig(entry.status); + return ( + + + + Marked {entry.status.toUpperCase()} by {entry.updatedBy.firstName} {entry.updatedBy.lastName} on{' '} + {formatTimestamp(entry.updatedAt)} + {entry.projectName ? ` in ${entry.projectName}` : ''} + + + ); + })} + + )} + + ); +}; + +export default RuleStatusHistoryModal; diff --git a/src/frontend/src/pages/RulesPage/components/RuleStatusTag.tsx b/src/frontend/src/pages/RulesPage/components/RuleStatusTag.tsx new file mode 100644 index 0000000000..d0ce9c5907 --- /dev/null +++ b/src/frontend/src/pages/RulesPage/components/RuleStatusTag.tsx @@ -0,0 +1,139 @@ +/* + * 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, IconButton, Tooltip } from '@mui/material'; +import { memo, useState } from 'react'; +import { InfoOutlined } from '@mui/icons-material'; +import { Rule, RuleStatus, formatTimestamp } from 'shared'; +import { getRuleStatusConfig } from '../../../utils/rules.utils'; + +interface RuleStatusTagProps { + rule: Rule; + // whether this rule is a leaf in the tree being displayed + isLeaf: boolean; + // called with the new status when a Pass/Fail checkbox is toggled + onStatusChange?: (rule: Rule, status: RuleStatus) => Promise; + // if provided, the info icon opens a full status-history modal instead of a one-line tooltip + onInfoClick?: (rule: Rule) => void; +} + +/** + * Status chip for a rule. Leaf rules show Pass/Fail checkboxes instead of the chip, plus an info icon/tooltip. + * Parent rules show their aggregated status as a read-only chip. + */ +const RuleStatusTag: React.FC = ({ rule, isLeaf, onStatusChange, onInfoClick }) => { + // clicking a checkbox redraws this one tag, instead of every row in the tree + const [isUpdating, setIsUpdating] = useState(false); + + const handleStatusChange = async (status: RuleStatus) => { + if (!onStatusChange) return; + setIsUpdating(true); + try { + await onStatusChange(rule, status); + } catch { + // the mutation hook toasts the failure, the tag only needs to stop showing pending + } finally { + setIsUpdating(false); + } + }; + + const { label, color } = getRuleStatusConfig(rule.status); + const passColor = getRuleStatusConfig(RuleStatus.PASS).color; + const failColor = getRuleStatusConfig(RuleStatus.FAIL).color; + + const statusUpdatedByName = rule.statusUpdatedBy && `${rule.statusUpdatedBy.firstName} ${rule.statusUpdatedBy.lastName}`; + const statusMessage = + statusUpdatedByName && rule.statusUpdatedAt + ? `Marked ${label.toUpperCase()} by ${statusUpdatedByName} on ${formatTimestamp(rule.statusUpdatedAt)}` + : ''; + + // hasStatusHistory persists even after the current status is reverted to PENDING + const showInfo = isLeaf && (onInfoClick ? rule.hasStatusHistory : Boolean(statusMessage)); + + const showCheckboxes = isLeaf && Boolean(onStatusChange); + + const checkboxSx = (checkedColor: string) => ({ + color: checkedColor, + '&.Mui-checked': { color: checkedColor }, + p: 0.1, + '&:hover::after': { + content: '""', + position: 'absolute', + inset: 0, + m: 'auto', + width: '18px', + height: '18px', + borderRadius: '2px', + bgcolor: 'currentColor', + opacity: 0.25 + } + }); + + return ( + + + {showCheckboxes ? ( + <> + e.stopPropagation()} + onChange={() => handleStatusChange(rule.status === RuleStatus.PASS ? RuleStatus.PENDING : RuleStatus.PASS)} + disabled={isUpdating} + disableRipple + sx={checkboxSx(passColor)} + slotProps={{ input: { 'aria-label': 'Pass' } }} + /> + e.stopPropagation()} + onChange={() => handleStatusChange(rule.status === RuleStatus.FAIL ? RuleStatus.PENDING : RuleStatus.FAIL)} + disabled={isUpdating} + disableRipple + sx={checkboxSx(failColor)} + slotProps={{ input: { 'aria-label': 'Fail' } }} + /> + + ) : ( + + {label} + + )} + + + {showInfo && ( + + { + e.stopPropagation(); + onInfoClick?.(rule); + }} + sx={{ padding: '2px', color: 'text.secondary' }} + > + + + + )} + + + ); +}; + +// memo so a re-render of the rules tree doesn't re-render every status chip +export default memo(RuleStatusTag); diff --git a/src/frontend/src/pages/RulesPage/components/RulesetDeleteModal.tsx b/src/frontend/src/pages/RulesPage/components/RulesetDeleteModal.tsx new file mode 100644 index 0000000000..911d4c8d00 --- /dev/null +++ b/src/frontend/src/pages/RulesPage/components/RulesetDeleteModal.tsx @@ -0,0 +1,27 @@ +import { Typography } from '@mui/material'; +import NERModal from '../../../components/NERModal'; + +interface RulesetDeleteModalProps { + rulesetName: string; + onDelete: () => void; + onHide: () => void; +} + +const RulesetDeleteModal: React.FC = ({ rulesetName, onDelete, onHide }) => { + return ( + + Are you sure you want to delete this ruleset? + {rulesetName} + + ); +}; + +export default RulesetDeleteModal; diff --git a/src/frontend/src/pages/RulesPage/components/RulesetGeneralView.tsx b/src/frontend/src/pages/RulesPage/components/RulesetGeneralView.tsx new file mode 100644 index 0000000000..4ce2bd5194 --- /dev/null +++ b/src/frontend/src/pages/RulesPage/components/RulesetGeneralView.tsx @@ -0,0 +1,101 @@ +import React, { useCallback, useMemo, useState } from 'react'; +import { Box, Paper, Table, TableBody, TableContainer, useTheme } from '@mui/material'; +import { Rule, RuleStatus, isLeadership } from 'shared'; +import RuleRow from '../RuleRow'; +import RuleStatusTag from './RuleStatusTag'; +import RuleContent from './RuleContent'; +import RuleStatusHistoryModal from './RuleStatusHistoryModal'; +import { useSetRuleStatus } from '../../../hooks/rules.hooks'; +import { useCurrentUser } from '../../../hooks/users.hooks'; +import { compareRuleCodes } from '../../../utils/rules.utils'; + +interface RulesetGeneralViewProps { + topLevelRules: Rule[]; + rulesetId: string; + expandedIds: Set; + toggleExpand: (ruleId: string) => void; + navigateToRule: (ruleId: string) => void; +} + +/** + * General view for displaying all top-level rules as dropdowns. + * Subrules are fetched only when a rule is expanded. + */ +const RulesetGeneralView: React.FC = ({ + topLevelRules, + rulesetId, + expandedIds, + toggleExpand, + navigateToRule +}) => { + const theme = useTheme(); + const user = useCurrentUser(); + const [historyModalRule, setHistoryModalRule] = useState(null); + + const canUpdateStatus = isLeadership(user.role); + + const backgroundColor = theme.palette.background.default; + const tableBackgroundColor = theme.palette.background.paper; + const tableTextColor = theme.palette.text.primary; + const tableHoverColor = theme.palette.action.hover; + + // Status in general view is independent of any project + const { mutateAsync: setStatus } = useSetRuleStatus(rulesetId); + + // Sort once by rule code so top-level rows render in a stable numeric order. + const sortedTopLevelRules = useMemo(() => [...topLevelRules].sort(compareRuleCodes), [topLevelRules]); + + const handleSetStatus = useCallback( + (rule: Rule, status: RuleStatus) => setStatus({ ruleId: rule.ruleId, status }), + [setStatus] + ); + + const renderMiddleContent = useCallback( + (r: Rule) => , + [navigateToRule, tableTextColor] + ); + + const renderRightContent = useCallback( + (r: Rule) => ( + + ), + [canUpdateStatus, handleSetStatus] + ); + + return ( + + + + + {sortedTopLevelRules.map((rule) => ( + + ))} + +
+
+ + {historyModalRule && setHistoryModalRule(null)} rule={historyModalRule} />} +
+ ); +}; + +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..0ca4016134 --- /dev/null +++ b/src/frontend/src/pages/RulesPage/components/RulesetTable.tsx @@ -0,0 +1,388 @@ +import React, { useState } from 'react'; +import { + Box, + Paper, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + useMediaQuery, + useTheme, + Card, + CardContent, + Typography, + Stack, + Checkbox, + IconButton, + Tooltip +} 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 { useCurrentUser } from '../../../hooks/users.hooks'; +import { Ruleset, isAdmin, isHead, isLeadership } 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; + disabledReason?: string; + onDelete: (rulesetId: string, name: string) => void; +} + +const RulesetTable: React.FC = () => { + const { rulesetTypeId } = useParams(); + const toast = useToast(); + const history = useHistory(); + const user = useCurrentUser(); + 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; + }; + + const canDelete = isLeadership(user.role); + const canSetActive = isHead(user.role); + + // admins can delete any inactive ruleset, leadership and heads only their own + const deleteDisabledReason = (ruleset: Ruleset): string | undefined => { + if (ruleset.active) return 'Cannot delete an active ruleset.'; + if (!isAdmin(user.role) && ruleset.createdByUserId !== user.userId) return 'This ruleset was uploaded by another user.'; + return undefined; + }; + + // 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' }, + ...(canDelete ? [{ 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, disabledReason, onDelete }) => { + const [showDeleteModal, setShowDeleteModal] = useState(false); + + const handleDeleteSubmit = () => { + onDelete(rulesetId, name); + setShowDeleteModal(false); + }; + return ( + <> + + + setShowDeleteModal(true)}> + + + + + {showDeleteModal && ( + setShowDeleteModal(false)} /> + )} + + ); + }; + + if (error) return ; + if (isLoading) return ; + + return ( + + {isMobile ? ( + + {rulesets.map((ruleset: Ruleset) => ( + + + + {ruleset.name} + + + + + Date Uploaded: + + + {datePipe(ruleset.dateCreated)} + + + + + % of Rules Assigned: + + + {ruleset.assignedPercentage} + + + + + Car: + + + {ruleset.car.name} + + + + + Active: + + + {ruleset.active} + + handleToggleActive(ruleset)} + disabled={!canSetActive || updateRuleset.isLoading} + sx={{ + color: '#fff', + '&.Mui-checked': { color: '#dd514c' } + }} + /> + + + {isLeadership(user.role) && ( + 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 + + {canDelete && ( + + )} + + + + + ))} + + ) : ( + + + + + {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={!canSetActive || updateRuleset.isLoading} + sx={{ + color: '#fff', + '&.Mui-checked': { color: '#dd514c' } + }} + /> + + + {isLeadership(user.role) && ( + 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 + + + {canDelete && ( + + + + )} + + )) + )} + +
+
+ )} +
+ ); +}; + +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..8b928fdbde --- /dev/null +++ b/src/frontend/src/pages/RulesPage/components/RulesetTeamView.tsx @@ -0,0 +1,63 @@ +import React from 'react'; +import { Box, Paper, Table, TableBody, TableContainer, useTheme } from '@mui/material'; +import { Rule } from 'shared'; +import RuleRow from '../RuleRow'; +import RuleContent from './RuleContent'; + +interface RulesetTeamViewProps { + topLevelItems: Rule[]; + rowsById: Rule[]; + actualRuleIds: Set; + expandedIds: Set; + toggleExpand: (ruleId: string) => void; +} + +/** + * Displays rules organized by team and project. + * Teams, projects, and unassigned sections are all rendered as RuleRows for consistent formatting. + */ +const RulesetTeamView: React.FC = ({ + topLevelItems, + rowsById, + actualRuleIds, + expandedIds, + toggleExpand +}) => { + const theme = useTheme(); + + const backgroundColor = theme.palette.background.default; + const tableBackgroundColor = theme.palette.background.paper; + const tableTextColor = theme.palette.text.primary; + const tableHoverColor = theme.palette.action.hover; + + return ( + + + + + {topLevelItems.map((item) => ( + } + rightContent={() => null} + backgroundColor={tableBackgroundColor} + textColor={tableTextColor} + hoverColor={tableHoverColor} + rowHeight="40px" + verticalPadding="8px" + indentRow + expandedIds={expandedIds} + onToggleExpand={toggleExpand} + fullWidthCode={(rule) => !actualRuleIds.has(rule.ruleId)} + /> + ))} + +
+
+
+ ); +}; + +export default RulesetTeamView; 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..9b89421b47 --- /dev/null +++ b/src/frontend/src/pages/RulesPage/components/RulesetTypeTable.tsx @@ -0,0 +1,184 @@ +import { + Box, + Paper, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + useMediaQuery, + useTheme, + Card, + CardContent, + Typography, + Stack +} from '@mui/material'; +import { useHistory } from 'react-router-dom'; +import { datePipe } from '../../../utils/pipes'; +import { routes } from '../../../utils/routes'; +import { useAllRulesetTypes } from '../../../hooks/rules.hooks'; +import LoadingIndicator from '../../../components/LoadingIndicator'; +import ErrorPage from '../../ErrorPage'; +import { RulesetType } from 'shared'; +import { NERButton } from '../../../components/NERButton'; + +type RulesetTypeColumnId = 'id' | 'name' | 'lastUpdated' | 'revisions' | 'actions' | 'delete'; + +interface RulesetTypeHeadCell { + id: RulesetTypeColumnId; + label: string; +} + +const RulesetTypeTable: React.FC = () => { + const history = useHistory(); + const theme = useTheme(); + const isMobile = useMediaQuery(theme.breakpoints.down('md')); + + const { data: rulesetTypes = [], isLoading, error } = useAllRulesetTypes(); + + const headCells: readonly RulesetTypeHeadCell[] = [ + { + id: 'name', + label: 'Ruleset Type Name' + }, + { + id: 'lastUpdated', + label: 'Last Updated' + }, + { + id: 'revisions', + label: 'Number of Revisions' + }, + { + id: 'actions', + label: 'Actions' + } + ]; + + const handleViewRulesetType = (rulesetTypeId: string) => { + history.push(routes.RULESET_BY_ID.replace(':rulesetTypeId', rulesetTypeId)); + }; + + if (error) return ; + if (isLoading) return ; + + return ( + + {isMobile ? ( + + {rulesetTypes.map((rulesetType: RulesetType) => ( + + + + {rulesetType.name} + + + + + Last Updated: + + + {datePipe(rulesetType.lastUpdated)} + + + + + Revisions: + + + {rulesetType.revisionFiles.length} + + + + handleViewRulesetType(rulesetType.rulesetTypeId)} + > + View Rulesets + + + + + + ))} + + ) : ( + + + + + {headCells.map((headCell) => ( + + {headCell.label} + + ))} + + + + {rulesetTypes.length === 0 ? ( + + + No Ruleset Types Found + + + ) : ( + rulesetTypes.map((rulesetType: RulesetType) => ( + + + {rulesetType.name} + + {datePipe(rulesetType.lastUpdated)} + {rulesetType.revisionFiles.length} + + handleViewRulesetType(rulesetType.rulesetTypeId)} + > + View Rulesets + + + + )) + )} + +
+
+ )} +
+ ); +}; + +export default RulesetTypeTable; diff --git a/src/frontend/src/pages/RulesPage/useRuleTreeNavigation.ts b/src/frontend/src/pages/RulesPage/useRuleTreeNavigation.ts new file mode 100644 index 0000000000..fc5b5ee27d --- /dev/null +++ b/src/frontend/src/pages/RulesPage/useRuleTreeNavigation.ts @@ -0,0 +1,91 @@ +/* + * 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 { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { Rule } from 'shared'; +import { getAncestorIds } from '../../utils/rules.utils'; + +/** + * Controlled expand + click-to-navigate for referenced rules. + * Clicking a referenced rule link expands its full ancestor path and scrolls to it on the page. + * @param rules the rules currently rendered/loaded on this page (may just be top-level rules) + * @param loadFullTree optional loader for the entire rule tree + * @returns expansion state + handlers + */ +export const useRuleTreeNavigation = (rules: Rule[], loadFullTree?: () => Promise) => { + // set of rule ids currently expanded + const [expandedIds, setExpandedIds] = useState>(new Set()); + // rule pending to scroll to + const [pendingScrollId, setPendingScrollId] = useState(null); + // ids of rules that are expandable (have sub-rules) + const expandableIds = useMemo(() => new Set(rules.filter((r) => r.subRuleIds.length > 0).map((r) => r.ruleId)), [rules]); + + const areAllExpanded = expandableIds.size > 0 && [...expandableIds].every((id) => expandedIds.has(id)); + + const [isLoadingFullTree, setIsLoadingFullTree] = useState(false); + + const latestRules = useRef(rules); + latestRules.current = rules; + + // the rules to work with, fetching the whole tree when this view only holds part of it + const resolveRules = useCallback(async () => { + // no loader means view already has every rule needed (project/team view) + if (!loadFullTree) return latestRules.current; + setIsLoadingFullTree(true); // loading for expand all and clicked ref rule link + try { + // fetch the whole tree + return await loadFullTree(); + } finally { + setIsLoadingFullTree(false); + } + }, [loadFullTree]); + + // expands every rule with children, fetching the whole tree first when this view loads lazily + const expandAll = useCallback(async () => { + if (!loadFullTree) return setExpandedIds(new Set(expandableIds)); + const allRules = await resolveRules(); + setExpandedIds(new Set(allRules.filter((r) => r.subRuleIds.length > 0).map((r) => r.ruleId))); + }, [expandableIds, loadFullTree, resolveRules]); + + const collapseAll = useCallback(() => { + setExpandedIds(new Set()); + }, []); + + // flips a rule's expanded/collapsed state + const toggleExpand = useCallback((ruleId: string) => { + setExpandedIds((prev) => { + const next = new Set(prev); + if (next.has(ruleId)) { + next.delete(ruleId); // expanded -> collapsed + } else { + next.add(ruleId); // collapsed -> expanded + } + return next; + }); + }, []); + + // Expand the target's full ancestor path and queue a scroll to it + const navigateToRule = useCallback( + async (targetId: string) => { + const searchRules = await resolveRules(); + if (!searchRules.some((r) => r.ruleId === targetId)) return; // ensure target rule exists in this view + setExpandedIds((prev) => new Set([...prev, ...getAncestorIds(targetId, searchRules), targetId])); + setPendingScrollId(targetId); // queue the scroll + }, + [resolveRules] + ); + + // Scrolls to target rule after ancestors expand + useEffect(() => { + if (!pendingScrollId) return; + const node = document.getElementById(`rule-row-${pendingScrollId}`); + if (node) { + node.scrollIntoView({ behavior: 'smooth', block: 'center' }); + setPendingScrollId(null); + } + }, [pendingScrollId, expandedIds]); + + return { expandedIds, toggleExpand, navigateToRule, expandAll, collapseAll, areAllExpanded, isLoadingFullTree }; +}; diff --git a/src/frontend/src/pages/RulesPage/useTeamRuleOrganization.ts b/src/frontend/src/pages/RulesPage/useTeamRuleOrganization.ts new file mode 100644 index 0000000000..f964aeacd3 --- /dev/null +++ b/src/frontend/src/pages/RulesPage/useTeamRuleOrganization.ts @@ -0,0 +1,68 @@ +/* + * 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 { useMemo } from 'react'; +import { Rule } from 'shared'; +import { getTeamOrganization, scopeRulesToBucket, makeSectionRow } from '../../utils/rules.utils'; + +/** + * Regroups a ruleset's rules by team and project into a rule row tree + */ +export const useTeamRuleOrganization = (allRules: Rule[]) => { + return useMemo(() => { + const { teamRules, unassignedToTeam } = getTeamOrganization(allRules); + + // real-rule rows (bucket-scoped) + const ruleRows: Rule[] = []; + // header rows: teams, projects, and unassigned sections + const sectionRows: Rule[] = []; + // top level: one header row per team, plus "unassigned to team" + const topLevelItems: Rule[] = []; + + teamRules.forEach((team) => { + const teamRowId = `team-${team.teamId}`; + const teamChildIds: string[] = []; + + // one section per project, holding the project's assigned rules + team.projects.forEach((project) => { + const projectRowId = `project-${team.teamId}-${project.projectId}`; + const { rows, rootIds } = scopeRulesToBucket(projectRowId, project.rules); + ruleRows.push(...rows); + sectionRows.push(makeSectionRow(projectRowId, project.projectName, rootIds)); + teamChildIds.push(projectRowId); + }); + + // one section for rules on the team but assigned to no project + if (team.unassignedRules.length > 0) { + const unassignedRowId = `team-${team.teamId}-unassigned`; + const { rows, rootIds } = scopeRulesToBucket(unassignedRowId, team.unassignedRules); + ruleRows.push(...rows); + sectionRows.push(makeSectionRow(unassignedRowId, 'Unassigned Rules - Unassigned to Project', rootIds)); + teamChildIds.push(unassignedRowId); + } + + const teamRow = makeSectionRow(teamRowId, team.teamName, teamChildIds); + sectionRows.push(teamRow); + topLevelItems.push(teamRow); + }); + + // top-level for rules unassigned to any team + if (unassignedToTeam.length > 0) { + const unassignedToTeamRowId = 'unassigned-to-team'; + const { rows, rootIds } = scopeRulesToBucket(unassignedToTeamRowId, unassignedToTeam); + ruleRows.push(...rows); + const unassignedToTeamRow = makeSectionRow(unassignedToTeamRowId, 'Unassigned Rules - Unassigned to Team', rootIds); + sectionRows.push(unassignedToTeamRow); + topLevelItems.push(unassignedToTeamRow); + } + + // unassigned headers do not need rule content, "rule code" can span full width + return { + topLevelItems, + rowsById: [...sectionRows, ...ruleRows], + actualRuleIds: new Set(ruleRows.map((r) => r.ruleId)) + }; + }, [allRules]); +}; diff --git a/src/frontend/src/pages/StatisticsPage/GraphForm/GraphFormView.tsx b/src/frontend/src/pages/StatisticsPage/GraphForm/GraphFormView.tsx index 4958389b19..40d37ec88c 100644 --- a/src/frontend/src/pages/StatisticsPage/GraphForm/GraphFormView.tsx +++ b/src/frontend/src/pages/StatisticsPage/GraphForm/GraphFormView.tsx @@ -1,7 +1,7 @@ import { Autocomplete, FormControl, FormHelperText, FormLabel, Grid, MenuItem, Select, TextField } from '@mui/material'; -import { Control, Controller, FieldErrors } from 'react-hook-form'; +import { Control, Controller, FieldErrors, useWatch } from 'react-hook-form'; import { DatePicker } from '@mui/x-date-pickers'; -import { Car, GraphDisplayType, GraphFormInput, GraphType, Measure, SpecialPermission } from 'shared'; +import { Car, GraphDisplayType, GraphFormInput, GraphType, isAttendanceGraphType, Measure, SpecialPermission } from 'shared'; import { displayEnum } from '../../../utils/pipes'; import NERAutocomplete from '../../../components/NERAutocomplete'; import { useEffect, useState } from 'react'; @@ -17,6 +17,7 @@ export const GraphFormView: React.FC = ({ control, errors, c const [startTimeDatePickerOpen, setStartTimeDatePickerOpen] = useState(false); const [endTimeDatePickerOpen, setEndTimeDatePickerOpen] = useState(false); const [carMap, setCarMap] = useState(new Map()); + const isAttendanceGraph = isAttendanceGraphType(useWatch({ control, name: 'graphType' })); useEffect(() => { const tempSet = new Map(); @@ -92,7 +93,7 @@ export const GraphFormView: React.FC = ({ control, errors, c {errors.endTime?.message} - + Graph Type = ({ control, errors, c {errors.graphType?.message} - - - Measure - ( - - )} - /> - - {errors.measure?.message} - + {!isAttendanceGraph && ( + + + Measure + ( + + )} + /> + + {errors.measure?.message} + + )} @@ -192,34 +195,36 @@ export const GraphFormView: React.FC = ({ control, errors, c - - - Select Cars To Segment Data By - { - return ( - option?.id === value?.id} - filterSelectedOptions - multiple - id="carSelector" - options={cars} - value={value.map((carId) => carMap.get(carId))} - onChange={(_event, newValue) => onChange(newValue.map((car) => car?.id))} - getOptionLabel={(option) => option?.name ?? ''} - renderInput={(params) => ( - - )} - /> - ); - }} - /> - {errors.carIds?.message} - - - + {!isAttendanceGraph && ( + + + Select Cars To Segment Data By + { + return ( + option?.id === value?.id} + filterSelectedOptions + multiple + id="carSelector" + options={cars} + value={value.map((carId) => carMap.get(carId))} + onChange={(_event, newValue) => onChange(newValue.map((car) => car?.id))} + getOptionLabel={(option) => option?.name ?? ''} + renderInput={(params) => ( + + )} + /> + ); + }} + /> + {errors.carIds?.message} + + + )} + Additional Permissions to Apply to the Graph { + let count = 1; + const children = allRules.filter((r) => rule.subRuleIds.includes(r.ruleId)); + for (const child of children) { + count += countRulesToDelete(child, allRules); + } + return count; +}; + +/** + * Gets all leaf rules of a given rule (the rule itself if it has no children). + * Does not include the rule's parent or siblings. + * + * @param rule - rule to start from + * @param allRules - all rules in scope + * @returns The leaf rules under the given rule, or rule if it is already a leaf + */ +export const getDescendantLeafRules = (rule: Rule, allRules: Rule[] = []): Rule[] => { + const children = allRules.filter((r) => r.parentRule?.ruleId === rule.ruleId); + if (children.length === 0) { + return [rule]; + } + return children.flatMap((child) => getDescendantLeafRules(child, allRules)); +}; + +/** + * Status chip label and color for a rule status. + */ +export const getRuleStatusConfig = (status: RuleStatus): { label: string; color: string } => { + switch (status) { + case RuleStatus.PASS: + return { label: 'Pass', color: '#4caf50' }; + case RuleStatus.FAIL: + return { label: 'Fail', color: '#f44336' }; + case RuleStatus.PENDING: + default: + return { label: 'Pending', color: '#9e9e9e' }; + } +}; + +/** + * Collects a rule and all of its descendants, down to the leaf rules. + */ +export const getRuleAndDescendantIds = (ruleId: string, allRules: Rule[]): string[] => { + const rule = allRules.find((r) => r.ruleId === ruleId); + if (!rule) { + return []; + } + + return [ruleId, ...rule.subRuleIds.flatMap((subId) => getRuleAndDescendantIds(subId, allRules))]; +}; + +/** + * Collects the ancestors of a rule, from its immediate parent up to the top-level rule. + */ +export const getAncestorIds = (ruleId: string, allRules: Rule[]): string[] => { + const ancestorIds: string[] = []; + let current = allRules.find((r) => r.ruleId === ruleId); + + while (current?.parentRule) { + const parentId = current.parentRule.ruleId; + ancestorIds.push(parentId); + current = allRules.find((r) => r.ruleId === parentId); + } + + return ancestorIds; +}; + +/** + * Comparator that orders rules by their rule code numerically, so codes sort + * as F.2 before F.10 rather than alphabetically + */ +export const compareRuleCodes = (a: Rule, b: Rule): number => + a.ruleCode.localeCompare(b.ruleCode, undefined, { numeric: true }); + +export interface TeamProject { + projectId: string; + projectName: string; + rules: Rule[]; +} + +export interface TeamRules { + teamId: string; + teamName: string; + projects: TeamProject[]; + unassignedRules: Rule[]; +} + +/** + * Groups ruleset's rules by team and project for team view. First groups by team + * assigned to, then by projects assigned to within that team, or unassigned to any + * project. Finally groups by rules not assigned to any team. + * Note that rules can be on more than one team and in more than one project for that team. + */ +export const getTeamOrganization = (allRules: Rule[]): { teamRules: TeamRules[]; unassignedToTeam: Rule[] } => { + const teamMap = new Map(); + const teamProjectMaps = new Map>(); + const unassignedToTeam: Rule[] = []; + + // iterate through all rules and organize by team and project + allRules.forEach((rule) => { + // rule with no team belongs to the top-level "unassigned to team" bucket. + if (!rule.teams || rule.teams.length === 0) { + unassignedToTeam.push(rule); + return; + } + + // otherwise place the rule under each team it belongs to + rule.teams.forEach((team) => { + // create the team's buckets the first time we see it + if (!teamMap.has(team.teamId)) { + teamMap.set(team.teamId, { + teamId: team.teamId, + teamName: team.teamName, + projects: [], + unassignedRules: [] + }); + teamProjectMaps.set(team.teamId, new Map()); + } + + const teamRules = teamMap.get(team.teamId)!; + const projectMap = teamProjectMaps.get(team.teamId)!; + + // Keep only the rule's projects that belong to this team. Since rules can be on multiple teams, + // this ensures projects don't show up under teams they are not a part of + const projectsForTeam = rule.projects?.filter((project) => project.teamIds.includes(team.teamId)) ?? []; + + // Add the rule under each of its projects, or unassigned to project, for this team + if (projectsForTeam.length > 0) { + projectsForTeam.forEach((project) => { + if (!projectMap.has(project.projectId)) { + projectMap.set(project.projectId, { + projectId: project.projectId, + projectName: project.projectName, + rules: [] + }); + } + projectMap.get(project.projectId)!.rules.push(rule); + }); + } else { + teamRules.unassignedRules.push(rule); + } + }); + }); + + teamMap.forEach((teamRules, teamId) => { + teamRules.projects = Array.from(teamProjectMaps.get(teamId)!.values()); + }); + + return { teamRules: Array.from(teamMap.values()), unassignedToTeam }; +}; + +/** + * Prepares one bucket's rules for the shared RuleRow tree. + * Ids are prefixed with their bucket since the same rule can live in multiple teams or projects + */ +export const scopeRulesToBucket = (bucketId: string, rules: Rule[]): { rows: Rule[]; rootIds: string[] } => { + const idsInBucket = new Set(rules.map((rule) => rule.ruleId)); + const bucketPrefixId = (ruleId: string) => `${bucketId}::${ruleId}`; + + // add bucket prefix id to each rule, filtering out subRuleIds that are not in this bucket + const rows: Rule[] = rules.map((rule) => ({ + ...rule, + ruleId: bucketPrefixId(rule.ruleId), + subRuleIds: rule.subRuleIds.filter((id) => idsInBucket.has(id)).map(bucketPrefixId) + })); + + // find top level rules in this bucket where parentRule is either not set or not in this bucket + const rootIds = rules + .filter((rule) => !rule.parentRule || !idsInBucket.has(rule.parentRule.ruleId)) + .map((rule) => bucketPrefixId(rule.ruleId)); + + return { rows, rootIds }; +}; + +/** + * Builds a structural header row (team, project, or "unassigned" section), not a real rule + */ +export const makeSectionRow = (ruleId: string, ruleCode: string, subRuleIds: string[]): Rule => ({ + ruleId, + ruleCode, + ruleContent: '', + imageFileIds: [], + parentRule: undefined, + subRuleIds, + referencedRules: [], + status: RuleStatus.PENDING, + hasStatusHistory: false +}); diff --git a/src/frontend/src/utils/urls.ts b/src/frontend/src/utils/urls.ts index 4c523d86e5..7d33f4175c 100644 --- a/src/frontend/src/utils/urls.ts +++ b/src/frontend/src/utils/urls.ts @@ -468,6 +468,43 @@ const retrospectiveTimelines = (startDate?: Date, endDate?: Date) => (endDate ? `end=${encodeURIComponent(new Date(endDate).toISOString())}` : ''); const retrospectiveBudgets = () => `${API_URL}/retrospective/budgets`; +/**************** Rules Endpoints ****************/ +const rules = () => `${API_URL}/rules`; +const rulesTopLevel = (rulesetId: string) => `${rules()}/${rulesetId}/parentRules`; +const rulesAllRules = (rulesetId: string) => `${rules()}/${rulesetId}/allRules`; +const rulesToggleTeam = (ruleId: string) => `${rules()}/rule/${ruleId}/toggle-team`; +const rulesChildRules = (ruleId: string) => `${rules()}/${ruleId}/subrules`; +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, projectId: string) => + `${rules()}/ruleset/${rulesetId}/project/${projectId}/rules/unassigned`; +const rulesCreateProjectRule = () => `${rules()}/projectRule/create`; +const rulesDeleteProjectRule = (projectRuleId: string) => `${rules()}/projectRule/${projectRuleId}/delete`; +const rulesSetRuleStatus = (ruleId: string) => `${rules()}/rule/${ruleId}/setStatus`; +const rulesSetProjectRuleStatus = (projectRuleId: string) => `${rules()}/projectRule/${projectRuleId}/setStatus`; +const rulesGetStatusHistory = (ruleId: string) => `${rules()}/rule/${ruleId}/status-history`; +const rulesResetRulesetStatuses = (rulesetId: string) => `${rules()}/ruleset/${rulesetId}/resetStatuses`; +const rulesResetProjectRuleStatuses = (rulesetId: string, projectId: string) => + `${rules()}/ruleset/${rulesetId}/project/${projectId}/resetStatuses`; +const rulesEdit = (ruleId: string) => `${rules()}/rule/${ruleId}/edit`; +const rulesDelete = (ruleId: string) => `${rules()}/rule/${ruleId}/delete`; +const rulesAddReferences = (ruleId: string) => `${rules()}/rule/${ruleId}/references/add`; +const rulesRemoveReferences = (ruleId: string) => `${rules()}/rule/${ruleId}/references/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`; @@ -889,6 +926,38 @@ export const apiUrls = { retrospectiveTimelines, retrospectiveBudgets, + rules, + rulesTopLevel, + rulesAllRules, + rulesToggleTeam, + rulesChildRules, + ruleset, + rulesetTypes, + rulesetsByType, + rulesetTypeCreate, + rulesetsCreate, + ruleCreate, + rulesGetActiveRuleset, + rulesGetProjectRules, + rulesGetUnassignedRulesForRuleset, + rulesCreateProjectRule, + rulesDeleteProjectRule, + rulesSetRuleStatus, + rulesSetProjectRuleStatus, + rulesGetStatusHistory, + rulesResetRulesetStatuses, + rulesResetProjectRuleStatuses, + rulesEdit, + rulesDelete, + rulesAddReferences, + rulesRemoveReferences, + rulesetUpdate, + rulesetDelete, + rulesetTypeDelete, + rulesetType, + parseRuleset, + uploadRulesetFile, + singleRuleset, calendarShops, calendarCreateShop, calendarFilterEvents, diff --git a/src/shared/index.ts b/src/shared/index.ts index 11ed95f5a3..342003a824 100644 --- a/src/shared/index.ts +++ b/src/shared/index.ts @@ -23,6 +23,7 @@ export * from './src/types/dropdown-types.js'; export * from './src/types/dashboard-types.js'; export * from './src/types/api-token-types.js'; export * from './src/types/mcp-types.js'; +export * from './src/types/exec-summary-shared-types.js'; export * from './src/validate-wbs.js'; export * from './src/validate-slack-id.js'; @@ -35,5 +36,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/exec-summary-shared-types.ts b/src/shared/src/types/exec-summary-shared-types.ts new file mode 100644 index 0000000000..690ab119ef --- /dev/null +++ b/src/shared/src/types/exec-summary-shared-types.ts @@ -0,0 +1,157 @@ +import { GraphData } from './statistics-types.js'; +import { TeamType } from './calendar-types.js'; +import { TeamPreview } from './team-types.js'; +import { User } from './user-types.js'; +import { Car } from './project-types.js'; + +export enum Competition { + FSAE = 'FSAE', + FHE = 'FHE' +} + +export enum Term { + FALL = 'FALL', + SPRING = 'SPRING' +} + +export enum DataSource { + AUTO = 'AUTO', + MANUAL = 'MANUAL' +} + +export interface ExecutiveSummary { + executiveSummaryId: string; + car: Car; + seasonStartDate?: Date; + seasonEndDate?: Date; + goals: string; + winsAndImprovements: string; + budgetNotes: string; + recruitmentNotes: string; + dateCreated: Date; + userCreated: User; + dateDeleted?: Date; + deletedBy?: User; + competitionPerformances: CompetitionPerformance[]; + competitionDocumentsSummary?: CompetitionDocumentsSummary; + recruitmentCycles: RecruitmentCycle[]; +} + +export interface CompetitionPerformance { + competitionPerformanceId: string; + executiveSummaryId: string; + competition: Competition; + finalPlace?: number; + totalPointsEarned?: number; + bestStaticEvent?: string; + worstStaticEvent?: string; + bestDynamicEvent?: string; + worstDynamicEvent?: string; + accelerationTopTimeSeconds?: number; + autocrossTopTimeSeconds?: number; + enduranceLapsCompleted?: number; + enduranceAvgLapTimeSeconds?: number; +} + +export interface CompetitionDocumentsSummary { + competitionDocumentsSummaryId: string; + executiveSummaryId: string; + submittedOnTimeCount?: number; + firstSubmissionRejectedCount?: number; + source: DataSource; + dateSynced?: Date; +} + +export interface RecruitmentCycle { + recruitmentCycleId: string; + executiveSummaryId: string; + term: Term; + eventsHeld?: number; + signUps?: number; + onboarded?: number; + activeMembers?: number; + divisionCounts: RecruitmentDivisionCount[]; +} + +export interface RecruitmentDivisionCount { + recruitmentDivisionCountId: string; + recruitmentCycleId: string; + teamType: TeamType; + newMembers?: number; + returningMembers?: number; +} + +export interface CreateExecutiveSummaryArgs { + carId: string; + seasonStartDate?: Date; + seasonEndDate?: Date; + goals?: string; + winsAndImprovements?: string; + budgetNotes?: string; + recruitmentNotes?: string; +} + +export type EditExecutiveSummaryArgs = Omit; + +export interface CreateCompetitionPerformanceArgs { + executiveSummaryId: string; + competition: Competition; + finalPlace?: number; + totalPointsEarned?: number; + bestStaticEvent?: string; + worstStaticEvent?: string; + bestDynamicEvent?: string; + worstDynamicEvent?: string; + accelerationTopTimeSeconds?: number; + autocrossTopTimeSeconds?: number; + enduranceLapsCompleted?: number; + enduranceAvgLapTimeSeconds?: number; +} + +export type EditCompetitionPerformanceArgs = Omit; + +export interface CreateCompetitionDocumentsSummaryArgs { + executiveSummaryId: string; + submittedOnTimeCount?: number; + firstSubmissionRejectedCount?: number; + source?: DataSource; + dateSynced?: Date; +} + +export type EditCompetitionDocumentsSummaryArgs = Omit; + +export interface RecruitmentDivisionCountArgs { + teamTypeId: string; + newMembers: number; + returningMembers: number; +} + +export interface CreateRecruitmentCycleArgs { + executiveSummaryId: string; + term: Term; + eventsHeld: number; + signUps: number; + onboarded: number; + activeMembers: number; + divisionCounts: RecruitmentDivisionCountArgs[]; +} + +export type EditRecruitmentCycleArgs = Omit; + +export interface VehicleDevelopmentProjectSummary { + wbsElementId: string; + name: string; + team: TeamPreview; + startDate: Date; + plannedEndDate: Date; +} + +export interface VehicleDevelopmentSummary { + executiveSummaryId: string; + projects: VehicleDevelopmentProjectSummary[]; +} + +export interface BudgetSummary { + executiveSummaryId: string; + budgetByDivision: GraphData; +} diff --git a/src/shared/src/types/rules-types.ts b/src/shared/src/types/rules-types.ts new file mode 100644 index 0000000000..667a7d4147 --- /dev/null +++ b/src/shared/src/types/rules-types.ts @@ -0,0 +1,122 @@ +/* + * This file is part of NER's FinishLine and licensed under GNU AGPLv3. + * See the LICENSE file in the repository root folder for details. + */ + +export enum RuleStatus { + PENDING = 'PENDING', + PASS = 'PASS', + FAIL = 'FAIL' +} + +export interface RulesetType { + rulesetTypeId: string; + name: string; + lastUpdated: Date; + revisionFiles: Ruleset[]; +} + +export interface Ruleset { + rulesetId: string; + fileId: string; + name: string; + dateCreated: Date; + createdByUserId: string; + 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[]; + referencedRules: Array<{ + ruleId: string; + ruleCode: string; + }>; + teams?: Array<{ + teamId: string; + teamName: string; + }>; + projects?: Array<{ + projectId: string; + projectName: string; + teamIds: string[]; + projectRuleId: string; + status: RuleStatus; + statusUpdatedBy?: { + firstName: string; + lastName: string; + }; + statusUpdatedAt?: Date; + hasStatusHistory: boolean; + }>; + status: RuleStatus; + statusUpdatedBy?: { + firstName: string; + lastName: string; + }; + statusUpdatedAt?: Date; + // true if this rule (general-view status, or its status in any project) has ever been marked PASS or FAIL + hasStatusHistory: boolean; +} + +export interface ProjectRule { + projectRuleId: string; + rule: Rule; + projectId: string; + status: RuleStatus; + statusUpdatedBy?: { + firstName: string; + lastName: string; + }; + statusUpdatedAt?: Date; + // true if this rule has ever been marked PASS or FAIL within this specific project + hasStatusHistory: boolean; +} + +export interface RuleStatusHistoryEntry { + status: RuleStatus; + updatedBy: { + firstName: string; + lastName: string; + }; + updatedAt: Date; + projectName?: string; +} + +export interface RulesetPreview { + name: string; + dateCreated: Date; + active: boolean; + assignedPercentage: number; + car: { + carId: string; + name: string; + }; +} + +// rule whose status was recalculated from its children rather than set directly +export interface RuleStatusRollup { + ruleId: string; + parentRuleId: string | null; + status: RuleStatus; +} + +// rule whose general-view status is updated +export interface RuleStatusUpdate { + rule: Rule; + ancestors: RuleStatusRollup[]; +} diff --git a/src/shared/src/types/statistics-types.ts b/src/shared/src/types/statistics-types.ts index 7ffbaeed65..4b04a4d869 100644 --- a/src/shared/src/types/statistics-types.ts +++ b/src/shared/src/types/statistics-types.ts @@ -17,7 +17,9 @@ export enum GraphType { REIMBURSEMENT_TOTAL_BY_PROJECT = 'REIMBURSEMENT_TOTAL_BY_PROJECT', REIMBURSEMENT_TOTAL_BY_TEAM = 'REIMBURSEMENT_TOTAL_BY_TEAM', PROJECT_BUDGET_VS_REIMBURSED_AMOUNT = 'PROJECT_BUDGET_VS_REIMBURSED_AMOUNT', - CHANGE_REQUESTS_BY_STATUS = 'CHANGE_REQUESTS_BY_STATUS' + CHANGE_REQUESTS_BY_STATUS = 'CHANGE_REQUESTS_BY_STATUS', + ATTENDANCE_BY_TEAM = 'ATTENDANCE_BY_TEAM', + ATTENDANCE_BY_DIVISION = 'ATTENDANCE_BY_DIVISION' } export enum SpecialPermission { diff --git a/src/shared/src/utils.ts b/src/shared/src/utils.ts index 8c3d48185a..b662bff305 100644 --- a/src/shared/src/utils.ts +++ b/src/shared/src/utils.ts @@ -1,5 +1,6 @@ import { WbsNumber } from './types/project-types.js'; import { wbsPipe } from './validate-wbs.js'; +import { GraphType } from './types/statistics-types.js'; export const deeplyCopy = (obj: T | T[], transformer: (obj: T) => T = (obj) => obj): T | T[] => { if (Array.isArray(obj)) { @@ -32,3 +33,11 @@ export const meetingStartTimePipeNumbers = (hours: number[]) => { }; export const MAX_FILE_SIZE = 25 * 1024 * 1024; // 25MB + +/** + * Attendance graphs are always averaged per session and aren't segmented by car, + * so the measure and car options don't apply to them. + */ +export const isAttendanceGraphType = (graphType?: string): boolean => { + return graphType === GraphType.ATTENDANCE_BY_TEAM || graphType === GraphType.ATTENDANCE_BY_DIVISION; +}; diff --git a/yarn.lock b/yarn.lock index 3b76e258b6..a1bebe7325 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9339,6 +9339,7 @@ __metadata: node-ical: "npm:^0.26.1" nodemailer: "npm:^6.9.1" nodemon: "npm:^2.0.16" + pdf-parse-new: "npm:^1.4.1" prisma: "npm:^6.2.1" shared: "npm:1.0.0" supertest: "npm:^6.2.4" @@ -19155,6 +19156,13 @@ __metadata: languageName: node linkType: hard +"node-ensure@npm:^0.0.0": + version: 0.0.0 + resolution: "node-ensure@npm:0.0.0" + checksum: 10/1124623beb1dec66889794286ec1761ac3bfac42ce93b8652903efa5af14227edce5bafa06c4e0675cdc850360931d7c9413e3b5406150f05b2c9bf5bb3ccce4 + languageName: node + linkType: hard + "node-exports-info@npm:^1.6.0": version: 1.6.0 resolution: "node-exports-info@npm:1.6.0" @@ -20002,6 +20010,16 @@ __metadata: languageName: node linkType: hard +"pdf-parse-new@npm:^1.4.1": + version: 1.4.1 + resolution: "pdf-parse-new@npm:1.4.1" + dependencies: + debug: "npm:^4.3.4" + node-ensure: "npm:^0.0.0" + checksum: 10/ac7a64517ce855de5d1da77ec7b0c657e51597c7ba5dfbdf75526447167ea647683aae69ccc721352d29e988ddc6995ae5a5c27036e60c956b076afb032f9272 + languageName: node + linkType: hard + "pdfjs-dist@npm:4.8.69": version: 4.8.69 resolution: "pdfjs-dist@npm:4.8.69"