diff --git a/express-api/src/controllers/tools/toolsController.ts b/express-api/src/controllers/tools/toolsController.ts index 0924c0317..f4c790177 100644 --- a/express-api/src/controllers/tools/toolsController.ts +++ b/express-api/src/controllers/tools/toolsController.ts @@ -3,6 +3,8 @@ import { Request, Response } from 'express'; import chesServices from '@/services/ches/chesServices'; import { ChesFilterSchema } from './toolsSchema'; import geocoderService from '@/services/geocoder/geocoderService'; +import { AppDataSource } from '@/appDataSource'; +import { JurRollPidXref } from '@/typeorm/Entities/JurRollPidXref'; /** * NOTE @@ -90,3 +92,24 @@ export const searchGeocoderAddresses = async (req: Request, res: Response) => { const geoReturn = await geocoderService.getSiteAddresses(address, minScore, maxResults); return res.status(200).send(geoReturn); }; + +/** + * Retrieves jurisdiction & roll number based on PID. + * Used to cross reference BC Assessment records with Parcels. + * @param req - The request object. + * @param res - The response object. + * @returns A response with the jurisdiction, roll number, and PID if found. + */ +export const getJurisdictionRollNumberByPid = async (req: Request, res: Response) => { + const pidQuery = req.query.pid as string; + if (parseInt(pidQuery)) { + const result = await AppDataSource.getRepository(JurRollPidXref).findOne({ + where: { PID: parseInt(pidQuery) }, + }); + if (!result) { + return res.status(404).send('PID not found.'); + } + return res.status(200).send(result); + } + return res.status(400).send('Invalid PID value.'); +}; diff --git a/express-api/src/express.ts b/express-api/src/express.ts index d69e5f9a8..03f8b2b41 100644 --- a/express-api/src/express.ts +++ b/express-api/src/express.ts @@ -107,7 +107,7 @@ app.use(`/v2/buildings`, protectedRoute(), userAuthCheck(), router.buildingsRout app.use(`/v2/notifications`, protectedRoute(), router.notificationsRouter); app.use(`/v2/projects`, protectedRoute(), router.projectsRouter); app.use(`/v2/reports`, protectedRoute(), userAuthCheck(), router.reportsRouter); -app.use(`/v2/tools`, protectedRoute(), userAuthCheck(), router.toolsRouter); +app.use(`/v2/tools`, protectedRoute(), router.toolsRouter); // If a non-existent route is called. Must go after other routes. app.use('*', (_req, _res, next) => next(EndpointNotFound404)); diff --git a/express-api/src/routes/tools.swagger.yaml b/express-api/src/routes/tools.swagger.yaml index 0d8178fea..889b9adb2 100644 --- a/express-api/src/routes/tools.swagger.yaml +++ b/express-api/src/routes/tools.swagger.yaml @@ -11,17 +11,17 @@ paths: This response comes from the BC Geocoder Service. Capable of any error code from BC Geocoder. parameters: - - in: path + - in: query name: address schema: type: string example: 742 Evergreen Terr - - in: path + - in: query name: minScore schema: type: integer example: 30 - - in: path + - in: query name: maxResults schema: type: integer @@ -40,9 +40,56 @@ paths: schema: type: string example: Failed to fetch data + /tools/jur-roll-xref: + get: + security: + - bearerAuth: [] + tags: + - Tools + summary: Returns a record matching the provided PID. + description: > + Used to cross reference the PID and Jurisdiction Code + Roll Number from BC Assessment. + parameters: + - in: query + name: pid + schema: + type: integer + example: 111222333 + responses: + '200': + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/JurRollXref' + '400': + content: + text/plain: + schema: + type: string + example: Invalid PID value. + '404': + content: + text/plain: + schema: + type: string + example: PID not found. ### SCHEMAS ### components: schemas: + JurRollXref: + type: object + properties: + PID: + type: integer + example: 123456789 + JurisdictionCode: + type: string + example: '123' + RollNumber: + type: string + example: '1342341' GeocoderAddress: type: object properties: diff --git a/express-api/src/routes/toolsRouter.ts b/express-api/src/routes/toolsRouter.ts index 9d714f8a1..d587bd9dc 100644 --- a/express-api/src/routes/toolsRouter.ts +++ b/express-api/src/routes/toolsRouter.ts @@ -1,4 +1,7 @@ +import { Roles } from '@/constants/roles'; import controllers from '@/controllers'; +import { getJurisdictionRollNumberByPid } from '@/controllers/tools/toolsController'; +import userAuthCheck from '@/middleware/userAuthCheck'; import catchErrors from '@/utilities/controllerErrorWrapper'; import express from 'express'; @@ -6,6 +9,12 @@ const router = express.Router(); const { searchGeocoderAddresses } = controllers; -router.route(`/geocoder/addresses`).get(catchErrors(searchGeocoderAddresses)); +router.route(`/geocoder/addresses`).get(userAuthCheck(), catchErrors(searchGeocoderAddresses)); +router + .route(`/jur-roll-xref`) + .get( + userAuthCheck({ requiredRoles: [Roles.ADMIN] }), + catchErrors(getJurisdictionRollNumberByPid), + ); export default router; diff --git a/express-api/src/typeorm/Entities/JurRollPidXref.ts b/express-api/src/typeorm/Entities/JurRollPidXref.ts new file mode 100644 index 000000000..e3ab83348 --- /dev/null +++ b/express-api/src/typeorm/Entities/JurRollPidXref.ts @@ -0,0 +1,16 @@ +import { Entity, PrimaryColumn } from 'typeorm'; + +/** + * Used to cross reference the records from BC Assessment and find one that matches a PID. + */ +@Entity() +export class JurRollPidXref { + @PrimaryColumn({ type: 'int', name: 'pid' }) + PID: number; + + @PrimaryColumn({ type: 'character varying', length: 3, name: 'jurisdiction_code' }) + JurisdictionCode: string; + + @PrimaryColumn({ type: 'character varying', length: 15, name: 'roll_number' }) + RollNumber: string; +} diff --git a/express-api/src/typeorm/Migrations/1729627184522-CreateXrefTable.ts b/express-api/src/typeorm/Migrations/1729627184522-CreateXrefTable.ts new file mode 100644 index 000000000..5ff80e650 --- /dev/null +++ b/express-api/src/typeorm/Migrations/1729627184522-CreateXrefTable.ts @@ -0,0 +1,15 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class CreateXrefTable1729627184522 implements MigrationInterface { + name = 'CreateXrefTable1729627184522'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "jur_roll_pid_xref" ("pid" integer NOT NULL, "jurisdiction_code" character varying(3) NOT NULL, "roll_number" character varying(15) NOT NULL, CONSTRAINT "PK_68f4d54ea088bb438e6100af993" PRIMARY KEY ("pid", "jurisdiction_code", "roll_number"))`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE "jur_roll_pid_xref"`); + } +} diff --git a/express-api/src/typeorm/entitiesIndex.ts b/express-api/src/typeorm/entitiesIndex.ts index 32c53e0e9..e604085c9 100644 --- a/express-api/src/typeorm/entitiesIndex.ts +++ b/express-api/src/typeorm/entitiesIndex.ts @@ -45,6 +45,7 @@ import { ImportResult } from './Entities/ImportResult'; import { ProjectJoin } from './Entities/views/ProjectJoinView'; import { AdministrativeAreaJoinView } from '@/typeorm/Entities/views/AdministrativeAreaJoinView'; import { AgencyJoinView } from '@/typeorm/Entities/views/AgencyJoinView'; +import { JurRollPidXref } from '@/typeorm/Entities/JurRollPidXref'; const views = [ BuildingRelations, @@ -97,5 +98,6 @@ export default [ User, NoteType, ImportResult, + JurRollPidXref, ...views, ]; diff --git a/express-api/tests/unit/controllers/tools/toolsController.test.ts b/express-api/tests/unit/controllers/tools/toolsController.test.ts index bb4e5b259..2b2f26312 100644 --- a/express-api/tests/unit/controllers/tools/toolsController.test.ts +++ b/express-api/tests/unit/controllers/tools/toolsController.test.ts @@ -7,6 +7,8 @@ import { produceEmailStatus, } from '../../../testUtils/factories'; import { randomUUID } from 'crypto'; +import { AppDataSource } from '@/appDataSource'; +import { JurRollPidXref } from '@/typeorm/Entities/JurRollPidXref'; const _getChesMessageStatusById = jest.fn().mockImplementation(() => produceEmailStatus({})); @@ -34,6 +36,14 @@ jest.mock('@/services/ches/chesServices.ts', () => ({ sendEmailAsync: () => _sendEmailAsync(), })); +const _xrefSpy = jest + .spyOn(AppDataSource.getRepository(JurRollPidXref), 'findOne') + .mockImplementation(async () => ({ + JurisdictionCode: '123', + RollNumber: '1234567', + PID: 111222333, + })); + describe('UNIT - Tools', () => { let mockRequest: Request & MockReq, mockResponse: Response & MockRes; @@ -163,4 +173,30 @@ describe('UNIT - Tools', () => { expect(mockResponse.statusValue).toBe(200); }); }); + + describe('GET /tools/jur-roll-xref', () => { + it('should return 200 if given a valid PID', async () => { + mockRequest.query.pid = '2134'; + await controllers.getJurisdictionRollNumberByPid(mockRequest, mockResponse); + expect(mockResponse.statusValue).toBe(200); + expect(mockResponse.sendValue).toHaveProperty('PID'); + expect(mockResponse.sendValue).toHaveProperty('JurisdictionCode'); + expect(mockResponse.sendValue).toHaveProperty('RollNumber'); + }); + + it('should return 400 if given an invalid PID', async () => { + mockRequest.query.pid = 'hi'; + await controllers.getJurisdictionRollNumberByPid(mockRequest, mockResponse); + expect(mockResponse.statusValue).toBe(400); + expect(mockResponse.sendValue).toBe('Invalid PID value.'); + }); + + it('should return 404 if a record with that PID is not found', async () => { + mockRequest.query.pid = '1234'; + _xrefSpy.mockImplementationOnce(async () => null); + await controllers.getJurisdictionRollNumberByPid(mockRequest, mockResponse); + expect(mockResponse.statusValue).toBe(404); + expect(mockResponse.sendValue).toBe('PID not found.'); + }); + }); }); diff --git a/react-app/src/components/map/parcelPopup/ParcelPopup.tsx b/react-app/src/components/map/parcelPopup/ParcelPopup.tsx index 8422243a7..cad31e8c3 100644 --- a/react-app/src/components/map/parcelPopup/ParcelPopup.tsx +++ b/react-app/src/components/map/parcelPopup/ParcelPopup.tsx @@ -3,7 +3,7 @@ import { ParcelData } from '@/hooks/api/useParcelLayerApi'; import usePimsApi from '@/hooks/usePimsApi'; import { Box, Grid, IconButton, Typography, Tab, SxProps } from '@mui/material'; import { LatLng } from 'leaflet'; -import React, { useCallback, useContext, useEffect, useState } from 'react'; +import React, { useCallback, useContext, useEffect, useMemo, useState } from 'react'; import { Popup, useMap, useMapEvents } from 'react-leaflet'; import KeyboardDoubleArrowLeftIcon from '@mui/icons-material/KeyboardDoubleArrowLeft'; import KeyboardDoubleArrowRightIcon from '@mui/icons-material/KeyboardDoubleArrowRight'; @@ -16,6 +16,7 @@ import BCAssessmentDetails from '@/components/map/parcelPopup/BCAssessmentDetail import LtsaDetails from '@/components/map/parcelPopup/LtsaDetails'; import ParcelLayerDetails from '@/components/map/parcelPopup/ParcelLayerDeatils'; import ParcelPopupSelect from '@/components/map/parcelPopup/ParcelPopupSelect'; +import { JurRollPidXref } from '@/hooks/api/useBCAssessmentApi'; interface ParcelPopupProps { size?: 'small' | 'large'; @@ -55,6 +56,14 @@ export const ParcelPopup = (props: ParcelPopupProps) => { api.bcAssessment.getBCAssessmentByLocation(clickPosition.lng, clickPosition.lat), ); + const { + data: xrefData, + refreshData: refreshXref, + isLoading: xrefLoading, + } = useDataLoader(() => + api.bcAssessment.getJurisdictionRoleByPid(parcelData.at(parcelIndex)?.PID_NUMBER), + ); + const map = useMap(); const api = usePimsApi(); @@ -67,6 +76,7 @@ export const ParcelPopup = (props: ParcelPopupProps) => { if (parcelData && clickPosition) { refreshLtsa(); if (pimsUser.hasOneOfRoles([Roles.ADMIN])) { + refreshXref(); refreshBCA(); } } @@ -106,6 +116,26 @@ export const ParcelPopup = (props: ParcelPopupProps) => { } }, [clickPosition]); + /** + * Gets a matching record from BC Assessment data by using the XREF table in the database to + * match the PID with a corresponding Jurisdiction Code and Roll Number. + */ + const getMatchingBcaRecord = useMemo(() => { + if (!xrefData || !bcAssessmentData) { + return undefined; + } + if (xrefData.status !== 200) { + return undefined; + } + const xrefRecord = xrefData.parsedBody as JurRollPidXref; + const matchingFolio = bcAssessmentData.features.find( + (folio) => + folio.properties.JURISDICTION_CODE === xrefRecord.JurisdictionCode && + folio.properties.ROLL_NUMBER === xrefRecord.RollNumber, + ); + return matchingFolio; + }, [bcAssessmentData, xrefData]); + if (!clickPosition) return <>; const tabPanelStyle: SxProps = { @@ -162,12 +192,8 @@ export const ParcelPopup = (props: ParcelPopupProps) => { diff --git a/react-app/src/hooks/api/useBCAssessmentApi.ts b/react-app/src/hooks/api/useBCAssessmentApi.ts index 97fb83863..08a7293e2 100644 --- a/react-app/src/hooks/api/useBCAssessmentApi.ts +++ b/react-app/src/hooks/api/useBCAssessmentApi.ts @@ -1,3 +1,4 @@ +import { IFetch } from '@/hooks/useFetch'; import { BBox, FeatureCollection, Geometry } from 'geojson'; export interface BCAssessmentProperties { @@ -29,7 +30,13 @@ export interface BCAssessmentProperties { bbox: BBox; } -const useBCAssessmentApi = () => { +export interface JurRollPidXref { + PID: number; + JurisdictionCode: string; + RollNumber: string; +} + +const useBCAssessmentApi = (absoluteFetch: IFetch) => { const url = window.location.href.includes('pims.gov.bc.ca') ? 'https://apps.gov.bc.ca/ext/sgw/geo.bca?REQUEST=GetFeature&SERVICE=WFS&VERSION=2.0.0&typeName=geo.bca:WHSE_HUMAN_CULTURAL_ECONOMIC.BCA_FOLIO_GNRL_PROP_VALUES_SV&outputFormat=application/json' : 'https://test.apps.gov.bc.ca/ext/sgw/geo.bca?REQUEST=GetFeature&SERVICE=WFS&VERSION=2.0.0&typeName=geo.bca:WHSE_HUMAN_CULTURAL_ECONOMIC.BCA_FOLIO_GNRL_PROP_VALUES_SV&outputFormat=application/json'; @@ -44,8 +51,14 @@ const useBCAssessmentApi = () => { return body as FeatureCollection; }; + const getJurisdictionRoleByPid = async (pid: number) => { + const response = await absoluteFetch.get(`/tools/jur-roll-xref?pid=${pid}`); + return response; + }; + return { getBCAssessmentByLocation, + getJurisdictionRoleByPid, }; }; diff --git a/react-app/src/hooks/usePimsApi.ts b/react-app/src/hooks/usePimsApi.ts index 107250d00..2d7c3a9e0 100644 --- a/react-app/src/hooks/usePimsApi.ts +++ b/react-app/src/hooks/usePimsApi.ts @@ -35,7 +35,7 @@ const usePimsApi = () => { const tools = useToolsApi(fetch); const parcelLayer = useParcelLayerApi(fetch); const projects = useProjectsApi(fetch); - const bcAssessment = useBCAssessmentApi(); + const bcAssessment = useBCAssessmentApi(fetch); const ltsa = useLtsaApi(fetch); const notifications = useProjectNotificationsApi(fetch);