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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/backend/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down Expand Up @@ -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');
});
Expand Down
15 changes: 15 additions & 0 deletions src/backend/src/controllers/exec-summaries.controllers.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>;

const summary = await ExecSummaryServices.getSingleExecutiveSummary(req.organization, execSummaryId, req.currentUser);
res.status(200).json(summary);
} catch (error: unknown) {
next(error);
}
}
}
32 changes: 32 additions & 0 deletions src/backend/src/prisma-query-args/executive-summary.query-args.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { Prisma } from '@prisma/client';
import { getUserQueryArgs } from './user.query-args.js';

export type ExecutiveSummaryQueryArgs = ReturnType<typeof getExecutiveSummaryQueryArgs>;
export type RecruitmentCycleQueryArgs = ReturnType<typeof getRecruitmentCycleQueryArgs>;
export type RecruitmentDivisionCountQueryArgs = ReturnType<typeof getRecruitmentDivisionCountQueryArgs>;

export const getExecutiveSummaryQueryArgs = (organizationId: string) =>
Prisma.validator<Prisma.Executive_SummaryDefaultArgs>()({
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<Prisma.Recruitment_CycleDefaultArgs>()({
include: {
divisionCounts: getRecruitmentDivisionCountQueryArgs()
}
});

export const getRecruitmentDivisionCountQueryArgs = () =>
Prisma.validator<Prisma.Recruitment_Division_CountDefaultArgs>()({
include: {
teamType: true
}
});
8 changes: 8 additions & 0 deletions src/backend/src/routes/exec-summaries.routes.ts
Original file line number Diff line number Diff line change
@@ -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;
38 changes: 38 additions & 0 deletions src/backend/src/services/exec-summaries.services.ts
Original file line number Diff line number Diff line change
@@ -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);
}
}
104 changes: 104 additions & 0 deletions src/backend/src/transformers/executive-summary.transformer.ts
Original file line number Diff line number Diff line change
@@ -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<ExecutiveSummaryQueryArgs>
): 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<null>
): 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<null>
): 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<RecruitmentCycleQueryArgs>
): 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<RecruitmentDivisionCountQueryArgs>
): RecruitmentDivisionCount => {
return {
recruitmentDivisionCountId: divisionCount.recruitmentDivisionCountId,
recruitmentCycleId: divisionCount.recruitmentCycleId,
teamType: teamTypeTransformer(divisionCount.teamType),
newMembers: divisionCount.newMembers ?? undefined,
returningMembers: divisionCount.returningMembers ?? undefined
};
};
3 changes: 2 additions & 1 deletion src/backend/src/utils/errors.utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -220,4 +220,5 @@ export type ExceptionObjectNames =
| 'Task Label'
| 'Notification Channel'
| 'Dashboard'
| 'Team Join Request';
| 'Team Join Request'
| 'Executive Summary';
5 changes: 5 additions & 0 deletions src/backend/tests/test-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
48 changes: 48 additions & 0 deletions src/backend/tests/unit/exec-summaries.test.ts
Original file line number Diff line number Diff line change
@@ -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')
);
});
});
});
7 changes: 5 additions & 2 deletions src/shared/src/types/exec-summary-shared-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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;
Expand Down
Loading