diff --git a/src/backend/index.ts b/src/backend/index.ts index cde8783ab0..b442f83c27 100644 --- a/src/backend/index.ts +++ b/src/backend/index.ts @@ -34,6 +34,7 @@ import agentRouter from './src/routes/agent.routes.js'; import { mcpNodeHandler } from './src/mcp/handler.js'; import { attachAuthInfo, requireApiToken } from './src/utils/mcp-auth.utils.js'; import dashboardsRouter from './src/routes/dashboards.routes.js'; +import execSummaryRouter from './src/routes/exec-summaries.routes.js'; const app = express(); @@ -140,6 +141,7 @@ app.use('/calendar', calendarRouter); app.use('/prospective-sponsors', prospectiveSponsorRouter); app.use('/attendance', attendanceRouter); app.use('/dashboards', dashboardsRouter); +app.use('/executive-summaries', execSummaryRouter); app.use('/', (_req, res) => { res.status(200).json('Welcome to FinishLine'); }); diff --git a/src/backend/src/controllers/exec-summaries.controllers.ts b/src/backend/src/controllers/exec-summaries.controllers.ts new file mode 100644 index 0000000000..bc53e37f6f --- /dev/null +++ b/src/backend/src/controllers/exec-summaries.controllers.ts @@ -0,0 +1,15 @@ +import { NextFunction, Request, Response } from 'express'; +import ExecSummaryServices from '../services/exec-summaries.services.js'; + +export default class ExecSummaryController { + static async getSingleExecutiveSummary(req: Request, res: Response, next: NextFunction) { + try { + const { execSummaryId } = req.params as Record; + + const summary = await ExecSummaryServices.getSingleExecutiveSummary(req.organization, execSummaryId, req.currentUser); + res.status(200).json(summary); + } catch (error: unknown) { + next(error); + } + } +} diff --git a/src/backend/src/prisma-query-args/executive-summary.query-args.ts b/src/backend/src/prisma-query-args/executive-summary.query-args.ts new file mode 100644 index 0000000000..76a5520b3d --- /dev/null +++ b/src/backend/src/prisma-query-args/executive-summary.query-args.ts @@ -0,0 +1,32 @@ +import { Prisma } from '@prisma/client'; +import { getUserQueryArgs } from './user.query-args.js'; + +export type ExecutiveSummaryQueryArgs = ReturnType; +export type RecruitmentCycleQueryArgs = ReturnType; +export type RecruitmentDivisionCountQueryArgs = ReturnType; + +export const getExecutiveSummaryQueryArgs = (organizationId: string) => + Prisma.validator()({ + include: { + car: { select: { wbsElement: { select: { organizationId: true, name: true } } } }, + userCreated: getUserQueryArgs(organizationId), + deletedBy: getUserQueryArgs(organizationId), + competitionPerformances: true, + competitionDocumentsSummary: true, + recruitmentCycles: getRecruitmentCycleQueryArgs() + } + }); + +export const getRecruitmentCycleQueryArgs = () => + Prisma.validator()({ + include: { + divisionCounts: getRecruitmentDivisionCountQueryArgs() + } + }); + +export const getRecruitmentDivisionCountQueryArgs = () => + Prisma.validator()({ + include: { + teamType: true + } + }); diff --git a/src/backend/src/routes/exec-summaries.routes.ts b/src/backend/src/routes/exec-summaries.routes.ts new file mode 100644 index 0000000000..7c3e6f7d82 --- /dev/null +++ b/src/backend/src/routes/exec-summaries.routes.ts @@ -0,0 +1,8 @@ +import express from 'express'; +import ExecSummaryController from '../controllers/exec-summaries.controllers.js'; + +const execSummaryRouter = express.Router(); + +execSummaryRouter.get('/:execSummaryId', ExecSummaryController.getSingleExecutiveSummary); + +export default execSummaryRouter; diff --git a/src/backend/src/services/exec-summaries.services.ts b/src/backend/src/services/exec-summaries.services.ts new file mode 100644 index 0000000000..eff718221d --- /dev/null +++ b/src/backend/src/services/exec-summaries.services.ts @@ -0,0 +1,38 @@ +import { Organization } from '@prisma/client'; +import { notGuest, User } from 'shared'; +import prisma from '../prisma/prisma.js'; +import { + AccessDeniedException, + DeletedException, + InvalidOrganizationException, + NotFoundException +} from '../utils/errors.utils.js'; +import { getExecutiveSummaryQueryArgs } from '../prisma-query-args/executive-summary.query-args.js'; +import { executiveSummaryTransformer } from '../transformers/executive-summary.transformer.js'; +import { userHasPermission } from '../utils/users.utils.js'; + +export default class ExecSummaryServices { + static async getSingleExecutiveSummary(organization: Organization, executiveSummaryId: string, viewer: User) { + const hasPermission = await userHasPermission(viewer.userId, organization.organizationId, notGuest); + if (!hasPermission) { + throw new AccessDeniedException('Only members can view executive summaries'); + } + + const executiveSummary = await prisma.executive_Summary.findUnique({ + where: { executiveSummaryId }, + ...getExecutiveSummaryQueryArgs(organization.organizationId) + }); + + if (!executiveSummary) { + throw new NotFoundException('Executive Summary', executiveSummaryId); + } + if (executiveSummary.car.wbsElement.organizationId !== organization.organizationId) { + throw new InvalidOrganizationException('Executive Summary'); + } + if (executiveSummary.dateDeleted) { + throw new DeletedException('Executive Summary', executiveSummaryId); + } + + return executiveSummaryTransformer(executiveSummary); + } +} diff --git a/src/backend/src/transformers/executive-summary.transformer.ts b/src/backend/src/transformers/executive-summary.transformer.ts new file mode 100644 index 0000000000..257ba23e2a --- /dev/null +++ b/src/backend/src/transformers/executive-summary.transformer.ts @@ -0,0 +1,104 @@ +import { Prisma } from '@prisma/client'; +import { + CompetitionDocumentsSummary, + CompetitionPerformance, + DataSource, + ExecutiveSummary, + RecruitmentCycle, + RecruitmentDivisionCount, + Competition, + Term +} from 'shared'; +import { + ExecutiveSummaryQueryArgs, + RecruitmentCycleQueryArgs, + RecruitmentDivisionCountQueryArgs +} from '../prisma-query-args/executive-summary.query-args.js'; +import { teamTypeTransformer } from './team-types.transformer.js'; +import { userTransformer } from './user.transformer.js'; + +export const executiveSummaryTransformer = ( + executiveSummary: Prisma.Executive_SummaryGetPayload +): ExecutiveSummary => { + return { + executiveSummaryId: executiveSummary.executiveSummaryId, + car: { name: executiveSummary.car.wbsElement.name }, + seasonStartDate: executiveSummary.seasonStartDate ?? undefined, + seasonEndDate: executiveSummary.seasonEndDate ?? undefined, + goals: executiveSummary.goals, + winsAndImprovements: executiveSummary.winsAndImprovements, + budgetNotes: executiveSummary.budgetNotes, + recruitmentNotes: executiveSummary.recruitmentNotes, + dateCreated: executiveSummary.dateCreated, + userCreated: userTransformer(executiveSummary.userCreated), + dateDeleted: executiveSummary.dateDeleted ?? undefined, + deletedBy: executiveSummary.deletedBy ? userTransformer(executiveSummary.deletedBy) : undefined, + competitionPerformances: executiveSummary.competitionPerformances.map(competitionPerformanceTransformer), + competitionDocumentsSummary: executiveSummary.competitionDocumentsSummary + ? competitionDocumentsSummaryTransformer(executiveSummary.competitionDocumentsSummary) + : undefined, + recruitmentCycles: executiveSummary.recruitmentCycles.map(recruitmentCycleTransformer) + }; +}; + +export const competitionPerformanceTransformer = ( + competitionPerformance: Prisma.Competition_PerformanceGetPayload +): CompetitionPerformance => { + return { + competitionPerformanceId: competitionPerformance.competitionPerformanceId, + executiveSummaryId: competitionPerformance.executiveSummaryId, + competition: competitionPerformance.competition as Competition, + finalPlace: competitionPerformance.finalPlace ?? undefined, + totalPointsEarned: competitionPerformance.totalPointsEarned ?? undefined, + maxPoints: competitionPerformance.maxPoints ?? undefined, + bestStaticEvent: competitionPerformance.bestStaticEvent ?? undefined, + worstStaticEvent: competitionPerformance.worstStaticEvent ?? undefined, + bestDynamicEvent: competitionPerformance.bestDynamicEvent ?? undefined, + worstDynamicEvent: competitionPerformance.worstDynamicEvent ?? undefined, + accelerationTopTimeSeconds: competitionPerformance.accelerationTopTimeSeconds ?? undefined, + autocrossTopTimeSeconds: competitionPerformance.autocrossTopTimeSeconds ?? undefined, + enduranceLapsCompleted: competitionPerformance.enduranceLapsCompleted ?? undefined, + maxLaps: competitionPerformance.maxLaps ?? undefined, + enduranceAvgLapTimeSeconds: competitionPerformance.enduranceAvgLapTimeSeconds ?? undefined + }; +}; + +export const competitionDocumentsSummaryTransformer = ( + competitionDocumentsSummary: Prisma.Competition_Documents_SummaryGetPayload +): CompetitionDocumentsSummary => { + return { + competitionDocumentsSummaryId: competitionDocumentsSummary.competitionDocumentsSummaryId, + executiveSummaryId: competitionDocumentsSummary.executiveSummaryId, + submittedOnTimeCount: competitionDocumentsSummary.submittedOnTimeCount ?? undefined, + firstSubmissionRejectedCount: competitionDocumentsSummary.firstSubmissionRejectedCount ?? undefined, + source: competitionDocumentsSummary.source as DataSource, + dateSynced: competitionDocumentsSummary.dateSynced ?? undefined + }; +}; + +export const recruitmentCycleTransformer = ( + recruitmentCycle: Prisma.Recruitment_CycleGetPayload +): RecruitmentCycle => { + return { + recruitmentCycleId: recruitmentCycle.recruitmentCycleId, + executiveSummaryId: recruitmentCycle.executiveSummaryId, + term: recruitmentCycle.term as Term, + eventsHeld: recruitmentCycle.eventsHeld ?? undefined, + signUps: recruitmentCycle.signUps ?? undefined, + onboarded: recruitmentCycle.onboarded ?? undefined, + activeMembers: recruitmentCycle.activeMembers ?? undefined, + divisionCounts: recruitmentCycle.divisionCounts.map(recruitmentDivisionCountTransformer) + }; +}; + +export const recruitmentDivisionCountTransformer = ( + divisionCount: Prisma.Recruitment_Division_CountGetPayload +): RecruitmentDivisionCount => { + return { + recruitmentDivisionCountId: divisionCount.recruitmentDivisionCountId, + recruitmentCycleId: divisionCount.recruitmentCycleId, + teamType: teamTypeTransformer(divisionCount.teamType), + newMembers: divisionCount.newMembers ?? undefined, + returningMembers: divisionCount.returningMembers ?? undefined + }; +}; diff --git a/src/backend/src/utils/errors.utils.ts b/src/backend/src/utils/errors.utils.ts index a83c036a1b..a2bf10440b 100644 --- a/src/backend/src/utils/errors.utils.ts +++ b/src/backend/src/utils/errors.utils.ts @@ -220,4 +220,5 @@ export type ExceptionObjectNames = | 'Task Label' | 'Notification Channel' | 'Dashboard' - | 'Team Join Request'; + | 'Team Join Request' + | 'Executive Summary'; diff --git a/src/backend/tests/test-utils.ts b/src/backend/tests/test-utils.ts index c90ffabb4d..ffa8788f7b 100644 --- a/src/backend/tests/test-utils.ts +++ b/src/backend/tests/test-utils.ts @@ -134,6 +134,11 @@ export const resetUsers = async () => { await prisma.reimbursement.deleteMany(); await prisma.vendor.deleteMany(); await prisma.account_Code.deleteMany(); + await prisma.recruitment_Division_Count.deleteMany(); + await prisma.recruitment_Cycle.deleteMany(); + await prisma.competition_Documents_Summary.deleteMany(); + await prisma.competition_Performance.deleteMany(); + await prisma.executive_Summary.deleteMany(); await prisma.car.deleteMany(); await prisma.task_Label.deleteMany(); await prisma.task.deleteMany(); diff --git a/src/backend/tests/unit/exec-summaries.test.ts b/src/backend/tests/unit/exec-summaries.test.ts new file mode 100644 index 0000000000..137cd5d133 --- /dev/null +++ b/src/backend/tests/unit/exec-summaries.test.ts @@ -0,0 +1,48 @@ +import { createTestCar, createTestOrganization, createTestUser, resetUsers } from '../test-utils.js'; +import { Car, Organization, User } from '@prisma/client'; +import prisma from '../../src/prisma/prisma.js'; +import { supermanAdmin } from '../test-data/users.test-data.js'; +import ExecSummaryServices from '../../src/services/exec-summaries.services.js'; +import { executiveSummaryTransformer } from '../../src/transformers/executive-summary.transformer.js'; +import { getExecutiveSummaryQueryArgs } from '../../src/prisma-query-args/executive-summary.query-args.js'; +import { NotFoundException } from '../../src/utils/errors.utils.js'; + +describe('Executive Summaries Tests', () => { + let organization: Organization; + let user: User; + let car: Car; + + beforeEach(async () => { + organization = await createTestOrganization(); + user = await createTestUser(supermanAdmin, organization.organizationId); + car = await createTestCar(organization.organizationId, user.userId); + }); + + afterEach(async () => { + await resetUsers(); + }); + + describe('Get single executive summary', () => { + it('successful get single exec summary', async () => { + const createdSummary = await prisma.executive_Summary.create({ + data: { + carId: car.carId, + userCreatedId: user.userId + }, + ...getExecutiveSummaryQueryArgs(organization.organizationId) + }); + + const summary = await ExecSummaryServices.getSingleExecutiveSummary( + organization, + createdSummary.executiveSummaryId, + user + ); + expect(summary).toStrictEqual(executiveSummaryTransformer(createdSummary)); + }); + it('invalid id get single exec summary', async () => { + await expect(async () => ExecSummaryServices.getSingleExecutiveSummary(organization, 'badid', user)).rejects.toThrow( + new NotFoundException('Executive Summary', 'badid') + ); + }); + }); +}); diff --git a/src/shared/src/types/exec-summary-shared-types.ts b/src/shared/src/types/exec-summary-shared-types.ts index 91fe2965da..5b0ae34e7f 100644 --- a/src/shared/src/types/exec-summary-shared-types.ts +++ b/src/shared/src/types/exec-summary-shared-types.ts @@ -2,7 +2,6 @@ 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', @@ -19,9 +18,13 @@ export enum DataSource { MANUAL = 'MANUAL' } +export interface ExecutiveSummaryCar { + name: string; +} + export interface ExecutiveSummary { executiveSummaryId: string; - car: Car; + car: ExecutiveSummaryCar; seasonStartDate?: Date; seasonEndDate?: Date; goals: string;