diff --git a/backend/core/package.json b/backend/core/package.json index 7b78bb0829..2989585877 100644 --- a/backend/core/package.json +++ b/backend/core/package.json @@ -59,6 +59,7 @@ "@nestjs/typeorm": "^7.1.0", "@types/cache-manager": "^3.4.0", "@types/cron": "^1.7.3", + "@types/mapbox": "^1.6.42", "async-retry": "^1.3.1", "axios": "0.21.1", "bull": "^4.1.0", @@ -78,6 +79,7 @@ "ioredis": "^4.24.4", "joi": "^17.3.0", "jwt-simple": "^0.5.6", + "mapbox": "^1.0.0-beta10", "nanoid": "^3.1.12", "nestjs-throttler-storage-redis": "^0.1.11", "nestjs-typeorm-paginate": "^3.1.3", @@ -115,6 +117,7 @@ "dotenv": "^8.2.0", "fishery": "^0.3.0", "jest": "^26.5.3", + "mapbox": "^1.0.0-beta10", "supertest": "^4.0.2", "swagger-axios-codegen": "0.11.16", "ts-jest": "26.4.1", diff --git a/backend/core/scripts/import-listings-basic.ts b/backend/core/scripts/import-listings-basic.ts index 5c977f7c6b..a0e1af2841 100644 --- a/backend/core/scripts/import-listings-basic.ts +++ b/backend/core/scripts/import-listings-basic.ts @@ -7,10 +7,15 @@ import dbOptions = require("../ormconfig") import { Program } from "../src/program/entities/program.entity" import { AddressCreateDto } from "../src/shared/dto/address.dto" -// TODO where are multiple yearbuilt values that are string in format number/number +var MapboxClient = require('mapbox'); +if (!process.env["MAPBOX_TOKEN"]) { + throw new Error("environment variable MAPBOX_TOKEN is undefined") +} const args = process.argv.slice(2) +const client = new MapboxClient(process.env["MAPBOX_TOKEN"]) + const filePath = args[0] if (typeof filePath !== "string" && !fs.existsSync(filePath)) { throw new Error(`usage: ts-node import-unit-groups.ts csv-file-path`) @@ -59,6 +64,7 @@ export class HeaderConstants { public static readonly RequiredDocuments: string = "Required Documents" public static readonly ImportantProgramRules: string = "Important Program Rules" public static readonly SpecialNotes: string = "Special Notes" + } async function fetchDetroitJurisdiction(connection: Connection): Promise { @@ -98,31 +104,37 @@ function destructureYearBuilt(yearBuilt: string): number { } if (yearBuilt.includes("/")) { - const [year1, _] = yearBuilt.split("/") - return Number.parseInt(year1) + const [year1, year2] = yearBuilt.split("/") + return Number.parseInt(year2) } return Number.parseInt(yearBuilt) } -function destructureAddressString(addressString: string): AddressCreateDto { +async function destructureAddressString(addressString: string): Promise { if (!addressString) { - return { - street: undefined, - city: undefined, - state: undefined, - zipCode: undefined, - } + return null } const tokens = addressString.split(",").map((addressString) => addressString.trim()) + let latitude + let longitude + + const res = await client.geocodeForward(addressString) + if (res.entity?.features?.length) { + latitude = res.entity.features[0].center[0] + longitude = res.entity.features[0].center[1] + } + if (tokens.length === 1) { return { street: tokens[0], city: undefined, state: undefined, zipCode: undefined, + latitude, + longitude } } @@ -133,6 +145,8 @@ function destructureAddressString(addressString: string): AddressCreateDto { city: tokens[1], state, zipCode, + latitude, + longitude } } @@ -165,7 +179,6 @@ async function main() { temporaryListingId: row[HeaderConstants.TemporaryId], assets: [], name: row[HeaderConstants.Name], - // TODO should displayWaitlistSize be false? displayWaitlistSize: false, property: { developer: row[HeaderConstants.Developer], @@ -193,7 +206,7 @@ async function main() { leasingAgentEmail: row[HeaderConstants.LeasingAgentEmail], leasingAgentPhone: row[HeaderConstants.LeasingAgentPhone], managementWebsite: row[HeaderConstants.ManagementWebsite], - leasingAgentAddress: destructureAddressString(row[HeaderConstants.LeasingAgentAddress]), + leasingAgentAddress: await destructureAddressString(row[HeaderConstants.LeasingAgentAddress]), applicationFee: row[HeaderConstants.ApplicationFee], depositMin: row[HeaderConstants.DepositMin], depositMax: row[HeaderConstants.DepositMax], diff --git a/backend/core/scripts/import-unit-groups.ts b/backend/core/scripts/import-unit-groups.ts index c3514cc3a7..9f76209600 100644 --- a/backend/core/scripts/import-unit-groups.ts +++ b/backend/core/scripts/import-unit-groups.ts @@ -11,13 +11,7 @@ import { MSHDA2021 } from "../src/seeder/seeds/ami-charts/MSHDA2021" import { MonthlyRentDeterminationType } from "../src/units-summary/types/monthly-rent-determination.enum" import dbOptions = require("../ormconfig") -// TODO how to solve 4+BR - type AmiChartNameType = "MSHDA" | "HUD" -type TAmiChartLevel = { - amiChartName: AmiChartNameType - amiPercentage: number -} const args = process.argv.slice(2) @@ -37,32 +31,22 @@ export class HeaderConstants { public static readonly WaitlistOpen: string = "Waitlist Open" public static readonly AMIChart: string = "AMI Chart" public static readonly AmiChartPercentage: string = "Percent AMIs" -} - -function generateAmiChartLevels( - amiChartsColumns: string, - amiPercentagesColumn: string | number -): Array { - const amiChartLevels = [] - - for (const amiChartName of amiChartsColumns.split("/")) { - // TODO remove && amiPercentagesColumn when empty AMI percentage column problem is solved - if (typeof amiPercentagesColumn === "string" && amiPercentagesColumn) { - for (const amiPercentage of amiPercentagesColumn.split(",").map((s) => s.trim())) { - amiChartLevels.push({ - amiChartName, - amiPercentage: Number.parseInt(amiPercentage), - }) - } - } else if (typeof amiPercentagesColumn === "number") { - amiChartLevels.push({ - amiChartName, - amiPercentage: amiPercentagesColumn, - }) - } - } - - return amiChartLevels + public static readonly Value20: string = "20% (Value)" + public static readonly Value25: string = "25% (Value)" + public static readonly Value30: string = "30% (Value)" + public static readonly Value35: string = "35% (Value)" + public static readonly Value40: string = "40% (Value)" + public static readonly Value45: string = "45% (Value)" + public static readonly Value50: string = "50% (Value)" + public static readonly Value55: string = "55% (Value)" + public static readonly Value60: string = "60% (Value)" + public static readonly Value70: string = "70% (Value)" + public static readonly Value80: string = "80% (Value)" + public static readonly Value100: string = "100% (Value)" + public static readonly Value120: string = "120% (Value)" + public static readonly Value125: string = "125% (Value)" + public static readonly Value140: string = "140% (Value)" + public static readonly Value150: string = "150% (Value)" } function findAmiChartByName( @@ -79,32 +63,99 @@ function findAmiChartByName( ) } -function getFlatRentValueForAmiChart(amiChart: AmiChart, amiPercentage: number) { - return amiChart.items.find((item) => item.percentOfAmi === amiPercentage).percentOfAmi +function parseAmiStringValue(value: string | number) { + if (typeof value === "number") { + return value + } else if (typeof value === "string") { + const retval = Number.parseInt(value.replace(/\$/, "").replace(/,/, "")) + if (!retval) { + console.log("dollar value") + console.log(value) + throw new Error("Failed to parse $ (dolar) value") + } + return retval + } else { + throw new Error("Unknown ami value type") + } +} + +function getAmiValueFromColumn(row, amiPercentage: number, type: "percentage" | "flat") { + const mapAmiPercentageToColumnName = { + 20: HeaderConstants.Value20, + 25: HeaderConstants.Value25, + 30: HeaderConstants.Value30, + 35: HeaderConstants.Value35, + 40: HeaderConstants.Value40, + 45: HeaderConstants.Value45, + 50: HeaderConstants.Value50, + 55: HeaderConstants.Value55, + 60: HeaderConstants.Value60, + 70: HeaderConstants.Value70, + 80: HeaderConstants.Value80, + 100: HeaderConstants.Value100, + 120: HeaderConstants.Value120, + 125: HeaderConstants.Value125, + 140: HeaderConstants.Value140, + 150: HeaderConstants.Value150, + } + const value = row[mapAmiPercentageToColumnName[amiPercentage]] + + if (value) { + const splitValues = value.split("/") + + if (splitValues.length === 1) { + return parseAmiStringValue(value) + } else if (splitValues.length === 2) { + return type === "flat" + ? parseAmiStringValue(splitValues[0]) + : parseAmiStringValue(splitValues[1]) + } + + throw new Error("This part should not be reached") + } } function generateUnitsSummaryAmiLevels( - amiCharts: Array, - inputAmiChartLevels: Array + row, + amiChartEntities: Array, + amiChartString: string, + amiChartPercentagesString: string ) { + const amiCharts = amiChartString.split("/") + + let amiPercentages: Array = [] + if (amiChartPercentagesString && typeof amiChartPercentagesString === "string") { + amiPercentages = amiChartPercentagesString + .split(",") + .map((s) => s.trim()) + .map((s) => Number.parseInt(s)) + } else if (amiChartPercentagesString && typeof amiChartPercentagesString === "number") { + amiPercentages = [amiChartPercentagesString] + } + const amiChartLevels: Array> = [] - for (const inputAmiChartLevel of inputAmiChartLevels) { - const amiChart = findAmiChartByName(amiCharts, inputAmiChartLevel.amiChartName) + for (const amiChartName of amiCharts) { + const amiChartEntity = findAmiChartByName(amiChartEntities, amiChartName as AmiChartNameType) const monthlyRentDeterminationType = - inputAmiChartLevel.amiChartName === "MSHDA" + amiChartName === "MSHDA" ? MonthlyRentDeterminationType.flatRent : MonthlyRentDeterminationType.percentageOfIncome - amiChartLevels.push({ - amiChart: amiChart, - amiPercentage: inputAmiChartLevel.amiPercentage, - monthlyRentDeterminationType, - flatRentValue: - monthlyRentDeterminationType === MonthlyRentDeterminationType.flatRent - ? getFlatRentValueForAmiChart(amiChart, inputAmiChartLevel.amiPercentage) - : null, - }) + for (const amiPercentage of amiPercentages) { + amiChartLevels.push({ + amiChart: amiChartEntity, + amiPercentage: + monthlyRentDeterminationType === MonthlyRentDeterminationType.percentageOfIncome + ? getAmiValueFromColumn(row, amiPercentage, "percentage") + : null, + monthlyRentDeterminationType, + flatRentValue: + monthlyRentDeterminationType === MonthlyRentDeterminationType.flatRent + ? getAmiValueFromColumn(row, amiPercentage, "flat") + : null, + }) + } } return amiChartLevels @@ -170,17 +221,12 @@ async function main() { unitTypes.push(unitType) } - const inputAmiChartLevels = generateAmiChartLevels( - row[HeaderConstants.AMIChart], - row[HeaderConstants.AmiChartPercentage] - ) - const newUnitsSummary: DeepPartial = { minOccupancy: row[HeaderConstants.MinOccupancy] ? row[HeaderConstants.MinOccupancy] : null, maxOccupancy: row[HeaderConstants.MaxOccupancy] - ? row[HeaderConstants.MinOccupancy] + ? row[HeaderConstants.MaxOccupancy] : null, totalCount: row[HeaderConstants.TotalCount] ? row[HeaderConstants.TotalCount] : null, totalAvailable: row[HeaderConstants.TotalAvailable] @@ -188,7 +234,12 @@ async function main() { : null, openWaitlist: getOpenWaitlistValue(row), unitType: unitTypes, - amiLevels: generateUnitsSummaryAmiLevels(amiCharts, inputAmiChartLevels), + amiLevels: generateUnitsSummaryAmiLevels( + row, + amiCharts, + row[HeaderConstants.AMIChart], + row[HeaderConstants.AmiChartPercentage] + ), } listing.unitGroups.push(newUnitsSummary) diff --git a/backend/core/src/migration/1646908736734-make-unit-group-ami-level-ami-percentage-optional.ts b/backend/core/src/migration/1646908736734-make-unit-group-ami-level-ami-percentage-optional.ts new file mode 100644 index 0000000000..d959aaccfa --- /dev/null +++ b/backend/core/src/migration/1646908736734-make-unit-group-ami-level-ami-percentage-optional.ts @@ -0,0 +1,15 @@ +import {MigrationInterface, QueryRunner} from "typeorm"; + +export class makeUnitGroupAmiLevelAmiPercentageOptional1646908736734 implements MigrationInterface { + name = 'makeUnitGroupAmiLevelAmiPercentageOptional1646908736734' + + public async up(queryRunner: QueryRunner): Promise { + + await queryRunner.query(`ALTER TABLE "unit_group_ami_levels" ALTER COLUMN "ami_percentage" DROP NOT NULL`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "unit_group_ami_levels" ALTER COLUMN "ami_percentage" SET NOT NULL`); + } + +} diff --git a/backend/core/src/units-summary/entities/unit-group-ami-level.entity.ts b/backend/core/src/units-summary/entities/unit-group-ami-level.entity.ts index abb1ba7fb6..3566e8949a 100644 --- a/backend/core/src/units-summary/entities/unit-group-ami-level.entity.ts +++ b/backend/core/src/units-summary/entities/unit-group-ami-level.entity.ts @@ -27,10 +27,11 @@ export class UnitGroupAmiLevel { @ManyToOne(() => UnitGroup, (unitGroup: UnitGroup) => unitGroup.amiLevels) unitGroup: UnitGroup - @Column({ type: "integer", nullable: false }) + @Column({ type: "integer", nullable: true }) @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) @IsNumber({}, { groups: [ValidationsGroupsEnum.default] }) - amiPercentage: number + amiPercentage: number | null @Column({ type: "enum", enum: MonthlyRentDeterminationType, nullable: false }) @Expose() diff --git a/yarn.lock b/yarn.lock index 8670f8d973..5d22f94e7b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7259,6 +7259,13 @@ resolved "https://registry.yarnpkg.com/@types/jwt-decode/-/jwt-decode-2.2.1.tgz#afdf5c527fcfccbd4009b5fd02d1e18241f2d2f2" integrity sha512-aWw2YTtAdT7CskFyxEX2K21/zSDStuf/ikI3yBqmwpwJF0pS+/IX5DWv+1UFffZIbruP6cnT9/LAJV1gFwAT1A== +"@types/leaflet@^0": + version "0.7.35" + resolved "https://registry.yarnpkg.com/@types/leaflet/-/leaflet-0.7.35.tgz#5eb474dee4ce4b126292cd0ba4db5e09190649a2" + integrity sha512-BK+pa9a9dYC1qJyYQulqkRI9N+ZnV4ycAmNSOUmom7C6xaAdmrhOoiCiDMhSQklyjPpasy3KWRTkTRTJuDbBSw== + dependencies: + "@types/geojson" "*" + "@types/long@^4.0.0", "@types/long@^4.0.1": version "4.0.1" resolved "https://registry.yarnpkg.com/@types/long/-/long-4.0.1.tgz#459c65fa1867dafe6a8f322c4c51695663cc55e9" @@ -7278,6 +7285,13 @@ dependencies: "@types/geojson" "*" +"@types/mapbox@^1.6.42": + version "1.6.42" + resolved "https://registry.yarnpkg.com/@types/mapbox/-/mapbox-1.6.42.tgz#e15d33d738b2242e490cc49985aa771ffc0050ef" + integrity sha512-mQlrYBOwqz/kGG8P5KOTXLRMlZFLNFDdxzNaEukig3Uh9I2ukk6LXg5yaCUS8bk3/3ie6o3h/ZKAaXuwTljpdw== + dependencies: + "@types/leaflet" "^0" + "@types/mapbox__mapbox-sdk@*", "@types/mapbox__mapbox-sdk@^0.13.2": version "0.13.2" resolved "https://registry.yarnpkg.com/@types/mapbox__mapbox-sdk/-/mapbox__mapbox-sdk-0.13.2.tgz#adb096d6f91642e7994cf8da9c09732f90a0aca8" @@ -12299,6 +12313,11 @@ es6-object-assign@^1.1.0: resolved "https://registry.yarnpkg.com/es6-object-assign/-/es6-object-assign-1.1.0.tgz#c2c3582656247c39ea107cb1e6652b6f9f24523c" integrity sha1-wsNYJlYkfDnqEHyx5mUrb58kUjw= +es6-promise@^4.0.5: + version "4.2.8" + resolved "https://registry.yarnpkg.com/es6-promise/-/es6-promise-4.2.8.tgz#4eb21594c972bc40553d276e510539143db53e0a" + integrity sha512-HJDGx5daxeIvxdBxvG2cb9g4tEvwIk3i8+nhX0yGrYmZUzbkdg8QbDevheDB8gd0//uPj4c1EQua8Q+MViT0/w== + es6-shim@^0.35.5: version "0.35.6" resolved "https://registry.yarnpkg.com/es6-shim/-/es6-shim-0.35.6.tgz#d10578301a83af2de58b9eadb7c2c9945f7388a0" @@ -17397,6 +17416,14 @@ mapbox-gl@^2.3.0: tinyqueue "^2.0.3" vt-pbf "^3.1.1" +mapbox@^1.0.0-beta10: + version "1.0.0-beta10" + resolved "https://registry.yarnpkg.com/mapbox/-/mapbox-1.0.0-beta10.tgz#037561bcb95cbdc066d1dff7e2ce1d6c8539cb8e" + integrity sha1-A3VhvLlcvcBm0d/34s4dbIU5y44= + dependencies: + es6-promise "^4.0.5" + rest "^2.0.0" + markdown-escapes@^1.0.0: version "1.0.4" resolved "https://registry.yarnpkg.com/markdown-escapes/-/markdown-escapes-1.0.4.tgz#c95415ef451499d7602b91095f3c8e8975f78535" @@ -21622,6 +21649,11 @@ responselike@1.0.2, responselike@^1.0.2: dependencies: lowercase-keys "^1.0.0" +rest@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/rest/-/rest-2.0.0.tgz#6dfadf66a405c49cfbd5b4bd25b59fd29cd861bc" + integrity sha1-bfrfZqQFxJz71bS9JbWf0pzYYbw= + restore-cursor@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/restore-cursor/-/restore-cursor-1.0.1.tgz#34661f46886327fed2991479152252df92daa541"