diff --git a/openapi/openapi.yaml b/openapi/openapi.yaml index a04d142d..38bee688 100644 --- a/openapi/openapi.yaml +++ b/openapi/openapi.yaml @@ -91,7 +91,7 @@ paths: $ref: "#/components/responses/InternalServerError" /boroughs/{boroughId}/community-districts/{communityDistrictId}/capital-projects: get: - summary: 🚧 Find paginated capital projects within a specified community district + summary: Find paginated capital projects within a specified community district operationId: findCapitalProjectsByBoroughIdCommunityDistrictId tags: - Capital Projects @@ -786,9 +786,9 @@ components: type: string nullable: true enum: - - "Fixed Asset" - - "Lump Sum" - - "ITT, Vehicles and Equipment" + - Fixed Asset + - Lump Sum + - ITT, Vehicles and Equipment - null description: The type of Capital Project. CapitalProject: diff --git a/src/borough/borough.controller.ts b/src/borough/borough.controller.ts index 455d5653..8794f9e3 100644 --- a/src/borough/borough.controller.ts +++ b/src/borough/borough.controller.ts @@ -3,12 +3,17 @@ import { Get, Injectable, Param, + Query, UseFilters, UsePipes, } from "@nestjs/common"; import { BoroughService } from "./borough.service"; import { + FindCapitalProjectsByBoroughIdCommunityDistrictIdPathParams, + FindCapitalProjectsByBoroughIdCommunityDistrictIdQueryParams, FindCommunityDistrictsByBoroughIdPathParams, + findCapitalProjectsByBoroughIdCommunityDistrictIdPathParamsSchema, + findCapitalProjectsByBoroughIdCommunityDistrictIdQueryParamsSchema, findCommunityDistrictsByBoroughIdPathParamsSchema, } from "src/gen"; import { @@ -44,4 +49,24 @@ export class BoroughController { params.boroughId, ); } + + @Get("/:boroughId/community-districts/:communityDistrictId/capital-projects") + async findCapitalProjectsByBoroughIdCommunityDistrictId( + @Param( + new ZodTransformPipe( + findCapitalProjectsByBoroughIdCommunityDistrictIdPathParamsSchema, + ), + ) + pathParams: FindCapitalProjectsByBoroughIdCommunityDistrictIdPathParams, + @Query( + new ZodTransformPipe( + findCapitalProjectsByBoroughIdCommunityDistrictIdQueryParamsSchema, + ), + ) + queryParams: FindCapitalProjectsByBoroughIdCommunityDistrictIdQueryParams, + ) { + return this.boroughService.findCapitalProjectsByBoroughIdCommunityDistrictId( + { ...pathParams, ...queryParams }, + ); + } } diff --git a/src/borough/borough.repository.schema.ts b/src/borough/borough.repository.schema.ts index 25d63f0e..5c1e95a9 100644 --- a/src/borough/borough.repository.schema.ts +++ b/src/borough/borough.repository.schema.ts @@ -1,3 +1,4 @@ +import { capitalProjectSchema } from "src/gen"; import { boroughEntitySchema, communityDistrictEntitySchema } from "src/schema"; import { z } from "zod"; @@ -11,6 +12,15 @@ export const checkByIdRepoSchema = boroughEntitySchema.pick({ export type CheckByIdRepo = z.infer; +export const checkByCommunityDistrictIdRepoSchema = + communityDistrictEntitySchema.pick({ + id: true, + }); + +export type CheckByCommunityDistrictIdRepo = z.infer< + typeof checkByCommunityDistrictIdRepoSchema +>; + export const findCommunityDistrictsByBoroughIdRepoSchema = z.array( communityDistrictEntitySchema, ); @@ -18,3 +28,10 @@ export const findCommunityDistrictsByBoroughIdRepoSchema = z.array( export type FindCommunityDistrictsByBoroughIdRepo = z.infer< typeof findCommunityDistrictsByBoroughIdRepoSchema >; + +export const findCapitalProjectsByBoroughIdCommunityDistrictIdRepoSchema = + z.array(capitalProjectSchema); + +export type FindCapitalProjectsByBoroughIdCommunityDistrictIdRepo = z.infer< + typeof findCapitalProjectsByBoroughIdCommunityDistrictIdRepoSchema +>; diff --git a/src/borough/borough.repository.ts b/src/borough/borough.repository.ts index 27d69bce..44c20694 100644 --- a/src/borough/borough.repository.ts +++ b/src/borough/borough.repository.ts @@ -5,9 +5,10 @@ import { CheckByIdRepo, FindManyRepo, FindCommunityDistrictsByBoroughIdRepo, + FindCapitalProjectsByBoroughIdCommunityDistrictIdRepo, } from "./borough.repository.schema"; -import { communityDistrict } from "src/schema"; -import { eq } from "drizzle-orm"; +import { capitalProject, communityDistrict } from "src/schema"; +import { eq, sql, and } from "drizzle-orm"; export class BoroughRepository { constructor( @@ -34,6 +35,28 @@ export class BoroughRepository { } } + #checkCommunityDistrictById = this.db.query.communityDistrict + .findFirst({ + columns: { + id: true, + }, + where: (communityDistrict, { eq, sql }) => + eq(communityDistrict.id, sql.placeholder("id")), + }) + .prepare("checkCommunityDistrictId"); + + async checkCommunityDistrictById( + id: string, + ): Promise { + try { + return await this.#checkCommunityDistrictById.execute({ + id, + }); + } catch { + throw new DataRetrievalException(); + } + } + async findMany(): Promise { try { return await this.db.query.borough.findMany(); @@ -57,4 +80,47 @@ export class BoroughRepository { throw new DataRetrievalException(); } } + + async findCapitalProjectsByBoroughIdCommunityDistrictId({ + boroughId, + communityDistrictId, + limit, + offset, + }: { + boroughId: string; + communityDistrictId: string; + limit: number; + offset: number; + }): Promise { + try { + return await this.db + .select({ + id: capitalProject.id, + description: capitalProject.description, + managingCode: capitalProject.managingCode, + managingAgency: capitalProject.managingAgency, + maxDate: capitalProject.maxDate, + minDate: capitalProject.minDate, + category: capitalProject.category, + }) + .from(capitalProject) + .leftJoin( + communityDistrict, + sql` + ST_Intersects(${communityDistrict.liFt}, ${capitalProject.liFtMPoly}) + OR ST_Intersects(${communityDistrict.liFt}, ${capitalProject.liFtMPnt})`, + ) + .where( + and( + eq(communityDistrict.boroughId, boroughId), + eq(communityDistrict.id, communityDistrictId), + ), + ) + .limit(limit) + .offset(offset) + .orderBy(capitalProject.managingCode, capitalProject.id); + } catch { + throw new DataRetrievalException(); + } + } } diff --git a/src/borough/borough.service.spec.ts b/src/borough/borough.service.spec.ts index 8fcec4ad..f9ca4f9f 100644 --- a/src/borough/borough.service.spec.ts +++ b/src/borough/borough.service.spec.ts @@ -4,6 +4,7 @@ import { BoroughRepositoryMock } from "../../test/borough/borough.repository.moc import { Test } from "@nestjs/testing"; import { findBoroughsQueryResponseSchema, + findCapitalProjectsByBoroughIdCommunityDistrictIdQueryResponseSchema, findCommunityDistrictsByBoroughIdQueryResponseSchema, } from "src/gen"; import { ResourceNotFoundException } from "src/exception"; @@ -53,4 +54,24 @@ describe("Borough service unit", () => { expect(zoningDistrict).rejects.toThrow(ResourceNotFoundException); }); }); + + describe("findCapitalProjectsByBoroughIdCommunityDistrictId", () => { + it("service should return a capital projects compliant object", async () => { + const boroughId = boroughRepositoryMock.checkBoroughByIdMocks[0].id; + const communityDistrictId = + boroughRepositoryMock.checkCommunityDistrictByIdMocks[0].id; + + const capitalProjects = + await boroughService.findCapitalProjectsByBoroughIdCommunityDistrictId({ + boroughId, + communityDistrictId, + }); + + expect(() => + findCapitalProjectsByBoroughIdCommunityDistrictIdQueryResponseSchema.parse( + capitalProjects, + ), + ).not.toThrow(); + }); + }); }); diff --git a/src/borough/borough.service.ts b/src/borough/borough.service.ts index 1b1c4cdb..95701369 100644 --- a/src/borough/borough.service.ts +++ b/src/borough/borough.service.ts @@ -1,6 +1,10 @@ import { Inject, Injectable } from "@nestjs/common"; import { BoroughRepository } from "./borough.repository"; import { ResourceNotFoundException } from "src/exception"; +import { + FindCapitalProjectsByBoroughIdCommunityDistrictIdPathParams, + FindCapitalProjectsByBoroughIdCommunityDistrictIdQueryParams, +} from "src/gen"; @Injectable() export class BoroughService { @@ -27,4 +31,41 @@ export class BoroughService { communityDistricts, }; } + + async findCapitalProjectsByBoroughIdCommunityDistrictId({ + boroughId, + communityDistrictId, + limit = 20, + offset = 0, + }: FindCapitalProjectsByBoroughIdCommunityDistrictIdPathParams & + FindCapitalProjectsByBoroughIdCommunityDistrictIdQueryParams) { + const boroughCheck = + await this.boroughRepository.checkBoroughById(boroughId); + if (boroughCheck === undefined) throw new ResourceNotFoundException(); + + const communityDistrictCheck = + await this.boroughRepository.checkCommunityDistrictById( + communityDistrictId, + ); + if (communityDistrictCheck === undefined) + throw new ResourceNotFoundException(); + + const capitalProjects = + await this.boroughRepository.findCapitalProjectsByBoroughIdCommunityDistrictId( + { + boroughId, + communityDistrictId, + limit, + offset, + }, + ); + + return { + limit, + offset, + total: capitalProjects.length, + order: "managingCode, capitalProjectId", + capitalProjects, + }; + } } diff --git a/src/community-district/community-district.repository.schema.ts b/src/community-district/community-district.repository.schema.ts index 38d50218..56fd3b5c 100644 --- a/src/community-district/community-district.repository.schema.ts +++ b/src/community-district/community-district.repository.schema.ts @@ -1,6 +1,13 @@ +import { communityDistrictEntitySchema } from "src/schema"; import { mvtEntitySchema } from "src/schema/mvt"; import { z } from "zod"; export const findTilesRepoSchema = mvtEntitySchema; export type FindTilesRepo = z.infer; + +export const checkByIdRepoSchema = communityDistrictEntitySchema.pick({ + id: true, +}); + +export type CheckByIdRepo = z.infer; diff --git a/src/schema/community-district.ts b/src/schema/community-district.ts index 5a467fd9..00dc2282 100644 --- a/src/schema/community-district.ts +++ b/src/schema/community-district.ts @@ -17,6 +17,7 @@ export const communityDistrict = pgTable( (table) => { return { pk: primaryKey({ columns: [table.boroughId, table.id] }), + liFtGix: index().using("GIST", table.liFt), mercatorFillGix: index().using("GIST", table.mercatorFill), mercatorLabelGix: index().using("GIST", table.mercatorLabel), }; diff --git a/test/borough/borough.e2e-spec.ts b/test/borough/borough.e2e-spec.ts index 20284d26..43741ddf 100644 --- a/test/borough/borough.e2e-spec.ts +++ b/test/borough/borough.e2e-spec.ts @@ -6,6 +6,7 @@ import { BoroughRepositoryMock } from "./borough.repository.mock"; import { BoroughModule } from "src/borough/borough.module"; import { findBoroughsQueryResponseSchema, + findCapitalProjectsByBoroughIdCommunityDistrictIdQueryResponseSchema, findCommunityDistrictsByBoroughIdQueryResponseSchema, } from "src/gen"; import { DataRetrievalException } from "src/exception"; @@ -101,6 +102,152 @@ describe("Borough e2e", () => { }); }); + describe("findCapitalProjectsByBoroughIdCommunityDistrictId", () => { + const borough = boroughRepositoryMock.checkBoroughByIdMocks[0]; + const communityDistrict = + boroughRepositoryMock.checkCommunityDistrictByIdMocks[0]; + it("should 200 and return capital projects for a given borough id community district id", async () => { + const response = await request(app.getHttpServer()) + .get( + `/boroughs/${borough.id}/community-districts/${communityDistrict.id}/capital-projects`, + ) + .expect(200); + + expect(() => { + findCapitalProjectsByBoroughIdCommunityDistrictIdQueryResponseSchema.parse( + response.body, + ); + }).not.toThrow(); + + const parsedBody = + findCapitalProjectsByBoroughIdCommunityDistrictIdQueryResponseSchema.parse( + response.body, + ); + expect(parsedBody.limit).toBe(20); + expect(parsedBody.offset).toBe(0); + }); + + it("should 200 and return capital projects for a given borough id community district id with user specified offset and limit", async () => { + const limit = 10; + const offset = 3; + + const response = await request(app.getHttpServer()) + .get( + `/boroughs/${borough.id}/community-districts/${communityDistrict.id}/capital-projects?limit=${limit}&offset=${offset}`, + ) + .expect(200); + + expect(() => { + findCapitalProjectsByBoroughIdCommunityDistrictIdQueryResponseSchema.parse( + response.body, + ); + }).not.toThrow(); + + const parsedBody = + findCapitalProjectsByBoroughIdCommunityDistrictIdQueryResponseSchema.parse( + response.body, + ); + expect(parsedBody.limit).toBe(10); + expect(parsedBody.offset).toBe(3); + }); + + it("should 404 and when finding by a missing borough id", async () => { + const missingId = "9"; + const response = await request(app.getHttpServer()) + .get( + `/boroughs/${missingId}/community-districts/${communityDistrict.id}/capital-projects`, + ) + .expect(404); + expect(response.body.message).toBe(HttpName.NOT_FOUND); + }); + + it("should 404 and when finding by a missing community district id", async () => { + const missingId = "99"; + const response = await request(app.getHttpServer()) + .get( + `/boroughs/${borough.id}/community-districts/${missingId}/capital-projects`, + ) + .expect(404); + expect(response.body.message).toBe(HttpName.NOT_FOUND); + }); + + it("should 400 and when finding by an invalid borough id", async () => { + const invalidId = "MN"; + const response = await request(app.getHttpServer()) + .get( + `/boroughs/${invalidId}/community-districts/${communityDistrict.id}/capital-projects`, + ) + .expect(400); + expect(response.body.error).toBe(HttpName.BAD_REQUEST); + }); + + it("should 400 and when finding by an invalid community district id", async () => { + const invalidId = "Q1"; + const response = await request(app.getHttpServer()) + .get( + `/boroughs/${borough.id}/community-districts/${invalidId}/capital-projects`, + ) + .expect(400); + expect(response.body.error).toBe(HttpName.BAD_REQUEST); + }); + + it("should 400 and when finding by an invalid limit", async () => { + const limit = "b4d"; + const response = await request(app.getHttpServer()) + .get( + `/boroughs/${borough.id}/community-districts/${communityDistrict.id}/capital-projects?limit=${limit}`, + ) + .expect(400); + expect(response.body.error).toBe(HttpName.BAD_REQUEST); + }); + + it("should 400 and when finding by too high a limit", async () => { + const limit = 101; + const response = await request(app.getHttpServer()) + .get( + `/boroughs/${borough.id}/community-districts/${communityDistrict.id}/capital-projects?limit=${limit}`, + ) + .expect(400); + expect(response.body.error).toBe(HttpName.BAD_REQUEST); + }); + + it("should 400 and when finding by an invalid limit", async () => { + const limit = 0; + const response = await request(app.getHttpServer()) + .get( + `/boroughs/${borough.id}/community-districts/${communityDistrict.id}/capital-projects?limit=${limit}`, + ) + .expect(400); + expect(response.body.error).toBe(HttpName.BAD_REQUEST); + }); + + it("should 400 and when finding by an invalid offset", async () => { + const offset = "b4d"; + const response = await request(app.getHttpServer()) + .get( + `/boroughs/${borough.id}/community-districts/${communityDistrict.id}/capital-projects?offset=${offset}`, + ) + .expect(400); + expect(response.body.error).toBe(HttpName.BAD_REQUEST); + }); + + it("should 500 when the database errors", async () => { + const dataRetrievalException = new DataRetrievalException(); + jest + .spyOn(boroughRepositoryMock, "checkBoroughById") + .mockImplementationOnce(() => { + throw dataRetrievalException; + }); + + const boroughId = boroughRepositoryMock.checkBoroughByIdMocks[0].id; + const response = await request(app.getHttpServer()) + .get(`/boroughs/${boroughId}/community-districts/01/capital-projects`) + .expect(500); + expect(response.body.error).toBe(HttpName.INTERNAL_SEVER_ERROR); + expect(response.body.message).toBe(dataRetrievalException.message); + }); + }); + afterAll(async () => { await app.close(); }); diff --git a/test/borough/borough.repository.mock.ts b/test/borough/borough.repository.mock.ts index 81e5c06b..e1f0a72c 100644 --- a/test/borough/borough.repository.mock.ts +++ b/test/borough/borough.repository.mock.ts @@ -2,6 +2,8 @@ import { findManyRepoSchema, checkByIdRepoSchema, findCommunityDistrictsByBoroughIdRepoSchema, + findCapitalProjectsByBoroughIdCommunityDistrictIdRepoSchema, + checkByCommunityDistrictIdRepoSchema, } from "src/borough/borough.repository.schema"; import { generateMock } from "@anatine/zod-mock"; @@ -16,6 +18,16 @@ export class BoroughRepositoryMock { return this.checkBoroughByIdMocks.find((row) => row.id === id); } + checkCommunityDistrictByIdMocks = Array.from( + Array(this.numberOfMocks), + (_, seed) => + generateMock(checkByCommunityDistrictIdRepoSchema, { seed: seed + 1 }), + ); + + async checkCommunityDistrictById(id: string) { + return this.checkCommunityDistrictByIdMocks.find((row) => row.id === id); + } + findManyMocks = generateMock(findManyRepoSchema); async findMany() { @@ -39,4 +51,22 @@ export class BoroughRepositoryMock { return results === undefined ? [] : results[id]; } + + findCapitalProjectsByBoroughIdCommunityDistrictIdMocks = + this.checkCommunityDistrictByIdMocks.map((checkCommunityDistrict) => { + return { + [checkCommunityDistrict.id]: generateMock( + findCapitalProjectsByBoroughIdCommunityDistrictIdRepoSchema, + ), + }; + }); + async findCapitalProjectsByBoroughIdCommunityDistrictId( + communityDistrictId: string, + ) { + const results = + this.findCapitalProjectsByBoroughIdCommunityDistrictIdMocks.find( + (capitalProjects) => communityDistrictId in capitalProjects, + ); + return results == undefined ? [] : results[communityDistrictId]; + } }