diff --git a/backend/core/src/applications/services/applications.service.ts b/backend/core/src/applications/services/applications.service.ts index 1e7490ca1e..f5fe9e2252 100644 --- a/backend/core/src/applications/services/applications.service.ts +++ b/backend/core/src/applications/services/applications.service.ts @@ -237,6 +237,17 @@ export class ApplicationsService { return await applicationsRepository.findOne({ where: { id: newApplication.id } }) } ) + + const listing = await this.listingsService.findOne(application.listingId) + + // Calculate geocoding preferences after save + if (listing.jurisdiction?.enableGeocodingPreferences) { + try { + void this.geocodingService.validateGeocodingPreferences(application, listing) + } catch (e) { + console.warn("error while validating geocoding preferences") + } + } return app } diff --git a/backend/core/src/applications/services/geocoding.service.spec.ts b/backend/core/src/applications/services/geocoding.service.spec.ts index 5a21a65d8d..b89ee9b6f3 100644 --- a/backend/core/src/applications/services/geocoding.service.spec.ts +++ b/backend/core/src/applications/services/geocoding.service.spec.ts @@ -8,6 +8,7 @@ import { Listing } from "../../listings/entities/listing.entity" import { InputType } from "../../shared/types/input-type" import { MapLayer } from "../../map-layers/entities/map-layer.entity" import { FeatureCollection } from "@turf/helpers" +import { ApplicationMultiselectQuestion } from "../entities/application-multiselect-question.entity" describe("GeocodingService", () => { let service: GeocodingService @@ -175,7 +176,7 @@ describe("GeocodingService", () => { multiselectQuestion: { options: [ { - text: "Geocoding option by radius", + text: "Geocoding option by Radius", collectAddress: true, radiusSize: 5, validationMethod: ValidationMethod.radius, @@ -186,54 +187,46 @@ describe("GeocodingService", () => { ], } const preferenceAddress = { ...address, latitude: 38.89485, longitude: -77.04251 } - const application = { - id: "applicationId", - preferences: [ + const 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", () => { + const response = service.validateRadiusPreferences( + (preferences as unknown) as ApplicationMultiselectQuestion[], + listing as Listing + ) + expect(response).toEqual([ { key: "Geocoding preference", options: [ { - key: "Geocoding option by radius", + key: "Geocoding option by Radius", checked: true, extraData: [ { type: InputType.address, value: preferenceAddress, }, + { key: "geocodingVerified", type: InputType.text, value: "true" }, ], }, ], }, - ], - } - 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", - }, - ], - }), - ]), - } - ) + ]) }) }) describe("validateGeoLayerPreferences", () => { @@ -255,9 +248,28 @@ describe("GeocodingService", () => { ], } const preferenceAddress = { ...address, latitude: 38.89485, longitude: -77.04251 } - const application = { - id: "applicationId", - preferences: [ + + const preference = { + key: "Geocoding preference", + options: [ + { + key: "Geocoding option by map", + checked: true, + extraData: [ + { + type: InputType.address, + value: preferenceAddress, + }, + ], + }, + ], + } + it("should save the validated value as extraData for map layer", async () => { + const response = await service.validateGeoLayerPreferences( + ([preference] as unknown) as ApplicationMultiselectQuestion[], + listing as Listing + ) + expect(response).toEqual([ { key: "Geocoding preference", options: [ @@ -269,23 +281,114 @@ describe("GeocodingService", () => { type: InputType.address, value: preferenceAddress, }, + { key: "geocodingVerified", type: InputType.text, value: "true" }, ], }, ], }, + ]) + }) + }) + describe("validateGeocodingPreferences", () => { + const listing = { + buildingAddress: address, + listingMultiselectQuestions: [ + { + multiselectQuestion: { + options: [ + { + text: "Geocoding option by radius", + collectAddress: true, + radiusSize: 5, + validationMethod: ValidationMethod.radius, + }, + ], + }, + }, + { + multiselectQuestion: { + options: [ + { + text: "Geocoding option by map", + collectAddress: true, + mapLayerId: "mapLayerId", + validationMethod: ValidationMethod.map, + }, + ], + }, + }, + { + multiselectQuestion: { + options: [ + { + text: "non-geocoding option", + }, + ], + }, + }, ], } - it("should save the validated value as extraData for map layer", async () => { - await service.validateGeoLayerPreferences( + + const preferenceAddress = { ...address, latitude: 38.89485, longitude: -77.04251 } + const preferences = [ + { + key: "Geocoding preference by map", + options: [ + { + key: "Geocoding option by map", + checked: true, + extraData: [ + { + type: InputType.address, + value: preferenceAddress, + }, + ], + }, + ], + }, + { + key: "Geocoding preference by radius", + options: [ + { + key: "Geocoding option by radius", + checked: true, + extraData: [ + { + type: InputType.address, + value: preferenceAddress, + }, + ], + }, + ], + }, + { + key: "non-geocoding preference", + options: [ + { + key: "non-geocoding option", + checked: true, + }, + ], + }, + ] + + const application = { + id: "applicationId", + preferences: preferences, + } + + it("should save all updated preferences", async () => { + await service.validateGeocodingPreferences( (application as unknown) as Application, - listing as Listing + (listing as unknown) as Listing ) + expect(applicationRepoUpdate).toBeCalledWith( { id: "applicationId" }, { preferences: expect.arrayContaining([ expect.objectContaining({ - key: "Geocoding preference", + key: "Geocoding preference by map", options: [ { checked: true, @@ -300,6 +403,31 @@ describe("GeocodingService", () => { }, ], }), + expect.objectContaining({ + key: "Geocoding preference by radius", + options: [ + { + checked: true, + extraData: [ + { + type: "address", + value: preferenceAddress, + }, + { key: "geocodingVerified", type: "text", value: "true" }, + ], + key: "Geocoding option by radius", + }, + ], + }), + expect.objectContaining({ + key: "non-geocoding preference", + options: [ + { + checked: true, + key: "non-geocoding option", + }, + ], + }), ]), } ) diff --git a/backend/core/src/applications/services/geocoding.service.ts b/backend/core/src/applications/services/geocoding.service.ts index 651827fa35..841dafc709 100644 --- a/backend/core/src/applications/services/geocoding.service.ts +++ b/backend/core/src/applications/services/geocoding.service.ts @@ -22,8 +22,11 @@ export class GeocodingService { ) {} public async validateGeocodingPreferences(application: Application, listing: Listing) { - await this.validateRadiusPreferences(application, listing) - await this.validateGeoLayerPreferences(application, listing) + let preferences = application.preferences + preferences = this.validateRadiusPreferences(preferences, listing) + preferences = await this.validateGeoLayerPreferences(preferences, listing) + + await this.applicationRepository.update({ id: application.id }, { preferences: preferences }) } verifyRadius( @@ -89,7 +92,18 @@ export class GeocodingService { return GeocodingValues.unknown } - public async validateRadiusPreferences(application: Application, listing: Listing) { + /** + * Checks if there are any preferences that have a validation method of radius, validates those preferences addresses, + * and then adds the appropriate validation check field to those preferences + * + * @param preferences + * @param listing + * @returns the preferences with the geocoding verified field added to preferences that have validation method of radius + */ + public validateRadiusPreferences( + preferences: ApplicationMultiselectQuestion[], + listing: Listing + ): ApplicationMultiselectQuestion[] { // Get all radius preferences from the listing const radiusPreferenceOptions: MultiselectOption[] = listing.listingMultiselectQuestions.reduce( (options, multiselectQuestion) => { @@ -102,45 +116,55 @@ export class GeocodingService { ) // 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 + const newPreferences: ApplicationMultiselectQuestion[] = 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 ) - 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, + extraData: [ + ...option.extraData, + { + key: "geocodingVerified", + type: InputType.text, + value: geocodingVerified, + }, + ], } } - return option } - ) - return { ...preference, options: newPreferenceOptions } - } - ) - await this.applicationRepository.update({ id: application.id }, { preferences: preferences }) + return option + } + ) + return { ...preference, options: newPreferenceOptions } + }) + return newPreferences } + return preferences } - public async validateGeoLayerPreferences(application: Application, listing: Listing) { + /** + * Checks if there are any preferences that have a validation method of 'map', validates those preferences addresses, + * and then adds the appropriate validation check field to those preferences + * + * @param preferences + * @param listing + * @returns all preferences on the application + */ + public async validateGeoLayerPreferences( + preferences: ApplicationMultiselectQuestion[], + listing: Listing + ): Promise { // Get all map layer preferences from the listing const mapPreferenceOptions: MultiselectOption[] = listing.listingMultiselectQuestions?.reduce( (options, multiselectQuestion) => { @@ -158,7 +182,7 @@ export class GeocodingService { ): ApplicationMultiselectQuestionOption[] => { const preferenceOptions = [] preference.options.forEach((option) => { - const addressData = option.extraData.find((data) => data.type === InputType.address) + const addressData = option.extraData?.find((data) => data.type === InputType.address) if (option.checked && addressData) { const foundOption = mapPreferenceOptions.find( (preferenceOption) => preferenceOption.text === option.key @@ -188,16 +212,16 @@ export class GeocodingService { return preferenceOptions } if (mapPreferenceOptions?.length) { - const preferences = [] + const newPreferences = [] const mapLayers = await this.mapLayerRepository.findBy({ id: In(mapPreferenceOptions.map((option) => option.mapLayerId)), }) - application.preferences.forEach((preference) => { + preferences.forEach((preference) => { const newPreferenceOptions = preferencesOptions(preference, mapLayers) - preferences.push({ ...preference, options: newPreferenceOptions }) + newPreferences.push({ ...preference, options: newPreferenceOptions }) }) - - await this.applicationRepository.update({ id: application.id }, { preferences: preferences }) + return newPreferences } + return preferences } } diff --git a/backend/core/src/applications/types/application-multiselect-question-option.ts b/backend/core/src/applications/types/application-multiselect-question-option.ts index f108c4133f..a2109dbf8c 100644 --- a/backend/core/src/applications/types/application-multiselect-question-option.ts +++ b/backend/core/src/applications/types/application-multiselect-question-option.ts @@ -1,5 +1,5 @@ import { Expose, Type } from "class-transformer" -import { ArrayMaxSize, IsBoolean, IsString, ValidateNested } from "class-validator" +import { ArrayMaxSize, IsBoolean, IsOptional, IsString, ValidateNested } from "class-validator" import { ValidationsGroupsEnum } from "../../shared/types/validations-groups-enum" import { ApiProperty, getSchemaPath } from "@nestjs/swagger" import { BooleanInput } from "./form-metadata/boolean-input" @@ -19,6 +19,12 @@ export class ApplicationMultiselectQuestionOption { @ApiProperty() checked: boolean + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @ApiProperty({ required: false }) + mapPinPosition?: string + @Expose() @ApiProperty({ type: "array", diff --git a/backend/core/src/multiselect-question/types/multiselect-option.ts b/backend/core/src/multiselect-question/types/multiselect-option.ts index 16296fba79..5a04a75a7b 100644 --- a/backend/core/src/multiselect-question/types/multiselect-option.ts +++ b/backend/core/src/multiselect-question/types/multiselect-option.ts @@ -74,6 +74,12 @@ export class MultiselectOption { @ApiProperty({ required: false }) collectRelationship?: boolean + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @ApiProperty({ required: false }) + mapPinPosition?: string + @Expose() @IsOptional({ groups: [ValidationsGroupsEnum.default] }) @IsBoolean({ groups: [ValidationsGroupsEnum.default] }) diff --git a/backend/core/test/applications/applications.e2e-spec.ts b/backend/core/test/applications/applications.e2e-spec.ts index b3fcbcdde0..9fff1a703f 100644 --- a/backend/core/test/applications/applications.e2e-spec.ts +++ b/backend/core/test/applications/applications.e2e-spec.ts @@ -362,7 +362,7 @@ describe("Applications", () => { expect(Array.isArray(res.body.items)).toBe(true) expect(res.body.items.length).toBe(1) expect(res.body.items[0].id === createRes.body.id) - expect(res.body.items[0]).toMatchObject(createRes.body) + expect(res.body.items[0]).toMatchObject({ ...createRes.body, updatedAt: expect.anything() }) }) it(`should not allow an admin to search for users application using a search query param of less than 3 characters`, async () => { @@ -402,7 +402,7 @@ describe("Applications", () => { expect(Array.isArray(res.body.items)).toBe(true) expect(res.body.items.length).toBe(1) expect(res.body.items[0].id === createRes.body.id) - expect(res.body.items[0]).toMatchObject(createRes.body) + expect(res.body.items[0]).toMatchObject({ ...createRes.body, updatedAt: expect.anything() }) }) it(`should allow an admin to search for users application using email as textquery`, async () => { @@ -425,7 +425,7 @@ describe("Applications", () => { expect(Array.isArray(res.body.items)).toBe(true) expect(res.body.items.length).toBe(1) expect(res.body.items[0].id === createRes.body.id) - expect(res.body.items[0]).toMatchObject(createRes.body) + expect(res.body.items[0]).toMatchObject({ ...createRes.body, updatedAt: expect.anything() }) }) // because we changed this to be done async to the request this is causing some problems with the tests @@ -568,7 +568,7 @@ describe("Applications", () => { const { createRes, firstName } = responses[index] expect(item.id === createRes.body.id) - expect(item).toMatchObject(createRes.body) + expect(item).toMatchObject({ ...createRes.body, updatedAt: expect.anything() }) expect(item.applicant).toMatchObject(createRes.body.applicant) expect(item.applicant.firstName === firstName) } diff --git a/backend/core/types/src/backend-swagger.ts b/backend/core/types/src/backend-swagger.ts index 0703790b18..499a2ec7bb 100644 --- a/backend/core/types/src/backend-swagger.ts +++ b/backend/core/types/src/backend-swagger.ts @@ -2674,6 +2674,9 @@ export interface ApplicationMultiselectQuestionOption { /** */ checked: boolean + /** */ + mapPinPosition?: string + /** */ extraData?: AllExtraDataTypes[] } @@ -4565,6 +4568,9 @@ export interface MultiselectOption { /** */ collectRelationship?: boolean + /** */ + mapPinPosition?: string + /** */ exclusive?: boolean } diff --git a/shared-helpers/src/views/multiselectQuestions.tsx b/shared-helpers/src/views/multiselectQuestions.tsx index 03e8a1005e..43234ba2aa 100644 --- a/shared-helpers/src/views/multiselectQuestions.tsx +++ b/shared-helpers/src/views/multiselectQuestions.tsx @@ -358,7 +358,7 @@ export const mapCheckboxesToApi = ( const addressHolderRelationshipData = addressFields.filter( (addressField) => addressField === `${key}-${AddressHolder.Relationship}` ) - if (addressData.length) { + if (data[key] === true && addressData.length) { extraData.push({ type: InputType.address, key: "address", value: data[addressData[0]] }) if (addressHolderNameData.length) { @@ -380,6 +380,7 @@ export const mapCheckboxesToApi = ( return { key, + mapPinPosition: data?.[`${key}-mapPinPosition`], checked: data[key] === true, extraData: extraData, } @@ -450,6 +451,9 @@ export const mapApiToMultiselectForm = ( if (addressHolderRelationship) { acc[`${curr.key}-${AddressHolder.Relationship}`] = addressHolderRelationship.value } + if (curr?.mapPinPosition) { + acc[`${curr.key}-mapPinPosition`] = curr.mapPinPosition + } } } diff --git a/sites/partners/src/components/applications/PaperApplicationForm/MultiselectQuestionsMap.tsx b/sites/partners/src/components/applications/PaperApplicationForm/MultiselectQuestionsMap.tsx new file mode 100644 index 0000000000..c78795d458 --- /dev/null +++ b/sites/partners/src/components/applications/PaperApplicationForm/MultiselectQuestionsMap.tsx @@ -0,0 +1,189 @@ +import React, { useEffect, useState } from "react" +import { FieldGroup, LatitudeLongitude, ListingMap, t } from "@bloom-housing/ui-components" +import { FieldValue, Grid } from "@bloom-housing/ui-seeds" +import { useFormContext, useWatch } from "react-hook-form" +import { GeocodeService as GeocodeServiceType } from "@mapbox/mapbox-sdk/services/geocoding" + +interface MapBoxFeature { + center: number[] // Index 0: longitude, Index 1: latitude +} + +interface MapboxApiResponseBody { + features: MapBoxFeature[] +} + +interface MapboxApiResponse { + body: MapboxApiResponseBody +} + +interface BuildingAddress { + city: string + state: string + street: string + zipCode: string + longitude?: number + latitude?: number +} + +type MultiselectQuestionsMapProps = { + geocodingClient: GeocodeServiceType + dataKey: string +} + +const MultiselectQuestionsMap = ({ geocodingClient, dataKey }: MultiselectQuestionsMapProps) => { + const [customMapPositionChosen, setCustomMapPositionChosen] = useState(true) + const formMethods = useFormContext() + + // eslint-disable-next-line @typescript-eslint/unbound-method + const { register, control, getValues, setValue, watch } = formMethods + + const buildingAddress: BuildingAddress = useWatch({ + control, + name: `${dataKey}-address`, + }) + const mapPinPosition = useWatch({ + control, + name: `${dataKey}-mapPinPosition`, + }) + + const [latLong, setLatLong] = useState({ + latitude: buildingAddress?.latitude ?? null, + longitude: buildingAddress?.longitude ?? null, + }) + + const displayMapPreview = () => { + return ( + buildingAddress?.city && + buildingAddress?.state && + buildingAddress?.street && + buildingAddress?.zipCode && + buildingAddress?.zipCode.length >= 5 + ) + } + + const getNewLatLong = () => { + if ( + buildingAddress?.city && + buildingAddress?.state && + buildingAddress?.street && + buildingAddress?.zipCode && + geocodingClient + ) { + geocodingClient + .forwardGeocode({ + query: `${buildingAddress.street}, ${buildingAddress.city}, ${buildingAddress.state}, ${buildingAddress.zipCode}`, + limit: 1, + }) + .send() + .then((response: MapboxApiResponse) => { + setLatLong({ + latitude: response.body.features[0].center[1], + longitude: response.body.features[0].center[0], + }) + }) + .catch((err) => console.error(`Error calling Mapbox API: ${err}`)) + } + } + + if ( + getValues(`${dataKey}-address.latitude`) !== latLong.latitude || + getValues(`${dataKey}-address.longitude`) !== latLong.longitude + ) { + setValue(`${dataKey}-address.latitude`, latLong.latitude) + setValue(`${dataKey}-address.longitude`, latLong.longitude) + } + + useEffect(() => { + if (watch(dataKey)) { + register(`${dataKey}-address.longitude`) + register(`${dataKey}-address.latitude`) + } + }, [dataKey, register, setValue, watch]) + + useEffect(() => { + let timeout + if (!customMapPositionChosen || mapPinPosition === "automatic") { + timeout = setTimeout(() => { + getNewLatLong() + }, 1000) + } + return () => { + clearTimeout(timeout) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ + buildingAddress?.city, + buildingAddress?.state, + buildingAddress?.street, + buildingAddress?.zipCode, + ]) + + useEffect(() => { + if (mapPinPosition === "automatic") { + getNewLatLong() + setCustomMapPositionChosen(false) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [mapPinPosition]) + + return ( + <> + + + {displayMapPreview() ? ( + + ) : ( +
+ {t("listings.mapPreviewNoAddress")} +
+ )} +
+
+ +

{t("listings.mapPinPosition")}

+
+ + + + + ) +} + +export default MultiselectQuestionsMap diff --git a/sites/partners/src/components/applications/PaperApplicationForm/sections/FormMultiselectQuestions.tsx b/sites/partners/src/components/applications/PaperApplicationForm/sections/FormMultiselectQuestions.tsx index 6f8c6546f1..cac836d64c 100644 --- a/sites/partners/src/components/applications/PaperApplicationForm/sections/FormMultiselectQuestions.tsx +++ b/sites/partners/src/components/applications/PaperApplicationForm/sections/FormMultiselectQuestions.tsx @@ -1,4 +1,4 @@ -import React, { useMemo } from "react" +import React, { useEffect, useMemo, useState } from "react" import { Field, t, FieldGroup, resolveObject } from "@bloom-housing/ui-components" import { FieldValue, Grid } from "@bloom-housing/ui-seeds" import { useFormContext } from "react-hook-form" @@ -11,6 +11,10 @@ import { } from "@bloom-housing/backend-core/types" import SectionWithGrid from "../../../shared/SectionWithGrid" import { FormAddressAlternate } from "@bloom-housing/shared-helpers/src/views/address/FormAddressAlternate" +import GeocodeService, { + GeocodeService as GeocodeServiceType, +} from "@mapbox/mapbox-sdk/services/geocoding" +import MultiselectQuestionsMap from "../MultiselectQuestionsMap" type FormMultiselectQuestionsProps = { questions: ListingMultiselectQuestion[] @@ -45,6 +49,18 @@ const FormMultiselectQuestions = ({ return keys }, [questions, applicationSection]) + const [geocodingClient, setGeocodingClient] = useState() + + useEffect(() => { + if (process.env.mapBoxToken || process.env.MAPBOX_TOKEN) { + setGeocodingClient( + GeocodeService({ + accessToken: process.env.mapBoxToken || process.env.MAPBOX_TOKEN, + }) + ) + } + }, []) + if (questions?.length === 0) { return null } @@ -106,6 +122,10 @@ const FormMultiselectQuestions = ({ stateKeys={stateKeys} data-testid={"app-question-extra-field"} /> + )} diff --git a/sites/partners/src/components/users/FormSignInAddPhone.tsx b/sites/partners/src/components/users/FormSignInAddPhone.tsx index 81fba45ebc..1f2aa00b6d 100644 --- a/sites/partners/src/components/users/FormSignInAddPhone.tsx +++ b/sites/partners/src/components/users/FormSignInAddPhone.tsx @@ -70,7 +70,7 @@ const FormSignInAddPhone = ({ />
-
diff --git a/sites/partners/src/components/users/FormSignInMFAType.tsx b/sites/partners/src/components/users/FormSignInMFAType.tsx index 5218d843eb..78a99f0968 100644 --- a/sites/partners/src/components/users/FormSignInMFAType.tsx +++ b/sites/partners/src/components/users/FormSignInMFAType.tsx @@ -84,7 +84,12 @@ const FormSignInMFAType = ({ {process.env.showSmsMfa && (
-