Skip to content

Commit

Permalink
fix: validate radius geocoding preferences (#3718)
Browse files Browse the repository at this point in the history
* fix: validate radius geocoding preferences

* fix: move to more generic

* fix: add test

* fix: one more test

* fix: change to true/false
  • Loading branch information
ludtkemorgan authored Dec 1, 2023
1 parent bf944b3 commit a8b13b0
Show file tree
Hide file tree
Showing 9 changed files with 383 additions and 1 deletion.
3 changes: 3 additions & 0 deletions backend/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 2 additions & 1 deletion backend/core/src/applications/applications.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand All @@ -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],
})
Expand Down
11 changes: 11 additions & 0 deletions backend/core/src/applications/services/applications.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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<Application>,
@InjectRepository(Listing) private readonly listingsRepository: Repository<Listing>
) {}
Expand Down Expand Up @@ -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
}

Expand Down
167 changes: 167 additions & 0 deletions backend/core/src/applications/services/geocoding.service.spec.ts
Original file line number Diff line number Diff line change
@@ -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",
},
],
}),
]),
}
)
})
})
})
101 changes: 101 additions & 0 deletions backend/core/src/applications/services/geocoding.service.ts
Original file line number Diff line number Diff line change
@@ -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<Application>
) {}

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 })
}
}
}
5 changes: 5 additions & 0 deletions backend/core/src/shared/types/geocoding-values.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export enum GeocodingValues {
true = "true",
false = "false",
unknown = "unknown",
}
3 changes: 3 additions & 0 deletions sites/public/src/components/applications/ValidateAddress.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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]

Expand All @@ -42,6 +43,8 @@ export const findValidatedAddress = (
city,
state: address.state,
zipCode,
longitude,
latitude,
},
})
}
Expand Down
2 changes: 2 additions & 0 deletions sites/public/src/pages/applications/contact/address.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Loading

0 comments on commit a8b13b0

Please sign in to comment.