From a8b13b039a8c608975d8c1f6e366a6886860c764 Mon Sep 17 00:00:00 2001 From: Morgan Ludtke <42942267+ludtkemorgan@users.noreply.github.com> Date: Fri, 1 Dec 2023 09:38:07 -0600 Subject: [PATCH] fix: validate radius geocoding preferences (#3718) * fix: validate radius geocoding preferences * fix: move to more generic * fix: add test * fix: one more test * fix: change to true/false --- backend/core/package.json | 3 + .../src/applications/applications.module.ts | 3 +- .../services/applications.service.ts | 11 ++ .../services/geocoding.service.spec.ts | 167 ++++++++++++++++++ .../services/geocoding.service.ts | 101 +++++++++++ .../core/src/shared/types/geocoding-values.ts | 5 + .../applications/ValidateAddress.tsx | 3 + .../pages/applications/contact/address.tsx | 2 + yarn.lock | 89 ++++++++++ 9 files changed, 383 insertions(+), 1 deletion(-) create mode 100644 backend/core/src/applications/services/geocoding.service.spec.ts create mode 100644 backend/core/src/applications/services/geocoding.service.ts create mode 100644 backend/core/src/shared/types/geocoding-values.ts diff --git a/backend/core/package.json b/backend/core/package.json index 0c958f28fc..267246317c 100644 --- a/backend/core/package.json +++ b/backend/core/package.json @@ -52,6 +52,9 @@ "@nestjs/swagger": "^6.3.0", "@nestjs/throttler": "^4.0.0", "@nestjs/typeorm": "^9.0.1", + "@turf/buffer": "6.5.0", + "@turf/helpers": "6.5.0", + "@turf/boolean-point-in-polygon": "6.5.0", "@types/cache-manager": "^3.4.0", "async-retry": "^1.3.1", "axios": "0.21.2", diff --git a/backend/core/src/applications/applications.module.ts b/backend/core/src/applications/applications.module.ts index 30b996348b..16bbc1dce7 100644 --- a/backend/core/src/applications/applications.module.ts +++ b/backend/core/src/applications/applications.module.ts @@ -16,6 +16,7 @@ import { CsvBuilder } from "./services/csv-builder.service" import { ApplicationCsvExporterService } from "./services/application-csv-exporter.service" import { EmailModule } from "../email/email.module" import { ActivityLogModule } from "../activity-log/activity-log.module" +import { GeocodingService } from "./services/geocoding.service" @Module({ imports: [ @@ -28,7 +29,7 @@ import { ActivityLogModule } from "../activity-log/activity-log.module" EmailModule, ScheduleModule.forRoot(), ], - providers: [ApplicationsService, CsvBuilder, ApplicationCsvExporterService], + providers: [ApplicationsService, CsvBuilder, ApplicationCsvExporterService, GeocodingService], exports: [ApplicationsService], controllers: [ApplicationsController, ApplicationsSubmissionController], }) diff --git a/backend/core/src/applications/services/applications.service.ts b/backend/core/src/applications/services/applications.service.ts index 503c561473..45060cad09 100644 --- a/backend/core/src/applications/services/applications.service.ts +++ b/backend/core/src/applications/services/applications.service.ts @@ -29,6 +29,7 @@ import { Listing } from "../../listings/entities/listing.entity" import { ApplicationCsvExporterService } from "./application-csv-exporter.service" import { User } from "../../auth/entities/user.entity" import { StatusDto } from "../../shared/dto/status.dto" +import { GeocodingService } from "./geocoding.service" @Injectable({ scope: Scope.REQUEST }) export class ApplicationsService { @@ -38,6 +39,7 @@ export class ApplicationsService { private readonly listingsService: ListingsService, private readonly emailService: EmailService, private readonly applicationCsvExporter: ApplicationCsvExporterService, + private readonly geocodingService: GeocodingService, @InjectRepository(Application) private readonly repository: Repository, @InjectRepository(Listing) private readonly listingsRepository: Repository ) {} @@ -421,6 +423,15 @@ export class ApplicationsService { if (application.applicant.emailAddress && shouldSendConfirmation) { await this.emailService.confirmation(listing, application, applicationCreateDto.appUrl) } + + // Calculate geocoding preferences after save and email sent + if (listing.jurisdiction?.enableGeocodingPreferences) { + try { + void this.geocodingService.validateGeocodingPreferences(application, listing) + } catch (e) { + console.warn("error while validating geocoding preferences") + } + } return application } diff --git a/backend/core/src/applications/services/geocoding.service.spec.ts b/backend/core/src/applications/services/geocoding.service.spec.ts new file mode 100644 index 0000000000..682749d7fc --- /dev/null +++ b/backend/core/src/applications/services/geocoding.service.spec.ts @@ -0,0 +1,167 @@ +import { Test, TestingModule } from "@nestjs/testing" +import { GeocodingService } from "./geocoding.service" +import { Address } from "../../shared/entities/address.entity" +import { getRepositoryToken } from "@nestjs/typeorm" +import { Application } from "../entities/application.entity" +import { ValidationMethod } from "../../multiselect-question/types/validation-method-enum" +import { Listing } from "../../listings/entities/listing.entity" +import { InputType } from "../../shared/types/input-type" + +describe("GeocodingService", () => { + let service: GeocodingService + const applicationRepoUpdate = jest.fn() + const mockApplicationRepo = { + createQueryBuilder: jest.fn(), + update: applicationRepoUpdate, + } + const date = new Date() + const listingAddress: Address = { + id: "id", + createdAt: date, + updatedAt: date, + city: "Washington", + county: null, + state: "DC", + street: "1600 Pennsylvania Avenue", + street2: null, + zipCode: "20500", + latitude: 38.8977, + longitude: -77.0365, + } + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + GeocodingService, + { + provide: getRepositoryToken(Application), + useValue: mockApplicationRepo, + }, + ], + }).compile() + + service = await module.resolve(GeocodingService) + }) + + describe("verifyRadius", () => { + it("should return 'unknown' if lat and long not there", () => { + expect( + service.verifyRadius( + { + ...listingAddress, + latitude: null, + longitude: null, + }, + 5, + listingAddress + ) + ).toBe("unknown") + }) + it("should return 'true' if within radius", () => { + expect( + service.verifyRadius( + { + ...listingAddress, + latitude: 38.89485, + longitude: -77.04251, + }, + 5, + listingAddress + ) + ).toBe("true") + }) + it("should return 'false' if not within radius", () => { + expect( + service.verifyRadius( + { + ...listingAddress, + latitude: 39.284205, + longitude: -76.621698, + }, + 5, + listingAddress + ) + ).toBe("false") + }) + it("should return 'true' if same lat long", () => { + expect( + service.verifyRadius( + { + ...listingAddress, + }, + 5, + listingAddress + ) + ).toBe("true") + }) + }) + describe("validateRadiusPreferences", () => { + const listing = { + buildingAddress: listingAddress, + listingMultiselectQuestions: [ + { + multiselectQuestion: { + options: [ + { + text: "Geocoding option by radius", + collectAddress: true, + radiusSize: 5, + validationMethod: ValidationMethod.radius, + }, + ], + }, + }, + ], + } + const preferenceAddress = { ...listingAddress, latitude: 38.89485, longitude: -77.04251 } + const application = { + id: "applicationId", + preferences: [ + { + key: "Geocoding preference", + options: [ + { + key: "Geocoding option by radius", + checked: true, + extraData: [ + { + type: InputType.address, + value: preferenceAddress, + }, + ], + }, + ], + }, + ], + } + it("should save the validated value as extraData", async () => { + await service.validateRadiusPreferences( + (application as unknown) as Application, + listing as Listing + ) + expect(applicationRepoUpdate).toBeCalledWith( + { id: "applicationId" }, + { + preferences: expect.arrayContaining([ + expect.objectContaining({ + key: "Geocoding preference", + options: [ + { + checked: true, + extraData: [ + { + type: "address", + value: preferenceAddress, + }, + { key: "geocodingVerified", type: "text", value: "true" }, + ], + key: "Geocoding option by radius", + }, + ], + }), + ]), + } + ) + }) + }) +}) diff --git a/backend/core/src/applications/services/geocoding.service.ts b/backend/core/src/applications/services/geocoding.service.ts new file mode 100644 index 0000000000..bc20daedc6 --- /dev/null +++ b/backend/core/src/applications/services/geocoding.service.ts @@ -0,0 +1,101 @@ +import { point } from "@turf/helpers" +import buffer from "@turf/buffer" +import booleanPointInPolygon from "@turf/boolean-point-in-polygon" +import { InjectRepository } from "@nestjs/typeorm" +import { Repository } from "typeorm" +import { Address } from "../../shared/entities/address.entity" +import { Application } from "../entities/application.entity" +import { Listing } from "../../listings/entities/listing.entity" +import { ValidationMethod } from "../../multiselect-question/types/validation-method-enum" +import { MultiselectOption } from "../../multiselect-question/types/multiselect-option" +import { ApplicationMultiselectQuestion } from "../entities/application-multiselect-question.entity" +import { ApplicationMultiselectQuestionOption } from "../types/application-multiselect-question-option" +import { InputType } from "../../shared/types/input-type" +import { GeocodingValues } from "../../shared/types/geocoding-values" + +export class GeocodingService { + constructor( + @InjectRepository(Application) private readonly repository: Repository + ) {} + + public async validateGeocodingPreferences(application: Application, listing: Listing) { + await this.validateRadiusPreferences(application, listing) + } + + verifyRadius( + preferenceAddress: Address, + radius: number, + listingAddress: Address + ): GeocodingValues { + try { + if (preferenceAddress.latitude && preferenceAddress.longitude) { + const preferencePoint = point([ + Number.parseFloat(preferenceAddress.longitude.toString()), + Number.parseFloat(preferenceAddress.latitude.toString()), + ]) + const listingPoint = point([ + Number.parseFloat(listingAddress.longitude.toString()), + Number.parseFloat(listingAddress.latitude.toString()), + ]) + const calculatedBuffer = buffer(listingPoint.geometry, radius, { units: "miles" }) + return booleanPointInPolygon(preferencePoint, calculatedBuffer) + ? GeocodingValues.true + : GeocodingValues.false + } + } catch (e) { + console.log("error happened while calculating radius") + } + return GeocodingValues.unknown + } + + public async validateRadiusPreferences(application: Application, listing: Listing) { + // Get all radius preferences from the listing + const radiusPreferenceOptions: MultiselectOption[] = listing.listingMultiselectQuestions.reduce( + (options, multiselectQuestion) => { + const newOptions = multiselectQuestion.multiselectQuestion.options?.filter( + (option) => option.validationMethod === ValidationMethod.radius + ) + return [...options, ...newOptions] + }, + [] + ) + // If there are any radius preferences do the calculation and save the new preferences + if (radiusPreferenceOptions.length) { + const preferences: ApplicationMultiselectQuestion[] = application.preferences.map( + (preference) => { + const newPreferenceOptions: ApplicationMultiselectQuestionOption[] = preference.options.map( + (option) => { + const addressData = option.extraData.find((data) => data.type === InputType.address) + if (option.checked && addressData) { + const foundOption = radiusPreferenceOptions.find( + (preferenceOption) => preferenceOption.text === option.key + ) + if (foundOption) { + const geocodingVerified = this.verifyRadius( + addressData.value as Address, + foundOption.radiusSize, + listing.buildingAddress + ) + return { + ...option, + extraData: [ + ...option.extraData, + { + key: "geocodingVerified", + type: InputType.text, + value: geocodingVerified, + }, + ], + } + } + } + return option + } + ) + return { ...preference, options: newPreferenceOptions } + } + ) + await this.repository.update({ id: application.id }, { preferences: preferences }) + } + } +} diff --git a/backend/core/src/shared/types/geocoding-values.ts b/backend/core/src/shared/types/geocoding-values.ts new file mode 100644 index 0000000000..fa3f90d5f5 --- /dev/null +++ b/backend/core/src/shared/types/geocoding-values.ts @@ -0,0 +1,5 @@ +export enum GeocodingValues { + true = "true", + false = "false", + unknown = "unknown", +} diff --git a/sites/public/src/components/applications/ValidateAddress.tsx b/sites/public/src/components/applications/ValidateAddress.tsx index 867663832a..112c906602 100644 --- a/sites/public/src/components/applications/ValidateAddress.tsx +++ b/sites/public/src/components/applications/ValidateAddress.tsx @@ -26,6 +26,7 @@ export const findValidatedAddress = ( .send() .then((response) => { const [street, city, region] = response.body.features[0].place_name.split(", ") + const [longitude, latitude] = response.body.features[0].geometry?.coordinates const regionElements = region.split(" ") const zipCode = regionElements[regionElements.length - 1] @@ -42,6 +43,8 @@ export const findValidatedAddress = ( city, state: address.state, zipCode, + longitude, + latitude, }, }) } diff --git a/sites/public/src/pages/applications/contact/address.tsx b/sites/public/src/pages/applications/contact/address.tsx index c1864f7ebd..0ed120bf97 100644 --- a/sites/public/src/pages/applications/contact/address.tsx +++ b/sites/public/src/pages/applications/contact/address.tsx @@ -80,6 +80,8 @@ const ApplicationAddress = () => { application.applicant.address.street = foundAddress.newAddress.street application.applicant.address.city = foundAddress.newAddress.city application.applicant.address.zipCode = foundAddress.newAddress.zipCode + application.applicant.address.longitude = foundAddress.newAddress.longitude + application.applicant.address.latitude = foundAddress.newAddress.latitude } if (application.applicant.noPhone) { diff --git a/yarn.lock b/yarn.lock index d780e4fc4c..40c3df77df 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4380,6 +4380,78 @@ resolved "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.2.tgz" integrity sha512-eZxlbI8GZscaGS7kkc/trHTT5xgrjH3/1n2JDwusC9iahPKWMRvRjJSAN5mCXviuTGQ/lHnhvv8Q1YTpnfz9gA== +"@turf/bbox@^6.5.0": + version "6.5.0" + resolved "https://registry.yarnpkg.com/@turf/bbox/-/bbox-6.5.0.tgz#bec30a744019eae420dac9ea46fb75caa44d8dc5" + integrity sha512-RBbLaao5hXTYyyg577iuMtDB8ehxMlUqHEJiMs8jT1GHkFhr6sYre3lmLsPeYEi/ZKj5TP5tt7fkzNdJ4GIVyw== + dependencies: + "@turf/helpers" "^6.5.0" + "@turf/meta" "^6.5.0" + +"@turf/boolean-point-in-polygon@6.5.0": + version "6.5.0" + resolved "https://registry.yarnpkg.com/@turf/boolean-point-in-polygon/-/boolean-point-in-polygon-6.5.0.tgz#6d2e9c89de4cd2e4365004c1e51490b7795a63cf" + integrity sha512-DtSuVFB26SI+hj0SjrvXowGTUCHlgevPAIsukssW6BG5MlNSBQAo70wpICBNJL6RjukXg8d2eXaAWuD/CqL00A== + dependencies: + "@turf/helpers" "^6.5.0" + "@turf/invariant" "^6.5.0" + +"@turf/buffer@6.5.0": + version "6.5.0" + resolved "https://registry.yarnpkg.com/@turf/buffer/-/buffer-6.5.0.tgz#22bd0d05b4e1e73eaebc69b8f574a410ff704842" + integrity sha512-qeX4N6+PPWbKqp1AVkBVWFerGjMYMUyencwfnkCesoznU6qvfugFHNAngNqIBVnJjZ5n8IFyOf+akcxnrt9sNg== + dependencies: + "@turf/bbox" "^6.5.0" + "@turf/center" "^6.5.0" + "@turf/helpers" "^6.5.0" + "@turf/meta" "^6.5.0" + "@turf/projection" "^6.5.0" + d3-geo "1.7.1" + turf-jsts "*" + +"@turf/center@^6.5.0": + version "6.5.0" + resolved "https://registry.yarnpkg.com/@turf/center/-/center-6.5.0.tgz#3bcb6bffcb8ba147430cfea84aabaed5dbdd4f07" + integrity sha512-T8KtMTfSATWcAX088rEDKjyvQCBkUsLnK/Txb6/8WUXIeOZyHu42G7MkdkHRoHtwieLdduDdmPLFyTdG5/e7ZQ== + dependencies: + "@turf/bbox" "^6.5.0" + "@turf/helpers" "^6.5.0" + +"@turf/clone@^6.5.0": + version "6.5.0" + resolved "https://registry.yarnpkg.com/@turf/clone/-/clone-6.5.0.tgz#895860573881ae10a02dfff95f274388b1cda51a" + integrity sha512-mzVtTFj/QycXOn6ig+annKrM6ZlimreKYz6f/GSERytOpgzodbQyOgkfwru100O1KQhhjSudKK4DsQ0oyi9cTw== + dependencies: + "@turf/helpers" "^6.5.0" + +"@turf/helpers@6.5.0", "@turf/helpers@^6.5.0": + version "6.5.0" + resolved "https://registry.yarnpkg.com/@turf/helpers/-/helpers-6.5.0.tgz#f79af094bd6b8ce7ed2bd3e089a8493ee6cae82e" + integrity sha512-VbI1dV5bLFzohYYdgqwikdMVpe7pJ9X3E+dlr425wa2/sMJqYDhTO++ec38/pcPvPE6oD9WEEeU3Xu3gza+VPw== + +"@turf/invariant@^6.5.0": + version "6.5.0" + resolved "https://registry.yarnpkg.com/@turf/invariant/-/invariant-6.5.0.tgz#970afc988023e39c7ccab2341bd06979ddc7463f" + integrity sha512-Wv8PRNCtPD31UVbdJE/KVAWKe7l6US+lJItRR/HOEW3eh+U/JwRCSUl/KZ7bmjM/C+zLNoreM2TU6OoLACs4eg== + dependencies: + "@turf/helpers" "^6.5.0" + +"@turf/meta@^6.5.0": + version "6.5.0" + resolved "https://registry.yarnpkg.com/@turf/meta/-/meta-6.5.0.tgz#b725c3653c9f432133eaa04d3421f7e51e0418ca" + integrity sha512-RrArvtsV0vdsCBegoBtOalgdSOfkBrTJ07VkpiCnq/491W67hnMWmDu7e6Ztw0C3WldRYTXkg3SumfdzZxLBHA== + dependencies: + "@turf/helpers" "^6.5.0" + +"@turf/projection@^6.5.0": + version "6.5.0" + resolved "https://registry.yarnpkg.com/@turf/projection/-/projection-6.5.0.tgz#d2aad862370bf03f2270701115464a8406c144b2" + integrity sha512-/Pgh9mDvQWWu8HRxqpM+tKz8OzgauV+DiOcr3FCjD6ubDnrrmMJlsf6fFJmggw93mtVPrZRL6yyi9aYCQBOIvg== + dependencies: + "@turf/clone" "^6.5.0" + "@turf/helpers" "^6.5.0" + "@turf/meta" "^6.5.0" + "@types/aria-query@^5.0.1": version "5.0.1" resolved "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.1.tgz" @@ -7634,6 +7706,18 @@ cz-conventional-changelog@^3.3.0: optionalDependencies: "@commitlint/load" ">6.1.1" +d3-array@1: + version "1.2.4" + resolved "https://registry.yarnpkg.com/d3-array/-/d3-array-1.2.4.tgz#635ce4d5eea759f6f605863dbcfc30edc737f71f" + integrity sha512-KHW6M86R+FUPYGb3R5XiYjXPq7VzwxZ22buHhAEVG5ztoEcZZMLov530mmccaqA1GghZArjQV46fuc8kUqhhHw== + +d3-geo@1.7.1: + version "1.7.1" + resolved "https://registry.yarnpkg.com/d3-geo/-/d3-geo-1.7.1.tgz#44bbc7a218b1fd859f3d8fd7c443ca836569ce99" + integrity sha512-O4AempWAr+P5qbk2bC2FuN/sDW4z+dN2wDf9QV3bxQt4M5HfOEeXLgJ/UKQW0+o1Dj8BE+L5kiDbdWUMjsmQpw== + dependencies: + d3-array "1" + damerau-levenshtein@^1.0.7: version "1.0.8" resolved "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz" @@ -17960,6 +18044,11 @@ tunnel@^0.0.6: resolved "https://registry.npmjs.org/tunnel/-/tunnel-0.0.6.tgz" integrity sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg== +turf-jsts@*: + version "1.2.3" + resolved "https://registry.yarnpkg.com/turf-jsts/-/turf-jsts-1.2.3.tgz#59757f542afbff9a577bbf411f183b8f48d38aa4" + integrity sha512-Ja03QIJlPuHt4IQ2FfGex4F4JAr8m3jpaHbFbQrgwr7s7L6U8ocrHiF3J1+wf9jzhGKxvDeaCAnGDot8OjGFyA== + tweetnacl@^0.14.3, tweetnacl@~0.14.0: version "0.14.5" resolved "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz"