diff --git a/openapi/openapi.yaml b/openapi/openapi.yaml index 38bee688..a2aad755 100644 --- a/openapi/openapi.yaml +++ b/openapi/openapi.yaml @@ -115,7 +115,7 @@ paths: $ref: "#/components/responses/InternalServerError" /capital-projects/{managingCode}/{capitalProjectId}/capital-commitments: get: - summary: 🚧 Find capital commitments associated with a specific capital project + summary: Find capital commitments associated with a specific capital project operationId: findCapitalCommitmentsByManagingCodeCapitalProjectId tags: - Capital Commitments @@ -761,12 +761,14 @@ components: type: string description: A string used to refer to the budget line. example: '0002Q' - sponsoringAgencies: + sponsoringAgency: type: string + nullable: true description: A string of variable length containing the initials of the sponsoring agency. example: DOT budgetType: type: string + nullable: true description: A string of variable length denoting the type of budget. example: 'Highways' totalValue: @@ -779,7 +781,7 @@ components: - plannedDate - budgetLineCode - budgetLineId - - sponsoringAgencies + - sponsoringAgency - budgetType - totalValue CapitalProjectCategory: diff --git a/src/capital-project/capital-project.controller.ts b/src/capital-project/capital-project.controller.ts index bc2032e8..942fde2e 100644 --- a/src/capital-project/capital-project.controller.ts +++ b/src/capital-project/capital-project.controller.ts @@ -8,8 +8,10 @@ import { } from "@nestjs/common"; import { Response } from "express"; import { + FindCapitalCommitmentsByManagingCodeCapitalProjectIdPathParams, FindCapitalProjectByManagingCodeCapitalProjectIdPathParams, FindCapitalProjectTilesPathParams, + findCapitalCommitmentsByManagingCodeCapitalProjectIdPathParamsSchema, findCapitalProjectByManagingCodeCapitalProjectIdPathParamsSchema, findCapitalProjectTilesPathParamsSchema, } from "src/gen"; @@ -20,7 +22,6 @@ import { NotFoundExceptionFilter, } from "src/filter"; import { ZodTransformPipe } from "src/pipes/zod-transform-pipe"; - @UseFilters( BadRequestExceptionFilter, InternalServerErrorExceptionFilter, @@ -54,4 +55,19 @@ export class CapitalProjectController { res.set("Content-Type", "application/x-protobuf"); res.send(tile); } + + @UsePipes( + new ZodTransformPipe( + findCapitalCommitmentsByManagingCodeCapitalProjectIdPathParamsSchema, + ), + ) + @Get("/:managingCode/:capitalProjectId/capital-commitments") + async findCapitalCommitmentsByManagingCodeCapitalProjectId( + @Param() + params: FindCapitalCommitmentsByManagingCodeCapitalProjectIdPathParams, + ) { + return await this.capitalProjectService.findCapitalCommitmentsByManagingCodeCapitalProjectId( + params, + ); + } } diff --git a/src/capital-project/capital-project.repository.schema.ts b/src/capital-project/capital-project.repository.schema.ts index 6edf9f7b..87712ef0 100644 --- a/src/capital-project/capital-project.repository.schema.ts +++ b/src/capital-project/capital-project.repository.schema.ts @@ -1,12 +1,23 @@ import { agencyBudgetEntitySchema, agencyEntitySchema, + capitalCommitmentEntitySchema, capitalCommitmentFundEntitySchema, capitalProjectEntitySchema, } from "src/schema"; import { mvtEntitySchema } from "src/schema/mvt"; import { z } from "zod"; +export const checkByManagingCodeCapitalProjectIdRepoSchema = + capitalProjectEntitySchema.pick({ + id: true, + managingCode: true, + }); + +export type CheckByManagingCodeCapitalProjectIdRepo = z.infer< + typeof checkByManagingCodeCapitalProjectIdRepoSchema +>; + export const findByManagingCodeCapitalProjectIdRepoSchema = z.array( capitalProjectEntitySchema.extend({ sponsoringAgencies: z.array(agencyEntitySchema.shape.initials), @@ -22,3 +33,24 @@ export type FindByManagingCodeCapitalProjectIdRepo = z.infer< export const findTilesRepoSchema = mvtEntitySchema; export type FindTilesRepo = z.infer; + +export const findCapitalCommitmentsByManagingCodeCapitalProjectIdRepoSchema = + z.array( + capitalCommitmentEntitySchema + .pick({ + id: true, + type: true, + plannedDate: true, + budgetLineCode: true, + budgetLineId: true, + }) + .extend({ + sponsoringAgency: agencyBudgetEntitySchema.shape.sponsor, + budgetType: agencyBudgetEntitySchema.shape.type, + totalValue: capitalCommitmentFundEntitySchema.shape.value, + }), + ); + +export type FindCapitalCommitmentsByManagingCodeCapitalProjectIdRepo = z.infer< + typeof findCapitalCommitmentsByManagingCodeCapitalProjectIdRepoSchema +>; diff --git a/src/capital-project/capital-project.repository.ts b/src/capital-project/capital-project.repository.ts index e57182e9..8d7d2225 100644 --- a/src/capital-project/capital-project.repository.ts +++ b/src/capital-project/capital-project.repository.ts @@ -2,18 +2,22 @@ import { Inject } from "@nestjs/common"; import { isNotNull, sql, and, eq, sum } from "drizzle-orm"; import { DataRetrievalException } from "src/exception"; import { + FindCapitalCommitmentsByManagingCodeCapitalProjectIdPathParams, FindCapitalProjectByManagingCodeCapitalProjectIdPathParams, FindCapitalProjectTilesPathParams, } from "src/gen"; import { DB, DbType } from "src/global/providers/db.provider"; import { agencyBudget, + budgetLine, capitalCommitment, capitalCommitmentFund, capitalProject, } from "src/schema"; import { + CheckByManagingCodeCapitalProjectIdRepo, FindByManagingCodeCapitalProjectIdRepo, + FindCapitalCommitmentsByManagingCodeCapitalProjectIdRepo, FindTilesRepo, } from "./capital-project.repository.schema"; @@ -23,6 +27,30 @@ export class CapitalProjectRepository { private readonly db: DbType, ) {} + #checkByManagingCodeCapitalProjectId = this.db.query.capitalProject + .findFirst({ + columns: { + managingCode: true, + id: true, + }, + where: (capitalProject, { and, eq, sql }) => + and( + eq(capitalProject.managingCode, sql.placeholder("managingCode")), + eq(capitalProject.id, sql.placeholder("capitalProjectId")), + ), + }) + .prepare("checkByManagingCodeCapitalProjectId"); + + async checkByManagingCodeCapitalProjectId( + managingCode: string, + capitalProjectId: string, + ): Promise { + return await this.#checkByManagingCodeCapitalProjectId.execute({ + managingCode, + capitalProjectId, + }); + } + async findByManagingCodeCapitalProjectId( params: FindCapitalProjectByManagingCodeCapitalProjectIdPathParams, ): Promise { @@ -121,4 +149,45 @@ export class CapitalProjectRepository { throw new DataRetrievalException(); } } + + async findCapitalCommitmentsByManagingCodeCapitalProjectId( + params: FindCapitalCommitmentsByManagingCodeCapitalProjectIdPathParams, + ): Promise { + const { managingCode, capitalProjectId } = params; + try { + return await this.db + .select({ + id: capitalCommitment.id, + type: capitalCommitment.type, + plannedDate: capitalCommitment.plannedDate, + budgetLineCode: capitalCommitment.budgetLineCode, + budgetLineId: capitalCommitment.budgetLineId, + sponsoringAgency: sql`${agencyBudget.sponsor}`, + budgetType: sql`${agencyBudget.type}`, + totalValue: sql`${capitalCommitmentFund.value}`.mapWith(Number), + }) + .from(capitalCommitment) + .leftJoin( + budgetLine, + and( + eq(budgetLine.id, capitalCommitment.budgetLineId), + eq(budgetLine.code, capitalCommitment.budgetLineCode), + ), + ) + .leftJoin(agencyBudget, eq(agencyBudget.code, budgetLine.code)) + .leftJoin( + capitalCommitmentFund, + eq(capitalCommitmentFund.capitalCommitmentId, capitalCommitment.id), + ) + .where( + and( + eq(capitalCommitmentFund.category, "total"), + eq(capitalCommitment.managingCode, managingCode), + eq(capitalCommitment.capitalProjectId, capitalProjectId), + ), + ); + } catch { + throw new DataRetrievalException(); + } + } } diff --git a/src/capital-project/capital-project.service.spec.ts b/src/capital-project/capital-project.service.spec.ts index 5484663d..a83189f4 100644 --- a/src/capital-project/capital-project.service.spec.ts +++ b/src/capital-project/capital-project.service.spec.ts @@ -3,6 +3,7 @@ import { CapitalProjectService } from "./capital-project.service"; import { Test } from "@nestjs/testing"; import { CapitalProjectRepository } from "./capital-project.repository"; import { + findCapitalCommitmentsByManagingCodeCapitalProjectIdQueryResponseSchema, findCapitalProjectByManagingCodeCapitalProjectIdQueryResponseSchema, findCapitalProjectTilesQueryResponseSchema, } from "src/gen"; @@ -69,4 +70,35 @@ describe("CapitalProjectService", () => { ).not.toThrow(); }); }); + + describe("findCapitalCommitmentsByManagingCodeCapitalProjectId", () => { + it("should return capital commitments for a capital project", async () => { + const { id: capitalProjectId, managingCode } = + capitalProjectRepository.checkByManagingCodeCapitalProjectIdMocks[0]; + const result = + await capitalProjectService.findCapitalCommitmentsByManagingCodeCapitalProjectId( + { capitalProjectId, managingCode }, + ); + + expect(() => + findCapitalCommitmentsByManagingCodeCapitalProjectIdQueryResponseSchema.parse( + result, + ), + ).not.toThrow(); + }); + + it.only("should throw a resource error when requesting a missing project", async () => { + const missingManagingCode = "725"; + const missingCapitalProjectId = "JIRO"; + + expect( + capitalProjectService.findCapitalCommitmentsByManagingCodeCapitalProjectId( + { + managingCode: missingManagingCode, + capitalProjectId: missingCapitalProjectId, + }, + ), + ).rejects.toThrow(ResourceNotFoundException); + }); + }); }); diff --git a/src/capital-project/capital-project.service.ts b/src/capital-project/capital-project.service.ts index f8604ac3..279dcc24 100644 --- a/src/capital-project/capital-project.service.ts +++ b/src/capital-project/capital-project.service.ts @@ -1,4 +1,5 @@ import { + FindCapitalCommitmentsByManagingCodeCapitalProjectIdPathParams, FindCapitalProjectByManagingCodeCapitalProjectIdPathParams, FindCapitalProjectTilesPathParams, } from "src/gen"; @@ -27,4 +28,25 @@ export class CapitalProjectService { async findTiles(params: FindCapitalProjectTilesPathParams) { return await this.capitalProjectRepository.findTiles(params); } + + async findCapitalCommitmentsByManagingCodeCapitalProjectId( + params: FindCapitalCommitmentsByManagingCodeCapitalProjectIdPathParams, + ) { + const capitalProjectCheck = + await this.capitalProjectRepository.checkByManagingCodeCapitalProjectId( + params.managingCode, + params.capitalProjectId, + ); + if (capitalProjectCheck === undefined) + throw new ResourceNotFoundException(); + + const capitalCommitments = + await this.capitalProjectRepository.findCapitalCommitmentsByManagingCodeCapitalProjectId( + params, + ); + + return { + capitalCommitments, + }; + } } diff --git a/src/gen/schemas/CapitalCommitment.json b/src/gen/schemas/CapitalCommitment.json index 4f4e1871..1205e909 100644 --- a/src/gen/schemas/CapitalCommitment.json +++ b/src/gen/schemas/CapitalCommitment.json @@ -28,15 +28,17 @@ "type": "string", "example": "0002Q" }, - "sponsoringAgencies": { + "sponsoringAgency": { "description": "A string of variable length containing the initials of the sponsoring agency.", "type": "string", - "example": "DOT" + "example": "DOT", + "nullable": true }, "budgetType": { "description": "A string of variable length denoting the type of budget.", "type": "string", - "example": "Highways" + "example": "Highways", + "nullable": true }, "totalValue": { "description": "A numeric string used to refer to the amount of total planned commitments.", @@ -50,7 +52,7 @@ "plannedDate", "budgetLineCode", "budgetLineId", - "sponsoringAgencies", + "sponsoringAgency", "budgetType", "totalValue" ], diff --git a/src/gen/types/CapitalCommitment.ts b/src/gen/types/CapitalCommitment.ts index 4e8f1ea1..b67c62c7 100644 --- a/src/gen/types/CapitalCommitment.ts +++ b/src/gen/types/CapitalCommitment.ts @@ -28,12 +28,12 @@ export type CapitalCommitment = { * @description A string of variable length containing the initials of the sponsoring agency. * @type string */ - sponsoringAgencies: string; + sponsoringAgency: string | null; /** * @description A string of variable length denoting the type of budget. * @type string */ - budgetType: string; + budgetType: string | null; /** * @description A numeric string used to refer to the amount of total planned commitments. * @type number diff --git a/src/gen/zod/capitalCommitmentSchema.ts b/src/gen/zod/capitalCommitmentSchema.ts index 8ff6091a..b0677dc0 100644 --- a/src/gen/zod/capitalCommitmentSchema.ts +++ b/src/gen/zod/capitalCommitmentSchema.ts @@ -21,14 +21,16 @@ export const capitalCommitmentSchema = z.object({ budgetLineId: z.coerce .string() .describe("A string used to refer to the budget line."), - sponsoringAgencies: z.coerce + sponsoringAgency: z.coerce .string() .describe( "A string of variable length containing the initials of the sponsoring agency.", - ), + ) + .nullable(), budgetType: z.coerce .string() - .describe("A string of variable length denoting the type of budget."), + .describe("A string of variable length denoting the type of budget.") + .nullable(), totalValue: z.coerce .number() .describe( diff --git a/src/schema/agency-budget.ts b/src/schema/agency-budget.ts index 1d86176c..1b5a39c0 100644 --- a/src/schema/agency-budget.ts +++ b/src/schema/agency-budget.ts @@ -4,8 +4,10 @@ import { agency } from "./agency"; export const agencyBudget = pgTable("agency_budget", { code: text("code").primaryKey(), - type: text("type"), - sponsor: text("sponsor").references(() => agency.initials), + type: text("type").notNull(), + sponsor: text("sponsor") + .notNull() + .references(() => agency.initials), }); export const agencyBudgetEntitySchema = z.object({ diff --git a/src/schema/capital-commitment-fund.ts b/src/schema/capital-commitment-fund.ts index 19ad8ca3..4bf439f6 100644 --- a/src/schema/capital-commitment-fund.ts +++ b/src/schema/capital-commitment-fund.ts @@ -9,7 +9,7 @@ export const capitalCommitmentFund = pgTable("capital_commitment_fund", { () => capitalCommitment.id, ), category: capitalFundCategoryEnum("capital_fund_category"), - value: numeric("value"), + value: numeric("value").notNull(), }); export const capitalCommitmentFundEntitySchema = z.object({ diff --git a/src/schema/capital-commitment.ts b/src/schema/capital-commitment.ts index 5cad2804..75256196 100644 --- a/src/schema/capital-commitment.ts +++ b/src/schema/capital-commitment.ts @@ -16,14 +16,14 @@ export const capitalCommitment = pgTable( "capital_commitment", { id: uuid("id").primaryKey(), - type: char("type", { length: 4 }).references( - () => capitalCommitmentType.code, - ), - plannedDate: date("planned_date"), + type: char("type", { length: 4 }) + .notNull() + .references(() => capitalCommitmentType.code), + plannedDate: date("planned_date").notNull(), managingCode: char("managing_code", { length: 3 }), capitalProjectId: text("capital_project_id"), - budgetLineCode: text("budget_line_code"), - budgetLineId: text("budget_line_id"), + budgetLineCode: text("budget_line_code").notNull(), + budgetLineId: text("budget_line_id").notNull(), }, (table) => { return { @@ -42,7 +42,7 @@ export const capitalCommitment = pgTable( export const capitalCommitmentEntitySchema = z.object({ id: z.string().uuid(), type: z.string().length(4), - plannedDate: z.date(), + plannedDate: z.string().date(), managingCode: managingCodeEntitySchema, capitalProjectId: z.string(), budgetLineCode: z.string(), diff --git a/test/capital-project/capital-project.e2e-spec.ts b/test/capital-project/capital-project.e2e-spec.ts index f5fcc044..4de834ea 100644 --- a/test/capital-project/capital-project.e2e-spec.ts +++ b/test/capital-project/capital-project.e2e-spec.ts @@ -145,4 +145,22 @@ describe("Capital Projects", () => { expect(response.body.error).toBe(HttpName.INTERNAL_SEVER_ERROR); }); }); + + describe.skip("findCapitalCommitmentsByManagingCodeCapitalProjectId", () => { + // it("should 200 and return a capital project with budget details", async () => { + // const capitalProjectMock = + // capitalProjectRepository.findCapitalCommitmentsByManagingCodeCapitalProjectId; + // const { managingCode, id: capitalProjectId } = capitalProjectMock; + // const response = await request(app.getHttpServer()) + // .get(`/capital-projects/${managingCode}/${capitalProjectId}`) + // .expect(200); + + // expect(() => + // findCapitalProjectByManagingCodeCapitalProjectIdQueryResponseSchema.parse( + // response.body, + // ), + // ).not.toThrow(); + // }); + return 200; + }); }); diff --git a/test/capital-project/capital-project.repository.mock.ts b/test/capital-project/capital-project.repository.mock.ts index a70c6d4c..904990e5 100644 --- a/test/capital-project/capital-project.repository.mock.ts +++ b/test/capital-project/capital-project.repository.mock.ts @@ -1,12 +1,34 @@ import { generateMock } from "@anatine/zod-mock"; import { + checkByManagingCodeCapitalProjectIdRepoSchema, FindByManagingCodeCapitalProjectIdRepo, findByManagingCodeCapitalProjectIdRepoSchema, + FindCapitalCommitmentsByManagingCodeCapitalProjectIdRepo, + findCapitalCommitmentsByManagingCodeCapitalProjectIdRepoSchema, findTilesRepoSchema, } from "src/capital-project/capital-project.repository.schema"; -import { FindCapitalProjectByManagingCodeCapitalProjectIdPathParams } from "src/gen"; +import { + FindCapitalCommitmentsByManagingCodeCapitalProjectIdPathParams, + FindCapitalProjectByManagingCodeCapitalProjectIdPathParams, +} from "src/gen"; +// nothing to see ehre export class CapitalProjectRepositoryMock { + checkByManagingCodeCapitalProjectIdMocks = Array.from(Array(5), (_, seed) => + generateMock(checkByManagingCodeCapitalProjectIdRepoSchema, { + seed: seed + 1, + }), + ); + + async checkByManagingCodeCapitalProjectId( + managingCode: string, + capitalProjectId: string, + ) { + return this.checkByManagingCodeCapitalProjectIdMocks.find((row) => { + return row.id === capitalProjectId && row.managingCode === managingCode; + }); + } + findByManagingCodeCapitalProjectIdMock = generateMock( findByManagingCodeCapitalProjectIdRepoSchema, { @@ -18,6 +40,37 @@ export class CapitalProjectRepositoryMock { }, ); + findCapitalCommitmentsByManagingCodeCapitalProjectIdMocks = + this.checkByManagingCodeCapitalProjectIdMocks.map( + (checkCapitalCommitment) => { + return { + [`${checkCapitalCommitment.managingCode}${checkCapitalCommitment.id}`]: + generateMock( + findCapitalCommitmentsByManagingCodeCapitalProjectIdRepoSchema, + { + stringMap: { + plannedDate: () => "2045-01-01", + }, + }, + ), + }; + }, + ); + + async findCapitalCommitmentsByManagingCodeCapitalProjectId({ + managingCode, + capitalProjectId, + }: FindCapitalCommitmentsByManagingCodeCapitalProjectIdPathParams): Promise { + const compositeKey = `${managingCode}${capitalProjectId}`; + const results = + this.findCapitalCommitmentsByManagingCodeCapitalProjectIdMocks.find( + (capitalProjectCapitalCommitments) => + compositeKey in capitalProjectCapitalCommitments, + ); + + return results === undefined ? [] : results[compositeKey]; + } + async findByManagingCodeCapitalProjectId({ managingCode, capitalProjectId,