diff --git a/.circleci/config.yml b/.circleci/config.yml new file mode 100644 index 0000000000..fcc73dab07 --- /dev/null +++ b/.circleci/config.yml @@ -0,0 +1,182 @@ +version: 2.1 +orbs: + cypress: cypress-io/cypress@1.26.0 + +executors: + standard-node: + docker: + - image: "cimg/node:14.17.6" + - image: "circleci/redis:6.2.1-alpine" + - image: "circleci/postgres:12.3-postgis" + environment: + POSTGRES_USER: bloom-ci + # Never do this in production or with any sensitive / non-test data: + POSTGRES_HOST_AUTH_METHOD: trust + POSTGRES_DB: bloom + cypress-node: + docker: + - image: "cypress/base:14.17.0" + - image: "circleci/redis:6.2.1-alpine" + - image: "circleci/postgres:12.3-postgis" + environment: + POSTGRES_USER: bloom-ci + # Never do this in production or with any sensitive / non-test data: + POSTGRES_HOST_AUTH_METHOD: trust + POSTGRES_DB: bloom + environment: + PORT: "3100" + EMAIL_API_KEY: "SG.SOME-LONG-SECRET-KEY" + EMAIL_FROM_ADDRESS: "Bloom Dev Housing Portal " + APP_SECRET: "CI-LONG-SECRET-KEY" + # DB URL for migration and seeds: + DATABASE_URL: "postgres://bloom-ci@localhost:5432/bloom" + # DB URL for the jest tests per ormconfig.test.ts + TEST_DATABASE_URL: "postgres://bloom-ci@localhost:5432/bloom" + REDIS_TLS_URL: "rediss://localhost:6379/0" + REDIS_URL: "redis://localhost:6379/0" + REDIS_USE_TLS: "0" + PARTNERS_PORTAL_URL: "http://localhost:3001" + puppeteer-node: + docker: + - image: "cimg/node:14.17.6-browsers" + +jobs: + setup: + executor: standard-node + steps: + - checkout + - run: yarn install --frozen-lockfile + - save_cache: + key: build-cache-{{ .Environment.CIRCLE_SHA1 }} + paths: + - ~/ + setup-with-db: + executor: standard-node + steps: + - restore_cache: + key: build-cache-{{ .Environment.CIRCLE_SHA1 }} + - run: yarn test:backend:core:dbsetup + lint: + executor: standard-node + steps: + - restore_cache: + key: build-cache-{{ .Environment.CIRCLE_SHA1 }} + - run: yarn lint + jest-shared-helpers: + executor: standard-node + steps: + - restore_cache: + key: build-cache-{{ .Environment.CIRCLE_SHA1 }} + - run: yarn test:shared:helpers + jest-ui-components: + executor: standard-node + steps: + - restore_cache: + key: build-cache-{{ .Environment.CIRCLE_SHA1 }} + - run: yarn test:shared:ui + jest-ui-components-a11y: + executor: puppeteer-node + steps: + - restore_cache: + key: build-cache-{{ .Environment.CIRCLE_SHA1 }} + - run: yarn test:shared:ui:a11y + jest-backend: + executor: standard-node + steps: + - restore_cache: + key: build-cache-{{ .Environment.CIRCLE_SHA1 }} + - run: + name: DB Setup + Backend Core Tests + command: | + yarn test:backend:core:dbsetup + yarn test:backend:core + yarn test:e2e:backend:core + environment: + PORT: "3100" + EMAIL_API_KEY: "SG.SOME-LONG-SECRET-KEY" + EMAIL_FROM_ADDRESS: "Bloom Dev Housing Portal " + APP_SECRET: "CI-LONG-SECRET-KEY" + # DB URL for migration and seeds: + DATABASE_URL: "postgres://bloom-ci@localhost:5432/bloom" + # DB URL for the jest tests per ormconfig.test.ts + TEST_DATABASE_URL: "postgres://bloom-ci@localhost:5432/bloom" + REDIS_TLS_URL: "rediss://localhost:6379/0" + REDIS_URL: "redis://localhost:6379/0" + REDIS_USE_TLS: "0" + CLOUDINARY_SIGNED_PRESET: "fake_secret" + CLOUDINARY_KEY: "fake_key" + CLOUDINARY_CLOUD_NAME: "exygy" + CLOUDINARY_SECRET: "fake_secret" + PARTNERS_PORTAL_URL: "http://localhost:3001" + build-public: + executor: standard-node + steps: + - restore_cache: + key: build-cache-{{ .Environment.CIRCLE_SHA1 }} + - run: yarn build:app:public + build-partners: + executor: standard-node + steps: + - restore_cache: + key: build-cache-{{ .Environment.CIRCLE_SHA1 }} + - run: yarn build:app:partners + unit-test-partners: + executor: standard-node + steps: + - restore_cache: + key: build-cache-{{ .Environment.CIRCLE_SHA1 }} + - run: yarn test:app:partners:unit + +workflows: + version: 2 + build: + jobs: + - setup + - lint: + requires: + - setup + - jest-shared-helpers: + requires: + - setup + - jest-ui-components: + requires: + - setup + - jest-ui-components-a11y: + requires: + - setup + - jest-backend: + requires: + - setup + - build-public: + requires: + - setup + - build-partners: + requires: + - setup + - unit-test-partners: + requires: + - setup + - cypress/run: + name: "cypress-public" + requires: + - setup + executor: cypress-node + working_directory: sites/public + yarn: true + build: | + yarn test:backend:core:dbsetup + start: yarn dev:all-cypress + wait-on: "http://0.0.0.0:3000" + store_artifacts: true + - cypress/run: + name: "cypress-partners" + requires: + - setup + executor: cypress-node + working_directory: sites/partners + yarn: true + build: | + yarn test:backend:core:dbsetup + start: yarn dev:all-cypress + wait-on: "http://0.0.0.0:3001" + store_artifacts: true diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000000..4f1ef7f6f7 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,32 @@ +**/.circleci +**/.classpath +**/.dockerignore +**/.env +**/.git +**/.gitignore +**/.next +**/.project +**/.settings +**/.toolstarget +**/.vs +**/.vscode +**/*.*proj.user +**/*.dbmdl +**/*.jfm +**/azds.yaml +**/charts +**/docker-compose* +**/compose* +**/cypress +**/docs +**/Dockerfile* +**/node_modules +**/npm-debug.log +**/obj +**/secrets.dev.yaml +**/values.dev.yaml +backend/core/dist +backend/core/test +ui-components/__tests__ +ui-components/storybook-static +README.md \ No newline at end of file diff --git a/.eslintrc.js b/.eslintrc.js index 3ec7d945de..ac87940c47 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -1,18 +1,55 @@ module.exports = { parser: "@typescript-eslint/parser", // Specifies the ESLint parser + parserOptions: { + project: ["./tsconfig.json", "./sites/public/tsconfig.json", "./sites/partners/tsconfig.json"], + ecmaVersion: 2018, // Allows for the parsing of modern ECMAScript features + sourceType: "module", // Allows for the use of imports + tsconfigRootDir: ".", + }, + plugins: ["react", "@typescript-eslint"], extends: [ + "eslint:recommended", // the set of rules which are recommended for all projects by the ESLint Team + "plugin:@typescript-eslint/eslint-recommended", // conflict resolution between above and below rulesets. "plugin:@typescript-eslint/recommended", // Uses the recommended rules from the @typescript-eslint/eslint-plugin + "plugin:@typescript-eslint/recommended-requiring-type-checking", // additional rules that take a little longer to run + "plugin:import/errors", // check for imports not resolving correctly + "plugin:import/warnings", + "plugin:import/typescript", + "plugin:react-hooks/recommended", // Make sure we follow https://reactjs.org/docs/hooks-rules.html "prettier/@typescript-eslint", // Uses eslint-config-prettier to disable ESLint rules from @typescript-eslint/eslint-plugin that would conflict with prettier - "plugin:prettier/recommended" // Enables eslint-plugin-prettier and displays prettier errors as ESLint errors. Make sure this is always the last configuration in the extends array. + "plugin:prettier/recommended", // Enables eslint-plugin-prettier and displays prettier errors as ESLint errors. Make sure this is always the last configuration in the extends array. ], - plugins: ["react", "typescript"], - parserOptions: { - ecmaVersion: 2018, // Allows for the parsing of modern ECMAScript features - sourceType: "module" // Allows for the use of imports - }, rules: { // Place to specify ESLint rules. Can be used to overwrite rules specified from the extended configs + "@typescript-eslint/camelcase": "off", "@typescript-eslint/explicit-function-return-type": "off", - "typescript/no-unused-vars": "warn" - } -}; + "@typescript-eslint/explicit-module-boundary-types": "off", + "@typescript-eslint/no-unused-vars": "warn", + "react/jsx-uses-vars": "warn", + "react/jsx-uses-react": "warn", + "@typescript-eslint/restrict-template-expressions": [ + "error", + { + allowNumber: true, + allowAny: true, + }, + ], + // These rules catches various usecases of variables typed as "any", since they won't be flagged by the TS + // compiler and thus are potential sources of issues. The current codebase has too many uses of `any` to make + // these effective rules though, so disabling them for now. + "@typescript-eslint/no-unsafe-member-access": "off", + "@typescript-eslint/no-unsafe-call": "off", + "@typescript-eslint/no-unsafe-assignment": "off", + "@typescript-eslint/no-unsafe-return": "off", + }, + ignorePatterns: [ + "node_modules", + "storybook-static", + ".next", + "dist", + "migration/", + "**/*.stories.tsx", + "**/.eslintrc.js", + "**/*.test.*", + ], +} diff --git a/.github/ISSUE_TEMPLATE/basic-story-issue.md b/.github/ISSUE_TEMPLATE/basic-story-issue.md new file mode 100644 index 0000000000..33dc6edb4b --- /dev/null +++ b/.github/ISSUE_TEMPLATE/basic-story-issue.md @@ -0,0 +1,18 @@ +--- +name: Basic Story Issue +about: For general tasks as part of a feature +title: "[Issue Title]" +labels: '' +assignees: '' + +--- + +What feature is this part of? + +Is there any context that will help with completing this task? + +Are there any potential sub-steps necessary to complete this task? + +Are there any potential solutions or alternatives? + +What is the definition of done? diff --git a/.github/ISSUE_TEMPLATE/bug-report.md b/.github/ISSUE_TEMPLATE/bug-report.md new file mode 100644 index 0000000000..7923706782 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug-report.md @@ -0,0 +1,19 @@ +--- +name: Bug Report +about: For bugs +title: "[Bug] " +labels: 'bug' +assignees: '' + +--- + +[REMEMBER: a bug should be understandable years later by someone who will never talk to the reporter or assignee.] + +STEPS TO REPRODUCE: +1. + +EXPECTED RESULTS: + +OBSERVED RESULTS: + +ADDITIONAL INFORMATION: diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000000..f3d5c415e0 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,38 @@ +--- +name: Bug report +about: Create a report to help us improve +title: '' +labels: bug +assignees: '' + +--- + +**Describe the bug** +A clear and concise description of what the bug is. + +**To Reproduce** +Steps to reproduce the behavior: +1. Go to '...' +2. Click on '....' +3. Scroll down to '....' +4. See error + +**Expected behavior** +A clear and concise description of what you expected to happen. + +**Screenshots** +If applicable, add screenshots to help explain your problem. + +**Desktop (please complete the following information):** + - OS: [e.g. iOS] + - Browser [e.g. chrome, safari] + - Version [e.g. 22] + +**Smartphone (please complete the following information):** + - Device: [e.g. iPhone6] + - OS: [e.g. iOS8.1] + - Browser [e.g. stock browser, safari] + - Version [e.g. 22] + +**Additional context** +Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000000..3ba13e0cec --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1 @@ +blank_issues_enabled: false diff --git a/.github/ISSUE_TEMPLATE/general-issue.md b/.github/ISSUE_TEMPLATE/general-issue.md new file mode 100644 index 0000000000..6465c969cd --- /dev/null +++ b/.github/ISSUE_TEMPLATE/general-issue.md @@ -0,0 +1,26 @@ +--- +name: General Issue +about: Issues for a feature +title: "[Issue Title]" +labels: '' +assignees: '' + +--- + +**What is this feature or what feature is this part of?** +A clear and concise description of what the problem is. + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. + +**Describe alternatives you've considered** +A clear and concise description of any alternative solutions or features you've considered. + +**Additional context** +Add any other context or screenshots about the feature request here. + +**What is the acceptance criteria/definition of done?** +A clear and concise description of the acceptance criteria required to close this issue. + +**QA Review Instructions** +This is to be filled out by the developer who completes this issue, before passing to QA. diff --git a/.github/card-labeler.yml b/.github/card-labeler.yml new file mode 100644 index 0000000000..d7da79f75a --- /dev/null +++ b/.github/card-labeler.yml @@ -0,0 +1,15 @@ +Milestones: + 'Epics': + - 'tracking' + 'M7': + - 'M7' + 'M8': + - 'M8' + 'M9': + - 'M9' + 'M10': + - 'M10' + 'M11': + - 'M11' + 'M12': + - 'M12' diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000000..cd51d2c60c --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,8 @@ +version: 2 +updates: + + - package-ecosystem: "github-actions" + directory: "/" + schedule: + # Check for updates to GitHub Actions every weekday + interval: "daily" diff --git a/.github/workflows/auto-assign-project.yml b/.github/workflows/auto-assign-project.yml new file mode 100644 index 0000000000..da6ef6eac0 --- /dev/null +++ b/.github/workflows/auto-assign-project.yml @@ -0,0 +1,25 @@ +name: Assign to One Project + +on: + issues: + types: [opened, labeled] +env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + +jobs: + assign_one_project: + runs-on: ubuntu-latest + name: Assign to One Project + steps: + - name: Assign NEW issues to the Backlog project + uses: srggrs/assign-one-project-github-action@1.3.1 + if: github.event.action == 'opened' && github.event.issue != null + with: + project: 'https://github.com/CityOfDetroit/bloom/projects/1' + column_name: 'Needs triage' + - name: Assign NEW issues to the Milestones project + uses: srggrs/assign-one-project-github-action@1.3.1 + if: github.event.action == 'opened' && github.event.issue != null + with: + project: 'https://github.com/CityOfDetroit/bloom/projects/2' + column_name: 'Triage' diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000000..240039b774 --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,39 @@ +name: Lint + +on: + # Trigger the workflow on pull request, + # but only for the dev branch + push: + branches: + - main + pull_request: + branches: + - main + +jobs: + run-linters: + name: Run linters + runs-on: ubuntu-latest + + steps: + - name: Check out Git repository + uses: actions/checkout@v3 + + - name: Set up Node.js + uses: actions/setup-node@v3 + with: + node-version: 14 + + # ESLint and Prettier must be in `package.json` + - name: Install Node.js dependencies + run: yarn install + + - name: Run linters + uses: wearerequired/lint-action@v2 + with: + eslint: true + prettier: true + auto_fix: true + + - name: Run separate lint command + run: yarn lint diff --git a/.github/workflows/pre-release_components.yml b/.github/workflows/pre-release_components.yml new file mode 100644 index 0000000000..63ea2bc840 --- /dev/null +++ b/.github/workflows/pre-release_components.yml @@ -0,0 +1,40 @@ +name: Pre-release ui-components + +on: + # Triggers the workflow on push only for the dev branch + push: + branches: [ dev ] + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - name: Config user name + run: git config --global user.name github.context.workflow + + - name: Config user email + run: git config --global user.email "github-actions@github.com" + + - name: Check out git repo + uses: actions/checkout@v3 + with: + fetch-depth: 0 + token: ${{ secrets.ADMIN_TOKEN }} + + - name: Set up Node.js + uses: actions/setup-node@v3 + with: + node-version: 14 + registry-url: 'https://registry.npmjs.org' + + - name: Version ui-components + run: yarn version:prerelease:ui-components + env: + GITHUB_TOKEN: ${{ secrets.ADMIN_TOKEN }} + + - name: Publish ui-components + run: yarn publish:ui-components + env: + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.github/workflows/project-card-moved.yml b/.github/workflows/project-card-moved.yml new file mode 100644 index 0000000000..b59965f9cc --- /dev/null +++ b/.github/workflows/project-card-moved.yml @@ -0,0 +1,10 @@ +on: + project_card: + types: [moved] +name: Project Card Event +jobs: + triage: + name: Auto card labeler + runs-on: ubuntu-latest + steps: + - uses: technote-space/auto-card-labeler@v2 diff --git a/.gitignore b/.gitignore index 941497a3a6..020178b1c4 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,17 @@ npm-debug.log* yarn-debug.log* yarn-error.log* +# Next.js cache and compiled artifacts +.next/ +**/out/_next* + +# Storybook build output +storybook-static + +# Cypress test output videos +**/cypress/videos +**/cypress/screenshots + # Complied Typescript dist @@ -57,10 +68,6 @@ typings/ # dotenv environment variables file .env -# gatsby files -.cache/ -public - # Mac files .DS_Store @@ -70,3 +77,21 @@ yarn-error.log .pnp.js # Yarn Integrity file .yarn-integrity + +# IDE configs +.idea +.vscode +*.code-workspace + +# VS code debugger config +launch.json + +# Jest unit test coverage +test-coverage/ + +# redis dumps +dump.rdb + +# csv import files +backend/core/detroit-listings.csv +backend/core/detroit-listings-units.csv diff --git a/.node-version b/.node-version new file mode 100644 index 0000000000..5595ae1aa9 --- /dev/null +++ b/.node-version @@ -0,0 +1 @@ +14.17.6 diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000000..b36566f8b6 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,9 @@ +**/*.hbs +.github +.travis.yml +ui-components/src/locales +ui-components/CHANGELOG.md +sites/public/CHANGELOG.md +sites/partners/CHANGELOG.md +shared-helpers/CHANGELOG.md +backend/core/CHANGELOG.md \ No newline at end of file diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000000..81142d6057 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,82 @@ +language: node_js + +node_js: + # Node version + - 14 + +cache: + yarn: true + +services: + - redis-server + +before_install: + # Trust all local connections to postgres, due to not being able to authenticate as the travis user. + - sudo sed -i -e '/local.*peer/s/postgres/all/' -e 's/peer\|md5/trust/g' /etc/postgresql/*/main/pg_hba.conf + - sudo systemctl restart postgresql@11-main + - sleep 1 + +before_script: + - cp sites/public/.env.template sites/public/.env + - cp sites/partners/.env.template sites/partners/.env + - cp backend/core/.env.template backend/core/.env + +jobs: + include: + - script: yarn test:shared:ui + name: "UI components tests" + - script: yarn build:app:public + name: "Build public site" + - script: yarn build:app:partners + name: "Build partners site" + - script: yarn test:backend:core:testdbsetup && yarn test:backend:core + name: "Backend unit tests" + - script: yarn test:e2e:backend:core + name: "Backend e2e tests" + - script: yarn test:public:unit-tests + name: "Public site unit tests" + # - stage: longer tests + # script: yarn test:shared:ui:a11y + # name: "Storybook a11y testing" + # - stage: longer tests + # name: "Public site Cypress tests" + # script: + # - yarn cypress install + # - yarn db:reseed + # - cd backend/core + # - yarn nest start & + # - cd ../../sites/public + # - yarn build + # - yarn start & + # - yarn wait-on "http-get://localhost:3000" && yarn cypress run + # - kill $(jobs -p) || true + # - stage: longer tests + # name: "Partners site Cypress tests" + # script: + # - yarn cypress install + # - yarn db:reseed + # - cd backend/core + # - yarn nest start & + # - cd ../../sites/partners + # - yarn build + # - yarn start -p 3001 & + # - yarn wait-on "http-get://localhost:3001" && yarn cypress run + # - kill $(jobs -p) || true + +addons: + postgresql: "11" + apt: + packages: + - postgresql-11 + - postgresql-client-11 + # if using Ubuntu 16 need this library + # # https://github.com/cypress-io/cypress-documentation/pull/1647 + - libgconf-2-4 + +env: + global: PGPORT=5433 + PGUSER=travis + TEST_DATABASE_URL=postgres://localhost:5433/bloom_test + REDIS_TLS_URL=redis://127.0.0.1:6379/0 + NEW_RELIC_ENABLED=false + NEW_RELIC_LOG_ENABLED=false diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000000..c4cc8874a0 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,622 @@ +# Changelog + +All notable changes to this project will be documented in this file. The format is loosely based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). + +(_Note:_ it our intention to improve our release process going forward by using [Semantic Versioning](https://semver.org/spec/v2.0.0.html).) + +## Detroit Team M12 + +- Fixed: + + - Removed units and application methods from seed listings + ([#590](https://github.com/CityOfDetroit/bloom/pull/590)) + +- Added: + + - Partially Senior reserved community type ([#572](https://github.com/CityOfDetroit/bloom/pull/572)) + - "About" page ([#589](https://github.com/CityOfDetroit/bloom/pull/589)) + - RTL support ([#627](https://github.com/CityOfDetroit/bloom/pull/627)) + - Add senior housing filtering to the filter modal ([#631](https://github.com/CityOfDetroit/bloom/pull/631)) + - Add AMI filter to frontend ([\$645](https://github.com/CityOfDetroit/bloom/pull/645)) + - Add independent living filtering([#639](https://github.com/CityOfDetroit/bloom/pull/639)) + +## Detroit Team M11 + +- Added: + + - Reset filter button + ([#489](https://github.com/CityOfDetroit/bloom/pull/489)) + - Number of filters in filter button + ([#489](https://github.com/CityOfDetroit/bloom/pull/489)) + - Filtering by availability ([#501](https://github.com/CityOfDetroit/bloom/pull/501)) + - Filtering by rent price ([#531](https://github.com/CityOfDetroit/bloom/pull/531)) + - UI for adding / editing units summaries ([#475](https://github.com/CityOfDetroit/bloom/pull/475)) + - Validate listing edit form ([#535](https://github.com/CityOfDetroit/bloom/pull/535)) + - Backend Filtering by AMI ([#532](https://github.com/CityOfDetroit/bloom/pull/532)) + - Add backend listing sorting ([#542](https://github.com/CityOfDetroit/bloom/pull/542) and [#548](https://github.com/CityOfDetroit/bloom/pull/548)) + +- Removed: + + - Removed minimum income from frontend forms and tables ([#578](https://github.com/CityOfDetroit/bloom/pull/578)) + +## Detroit Team M10 + +- Fixed: + + - Display a message when there are no results after applying filters + ([#456](https://github.com/CityOfDetroit/bloom/pull/456)) + +- Added: + + - Zipcode filtering to backend ([#399](https://github.com/CityOfDetroit/bloom/pull/399)) + - CSV import script ([#404](https://github.com/CityOfDetroit/bloom/pull/404)) + - Zipcode filtering to frontend ([#417](https://github.com/CityOfDetroit/bloom/pull/417)) + - Detroit AMI data and import script + ([#443](https://github.com/CityOfDetroit/bloom/pull/443)) + - Fetch UnitsSummary data in listing query ([#441](https://github.com/CityOfDetroit/bloom/pull/441)) + - Senior housing filtering for the eligibility questionnaire ([#446](https://github.com/CityOfDetroit/bloom/issues/446)] + +- Removed: + - Eligibility section of detailed listing view + ([#422](https://github.com/CityOfDetroit/bloom/pull/422)) + - Application section of partner portal + ([#438](https://github.com/CityOfDetroit/bloom/pull/438)) + +## Detroit Team M9 + +- Fixed: + + - Cypress tests for `sites/public` ([#171](https://github.com/CityOfDetroit/bloom/issues/171)) + - Change the COUNTY_CODE to Detroit ([#351](https://github.com/CityOfDetroit/bloom/pull/351)) + +- Added: + - Added fields to Listing and Property to accommodate Detroit listing data ([#311](https://github.com/CityOfDetroit/bloom/pull/311)) + - Add eligibility questionnaire validation ([#327](https://github.com/CityOfDetroit/bloom/pull/327)) + - Add support for comma-separated lists to filters ([#356](https://github.com/CityOfDetroit/bloom/pull/356)) + - Add eligibility questionnaire state management and back buttons ([#371](https://github.com/CityOfDetroit/bloom/pull/371)) + - Detroit seed properties ([#362](https://github.com/CityOfDetroit/bloom/pull/362)) + - Add bedrooms/unit size filter to backend ([#368](https://github.com/CityOfDetroit/bloom/pull/368)) + - Add bedrooms/unit size filter to frontend ([#391](https://github.com/CityOfDetroit/bloom/pull/391)) + +## Detroit Team M8 + +- Added: + - Made `addFilters()` more generic ([#257](https://github.com/CityOfDetroit/bloom/pull/257)) + - Fixed lowercaseing issue in query built by `addFilters()` ([#264](https://github.com/CityOfDetroit/bloom/pull/264)) + - Move where clauses and pagination to subquery, so filtered results return all listing data ([#271](https://github.com/CityOfDetroit/bloom/pull/271)) + +## Detroit Team M7 + +- Added: + - Debug flags for public and partner site ([#195](https://github.com/CityOfDetroit/bloom/pull/195)) + - Upstream filter param parsing, with changes to support pagination params and filters that aren't on the listings table ([#180](https://github.com/CityOfDetroit/bloom/pull/180)) + - Eligibility questionnaire ([#154](https://github.com/CityOfDetroit/bloom/pull/154), [#198](https://github.com/CityOfDetroit/bloom/pull/198), [#208](https://github.com/CityOfDetroit/bloom/pull/208)) + +## Unreleased + +## Frontend + +- Fixed: + + - Language typo in the paper applications table ([#1965](https://github.com/bloom-housing/bloom/pull/1965)) (Jared White) + - Improved UX for the Building Selection Criteria drawer ([#1994](https://github.com/bloom-housing/bloom/pull/1994)) (Jared White) + - alternate contact email is validated ([#2035](https://github.com/bloom-housing/bloom/pull/2035)) (Yazeed) + - Incorrect last name ([#2107](https://github.com/bloom-housing/bloom/pull/2107)) (Dominik Barcikowski) + +## Backend + +- Fixed: + + - Incorrect listing status ([#2015](https://github.com/bloom-housing/bloom/pull/2015)) (Dominik Barcikowski) + +## v2.0.0-pre-tailwind 09/16/2021 + +## Frontend + +- Added: + + - Support PDF uploads or webpage links for building selection criteria ([#1893](https://github.com/bloom-housing/bloom/pull/1893)) (Jared White) + - Show confirmation modal when publishing listings ([#1772](https://github.com/bloom-housing/bloom/pull/1772)) (Jared White) + - Split Listing form up into two main tabs ([#1644](https://github.com/bloom-housing/bloom/pull/1644)) (Jared White) + - Allow lottery results to be uploaded for a closed listing ([#1568](https://github.com/bloom-housing/bloom/pull/1568)) (Jared White) + - Update buttons / pages visibility depending on a user role ([#1609](https://github.com/bloom-housing/bloom/pull/1609)) (Dominik Barcikowski) + - Terms page checkbox text changed to blue ([#1645](https://github.com/bloom-housing/bloom/pull/1645)) (Emily Jablonski) + - Add FCFS and Lottery section to listing management ([#1485](https://github.com/bloom-housing/bloom/pull/1485)) (Emily Jablonski) + - Allow application status to show both FCFS and a due date ([#1680](https://github.com/bloom-housing/bloom/pull/1680)) (Emily Jablonski) + - Add new /users page with table ([#1679](https://github.com/bloom-housing/bloom/pull/1679)) (Dominik Barcikowski) + - Add new /unauthorized page ([#1763](https://github.com/bloom-housing/bloom/pull/1763)) (Dominik Barcikowski) + - Adds ability to create AMI chart overrides in listings management and refactors the unit form ([#1706](https://github.com/bloom-housing/bloom/pull/1706)) (Emily Jablonski) + - Add the "Add User" form ([#1857](https://github.com/bloom-housing/bloom/pull/1857)) (Dominik Barcikowski) + - Add a new account confirmation page ([#1654](https://github.com/bloom-housing/bloom/pull/1654)) (Dominik Barcikowski) + +- Fixed: + + - Responsive Tailwind grid classes and nested drawer overlays now work ([#1881](https://github.com/bloom-housing/bloom/pull/1881)) (Jared White) + - Update Listings component to sort listings by status ([#1585](https://github.com/bloom-housing/bloom/pull/1585)) + - Preferences ordinal bug in listings management ([#1641](https://github.com/bloom-housing/bloom/pull/1641)) (Emily Jablonski) + - Updates EnumListingReviewOrderType to be ListingReviewOrder ([#1679](https://github.com/bloom-housing/bloom/pull/1679)) + - Routing to individual application ([#1715](https://github.com/bloom-housing/bloom/pull/1715)) (Emily Jablonski) + - Application due date label in the listings table ([#1764](https://github.com/bloom-housing/bloom/pull/1764)) (Dominik Barcikowski) + - Update textarea character limit ([#1751](https://github.com/bloom-housing/bloom/pull/1751)) (Dominik Barcikowski) + - Update unit availability field ([#1767](https://github.com/bloom-housing/bloom/pull/1767)) (Dominik Barcikowski) + - Update select width ([#1765](https://github.com/bloom-housing/bloom/pull/1765)) (Dominik Barcikowski) + - Reset page to 1 on limit change ([#1809](https://github.com/bloom-housing/bloom/pull/1809)) (Dominik Barcikowski) + - Update public and partners to support preferred unit ids ([#1774](https://github.com/bloom-housing/bloom/pull/1774)) (Dominik Barcikowski) + - Update select options ([#1768](https://github.com/bloom-housing/bloom/pull/1768)) (Dominik Barcikowski) + - Can toggle application pick up and drop off addresses off ([#1954](https://github.com/bloom-housing/bloom/pull/1954)) (Emily Jablonski) + - Listings management AMI charts populate after Save and New on units ([#1952](https://github.com/bloom-housing/bloom/pull/1952)) (Emily Jablonski) + - Brings in updates from Alameda which fixes some issues with preference handling and lisitngs getStaticProps in production ([#1958](https://github.com/bloom-housing/bloom/pull/1958)) + - Preview can load without building address ([#1960](https://github.com/bloom-housing/bloom/pull/1960)) (Emily Jablonski) + - Page now scrolls after closing modal ([#1962](https://github.com/bloom-housing/bloom/pull/1962)) (Emily Jablonski) + - Copy & New and Save & New in LM will no longer create duplicate units ([#1963](https://github.com/bloom-housing/bloom/pull/1963)) (Emily Jablonski) + +- Changed: + + - Update text for preferred unit types and terms ([#1934](https://github.com/bloom-housing/bloom/pull/1934)) (Jared White) + - Upgrade the public and partners sites to Next v11 and React v17 ([#1793](https://github.com/bloom-housing/bloom/pull/1793)) (Jared White) + - **Breaking Change** + - The main changes are around removing the try catch blocks so errors prevent the build from finishing (should cover #1618) and the export script was removed, since it isn't valid with [fallback: true](https://nextjs.org/docs/advanced-features/static-html-export#caveats). So we'll have to change the build command to replace `export` with `start`. ([#1861](https://github.com/bloom-housing/bloom/pull/1861)) + - ** Breaking Change**: if your implementation relies on the export script, you'll need to use the start script, especially if you want to take advantage of the "fallback" setting for getStaticPaths + - Update textarea character limit and set default to 1000 ([#1906](https://github.com/bloom-housing/bloom/pull/1906)) (Dominik Barcikowski) + +### UI Components + +- Added: + + - Add ResponsiveTable for pricing + - Ability to have multiple statuses under the ImageCard ([#1700](https://github.com/bloom-housing/bloom/pull/1700)) (Emily Jablonski) + - **Breaking Change**: Removed three props (appStatus, appStatusContent, and appStatusSubContent) in favor of an array that contains that data - will need to transition any status information to the new array format + - Add Heading component and numbered-list styles ([#1405](https://github.com/bloom-housing/bloom/pull/1405)) (Marcin Jedras) + - Re-factor ResponsiveTable ([#1937](https://github.com/bloom-housing/bloom/pull/1937)) (Emily Jablonski) + - Add subNote to some form fields ([#1924](https://github.com/bloom-housing/bloom/pull/1924)) (Marcin Jedras) + +- Fixed: + + - StandardTable styling bug ([#1632](https://github.com/bloom-housing/bloom/pull/1632)) (Emily Jablonski) + - More robust Features section for public listing view ([#1688](https://github.com/bloom-housing/bloom/pull/1688)) + - A11Y issues with the image tint in ImageCard ([#1964](https://github.com/bloom-housing/bloom/pull/1964)) (Emily Jablonski) + - Visual bugs with SiteHeader ([#2010](https://github.com/bloom-housing/bloom/pull/2010)) (Emily Jablonski) + - HouseholdSizeField bug when householdSizeMax is 0 ([#1991](https://github.com/bloom-housing/bloom/pull/1991)) (Yazeed) + +- Changed: + + - Upgraded Tailwind to v2, PostCSS to v8, and Storybook to 6.3 ([#1805])(https://github.com/bloom-housing/bloom/pull/1805)) (Jared White) + - Upgraded React to v17 + - StandardTable new optional prop to translate cell content ([#1707](https://github.com/bloom-housing/bloom/pull/1707)) (Emily Jablonski) + - Removed business logic from ListingsList component ([#1752](https://github.com/bloom-housing/bloom/pull/1752)) (Emily Jablonski) + - **Breaking Change**: Removed listings prop and replaced with children and a listingsCount prop + - Removed business logic from HouseholdSizeField component ([#1724](https://github.com/bloom-housing/bloom/pull/1724)) (Emily Jablonski) + - **Breaking Change**: Removed listing prop and replaced with a set not dependent on data model + - Removed business logic from HousingCounselor component ([#1717](https://github.com/bloom-housing/bloom/pull/1717)) (Emily Jablonski) + - **Breaking Change**: Removed existing prop and replaced with a set not dependent on data model + - Removed business logic from ListingsList component ([#1773](https://github.com/bloom-housing/bloom/pull/1773)) (Emily Jablonski) + - **Breaking Change**: Removed ListingsList component and replaced with more generalizable ListingCard component which represents the image and table for one listing + - Remove formatIncome helper from ui-components ([#1744](https://github.com/bloom-housing/bloom/pull/1744)) (Emily Jablonski) + - **Breaking Change** + - Removed business logic from HouseholdMemberForm component ([#1722](https://github.com/bloom-housing/bloom/pull/1722)) (Emily Jablonski) + - **Breaking Change**: Removed existing props except for editMode and replaced with a set not dependent on data model + - Removed business logic from Hero component ([#1816](https://github.com/bloom-housing/bloom/pull/1816)) (Emily Jablonski) + - **Breaking Change**: Removed listings prop and replaced with allApplicationsClosed prop + - Removed business logic from AppStatusItem component ([#1714](https://github.com/bloom-housing/bloom/pull/1714)) (Emily Jablonski) + - **Breaking Change**: Removed both existing props, replaced with a set not dependent on data model, and renamed component to StatusItem + - Removed business logic from AdditionalFees component ([#1844](https://github.com/bloom-housing/bloom/pull/1844)) (Emily Jablonski) + - **Breaking Change**: Removed listing prop and replaced with a set not dependent on data model + - Removed business logic from Apply, Waitlist components ([#1819](https://github.com/bloom-housing/bloom/pull/1819)) (Emily Jablonski) + - **Breaking Change**: Removed existing props from both components and replaced with a set not dependent on data model, split "Apply" component into two new components GetApplication and Submit Application, removed ApplicationSection components + - Allow for a style-able subheader to be added to ListingCard ([#1880](https://github.com/bloom-housing/bloom/pull/1880)) (Emily Jablonski) + - **Breaking Change**: Moved tableHeader prop into new tableHeaderProps object + - Re-wrote SiteHeader to remove Bulma dependency and bugs ([#1885](https://github.com/bloom-housing/bloom/pull/1885)) (Emily Jablonski) + - **Breaking Change**: SiteHeader has a new prop set, including some props to toggle new visual features + - Set a max width for hero buttons when there are secondary buttons ([#2002](https://github.com/bloom-housing/bloom/pull/2002)) (Andrea Egan) + +### Backend + +- Added: + + - Add POST /users/invite endpoint and extend PUT /users/confirm with optional password change ([#1801](https://github.com/bloom-housing/bloom/pull/1801)) + - Add `isPartner` filter to GET /user/list endpoint ([#1830](https://github.com/bloom-housing/bloom/pull/1830)) + - Changes to applications done through `PUT /applications/:id` are now reflected in AFS ([#1810](https://github.com/bloom-housing/bloom/pull/1810)) + - Add logic for connecting newly created user account to existing applications (matching based on applicant.emailAddress) ([#1807](https://github.com/bloom-housing/bloom/pull/1807)) + - ** Breaking Change**: Add `jurisdiction` relation to `ReservedCommunitType` entity ([#1889](https://github.com/bloom-housing/bloom/pull/1889)) + - Added new userProfile resource and endpoint `PUT /userProfile/:id` suited specifically for users updating their own profiles ([#1862](https://github.com/bloom-housing/bloom/pull/1862)) + - Filtering, pagination, and tests for listings endpoint (Parts of Detroit Team [#18](https://github.com/CityOfDetroit/bloom/pull/18), [#133](https://github.com/CityOfDetroit/bloom/pull/133), [#180](https://github.com/CityOfDetroit/bloom/pull/180), [#257](https://github.com/CityOfDetroit/bloom/pull/257), [#264](https://github.com/CityOfDetroit/bloom/pull/264), [#271](https://github.com/CityOfDetroit/bloom/pull/271)) [#1578](https://github.com/CityOfDetroit/bloom/pull/1578) + - Units summary table ([#1607](https://github.com/bloom-housing/bloom/pull/1607)) + - Add support for comma-separated lists to filters, ensure comparison is valid ([Detroit Team #356](https://github.com/CityOfDetroit/bloom/pull/356), [#1634](https://github.com/bloom-housing/bloom/pull/1634)) + - Add bedrooms/unit size filter to backend ([Detroit Team #368](https://github.com/CityOfDetroit/bloom/pull/368), [#1660](https://github.com/bloom-housing/bloom/pull/1660)) + - Adds "view" parameter and "views" to specify selects and joins [#1626](https://github.com/bloom-housing/bloom/pull/1626) + - Adds `roles` property to `UserDto [#1575](https://github.com/bloom-housing/bloom/pull/1575) + - Adds UnitAmiChartOverride entity and implements ami chart overriding at Unit level [#1575](https://github.com/bloom-housing/bloom/pull/1575) + - Adds `authz.e2e-spec.ts` test cover for preventing user from voluntarily changing his associated `roles` object [#1575](https://github.com/bloom-housing/bloom/pull/1575) + - Adds Jurisdictions to users, listings and translations. The migration script assigns the first alpha sorted jurisdiction to users, so this piece may need to be changed for Detroit, if they have more than Detroit in their DB. [#1776](https://github.com/bloom-housing/bloom/pull/1776) + - Added the optional jurisdiction setting notificationsSignUpURL, which now appears on the home page if set ([#1802](https://github.com/bloom-housing/bloom/pull/1802)) (Emily Jablonski) + - Adds Listings managment validations required for publishing a Listing [#1850](https://github.com/bloom-housing/bloom/pull/1850) (Michał Plebański & Emily Jablonski) + - Add UnitCreateDto model changes to prevent form submission from creating UnitType, UnitRentType and AccessibilityType from creating a new DB row on each submission. ([#1956](https://github.com/bloom-housing/bloom/pull/1956)) + - Adds Program entity to Listing (Many to Many) and to Jurisdiction (Many to many) and seed programs ([1968](https://github.com/bloom-housing/bloom/pull/1968)) + - Add Language to Jurisidiction entity ([#1998](https://github.com/bloom-housing/bloom/pull/1998)) + - Add `DELETE /user/:id` and `GET /user/:id` endpoints and add leasingAgentInListings to UserUpdateDto + +- Changed: + + - ** Breaking Change**: Endpoint `PUT /user/:id` is admin only now, because it allows edits over entire `user` table ([#1862](https://github.com/bloom-housing/bloom/pull/1862)) + - Changes to applications done through `PUT /applications/:id` are now reflected in AFS ([#1810](https://github.com/bloom-housing/bloom/pull/1810)) + - Adds confirmationCode to applications table ([#1854](https://github.com/bloom-housing/bloom/pull/1854)) + - Add various backend filters ([#1884](https://github.com/bloom-housing/bloom/pull/1884)) + - Adds jurisdiction relation to AmiChart entity ([#1905](https://github.com/bloom-housing/bloom/pull/1905)) + - Updated listing's importer to handle latest unit and priority types changes ([#1584](https://github.com/bloom-housing/bloom/pull/1584)) (Marcin Jedras) + - Sets cache manager to use Redis [#1589](https://github.com/bloom-housing/bloom/compare/dev...seanmalbert:1589/redis-cache-manager) + - removed roles for public users and assigned a "partner" role for leasing agents([#1628](https://github.com/bloom-housing/bloom/pull/1628)) + - Updates redis reset call to flush all keys + - Updated listing's importer to handle latest reserved community type changes ([#1667](https://github.com/bloom-housing/bloom/pull/1667)) (Emily Jablonski) + - Change whatToExpect to be a string instead of a json blob, make it editable in listings management ([#1681](https://github.com/bloom-housing/bloom/pull/1681)) (Emily Jablonski) + - Updates listing post/put/delete endpoints to call cacheManager.reset instead of clearing and maintaining a growing set of keys. Updates transformUnits to check for units and length before continuing ([#1698](https://github.com/bloom-housing/bloom/pull/1698)) + - Allow for unit sets to have multiple ami charts ([#1678](https://github.com/bloom-housing/bloom/pull/1678)) (Emily Jablonski) + - UnitDto now only contains an AMI chart ID instead of the entire object AmiChart. AmiCharts must now be fetched separately from `/amiCharts` ([#1575](https://github.com/bloom-housing/bloom/pull/1575) + - `GET /listings` filters query param has been changed to support a querystring serialized array of filters, it's a breaking change because comparison property can no longer be an array. Also a property ordering problem has been resolved. Now the strict requirement for every client using the API is to use `qs` serialization format for query params. ([#1782](https://github.com/bloom-housing/bloom/pull/1782)) + - `amiPercentage` field on UnitsSummary is migrated to an integer instead of a string. ((#1797)[https://github.com/bloom-housing/bloom/pull/1797]) + - Change preferredUnit property to store unitType ids ([#1787](https://github.com/bloom-housing/bloom/pull/1787)) (Sean Albert) + - Trying to confirm already confirmed user now throws account already confirmed error instead of tokenMissing ([#1971](https://github.com/bloom-housing/bloom/pull/1971)) + - Updates CSV Builder service to work with any data set, predefined or not. ([#1955](https://github.com/bloom-housing/bloom/pull/1955)) + - Remove field applicationAddress ([#2009](https://github.com/bloom-housing/bloom/pull/2009)) (Emily Jablonski) + - Introduce N-M Listing-Preference relation through a self managed (not TypeORM managed) intermediate entity ListingPreference, which now holds ordinal and page. Remove Preference entity entirely with an appropriate DB migration. ([1947](https://github.com/bloom-housing/bloom/pull/1947)) + +- Fixed: + - Added checks for property in listing.dto transforms + - Display all listings on partners with `limit=all` ([#1635](https://github.com/bloom-housing/bloom/issues/1635)) (Marcin Jędras) + - Seed data should create unique application methods ([#1662](https://github.com/bloom-housing/bloom/issues/1662)) (Emily Jablonski) + - fixes issue with unexposed user roles ((#1627)[https://github.com/bloom-housing/bloom/pull/1627])) + - updates translations to check for values before sending to service ((#1699)[https://github.com/bloom-housing/bloom/pull/1699]) + - Fixes flakiness in authz.e2e-spec.ts related to logged in user trying to GET /applications which did not belong to him (sorting of UUID is not deterministic, so the user should fetch by specying a query param userId = self) [#1575](https://github.com/bloom-housing/bloom/pull/1575) + - Fixed ListingsService.retrieve `view` query param not being optional in autogenerated client (it should be) [#1575](https://github.com/bloom-housing/bloom/pull/1575) + - updated DTOs to omit entities and use DTOs for application-method, user-roles, user, listing and units-summary ([#1679](https://github.com/bloom-housing/bloom/pull/1679)) + - makes application flagged sets module take applications edits into account (e.g. a leasing agent changes something in the application) ([#1810](https://github.com/bloom-housing/bloom/pull/1810)) + - Listings with multiple AMI charts show a max value instead of a range ([#1925](https://github.com/bloom-housing/bloom/pull/1925)) (Emily Jablonski) + - fix AFS totalFlagged missing in swagger documentation + - lower cases email during user creation, across saved users, and where that now lower cased email is compared to a possibly non-lower cased email ([#1972](https://github.com/bloom-housing/bloom/pull/1972)) (Yazeed) + +### General + +- Added: + + - A new `shared-helpers` package for consolidating functions and constants between the Next.js sites and possibly the backend ([#1911](https://github.com/bloom-housing/bloom/issues/1911)) (Jared White) + **Breaking Change**: Various constants were extracted out of the `ui-components` package + +- Updated: + + - Updates so leasing agent can access listing detail on frontend and removes applicationCount logic on backend and the ability for a leasing agent to create a new listing [#1627](https://github.com/bloom-housing/bloom/pull/1627) + +- Fixed: + + - Prettier action issues ([#1826](https://github.com/bloom-housing/bloom/issues/1826)) (Emily Jablonski) + - Issues with the Apply section and different application types. This includes updates to the ApplicationTypes section. This also renames `openDateState` to `openInFuture`, which is more descriptive and pulls logic out of UI-Components. This also has a fix for the re-submit issues on the listing form. ([#1853](https://github.com/bloom-housing/bloom/pull/1853)) + - **Breaking Change**: LeasingAgent no longer uses openDateState to determine if it should render. So if you need LeasingAgent to conditionally render, you have to add that check to where you call it. + +## v1.0.5 08/03/2021 + +- Added: + - Debug flags for public and partner site ([Detroit Team #195](https://github.com/CityOfDetroit/bloom/pull/195), [#1519](https://github.com/bloom-housing/bloom/pull/1519)) + +### Backend + +- Added: + + - /applicationMethods and /paperApplications endpoints and corresponding DB schema + - Added "bhaFormat" to CSV exporter ([#1640](https://github.com/bloom-housing/bloom/pull/1640)) (Emily Jablonski) + +- Fixed: + + - optional fields not being marked as optional in frontend client (missing '?' indicator) ([#1470](https://github.com/bloom-housing/bloom/pull/1470)) + - add duplicates to CSV export ([#1352](https://github.com/bloom-housing/bloom/issues/1352)) + - unit summaries transformations after a regression coming from separating unitTypes from jsonb column into a table + +- Changed: + + - User module has been removed and incorporated into Auth module + - convert listing events jsonb column to separate listing_events table + - convert listing address jsonb columns to separate address tables + - removed unused inverse relations from entities + - recreated foreign keys constraints for `application_flagged_set_applications_applications`, `listings_leasing_agents_user_accounts`, `property_group_properties_property` and add missing `NOT NULL` migration for listing name column + - add google translations for GET listing API call ([#1590](https://github.com/bloom-housing/bloom/pull/1590)) (Marcin Jędras) + - Listing applicationMethods jsonb column has been converted to a separate table + +- Added: + - ability for an admin to confirm users by `/users` POST/PUT methods + +### Frontend + +- Fixed: + + - refactors listing form submit to fix double submit issue ([#1501](https://github.com/bloom-housing/bloom/pull/1501)) + +- Added: + + - A notice bar to the preview page of a listing ([#1532](https://github.com/bloom-housing/bloom/pull/1532)) (Jared White) + - Photo upload and preview to the Partner Listing screens ([#1491](https://github.com/bloom-housing/bloom/pull/1491)) (Jared White) + - AG-grid sorting now is connected with the backend sorting ([#1083](https://github.com/bloom-housing/bloom/issues/1083)) (Michał Plebański) + - Add Preferences section to listing management ([#1564](https://github.com/bloom-housing/bloom/pull/1564)) (Emily Jablonski) + - Add Community Type to listing management ([#1540](https://github.com/bloom-housing/bloom/pull/1540)) (Emily Jablonski) + +- Changed: + - Remove coming soon text, use application open text instead ([#1602](https://github.com/bloom-housing/bloom/pull/1602)) (Emily Jablonski) + +### UI Components + +- Fixed: + + - Fix a11y language navigation ([#1528](https://github.com/bloom-housing/bloom/pull/1528)) (Dominik Barcikowski) + - Update default mobile height for image-only navbar-logo ([#1466](https://github.com/bloom-housing/bloom/issues/1466))) (Andrea Egan) + - Remove border from navbar wrapper and align border color on primary button ([#1596](https://github.com/bloom-housing/bloom/pull/1596)) (Marcin Jedras) + +- Added: + - Preview (disabled) state for Listings Application button ([#1502](https://github.com/bloom-housing/bloom/pull/1502)) (Jared White) + - Automated a11y testing for ui-components ([#1450](https://github.com/bloom-housing/bloom/pull/1450)) + - Add ActionBlock component ([#1404](https://github.com/bloom-housing/bloom/pull/1459)) (Marcin Jedras) + +## 1.0.4 / 2021-07-07 + +### General + +- Added: + + - Added backend/proxy ([#1380](https://github.com/bloom-housing/bloom/pull/1380)) + - Added cache manager to lisitngs controller, added add listing button and cleanup ([#1422](https://github.com/bloom-housing/bloom/pull/1422)) + - Added unit_accessibility_priority_types, unit_rent_types, unit_types table and corresponding API endpoints (also created a relation between Unit and mentioned tables) ([#1439](https://github.com/bloom-housing/bloom/pull/1439) + +### Backend + +- Fixed: + + - Poor TypeORM performance in `/applications` endpoint ([#1131](https://github.com/bloom-housing/bloom/issues/1131)) (Michał Plebański) + - POST `/users` endpoint response from StatusDTO to UserBasicDto (Michał Plebański) + - Replaces `toPrecision` function on `units-transformations` to `toFixed` ([#1304](https://github.com/bloom-housing/bloom/pull/1304)) (Marcin Jędras) + - "totalFlagged" computation and a race condition on Application insertion ([#1366](https://github.com/bloom-housing/bloom/pull/1366)) + - Fix units availability ([#1397](https://github.com/bloom-housing/bloom/issues/1397)) + +- Added: + + - Added "closed" to ListingStatus enum + - Added Transform to ListingStatus field to return closed if applicationDueDate is in the past + - Added "ohaFormat" to CSV exporter (includes OHA and HOPWA preferences) ([#1292](https://github.com/bloom-housing/bloom/pull/1292)) (Michał Plebański) + - `/assets` endpoints (create and createPresignedUploadMetadata) + - "noEmailConfirmation" query param to `POST /users` endpoint + - POST `/users` endpoint response from StatusDTO to UserBasicDto (Michał Plebański) + - `/jurisdictions` endpoint and DB schema ([#1391](https://github.com/bloom-housing/bloom/pull/1391)) + - `/reservedCommunityTypes` endpoint and DB schema ([#1395](https://github.com/bloom-housing/bloom/pull/1395)) + - list and retrieve methods to `/assets` endpoint + - `image` field to `listing` model ([#1413](https://github.com/bloom-housing/bloom/pull/1413)) + - reserved_community_type table seeds (`senior` and `specialNeeds`) + - add applicationDueDate check on submission ([#1409](https://github.com/bloom-housing/bloom/pull/1409)) + - list and retrieve methods to `/assets` endpoint + - added result_id to Listing model, allow creating `image` and `result` through listing endpoint (cascade) + - added resultLink, isWaitlistOpen and waitlistOpenSpots to Listing model + - Added applicationPickUpAddressType and applicationDropOffAddressType columns to Listing model ([#1425](https://github.com/bloom-housing/bloom/pull/1425)) (Michał Plebański) + +- Changed: + + - Cleanup seed data generation and add more variety ([#1312](https://github.com/bloom-housing/bloom/pull/1312)) Emily Jablonski + - Moved Property model to Listing (https://github.com/bloom-housing/bloom/issues/1328) + - removed eager relation to listing from User model + +### Frontend + +- Added: + + - Adds filtering capability to listings list and implements on public site ([#1351](https://github.com/bloom-housing/bloom/pull/1359)) + - Listings Management pieces added to Parnter's app, including add and detail pages + - add accessible at `/listings/add` + - detail page accessible at `/listings/[id]` + - New unit summary breaks down units by both type and rent ([#1253](https://github.com/bloom-housing/bloom/pull/1253)) (Emily Jablonski) + - Custom exclusive preference options ([#1272](https://github.com/bloom-housing/bloom/pull/1272)) (Emily Jablonski) + - Optionally hide preferences from Listing page ([#1280](https://github.com/bloom-housing/bloom/pull/1280)) (Emily Jablonski) + - Add ability for site header logo to have custom widths, image only ([#1346](https://github.com/bloom-housing/bloom/pull/1346)) (Emily Jablonski) + - Created duplicates pages ([#1132](https://github.com/bloom-housing/bloom/pull/1132)) (Dominik Barcikowski) + - Add Additional Details section to listing management ([#1338](https://github.com/bloom-housing/bloom/pull/1338)) (Emily Jablonski) + - Add Additional Eligibility section to listing management ([#1374](https://github.com/bloom-housing/bloom/pull/1374)) (Emily Jablonski) + - Add Leasing Agent section to listing management ([#1349](https://github.com/bloom-housing/bloom/pull/1349)) (Emily Jablonski) + - Add Additional Fees section to listing management ([#1377](https://github.com/bloom-housing/bloom/pull/1377)) (Emily Jablonski) + - Add Building Details and Intro section to listing management ([#1420](https://github.com/bloom-housing/bloom/pull/1420)) (Emily Jablonski) + - Add Building Features section to listing management ([#1412](https://github.com/bloom-housing/bloom/pull/1412)) (Emily Jablonski) + - Adds units to listings ([#1448](https://github.com/bloom-housing/bloom/pull/1448)) + - Add Rankings and Results section to listing management ([#1433](https://github.com/bloom-housing/bloom/pull/1433)) (Emily Jablonski) + - Add Application Address section to listing management ([#1425](https://github.com/bloom-housing/bloom/pull/1425)) (Emily Jablonski) + - Add Application Dates section to listing management ([#1432](https://github.com/bloom-housing/bloom/pull/1432)) (Emily Jablonski) + - Adds cache revalidation to frontend public app + - Add FCFS and Lottery section to listing management ([#1485](https://github.com/bloom-housing/bloom/pull/1485)) (Emily Jablonski) + +- Fixed: + + - Save application language in the choose-language step ([#1234](https://github.com/bloom-housing/bloom/pull/1234)) (Dominik Barcikowski) + - Fixed broken Cypress tests on the CircleCI ([#1262](https://github.com/bloom-housing/bloom/pull/1262)) (Dominik Barcikowski) + - Fix repetition of select text on preferences ([#1270](https://github.com/bloom-housing/bloom/pull/1270)) (Emily Jablonski) + - Fix aplication submission and broken test ([#1270](https://github.com/bloom-housing/bloom/pull/1282)) (Dominik Barcikowski) + - Fix broken application search in Partners ([#1301](https://github.com/bloom-housing/bloom/pull/1301)) (Dominik Barcikowski) + - Fix multiple unit rows in summaries, sorting issues ([#1306](https://github.com/bloom-housing/bloom/pull/1306)) (Emily Jablonski) + - Fix partners application submission ([#1340](https://github.com/bloom-housing/bloom/pull/1340)) (Dominik Barcikowski) + - Hide Additional Eligibility header if no sections present ([#1457](https://github.com/bloom-housing/bloom/pull/1457)) (Emily Jablonski) + - Listings Management MVP visual QA round ([#1463](https://github.com/bloom-housing/bloom/pull/1463)) (Emily Jablonski) + +- Changed: + + - Allow preferences to have optional descriptions and an optional generic decline ([#1267](https://github.com/bloom-housing/bloom/pull/1267)) Emily Jablonski + - Refactored currency field logic to be generic & reusable ([#1356](https://github.com/bloom-housing/bloom/pull/1356)) Emily Jablonski + +### UI Components + +- Added: + + - Dropzone-style file upload component ([#1437](https://github.com/bloom-housing/bloom/pull/1437)) (Jared White) + - Table image thumbnails component along with minimal left/right flush table styles ([#1339](https://github.com/bloom-housing/bloom/pull/1339)) (Jared White) + - Tabs component based on React Tabs ([#1305](https://github.com/bloom-housing/bloom/pull/1305)) (Jared White) + - **Note**: the previous `Tab` child of `TabNav` has been renamed to `TabNavItem` + - Icon support for standard variants of Button component ([#1268](https://github.com/bloom-housing/bloom/pull/1268)) (Jared White) + - Generic date component ([#1392](https://github.com/bloom-housing/bloom/pull/1392)) (Emily Jablonski) + +- Fixed: + + - Correct LinkButton and other styles in Storybook ([#1309](https://github.com/bloom-housing/bloom/pull/1309)) (Jared White & Jesse James Arnold) + - Fix aria reserved for future use warning ([#1378](https://github.com/bloom-housing/bloom/issues/1378)) (Andrea Egan) + +## 1.0.0 / 2021-05-21 + +### General + +- Added: + + - This new Changelog format! =) + - New GitHub template for pull requests ([#1208](https://github.com/bloom-housing/bloom/pull/1208)) (Sean Albert) + - Add missing listing fields ([#1186](https://github.com/bloom-housing/bloom/pull/1186)) (Marcin Jędras) + +- Changed: + + - Upgrade the public + partners apps to Next v10 along with new architectural patterns [#1087](https://github.com/bloom-housing/bloom/pull/1087)) + - **Breaking Change**: you will need to update any downstream app to Next 10 and provide a `NavigationContext` in order for the `ui-components` package to work. Also all handling of locales and i18n routing has been refactored to leverage Next 10. + - If hosting on Netlify, make sure you've installed the Next plugin for SSR routes after upgrading to Next 10. + - The `ui-components` package no longer has a dependency on Next and can be imported into generalized React codebases. + - Dynamic preferences (switch from hardcoded preference options) ([#1167](https://github.com/bloom-housing/bloom/pull/1167)) (dominikx96) + - Update license ([#1189](https://github.com/bloom-housing/bloom/pull/1189)) (Emily Jablonski) + - Bump ssri from 6.0.1 to 6.0.2 ([#1194](https://github.com/bloom-housing/bloom/pull/1194)) (dependabot) + +- Fixed: + + - StandardTable translation issue and BMR display ([#1203](https://github.com/bloom-housing/bloom/pull/1194)) (Emily Jablonski) + +### Backend + +- Added: + + - Missing resident state to CSV formatters ([#1223](https://github.com/bloom-housing/bloom/pull/1223)) (Michał Plebański) + - ENV variables to control rate limits ([#1155](https://github.com/bloom-housing/bloom/pull/1155)) (Bartłomiej Ponikiewski) + - Adjust via THROTTLE_TTL and THROTTLE_LIMIT + - User language ([#1181](https://github.com/bloom-housing/bloom/pull/1181)) (Michał Plebański) + - ApiProperty to files that have no .dto.ts suffixes ([#1180](https://github.com/bloom-housing/bloom/pull/1180)) (Michał Plebański) + - Get AFS by id and pagination meta for totalFlagged ([#1137](https://github.com/bloom-housing/bloom/pull/1137)) (Michał Plebański) + - Translations module and county codes for frontend ([#1145](https://github.com/bloom-housing/bloom/pull/1145)) (Michał Plebański) + +- Changed: + + - Bump handlebars from 4.7.6 to 4.7.7 ([#1218](https://github.com/bloom-housing/bloom/pull/1218)) (dependabot) + - Downgrade nestjs/swagger ([#1188](https://github.com/bloom-housing/bloom/pull/1188)) (Michał Plebański) + - Refactor: backend directories structure ([#1166](https://github.com/bloom-housing/bloom/pull/1166)) (Michał Plebański) + +### Frontend + +- Added: + + - Make additional eligibility sections optional ([#1213](https://github.com/bloom-housing/bloom/pull/1213)) (Emily Jablonski) + - Support rent as percent income ([#1195](https://github.com/bloom-housing/bloom/pull/1195)) (Emily Jablonski) + +- Fixed: + + - Add spinner to the Application Form terms page upon submit ([#1225](https://github.com/bloom-housing/bloom/pull/1225)) (Jared White) + - The loading overlay in ui-components was updated to use the same spinner as the button component + - Birthdate localizing issues ([#1202](https://github.com/bloom-housing/bloom/pull/1202)) + - Preferences translations in Partners ([#1206](https://github.com/bloom-housing/bloom/pull/1206)) (dominikx96) + - Referral application section on mobile ([#1201](https://github.com/bloom-housing/bloom/pull/1201)) + - Use correct listingId when redirecting from Sign In ([#1147](https://github.com/bloom-housing/bloom/pull/1147)) (Jared White) + - Flaky cypress tests ([#1115](https://github.com/bloom-housing/bloom/pull/1115)) (Emily Jablonski) + - 404 issues in my application and settings ([#1164](https://github.com/bloom-housing/bloom/pull/1164)) (Emily Jablonski) + +### UI Components + +- Added: + + - New application autofill translations ([#1196](https://github.com/bloom-housing/bloom/pull/1196)) (Emily Jablonski) + - New referral component ([#1146](https://github.com/bloom-housing/bloom/pull/1146)) (Emily Jablonski) + +- Changed: + + - Use translated strings on responsive table headers ([#1128](https://github.com/bloom-housing/bloom/pull/1128)) (Jared White) + +## 0.3.11 / 2021-04-22 + +### General + +- Added: + + - LICENSE.txt for AGPL ([#1100](https://github.com/bloom-housing/bloom/pull/1100)) (Emily Jablonski) + +- Changed: + + - Bump elliptic from 6.5.3 to 6.5.4 ([#1062](https://github.com/bloom-housing/bloom/pull/1062)) (dependabot) + - Bump y18n from 4.0.0 to 4.0.1 ([#1093](https://github.com/bloom-housing/bloom/pull/1093)) (dependabot) + +### Backend + +- Added: + + - Applications rate limit ([#1103](https://github.com/bloom-housing/bloom/pull/1103)) (Bartłomiej Ponikiewski) + - Add 3rd listing with 0 preferences ([#1124](https://github.com/bloom-housing/bloom/pull/1124)) (Michał Plebański) + - Better support for applications search ([#1092](https://github.com/bloom-housing/bloom/pull/1092)) (Bartłomiej Ponikiewski) + - Missing address and workAddress as joins to application householdMembers ([#1107](https://github.com/bloom-housing/bloom/pull/1107)) (Bartłomiej Ponikiewski) + - includeDemographics query param to CSV exporter ([#1031](https://github.com/bloom-housing/bloom/pull/1031)) (Michał Plebański) + +- Changed: + + - Move business logic from controller to service ([#1125](https://github.com/bloom-housing/bloom/pull/1125)) (Michał Plebański) + - Applications duplicate logic ([#1096](https://github.com/bloom-housing/bloom/pull/1096)) (Michał Plebański) + - Improve seeds ([#1110](https://github.com/bloom-housing/bloom/pull/1110)) (Michał Plebański) + +- Removed: + + - Redundant residence zip in CSV exporter ([#1056](https://github.com/bloom-housing/bloom/pull/1056)) (Michał Plebański) + +- Fixed: + + - Add exception handling in \_encodeDoubleQuotes ([#1141](https://github.com/bloom-housing/bloom/pull/1141)) (Michał Plebański) + - Cypress-node missing redis related envs ([#1136](https://github.com/bloom-housing/bloom/pull/1136)) (Michał Plebański) + - User service making use of env.SECRET instead of env.APP_SECRET ([#1108](https://github.com/bloom-housing/bloom/pull/1108)) (Michał Plebański) + - Order of application list ([#1063](https://github.com/bloom-housing/bloom/pull/1063)) (Bartłomiej Ponikiewski) + - MaxLength validations for alternateContact ([#1057](https://github.com/bloom-housing/bloom/pull/1057)) (Michał Plebański) + +### Frontend + +- Added: + + - Clean up aside block styling and add missing mobile blocks ([#1140](https://github.com/bloom-housing/bloom/pull/1140)) (Jared White) + - Autofill applications for signed in users ([#1111](https://github.com/bloom-housing/bloom/pull/1111)) (Jared White) + - Account settings ([#1106](https://github.com/bloom-housing/bloom/pull/1106)) (Emily Jablonski) + - My Applications loading state ([#1121](https://github.com/bloom-housing/bloom/pull/1121)) (Emily Jablonski) + - Missing translation for account audit ([#1116](https://github.com/bloom-housing/bloom/pull/1116)) (Marcin Jędras) + - My Applications screen ([#1079](https://github.com/bloom-housing/bloom/pull/1079)) (Emily Jablonski) + - CSV export error message ([#1104](https://github.com/bloom-housing/bloom/pull/1104)) (dominikx96) + - Loading state for csv export ([#1060](https://github.com/bloom-housing/bloom/pull/1060)) (dominikx96) + - Filters to the ada and preferences ([#1039](https://github.com/bloom-housing/bloom/pull/1039)) (dominikx96) + +- Changed: + + - Improved Create Account flow based on audit ([#1089](https://github.com/bloom-housing/bloom/pull/1089)) (Marcin Jędras) + - Tweak sizes for application table columns ([#1048](https://github.com/bloom-housing/bloom/pull/1048)) (Jared White) + +- Fixed: + + - Broken Cypress tests on master ([#1138](https://github.com/bloom-housing/bloom/pull/1138)) (Emily Jablonski) + - Message after account confirmation ([#1122](https://github.com/bloom-housing/bloom/pull/1122)) (Marcin Jędras) + - useRouter for confirmation ([#1114](https://github.com/bloom-housing/bloom/pull/1114)) (Marcin Jędras) + - Household maximum income translations on mobile view ([#1091](https://github.com/bloom-housing/bloom/pull/1091)) (Netra Badami) + - Annual and monthly income values in the applications table ([#1069](https://github.com/bloom-housing/bloom/pull/1069)) (dominikx96) + - Fixes to date format, allow markup for street, add lottery time ([#1064](https://github.com/bloom-housing/bloom/pull/1064)) (Marcin Jędras) + +### UI Components + +- Added: + + - New unit test suite ([#1050](https://github.com/bloom-housing/bloom/pull/1050)) (Emily Jablonski) + - Lottery results section ([#1034](https://github.com/bloom-housing/bloom/pull/1034)) (Marcin Jędras) + +- Changed: + + - Updated translations ([#1109](https://github.com/bloom-housing/bloom/pull/1109)) (dominikx96) + - Translation to Displacee Tenant ([#1061](https://github.com/bloom-housing/bloom/pull/1061)) (dominikx96) + - Updates for public lottery and open house ([#1058](https://github.com/bloom-housing/bloom/pull/1058)) (Marcin Jędras) + +- Removed: + + - Self package reference ([#1123](https://github.com/bloom-housing/bloom/pull/1123)) (James Wills) + - Storyshots functionality ([#1101](https://github.com/bloom-housing/bloom/pull/1101)) (Jared White) + +- Fixed: + + - Add missing string ([#1143](https://github.com/bloom-housing/bloom/pull/1143)) (Emily Jablonski) + +## 0.3.10 / 2021-03-04 + +## 0.3.9 / 2021-02-16 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000000..aa0a6773a3 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,29 @@ +# Contributing to Bloom + +Contributions to the core Bloom applications and services are welcomed. To help us meet the project's goals around quality and maintainability, we ask that all contributors read, understand, and agree to these guidelines. + +## Reporting an Issue + +We use GitHub issues to track both bugs and feature ideas, and encourage all developers working with Bloom to file issues for consideration. + +Please note that implementation-specific issues with individual Bloom sites should be tracked in the repositories for those sites. Our issue tracker is for issues with the core software and reference implementations only. + +## Pull Requests + +Pull requests (PRs) are the most effective way to make a contribution to Bloom, and can range from small one-line fixes to major feature additions. Here are some suggestions for having your pull requests quickly accepted: + +- Clearly document the issue your pull request is meant to address in the initial comment, and make sure the PR title summarizes the work in a useful way. If your PR addresses an issue in the issue tracker, make sure to link it. You do not need to have a corresponding issue before submitting a PR, but it's usually a good method of getting feedback on your approach before starting major work. + +- Make sure that all files touched by your PR still pass all lint and style rules (see below). If you're adding any meaningful functionality to the system, please add matching tests -- the team will be happy to provide guidance on the most time-efficient method for helping with test coverage. + +- Since we typically will have a number of PRs open in various states of progress, please label your PR appropriately in GitHub when it's ready for review, and one or more core team members will take a look at it. + +## Continuous Integration + +Bloom uses the Circle CI system to automatically run tests on any pull requests. No contribututions that introduce errors in existing tests will be accepted, so please make sure you see the confidence-inspiring green checkmark next to your commits before marking your PR ready for review. + +## Code Style + +We use the ESlint linting system and the automatic code formatter Prettier (which the linter also enforces). **All** pull requests must pass linting to be accepted, so they don't create fix work for future contributors. If you're not using an IDE that integrates with these tools, please run eslint + prettier from the cli on all added or changed code before you submit a pull request. + +As much as we'll try to keep current, the linting rules can become out of date, and in this case you should file an issue with the adjustments you're looking for. We'll be looking for a resulting PR with a minimum of two commits - one to adjust the rules or tooling, and another that updates the codebase to match. If the latter changes are complex, please discuss in advance and break up the work into reasonably reviewable commits. diff --git a/Dockerfile.sites-partners b/Dockerfile.sites-partners new file mode 100644 index 0000000000..7b83524159 --- /dev/null +++ b/Dockerfile.sites-partners @@ -0,0 +1,68 @@ +FROM node:14.17-alpine AS development +# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed. +RUN apk add --no-cache libc6-compat + +WORKDIR /usr/src/app/ + +# Copy all the source code from the root of the repo into the container +COPY . ./ + +WORKDIR /usr/src/app/sites/partners + +COPY sites/partners/package.json ./ +COPY sites/partners/yarn*.lock ./ +COPY sites/partners/tsconfig*.json ./ +COPY sites/partners/*config.js ./ + +RUN yarn install --frozen-lockfile + +COPY sites/partners . + +# If not defined, use our production backend site for build-time rendering. +# In order to override it for local development when building the image on +# its own, use --build-arg BACKEND_API_BASE=. +ARG BACKEND_API_BASE=https://backend-core-tj3gg4i5eq-uc.a.run.app +ARG PUBLIC_BASE_URL=https://sites-public-tj3gg4i5eq-uc.a.run.app +ARG SHOW_LM_LINKS=TRUE +ARG LANGUAGES=en,es,ar,bn + +RUN yarn build + +FROM node:14.17-alpine AS production + +# Use the NEXTJS_PORT variable as the PORT if defined, otherwise use PORT. +ENV NEXTJS_PORT=3001 +ENV PORT=${NEXTJS_PORT} +ARG NODE_ENV=production +ENV NODE_ENV=${NODE_ENV} + +WORKDIR /usr/src/app/ + +# We need to copy the source code for backend/core and ui-components as well in order +# to ensure that breaking changes in local dependencies from those packages are included +# instead of being pulled from npm. +COPY backend/core ./backend/core +COPY ui-components ./ui-components + +COPY --from=development /usr/src/app/package.json ./ +COPY --from=development /usr/src/app/yarn*.lock ./ +COPY --from=development /usr/src/app/tsconfig*.json ./ +COPY --from=development /usr/src/app/node_modules ./node_modules + +WORKDIR /usr/src/app/sites/partners + +COPY sites/partners . + +COPY --from=development /usr/src/app/sites/partners/next.config.js ./next.config.js +COPY --from=development /usr/src/app/sites/partners/public ./public +COPY --from=development /usr/src/app/sites/partners/.next ./.next +COPY --from=development /usr/src/app/sites/partners/node_modules ./node_modules + +EXPOSE ${PORT} + +# Next.js collects completely anonymous telemetry data about general usage. +# Learn more here: https://nextjs.org/telemetry +# Uncomment the following line in case you want to disable telemetry. +# ENV NEXT_TELEMETRY_DISABLED 1 + +CMD yarn next start -p ${PORT} diff --git a/Dockerfile.sites-public b/Dockerfile.sites-public new file mode 100644 index 0000000000..01d790cde0 --- /dev/null +++ b/Dockerfile.sites-public @@ -0,0 +1,66 @@ +FROM node:14.17-alpine AS development +# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed. +RUN apk add --no-cache libc6-compat + +WORKDIR /usr/src/app/ + +# Copy all the source code from the root of the repo into the container +COPY . ./ + +WORKDIR /usr/src/app/sites/public + +COPY sites/public/package.json ./ +COPY sites/public/yarn*.lock ./ +COPY sites/public/tsconfig*.json ./ +COPY sites/public/*config.js ./ + +RUN yarn install --frozen-lockfile + +COPY sites/public . + +# If not defined, use our production backend site for build-time rendering. +# In order to override it for local development when building the image on +# its own, use --build-arg BACKEND_API_BASE=. +ARG BACKEND_API_BASE=https://backend-core-tj3gg4i5eq-uc.a.run.app +ARG LANGUAGES=en,es,ar,bn + +RUN yarn build + +FROM node:14.17-alpine AS production + +# Use the NEXTJS_PORT variable as the PORT if defined, otherwise use PORT. +ENV NEXTJS_PORT=3000 +ENV PORT=${NEXTJS_PORT} +ARG NODE_ENV=production +ENV NODE_ENV=${NODE_ENV} + +WORKDIR /usr/src/app/ + +# We need to copy the source code for backend/core and ui-components as well in order +# to ensure that breaking changes in local dependencies from those packages are included +# instead of being pulled from npm. +COPY backend/core ./backend/core +COPY ui-components ./ui-components + +COPY --from=development /usr/src/app/package.json ./package.json +COPY --from=development /usr/src/app/yarn*.lock ./ +COPY --from=development /usr/src/app/tsconfig*.json ./ +COPY --from=development /usr/src/app/node_modules ./node_modules + +WORKDIR /usr/src/app/sites/public + +COPY sites/public . + +COPY --from=development /usr/src/app/sites/public/next.config.js ./next.config.js +COPY --from=development /usr/src/app/sites/public/public ./public +COPY --from=development /usr/src/app/sites/public/.next ./.next +COPY --from=development /usr/src/app/sites/public/node_modules ./node_modules + +EXPOSE ${PORT} + +# Next.js collects completely anonymous telemetry data about general usage. +# Learn more here: https://nextjs.org/telemetry +# Uncomment the following line in case you want to disable telemetry. +# ENV NEXT_TELEMETRY_DISABLED 1 + +CMD yarn next start -p ${PORT} diff --git a/Dockerfile.ui-components b/Dockerfile.ui-components new file mode 100644 index 0000000000..b228acb01b --- /dev/null +++ b/Dockerfile.ui-components @@ -0,0 +1,28 @@ +# Here we're using a non alpine image because it includes python +FROM node:14.17 AS build + +WORKDIR /usr/src/app/ + +# default port is 40953 +ENV PORT=40953 + +COPY . . + +WORKDIR /usr/src/app/ui-components + +COPY ui-components/yarn*.lock ui-components/package.json ./ + +RUN yarn install + +COPY ./ui-components . + +RUN yarn build-storybook + +EXPOSE ${PORT} + +WORKDIR /usr/src/app/ui-components/storybook-static + +# Can't use the ["", ""] syntax here because it doesn't interpolate variables. +# This is the "shell" form of CMD +# We're serving the static assets with python because start-storybook takes a long time +CMD python -m SimpleHTTPServer ${PORT} diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000000..413d06d08d --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2021 Exygy, Inc. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.md b/README.md index 0120ff253b..474c379978 100644 --- a/README.md +++ b/README.md @@ -1 +1,116 @@ -# Experimental DAHLIA \ No newline at end of file +# Bloom Affordable Housing System + +This is the repository for the Bloom affordable housing system. + +## System Overview + +Bloom consists of a client/server architecture using [Next.js](https://nextjs.org) (a React-based site framework) for the frontend applications and [NestJS](https://nestjs.com) for the backend API. + +The frontend apps can easily be deployed to any Jamstack-friendly web host such as Netlify or Vercel. The frontend build process performs a static rendering of as much of the React page component trees as possible based on API data available at the time of the build. Additional real-time interactivity is made possible by React components at run-time. + +The backend can be simultaenously deployed to PaaS-style hosts such as Heroku. Its primary architectural dependency is a PostgreSQL database. + +### Structure + +Bloom uses a monorepo-style repository, containing multiple user-facing applications and back-end services. The three main high-level packages are `backend/core`, `sites`, and `ui-components`. + +The sites package contains reference implementations for each of the two main user-facing applications in the system: + +--- + +- `sites/public` is the applicant-facing site available to the general public. It provides the ability to browse available listings and to apply for listings either using the Common Application (which we built and maintain) or an external link to an online or paper PDF application. +- Visit [sites/public/README](https://github.com/bloom-housing/bloom/blob/dev/sites/public/README.md) for more details. + +- `sites/partners` is the site designed for housing developers, property managers, and city/county (jurisdiction) employees. At the moment it offers the ability to view, edit, and export applications for listings and other administrative tasks. In the near future it will offer the ability to create, edit, and publish listings (which at the moment is done internally by our team). A login is required to use the Partners Portal. +- Visit [sites/partners/README](https://github.com/bloom-housing/bloom/blob/dev/sites/partners/README.md) for more details. + +Currently across our jurisdictions, our backend and partners portal implementations are shared, and the public site diverges slightly to accomodate jurisdictional customizations. The [housingbayarea Bloom fork](https://github.com/housingbayarea/bloom) is an example with customized public sites. In this fork of Bloom, our jurisdictions are each a separate branch. + +--- + +- `backend/core` is the container for the key backend services (e.g. listings, applications, users). Information is stored in a Postgres database and served over HTTPS to the front-end (either at build time for things that can be server-rendered, or at run time). Most services are part of a NestJS application which allows for consolidated operation in one runtime environment. Services expose a REST API, and aren't expected to have any UI other than for debugging. You can read more about our backend in the README in that package. +- Visit [backend/core/README](https://github.com/bloom-housing/bloom/blob/dev/backend/core/README.md) for more details. + +--- + +- `shared-helpers` contains types and functions intended for shared use between the Next.js sites, and in certain instances the frontend plus the backend (not currently but perhaps in the future). +- Visit [shared-helpers/README](https://github.com/bloom-housing/bloom/blob/dev/shared-helpers/README.md) for more details. + +--- + +- `ui-components` contains React components that are either shared between our applications or pulled out to be more customizable for our consumers. We use [Storybook](https://storybook.js.org/), an environment for easily browing the UI components independent of their implementation. Contributions to component stories are encouraged. +- Visit [ui-components/README](https://github.com/bloom-housing/bloom/blob/dev/ui-components/README.md) for more details and view our [published Storybook](https://storybook.bloom.exygy.dev/). + +## Getting Started for Developers + +If this is your first time working with Bloom, please be sure to check out the `sites/public`, `sites/partners` and `backend/core` README files for important configuration information specific to those pieces. + +## General Local Setup + +### Dependencies + +``` +yarn install +``` + +### Local environment variables + +Configuration of each app and service is read from environment variables. There is an `.env.template` file in each app or service directory that must be copied to `.env` (or equivalent). Some keys are purposefully missing for security concerns and are internally available. + +### Installing Dependencies and Seeding the Database + +This alias does a `yarn:install` in the root of the repo and `yarn install` and `yarn db:reseed` in the `backend/core` dir. + +``` +yarn setup +``` + +### Setting up a test Database + +The new `backend/core` uses a postgres database, which is accessed via TypeORM. Once postgres is set up and a blank database is initialized, yarn scripts are available within that package to create/migrate the schema, and to seed the database for development and testing. See [backend/core/README.md](https://github.com/bloom-housing/bloom/blob/master/backend/core/README.md) for more details. + +### Running a Local Test Server + +``` +yarn dev:all +``` + +This runs 3 processes for both apps and the backend services on 3 different ports: + +- 3000 for the public app +- 3001 for the partners app +- 3100 for backend/core + +## Contributing + +Contributions to the core Bloom applications and services are welcomed. To help us meet the project's goals around quality and maintainability, we ask that all contributors read, understand, and agree to our guidelines. + +### Issue tracking + +Our development tasks are managed through GitHub issues and any development (in the vast majority of cases) should be tied to an issue. Even if you don't plan on implementing an issue yourself, please feel free to submit them if you run into issues. Before creating an issue, check first to see if one already exists. When creating an issue, give it a descriptive title and include screenshots if relevant. Please don't start work on an issue without checking in with the Bloom team first as it may already be in development! If you have questions, feel free to tag us on issues (@seanmalbert, @emilyjablonski) and note that we are also using GitHub discussions. + +### Committing, Versioning, and Releasing + +We are using [lerna](https://lerna.js.org/) as a monorepo management tool. It automatically versions, releases, and generates a changelog across our packages. In conjunction with lerna we are also using [conventional commits](https://www.conventionalcommits.org/en/v1.0.0/), a specification for commit messages that helps lerna understand what level of change each commit is in order to automate our processes. + +On commit, two steps automatically run: (1) linting and (2) a verification of the conventional commit standard. We recommend not running `git commit` and instead globally installing commitizen (`npm install -g commitizen`) and committing with `git cz` which will run a commit message CLI. The CLI asks a series of questions and builds the commit message for you in the conventional commit format. You can also `git commit` with a message if you are 100% confident you have indicated the right level of change (it will still lint the message format). + +In addition to commits needing to be formatted as conventional commits, if you are making version changes across multiple packages, your commits must also be separated by package in order to not improperly version a package. + +On every merge to dev, a pre-release of the ui-components package is automatically published to npm, and our Netlify dev environment is updated. + +On every merge to master (bi-weekly), a release of the backend/core and ui-components packages are automatically published to npm, and our Netlify staging environment is updated. + +Once staging has been QAed, we manually update production. + +### Pull Requests + +Pull requests are opened to the dev branch, not to master. When opening a pull request please fill out the entire pull request template which includes tagging the issue your PR is related to, a description of your PR, indicating the type of change, including details for the reviewer about how to test your PR, and a testing checklist. Additionally, officially link the issue in GitHub's right-hand panel. + +Every PR needs to manually update our changelog. Find the relevant section (General, Frontend, Backend, UI Components) and subsection (Added, Changed, Fixed) and add a short description of your change followed by a link to the PR and your name (- Description Here ([#1234](https://github.com/bloom-housing/bloom/pull/1234)) (Your Name)). If it is a breaking change, please include **Breaking Change** and some notes below it about how to migrate. + +When your PR is ready for review, add the `ready for review` label to help surface it to our internal team. If there are specific team members working frequently on pieces you're changing, assign them as reviewers. If you put up a PR that is not yet ready, add the `wip` label. + +Once the PR has been approved, you either squash and merge if your changes are in one package, or rebase and merge if your changes are across packages to allow the versions based off of your commit messages to propagate appropriately. + +As a review on a PR, try not to leave only comments. If the PR requires further discussion or changes, mark it with Requested Changes. If a PR looks good to you or even if there are smaller changes requested that won't require an additional review, please mark it with Approved and comment on the last few changes needed. This helps other reviewers better understand the state of PRs at the list view and prevents an additionl unnecessary review cycle. diff --git a/app.json b/app.json new file mode 100644 index 0000000000..e3b23a5aa5 --- /dev/null +++ b/app.json @@ -0,0 +1,20 @@ +{ + "name": "Bloom Listing Service", + "repository": "https://github.com/bloom-housing/bloom", + "buildpacks": [ + { + "url": "https://github.com/lstoll/heroku-buildpack-monorepo.git" + }, + { + "url": "heroku/nodejs" + } + ], + "environments": { + "review": { + "addons": ["heroku-postgresql:hobby-dev"], + "scripts": { + "postdeploy": "yarn db:reseed" + } + } + } +} diff --git a/backend/core/.dockerignore b/backend/core/.dockerignore new file mode 100644 index 0000000000..4c55262482 --- /dev/null +++ b/backend/core/.dockerignore @@ -0,0 +1,24 @@ +**/.classpath +**/.dockerignore +**/.env +**/.git +**/.gitignore +**/.project +**/.settings +**/.toolstarget +**/.vs +**/.vscode +**/*.*proj.user +**/*.dbmdl +**/*.jfm +**/azds.yaml +**/charts +**/docker-compose* +**/compose* +**/Dockerfile* +**/node_modules +**/npm-debug.log +**/obj +**/secrets.dev.yaml +**/values.dev.yaml +README.md \ No newline at end of file diff --git a/backend/core/.env.template b/backend/core/.env.template new file mode 100644 index 0000000000..07adab1f64 --- /dev/null +++ b/backend/core/.env.template @@ -0,0 +1,26 @@ +PORT=3100 +NODE_ENV=development +DATABASE_URL=postgres://localhost/bloom_detroit +TEST_DATABASE_URL=postgres://localhost/bloom_test +REDIS_TLS_URL=redis://127.0.0.1:6379/0 +REDIS_URL=redis://127.0.0.1:6379/0 +REDIS_USE_TLS=0 +THROTTLE_TTL=60 +THROTTLE_LIMIT=2 +EMAIL_API_KEY='SOME-LONG-SECRET-KEY' +EMAIL_FROM_ADDRESS='Detroit Home Connect ' +APP_SECRET='SOME-LONG-SECRET-KEY' +CLOUDINARY_SECRET=CLOUDINARY_SECRET +CLOUDINARY_KEY=CLOUDINARY_KEY +PARTNERS_BASE_URL=http://localhost:3001 +NEW_RELIC_APP_NAME=Bloom Backend Local +NEW_RELIC_LICENSE_KEY= +NEW_RELIC_ENABLED=false +NEW_RELIC_LOG_ENABLED=false +GOOGLE_API_ID= +GOOGLE_API_KEY= +GOOGLE_API_EMAIL= +PARTNERS_PORTAL_URL=http://localhost:3001 +TWILIO_ACCOUNT_SID='AC.THE-TWILIO-ACCOUNT-SID' +TWILIO_AUTH_TOKEN='THE-TWILIO-AUTH-TOKEN' +TWILIO_FROM_NUMBER='THE-TWILIO-FROM-NUMBER' diff --git a/backend/core/.npmignore b/backend/core/.npmignore new file mode 100644 index 0000000000..5233d11d3a --- /dev/null +++ b/backend/core/.npmignore @@ -0,0 +1,84 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Tests +__tests__/** +# Complied Typescript +dist + +# Heroku Setup +Procfile +heroku.setup.* + +# AMI Data +ami_charts/** + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (http://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/** + +# Typescript v1 declaration files +typings/ + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# dotenv environment variables file +.env + +# Mac files +.DS_Store + +# Yarn +yarn-error.log +.pnp/ +.pnp.js +# Yarn Integrity file +.yarn-integrity +.yarn/** + +# IDE configs +.env +.env.template +nest-cli.json +.idea +.vscode diff --git a/backend/core/Aptfile b/backend/core/Aptfile new file mode 100644 index 0000000000..939ef80033 --- /dev/null +++ b/backend/core/Aptfile @@ -0,0 +1,2 @@ +# list packages +lsof diff --git a/backend/core/CHANGELOG.md b/backend/core/CHANGELOG.md new file mode 100644 index 0000000000..9dc9eeda50 --- /dev/null +++ b/backend/core/CHANGELOG.md @@ -0,0 +1,1609 @@ +# Change Log + +All notable changes to this project will be documented in this file. +See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. + +# [4.2.0](https://github.com/seanmalbert/bloom/compare/@bloom-housing/backend-core@4.1.2...@bloom-housing/backend-core@4.2.0) (2022-04-06) + + +* 2022-04-05 release (#2627) ([485fb48](https://github.com/seanmalbert/bloom/commit/485fb48cfbad48bcabfef5e2e704025f608aee89)), closes [#2627](https://github.com/seanmalbert/bloom/issues/2627) [#2352](https://github.com/seanmalbert/bloom/issues/2352) [#2316](https://github.com/seanmalbert/bloom/issues/2316) [#2356](https://github.com/seanmalbert/bloom/issues/2356) [#2353](https://github.com/seanmalbert/bloom/issues/2353) [#2338](https://github.com/seanmalbert/bloom/issues/2338) [#2377](https://github.com/seanmalbert/bloom/issues/2377) [#2320](https://github.com/seanmalbert/bloom/issues/2320) [#2386](https://github.com/seanmalbert/bloom/issues/2386) [#2362](https://github.com/seanmalbert/bloom/issues/2362) [#2395](https://github.com/seanmalbert/bloom/issues/2395) [#2410](https://github.com/seanmalbert/bloom/issues/2410) [#2407](https://github.com/seanmalbert/bloom/issues/2407) [#2430](https://github.com/seanmalbert/bloom/issues/2430) [#2418](https://github.com/seanmalbert/bloom/issues/2418) [#2434](https://github.com/seanmalbert/bloom/issues/2434) [#2374](https://github.com/seanmalbert/bloom/issues/2374) [#2435](https://github.com/seanmalbert/bloom/issues/2435) [#2420](https://github.com/seanmalbert/bloom/issues/2420) [#2412](https://github.com/seanmalbert/bloom/issues/2412) [#2438](https://github.com/seanmalbert/bloom/issues/2438) [#2429](https://github.com/seanmalbert/bloom/issues/2429) [#2452](https://github.com/seanmalbert/bloom/issues/2452) [#2458](https://github.com/seanmalbert/bloom/issues/2458) [#2423](https://github.com/seanmalbert/bloom/issues/2423) [#2432](https://github.com/seanmalbert/bloom/issues/2432) [#2437](https://github.com/seanmalbert/bloom/issues/2437) [#2440](https://github.com/seanmalbert/bloom/issues/2440) [#2441](https://github.com/seanmalbert/bloom/issues/2441) [#2460](https://github.com/seanmalbert/bloom/issues/2460) [#2459](https://github.com/seanmalbert/bloom/issues/2459) [#2464](https://github.com/seanmalbert/bloom/issues/2464) [#2465](https://github.com/seanmalbert/bloom/issues/2465) [#2466](https://github.com/seanmalbert/bloom/issues/2466) [#2436](https://github.com/seanmalbert/bloom/issues/2436) [#2451](https://github.com/seanmalbert/bloom/issues/2451) [#2415](https://github.com/seanmalbert/bloom/issues/2415) [#2354](https://github.com/seanmalbert/bloom/issues/2354) [#2455](https://github.com/seanmalbert/bloom/issues/2455) [#2484](https://github.com/seanmalbert/bloom/issues/2484) [#2482](https://github.com/seanmalbert/bloom/issues/2482) [#2483](https://github.com/seanmalbert/bloom/issues/2483) [#2476](https://github.com/seanmalbert/bloom/issues/2476) [#2485](https://github.com/seanmalbert/bloom/issues/2485) [#2470](https://github.com/seanmalbert/bloom/issues/2470) [#2488](https://github.com/seanmalbert/bloom/issues/2488) [#2487](https://github.com/seanmalbert/bloom/issues/2487) [#2496](https://github.com/seanmalbert/bloom/issues/2496) [#2498](https://github.com/seanmalbert/bloom/issues/2498) [#2499](https://github.com/seanmalbert/bloom/issues/2499) [#2291](https://github.com/seanmalbert/bloom/issues/2291) [#2461](https://github.com/seanmalbert/bloom/issues/2461) [#2485](https://github.com/seanmalbert/bloom/issues/2485) [#2494](https://github.com/seanmalbert/bloom/issues/2494) [#2503](https://github.com/seanmalbert/bloom/issues/2503) [#2495](https://github.com/seanmalbert/bloom/issues/2495) [#2477](https://github.com/seanmalbert/bloom/issues/2477) [#2505](https://github.com/seanmalbert/bloom/issues/2505) [#2372](https://github.com/seanmalbert/bloom/issues/2372) [#2489](https://github.com/seanmalbert/bloom/issues/2489) [#2497](https://github.com/seanmalbert/bloom/issues/2497) [#2506](https://github.com/seanmalbert/bloom/issues/2506) [#2486](https://github.com/seanmalbert/bloom/issues/2486) +* 2022-04-04 release (#2614) ([fecab85](https://github.com/seanmalbert/bloom/commit/fecab85c748a55ab4aff5d591c8e0ac702254559)), closes [#2614](https://github.com/seanmalbert/bloom/issues/2614) [#2349](https://github.com/seanmalbert/bloom/issues/2349) [#2350](https://github.com/seanmalbert/bloom/issues/2350) [#2351](https://github.com/seanmalbert/bloom/issues/2351) [#2348](https://github.com/seanmalbert/bloom/issues/2348) [#2352](https://github.com/seanmalbert/bloom/issues/2352) [#2316](https://github.com/seanmalbert/bloom/issues/2316) [#2356](https://github.com/seanmalbert/bloom/issues/2356) [#2353](https://github.com/seanmalbert/bloom/issues/2353) [#2338](https://github.com/seanmalbert/bloom/issues/2338) [#2377](https://github.com/seanmalbert/bloom/issues/2377) [#2320](https://github.com/seanmalbert/bloom/issues/2320) [#2386](https://github.com/seanmalbert/bloom/issues/2386) [#2362](https://github.com/seanmalbert/bloom/issues/2362) [#2395](https://github.com/seanmalbert/bloom/issues/2395) [#2410](https://github.com/seanmalbert/bloom/issues/2410) [#2407](https://github.com/seanmalbert/bloom/issues/2407) [#2430](https://github.com/seanmalbert/bloom/issues/2430) [#2418](https://github.com/seanmalbert/bloom/issues/2418) [#2434](https://github.com/seanmalbert/bloom/issues/2434) [#2374](https://github.com/seanmalbert/bloom/issues/2374) [#2435](https://github.com/seanmalbert/bloom/issues/2435) [#2420](https://github.com/seanmalbert/bloom/issues/2420) [#2412](https://github.com/seanmalbert/bloom/issues/2412) [#2438](https://github.com/seanmalbert/bloom/issues/2438) [#2429](https://github.com/seanmalbert/bloom/issues/2429) [#2452](https://github.com/seanmalbert/bloom/issues/2452) [#2458](https://github.com/seanmalbert/bloom/issues/2458) [#2423](https://github.com/seanmalbert/bloom/issues/2423) [#2432](https://github.com/seanmalbert/bloom/issues/2432) [#2437](https://github.com/seanmalbert/bloom/issues/2437) [#2440](https://github.com/seanmalbert/bloom/issues/2440) [#2441](https://github.com/seanmalbert/bloom/issues/2441) [#2460](https://github.com/seanmalbert/bloom/issues/2460) [#2459](https://github.com/seanmalbert/bloom/issues/2459) [#2464](https://github.com/seanmalbert/bloom/issues/2464) [#2465](https://github.com/seanmalbert/bloom/issues/2465) [#2466](https://github.com/seanmalbert/bloom/issues/2466) [#2436](https://github.com/seanmalbert/bloom/issues/2436) [#2451](https://github.com/seanmalbert/bloom/issues/2451) [#2415](https://github.com/seanmalbert/bloom/issues/2415) [#2354](https://github.com/seanmalbert/bloom/issues/2354) [#2455](https://github.com/seanmalbert/bloom/issues/2455) [#2484](https://github.com/seanmalbert/bloom/issues/2484) [#2482](https://github.com/seanmalbert/bloom/issues/2482) [#2483](https://github.com/seanmalbert/bloom/issues/2483) [#2476](https://github.com/seanmalbert/bloom/issues/2476) [#2485](https://github.com/seanmalbert/bloom/issues/2485) [#2470](https://github.com/seanmalbert/bloom/issues/2470) [#2488](https://github.com/seanmalbert/bloom/issues/2488) [#2487](https://github.com/seanmalbert/bloom/issues/2487) [#2496](https://github.com/seanmalbert/bloom/issues/2496) [#2498](https://github.com/seanmalbert/bloom/issues/2498) [#2499](https://github.com/seanmalbert/bloom/issues/2499) [#2291](https://github.com/seanmalbert/bloom/issues/2291) [#2461](https://github.com/seanmalbert/bloom/issues/2461) [#2485](https://github.com/seanmalbert/bloom/issues/2485) [#2494](https://github.com/seanmalbert/bloom/issues/2494) [#2503](https://github.com/seanmalbert/bloom/issues/2503) [#2495](https://github.com/seanmalbert/bloom/issues/2495) [#2477](https://github.com/seanmalbert/bloom/issues/2477) [#2505](https://github.com/seanmalbert/bloom/issues/2505) [#2372](https://github.com/seanmalbert/bloom/issues/2372) [#2489](https://github.com/seanmalbert/bloom/issues/2489) [#2497](https://github.com/seanmalbert/bloom/issues/2497) [#2506](https://github.com/seanmalbert/bloom/issues/2506) [#2486](https://github.com/seanmalbert/bloom/issues/2486) + + +### BREAKING CHANGES + +* remove applicationDueTime field and consolidated into applicationDueDate + +* chore(release): version + + - @bloom-housing/backend-core@3.0.2-alpha.13 + - @bloom-housing/shared-helpers@4.0.1-alpha.21 + - @bloom-housing/partners@4.0.1-alpha.23 + - @bloom-housing/public@4.0.1-alpha.22 + - @bloom-housing/ui-components@4.0.1-alpha.21 +* consolidated all event section components in one new component, uptake will require removing the deprecated components and uptaking EventSection + +* chore(release): version + + - @bloom-housing/backend-core@3.0.2-alpha.38 + - @bloom-housing/shared-helpers@4.0.1-alpha.63 + - @bloom-housing/partners@4.0.1-alpha.67 + - @bloom-housing/public@4.0.1-alpha.66 + - @bloom-housing/ui-components@4.0.1-alpha.62 +* remove applicationDueTime field and consolidated into applicationDueDate + +* chore(release): version + + - @bloom-housing/backend-core@3.0.2-alpha.13 + - @bloom-housing/shared-helpers@4.0.1-alpha.21 + - @bloom-housing/partners@4.0.1-alpha.23 + - @bloom-housing/public@4.0.1-alpha.22 + - @bloom-housing/ui-components@4.0.1-alpha.21 +* consolidated all event section components in one new component, uptake will require removing the deprecated components and uptaking EventSection + +* chore(release): version + + - @bloom-housing/backend-core@3.0.2-alpha.38 + - @bloom-housing/shared-helpers@4.0.1-alpha.63 + - @bloom-housing/partners@4.0.1-alpha.67 + - @bloom-housing/public@4.0.1-alpha.66 + - @bloom-housing/ui-components@4.0.1-alpha.62 + + + + + +## [4.1.3-alpha.1](https://github.com/bloom-housing/bloom/compare/@bloom-housing/backend-core@4.1.3-alpha.0...@bloom-housing/backend-core@4.1.3-alpha.1) (2022-04-04) + +**Note:** Version bump only for package @bloom-housing/backend-core + + + + + +## [4.1.3-alpha.0](https://github.com/bloom-housing/bloom/compare/@bloom-housing/backend-core@4.1.1-alpha.3...@bloom-housing/backend-core@4.1.3-alpha.0) (2022-03-30) + +**Note:** Version bump only for package @bloom-housing/backend-core + + + + + +## [4.1.2](https://github.com/seanmalbert/bloom/compare/@bloom-housing/backend-core@4.1.0...@bloom-housing/backend-core@4.1.2) (2022-03-29) + +### Features + +* temp disable terms and set mfa enabled to false ([#2595](https://github.com/seanmalbert/bloom/issues/2595)) ([6de2dcd](https://github.com/seanmalbert/bloom/commit/6de2dcd8baeb28166d7a6c383846a7ab9a84b0e2)) + + + +## [4.1.1-alpha.3](https://github.com/bloom-housing/bloom/compare/@bloom-housing/backend-core@4.1.1-alpha.2...@bloom-housing/backend-core@4.1.1-alpha.3) (2022-03-29) + +**Note:** Version bump only for package @bloom-housing/backend-core + + + + + +## [4.1.1-alpha.2](https://github.com/bloom-housing/bloom/compare/@bloom-housing/backend-core@4.1.1-alpha.1...@bloom-housing/backend-core@4.1.1-alpha.2) (2022-03-28) + + +### Features + +* adds partners re-request confirmation ([#2574](https://github.com/bloom-housing/bloom/issues/2574)) ([235af78](https://github.com/bloom-housing/bloom/commit/235af781914e5c36104bb3862dd55152a16e6750)), closes [#2577](https://github.com/bloom-housing/bloom/issues/2577) + + + + + +## [4.1.1-alpha.1](https://github.com/bloom-housing/bloom/compare/@bloom-housing/backend-core@4.1.1-alpha.0...@bloom-housing/backend-core@4.1.1-alpha.1) (2022-03-25) + + +### Bug Fixes + +* update for subject line ([#2578](https://github.com/bloom-housing/bloom/issues/2578)) ([dace763](https://github.com/bloom-housing/bloom/commit/dace76332bbdb3ad104638f32a07e71fd85edc0c)) +* update to mfa text's text ([#2579](https://github.com/bloom-housing/bloom/issues/2579)) ([ac5b812](https://github.com/bloom-housing/bloom/commit/ac5b81242f3177de09ed176a60f06be871906178)) + + + + + +## [4.1.1-alpha.0](https://github.com/bloom-housing/bloom/compare/@bloom-housing/backend-core@3.0.2...@bloom-housing/backend-core@4.1.1-alpha.0) (2022-03-02) + +**Note:** Version bump only for package @bloom-housing/backend-core + + + + + +## [3.0.2](https://github.com/seanmalbert/bloom/compare/@bloom-housing/backend-core@3.0.2-alpha.45...@bloom-housing/backend-core@3.0.2) (2022-03-02) + +**Note:** Version bump only for package @bloom-housing/backend-core +# [4.1.0](https://github.com/seanmalbert/bloom/compare/@bloom-housing/backend-core@4.0.3...@bloom-housing/backend-core@4.1.0) (2022-03-02) + + +* 2022-03-01 release (#2550) ([2f2264c](https://github.com/seanmalbert/bloom/commit/2f2264cffe41d0cc1ebb79ef5c894458694d9340)), closes [#2550](https://github.com/seanmalbert/bloom/issues/2550) [#2288](https://github.com/seanmalbert/bloom/issues/2288) [#2317](https://github.com/seanmalbert/bloom/issues/2317) [#2319](https://github.com/seanmalbert/bloom/issues/2319) [#2108](https://github.com/seanmalbert/bloom/issues/2108) [#2326](https://github.com/seanmalbert/bloom/issues/2326) [#2349](https://github.com/seanmalbert/bloom/issues/2349) [#2350](https://github.com/seanmalbert/bloom/issues/2350) [#2351](https://github.com/seanmalbert/bloom/issues/2351) [#2348](https://github.com/seanmalbert/bloom/issues/2348) [#2352](https://github.com/seanmalbert/bloom/issues/2352) [#2316](https://github.com/seanmalbert/bloom/issues/2316) [#2356](https://github.com/seanmalbert/bloom/issues/2356) [#2353](https://github.com/seanmalbert/bloom/issues/2353) [#2338](https://github.com/seanmalbert/bloom/issues/2338) [#2377](https://github.com/seanmalbert/bloom/issues/2377) [#2320](https://github.com/seanmalbert/bloom/issues/2320) [#2386](https://github.com/seanmalbert/bloom/issues/2386) [#2362](https://github.com/seanmalbert/bloom/issues/2362) [#2395](https://github.com/seanmalbert/bloom/issues/2395) [#2410](https://github.com/seanmalbert/bloom/issues/2410) [#2407](https://github.com/seanmalbert/bloom/issues/2407) [#2430](https://github.com/seanmalbert/bloom/issues/2430) [#2418](https://github.com/seanmalbert/bloom/issues/2418) [#2434](https://github.com/seanmalbert/bloom/issues/2434) [#2374](https://github.com/seanmalbert/bloom/issues/2374) [#2435](https://github.com/seanmalbert/bloom/issues/2435) [#2420](https://github.com/seanmalbert/bloom/issues/2420) [#2412](https://github.com/seanmalbert/bloom/issues/2412) [#2438](https://github.com/seanmalbert/bloom/issues/2438) [#2429](https://github.com/seanmalbert/bloom/issues/2429) [#2452](https://github.com/seanmalbert/bloom/issues/2452) [#2458](https://github.com/seanmalbert/bloom/issues/2458) [#2423](https://github.com/seanmalbert/bloom/issues/2423) [#2432](https://github.com/seanmalbert/bloom/issues/2432) [#2437](https://github.com/seanmalbert/bloom/issues/2437) [#2440](https://github.com/seanmalbert/bloom/issues/2440) [#2441](https://github.com/seanmalbert/bloom/issues/2441) [#2460](https://github.com/seanmalbert/bloom/issues/2460) [#2459](https://github.com/seanmalbert/bloom/issues/2459) [#2464](https://github.com/seanmalbert/bloom/issues/2464) [#2465](https://github.com/seanmalbert/bloom/issues/2465) [#2466](https://github.com/seanmalbert/bloom/issues/2466) [#2436](https://github.com/seanmalbert/bloom/issues/2436) [#2451](https://github.com/seanmalbert/bloom/issues/2451) [#2415](https://github.com/seanmalbert/bloom/issues/2415) [#2354](https://github.com/seanmalbert/bloom/issues/2354) [#2455](https://github.com/seanmalbert/bloom/issues/2455) [#2484](https://github.com/seanmalbert/bloom/issues/2484) [#2482](https://github.com/seanmalbert/bloom/issues/2482) [#2483](https://github.com/seanmalbert/bloom/issues/2483) [#2476](https://github.com/seanmalbert/bloom/issues/2476) [#2485](https://github.com/seanmalbert/bloom/issues/2485) [#2470](https://github.com/seanmalbert/bloom/issues/2470) [#2488](https://github.com/seanmalbert/bloom/issues/2488) [#2487](https://github.com/seanmalbert/bloom/issues/2487) [#2496](https://github.com/seanmalbert/bloom/issues/2496) [#2498](https://github.com/seanmalbert/bloom/issues/2498) [#2499](https://github.com/seanmalbert/bloom/issues/2499) [#2291](https://github.com/seanmalbert/bloom/issues/2291) [#2461](https://github.com/seanmalbert/bloom/issues/2461) [#2485](https://github.com/seanmalbert/bloom/issues/2485) [#2494](https://github.com/seanmalbert/bloom/issues/2494) [#2503](https://github.com/seanmalbert/bloom/issues/2503) [#2495](https://github.com/seanmalbert/bloom/issues/2495) [#2477](https://github.com/seanmalbert/bloom/issues/2477) [#2505](https://github.com/seanmalbert/bloom/issues/2505) [#2372](https://github.com/seanmalbert/bloom/issues/2372) [#2489](https://github.com/seanmalbert/bloom/issues/2489) [#2497](https://github.com/seanmalbert/bloom/issues/2497) [#2506](https://github.com/seanmalbert/bloom/issues/2506) [#2486](https://github.com/seanmalbert/bloom/issues/2486) + + +### BREAKING CHANGES + +* moved some helpers from ui-components to shared-helpers +* remove applicationDueTime field and consolidated into applicationDueDate + +* chore(release): version + + - @bloom-housing/backend-core@3.0.2-alpha.13 + - @bloom-housing/shared-helpers@4.0.1-alpha.21 + - @bloom-housing/partners@4.0.1-alpha.23 + - @bloom-housing/public@4.0.1-alpha.22 + - @bloom-housing/ui-components@4.0.1-alpha.21 +* consolidated all event section components in one new component, uptake will require removing the deprecated components and uptaking EventSection + +* chore(release): version + + - @bloom-housing/backend-core@3.0.2-alpha.38 + - @bloom-housing/shared-helpers@4.0.1-alpha.63 + - @bloom-housing/partners@4.0.1-alpha.67 + - @bloom-housing/public@4.0.1-alpha.66 + - @bloom-housing/ui-components@4.0.1-alpha.62 + + + + + +## [3.0.2-alpha.45](https://github.com/bloom-housing/bloom/compare/@bloom-housing/backend-core@3.0.2-alpha.44...@bloom-housing/backend-core@3.0.2-alpha.45) (2022-02-28) + + +### Features + +* updates to mfa styling ([#2532](https://github.com/bloom-housing/bloom/issues/2532)) ([7654efc](https://github.com/bloom-housing/bloom/commit/7654efc8a7c5cba0f7436fda62b886f646fe8a03)) + + + + + +## [4.0.3](https://github.com/seanmalbert/bloom/compare/@bloom-housing/backend-core@4.0.2...@bloom-housing/backend-core@4.0.3) (2022-02-25) + + +### Bug Fixes + +* csv export auth check ([#2488](https://github.com/seanmalbert/bloom/issues/2488)) ([6faf8f5](https://github.com/seanmalbert/bloom/commit/6faf8f59b115adf73e70d56c855ba5b6d325d22a)) + +### Features + +* Add San Jose email translations ([#2519](https://github.com/seanmalbert/bloom/issues/2519)) ([d1db032](https://github.com/seanmalbert/bloom/commit/d1db032672f40d325eba9e4a833d24f8b02464cc)) +* **backend:** fix translations table relation to jurisdiction ([#2506](https://github.com/seanmalbert/bloom/issues/2506)) ([22b9f23](https://github.com/seanmalbert/bloom/commit/22b9f23eb405f701796193515dff35058cc4f7dc)) + + + + + +## [3.0.2-alpha.44](https://github.com/bloom-housing/bloom/compare/@bloom-housing/backend-core@3.0.2-alpha.43...@bloom-housing/backend-core@3.0.2-alpha.44) (2022-02-22) + +**Note:** Version bump only for package @bloom-housing/backend-core + + + + + +## [3.0.2-alpha.43](https://github.com/bloom-housing/bloom/compare/@bloom-housing/backend-core@3.0.2-alpha.42...@bloom-housing/backend-core@3.0.2-alpha.43) (2022-02-17) + + +### Features + +* adds NULLS LAST to mostRecentlyClosed ([#2521](https://github.com/bloom-housing/bloom/issues/2521)) ([39737a3](https://github.com/bloom-housing/bloom/commit/39737a3207e22815d184fc19cb2eaf6b6390dda8)) + + + + + +## [3.0.2-alpha.42](https://github.com/bloom-housing/bloom/compare/@bloom-housing/backend-core@3.0.2-alpha.41...@bloom-housing/backend-core@3.0.2-alpha.42) (2022-02-17) + + +### Features + +* **backend:** add listing order by mostRecentlyClosed param ([#2478](https://github.com/bloom-housing/bloom/issues/2478)) ([0f177c1](https://github.com/bloom-housing/bloom/commit/0f177c1847ac254f63837b0aca7fa8a705e3632c)) + + + + + +## [3.0.2-alpha.41](https://github.com/bloom-housing/bloom/compare/@bloom-housing/backend-core@3.0.2-alpha.40...@bloom-housing/backend-core@3.0.2-alpha.41) (2022-02-16) + + +### Features + +* **backend:** fix translations table relation to jurisdiction (make … ([#2506](https://github.com/bloom-housing/bloom/issues/2506)) ([8e1e3a9](https://github.com/bloom-housing/bloom/commit/8e1e3a9eb0ff76412831e122390ac25ad7754645)) + + + + + +## [3.0.2-alpha.40](https://github.com/bloom-housing/bloom/compare/@bloom-housing/backend-core@3.0.2-alpha.39...@bloom-housing/backend-core@3.0.2-alpha.40) (2022-02-16) + + +### Bug Fixes + +* checks for existance of image_id ([#2505](https://github.com/bloom-housing/bloom/issues/2505)) ([d2051af](https://github.com/bloom-housing/bloom/commit/d2051afa188ce62c42f3d6bf737fd2059f9b7599)) + + + + + +## [3.0.2-alpha.39](https://github.com/bloom-housing/bloom/compare/@bloom-housing/backend-core@3.0.2-alpha.38...@bloom-housing/backend-core@3.0.2-alpha.39) (2022-02-15) + + +### Features + +* **backend:** make listing image an array ([#2477](https://github.com/bloom-housing/bloom/issues/2477)) ([cab9800](https://github.com/bloom-housing/bloom/commit/cab98003e640c880be2218fa42321eadeec35e9c)) + + + + + +## [3.0.2-alpha.38](https://github.com/bloom-housing/bloom/compare/@bloom-housing/backend-core@3.0.2-alpha.37...@bloom-housing/backend-core@3.0.2-alpha.38) (2022-02-15) + + +### Code Refactoring + +* remove backend dependencies from events components, consolidate ([#2495](https://github.com/bloom-housing/bloom/issues/2495)) ([d884689](https://github.com/bloom-housing/bloom/commit/d88468965bc67c74b8b3eaced20c77472e90331f)) + + +### BREAKING CHANGES + +* consolidated all event section components in one new component, uptake will require removing the deprecated components and uptaking EventSection + + + + + +## [3.0.2-alpha.37](https://github.com/bloom-housing/bloom/compare/@bloom-housing/backend-core@3.0.2-alpha.36...@bloom-housing/backend-core@3.0.2-alpha.37) (2022-02-15) + + +### Bug Fixes + +* **backend:** mfa_enabled migration fix ([#2503](https://github.com/bloom-housing/bloom/issues/2503)) ([a5b9a60](https://github.com/bloom-housing/bloom/commit/a5b9a604faccef55775dbbc54441251e29999fa4)) + + + + + +## [3.0.2-alpha.36](https://github.com/bloom-housing/bloom/compare/@bloom-housing/backend-core@3.0.2-alpha.35...@bloom-housing/backend-core@3.0.2-alpha.36) (2022-02-15) + + +### Features + +* **backend:** add partners portal users multi factor authentication ([#2291](https://github.com/bloom-housing/bloom/issues/2291)) ([5b10098](https://github.com/bloom-housing/bloom/commit/5b10098d8668f9f42c60e90236db16d6cc517793)), closes [#2461](https://github.com/bloom-housing/bloom/issues/2461) [#2485](https://github.com/bloom-housing/bloom/issues/2485) + + + + + +## [3.0.2-alpha.35](https://github.com/bloom-housing/bloom/compare/@bloom-housing/backend-core@3.0.2-alpha.34...@bloom-housing/backend-core@3.0.2-alpha.35) (2022-02-10) + + +### Bug Fixes + +* csv export auth check ([#2488](https://github.com/bloom-housing/bloom/issues/2488)) ([2471d4a](https://github.com/bloom-housing/bloom/commit/2471d4afdd747843f58c0c154d6e94a9c76d733d)) + + + + + +## [3.0.2-alpha.34](https://github.com/bloom-housing/bloom/compare/@bloom-housing/backend-core@3.0.2-alpha.33...@bloom-housing/backend-core@3.0.2-alpha.34) (2022-02-10) + +**Note:** Version bump only for package @bloom-housing/backend-core + + + + +## [4.0.2](https://github.com/seanmalbert/bloom/compare/@bloom-housing/backend-core@4.0.1...@bloom-housing/backend-core@4.0.2) (2022-02-09) + + +### Bug Fixes + +* updates partner check for listing perm ([#2484](https://github.com/seanmalbert/bloom/issues/2484)) ([c2ab01f](https://github.com/seanmalbert/bloom/commit/c2ab01f6520b138bead01dec7352618b90635432)) + + + + + +## [3.0.2-alpha.33](https://github.com/bloom-housing/bloom/compare/@bloom-housing/backend-core@3.0.2-alpha.32...@bloom-housing/backend-core@3.0.2-alpha.33) (2022-02-09) + + +### Features + +* **backend:** remove assigning partner user as an application owner ([#2476](https://github.com/bloom-housing/bloom/issues/2476)) ([4f6edf7](https://github.com/bloom-housing/bloom/commit/4f6edf7ed882ae926e363e4db4e40e6f19ed4746)) + + + + + +## [3.0.2-alpha.32](https://github.com/bloom-housing/bloom/compare/@bloom-housing/backend-core@3.0.2-alpha.31...@bloom-housing/backend-core@3.0.2-alpha.32) (2022-02-09) + + +### Bug Fixes + +* updates partner check for listing perm ([#2484](https://github.com/bloom-housing/bloom/issues/2484)) ([9b0a6f5](https://github.com/bloom-housing/bloom/commit/9b0a6f560ec5dd95f846b330afb71eed40cbfa1b)) + + + + + +## [3.0.2-alpha.31](https://github.com/bloom-housing/bloom/compare/@bloom-housing/backend-core@3.0.2-alpha.30...@bloom-housing/backend-core@3.0.2-alpha.31) (2022-02-09) + + +### Bug Fixes + +* cannot remove some fields in listings management ([#2455](https://github.com/bloom-housing/bloom/issues/2455)) ([acd9b51](https://github.com/bloom-housing/bloom/commit/acd9b51bb49581b4728b445d56c5c0a3c43e2777)) + + + + + +## [3.0.2-alpha.30](https://github.com/bloom-housing/bloom/compare/@bloom-housing/backend-core@3.0.2-alpha.29...@bloom-housing/backend-core@3.0.2-alpha.30) (2022-02-07) + +**Note:** Version bump only for package @bloom-housing/backend-core + + + + +## [4.0.1](https://github.com/seanmalbert/bloom/compare/@bloom-housing/backend-core@3.0.1-alpha.23...@bloom-housing/backend-core@4.0.1) (2022-02-03) + +### Bug Fixes + +* ami charts without all households ([#2430](https://github.com/seanmalbert/bloom/issues/2430)) ([5e18eba](https://github.com/seanmalbert/bloom/commit/5e18eba1d24bff038b192477b72d9d3f1f05a39d)) +* app submission w/ no due date ([8e5a81c](https://github.com/seanmalbert/bloom/commit/8e5a81c37c4efc3404e5536bd54c10cd2962bca3)) +* await casbin enforcer ([d7eb196](https://github.com/seanmalbert/bloom/commit/d7eb196be0b05732325938e2db7b583d66cbc9cf)) +* cannot save custom mailing, dropoff, or pickup address ([edcb068](https://github.com/seanmalbert/bloom/commit/edcb068ca23411e0a34f1dc2ff4c77ab489ac0fc)) +* fix for csv dempgraphics and preference patch ([0ffc090](https://github.com/seanmalbert/bloom/commit/0ffc0900fee73b34fd953e5355552e2e763c239c)) +* listings management keep empty strings, remove empty objects ([3aba274](https://github.com/seanmalbert/bloom/commit/3aba274a751cdb2db55b65ade1cda5d1689ca681)) +* patches translations for preferences ([#2410](https://github.com/seanmalbert/bloom/issues/2410)) ([21f517e](https://github.com/seanmalbert/bloom/commit/21f517e3f62dc5fefc8b4031d8915c8d7690677d)) +* recalculate units available on listing update ([9c3967f](https://github.com/seanmalbert/bloom/commit/9c3967f0b74526db39df4f5dbc7ad9a52741a6ea)) +* units with invalid ami chart ([621ff02](https://github.com/seanmalbert/bloom/commit/621ff0227270861047e885467f9ddd77459adec1)) +* updates household member count ([f822713](https://github.com/seanmalbert/bloom/commit/f82271397d02025629d7ea039b40cdac95877c45)) + + +* 2022-01-27 release (#2439) ([860f6af](https://github.com/seanmalbert/bloom/commit/860f6af6204903e4dcddf671d7ba54f3ec04f121)), closes [#2439](https://github.com/seanmalbert/bloom/issues/2439) [#2196](https://github.com/seanmalbert/bloom/issues/2196) [#2238](https://github.com/seanmalbert/bloom/issues/2238) [#2226](https://github.com/seanmalbert/bloom/issues/2226) [#2230](https://github.com/seanmalbert/bloom/issues/2230) [#2243](https://github.com/seanmalbert/bloom/issues/2243) [#2195](https://github.com/seanmalbert/bloom/issues/2195) [#2215](https://github.com/seanmalbert/bloom/issues/2215) [#2266](https://github.com/seanmalbert/bloom/issues/2266) [#2188](https://github.com/seanmalbert/bloom/issues/2188) [#2270](https://github.com/seanmalbert/bloom/issues/2270) [#2188](https://github.com/seanmalbert/bloom/issues/2188) [#2213](https://github.com/seanmalbert/bloom/issues/2213) [#2234](https://github.com/seanmalbert/bloom/issues/2234) [#1901](https://github.com/seanmalbert/bloom/issues/1901) [#2260](https://github.com/seanmalbert/bloom/issues/2260) [#1927](https://github.com/seanmalbert/bloom/issues/1927) [#1927](https://github.com/seanmalbert/bloom/issues/1927) [#1927](https://github.com/seanmalbert/bloom/issues/1927) [#1927](https://github.com/seanmalbert/bloom/issues/1927) [#1927](https://github.com/seanmalbert/bloom/issues/1927) [#1927](https://github.com/seanmalbert/bloom/issues/1927) [#1927](https://github.com/seanmalbert/bloom/issues/1927) [#1927](https://github.com/seanmalbert/bloom/issues/1927) [#1927](https://github.com/seanmalbert/bloom/issues/1927) [#1927](https://github.com/seanmalbert/bloom/issues/1927) [#1927](https://github.com/seanmalbert/bloom/issues/1927) [#1927](https://github.com/seanmalbert/bloom/issues/1927) [#1927](https://github.com/seanmalbert/bloom/issues/1927) [#2280](https://github.com/seanmalbert/bloom/issues/2280) [#2253](https://github.com/seanmalbert/bloom/issues/2253) [#2276](https://github.com/seanmalbert/bloom/issues/2276) [#2282](https://github.com/seanmalbert/bloom/issues/2282) [#2262](https://github.com/seanmalbert/bloom/issues/2262) [#2278](https://github.com/seanmalbert/bloom/issues/2278) [#2293](https://github.com/seanmalbert/bloom/issues/2293) [#2295](https://github.com/seanmalbert/bloom/issues/2295) [#2296](https://github.com/seanmalbert/bloom/issues/2296) [#2294](https://github.com/seanmalbert/bloom/issues/2294) [#2277](https://github.com/seanmalbert/bloom/issues/2277) [#2290](https://github.com/seanmalbert/bloom/issues/2290) [#2299](https://github.com/seanmalbert/bloom/issues/2299) [#2292](https://github.com/seanmalbert/bloom/issues/2292) [#2303](https://github.com/seanmalbert/bloom/issues/2303) [#2305](https://github.com/seanmalbert/bloom/issues/2305) [#2306](https://github.com/seanmalbert/bloom/issues/2306) [#2308](https://github.com/seanmalbert/bloom/issues/2308) [#2190](https://github.com/seanmalbert/bloom/issues/2190) [#2239](https://github.com/seanmalbert/bloom/issues/2239) [#2311](https://github.com/seanmalbert/bloom/issues/2311) [#2302](https://github.com/seanmalbert/bloom/issues/2302) [#2301](https://github.com/seanmalbert/bloom/issues/2301) [#1927](https://github.com/seanmalbert/bloom/issues/1927) [#2313](https://github.com/seanmalbert/bloom/issues/2313) [#2289](https://github.com/seanmalbert/bloom/issues/2289) [#2279](https://github.com/seanmalbert/bloom/issues/2279) [#2288](https://github.com/seanmalbert/bloom/issues/2288) [#2317](https://github.com/seanmalbert/bloom/issues/2317) [#2319](https://github.com/seanmalbert/bloom/issues/2319) [#2108](https://github.com/seanmalbert/bloom/issues/2108) [#2326](https://github.com/seanmalbert/bloom/issues/2326) [#2349](https://github.com/seanmalbert/bloom/issues/2349) [#2350](https://github.com/seanmalbert/bloom/issues/2350) [#2351](https://github.com/seanmalbert/bloom/issues/2351) [#2348](https://github.com/seanmalbert/bloom/issues/2348) [#2352](https://github.com/seanmalbert/bloom/issues/2352) [#2316](https://github.com/seanmalbert/bloom/issues/2316) [#2356](https://github.com/seanmalbert/bloom/issues/2356) [#2353](https://github.com/seanmalbert/bloom/issues/2353) [#2338](https://github.com/seanmalbert/bloom/issues/2338) [#2377](https://github.com/seanmalbert/bloom/issues/2377) [#2320](https://github.com/seanmalbert/bloom/issues/2320) [#2386](https://github.com/seanmalbert/bloom/issues/2386) [#2362](https://github.com/seanmalbert/bloom/issues/2362) [#2395](https://github.com/seanmalbert/bloom/issues/2395) [#2410](https://github.com/seanmalbert/bloom/issues/2410) [#2407](https://github.com/seanmalbert/bloom/issues/2407) [#2430](https://github.com/seanmalbert/bloom/issues/2430) [#2418](https://github.com/seanmalbert/bloom/issues/2418) [#2434](https://github.com/seanmalbert/bloom/issues/2434) [#2374](https://github.com/seanmalbert/bloom/issues/2374) [#2435](https://github.com/seanmalbert/bloom/issues/2435) [#2420](https://github.com/seanmalbert/bloom/issues/2420) [#2412](https://github.com/seanmalbert/bloom/issues/2412) [#2434](https://github.com/seanmalbert/bloom/issues/2434) +* Release 11 11 21 (#2162) ([4847469](https://github.com/seanmalbert/bloom/commit/484746982e440c1c1c87c85089d86cd5968f1cae)), closes [#2162](https://github.com/seanmalbert/bloom/issues/2162) + +### Features + +* add a phone number column to the user_accounts table ([44881da](https://github.com/seanmalbert/bloom/commit/44881da1a7ccc17b7d4db1fcf79513632c18066d)) +* add SRO unit type ([a4c1403](https://github.com/seanmalbert/bloom/commit/a4c140350a84a5bacfa65fb6714aa594e406945d)) +* adds jurisdictions to pref seeds ([8a70b68](https://github.com/seanmalbert/bloom/commit/8a70b688ec8c6eb785543d5ce91ae182f62af168)) +* adds new preferences, reserved community type ([90c0673](https://github.com/seanmalbert/bloom/commit/90c0673779eeb028041717d0b1e0e69fb0766c71)) +* adds whatToExpect to GTrans ([461961a](https://github.com/seanmalbert/bloom/commit/461961a4dd48d7a1c935e4dc03e9a62d2f455088)) +* adds whatToExpect to GTrans ([#2303](https://github.com/seanmalbert/bloom/issues/2303)) ([38e672a](https://github.com/seanmalbert/bloom/commit/38e672a4dbd6c39a7a01b04698f2096a62eed8a1)) +* ami chart jurisdictionalized ([b2e2537](https://github.com/seanmalbert/bloom/commit/b2e2537818d92ff41ea51fbbeb23d9d7e8c1cf52)) +* **backend:** add storing listing translations ([#2215](https://github.com/seanmalbert/bloom/issues/2215)) ([d6a1337](https://github.com/seanmalbert/bloom/commit/d6a1337fbe3da8a159e2b60638fc527aa65aaef0)) +* **backend:** all programs to csv export ([#2302](https://github.com/seanmalbert/bloom/issues/2302)) ([48b50f9](https://github.com/seanmalbert/bloom/commit/48b50f95be794773cc68ebee3144c1f44db26f04)) +* better seed data for ami-charts ([24eb7e4](https://github.com/seanmalbert/bloom/commit/24eb7e41512963f8dc716b74e8a8684e1272e1b7)) +* feat(backend): make use of new application confirmation codes ([8f386e8](https://github.com/seanmalbert/bloom/commit/8f386e8e656c8d498d41de947f2e5246d3c16b19)) +* new demographics sub-race questions ([910df6a](https://github.com/seanmalbert/bloom/commit/910df6ad3985980becdc2798076ed5dfeeb310b5)) +* one month rent ([319743d](https://github.com/seanmalbert/bloom/commit/319743d23268f5b55e129c0878510edb4204b668)) +* overrides fallback to english, tagalog support ([b79fd10](https://github.com/seanmalbert/bloom/commit/b79fd1018619f618bd9be8e870d35c1180b81dfb)) +* updates email confirmation for lottery ([768064a](https://github.com/seanmalbert/bloom/commit/768064a985ed858fae681caebcbcdb561319eaf9)) + + +### Reverts + +* Revert "chore: removes application program partners" ([91e22d8](https://github.com/seanmalbert/bloom/commit/91e22d891104e8d4fc024d709a6a14cec1400733)) +* Revert "chore: removes application program display" ([740cf00](https://github.com/seanmalbert/bloom/commit/740cf00dc3a729eed037d56a8dfc5988decd2651)) + +### BREAKING CHANGES + +* sign-in pages have been updated +* moved some helpers from ui-components to shared-helpers +* remove applicationDueTime field and consolidated into applicationDueDate + +* chore(release): version + + - @bloom-housing/backend-core@3.0.2-alpha.13 + - @bloom-housing/shared-helpers@4.0.1-alpha.21 + - @bloom-housing/partners@4.0.1-alpha.23 + - @bloom-housing/public@4.0.1-alpha.22 + - @bloom-housing/ui-components@4.0.1-alpha.21 +* preferences model and relationships changed + +* feat: feat(backend): extend UserUpdateDto to support email change + +picked from dev 3e1fdbd0ea91d4773973d5c485a5ba61303db90a + +* fix: 2056/user account edit fix + +picked from dev a15618c0cb548ff5b2ae913b802c9e08bb673f30 + +* refactor: 2085/adds top level catchAll exception filter + +picked from dev aeaa63d1af1fa3d11671e169cb3bd23d356fface + +* feat: feat: Change unit number field type to text + +picked from dev f54be7c7ba6aac8e00fee610dc86584b60cc212d + +* feat(backend): improve application flagged set saving efficiency + +* fix: fix: updates address order + +picked from dev 252e014dcbd2e4c305384ed552135f5a8e4e4767 + +* fix: sets programs to optoinal and updates versions + +* chore: chore(deps): bump electron from 13.1.7 to 13.3.0 + +* chore: chore(deps): bump axios from 0.21.1 to 0.21.2 + +* fix: adds programs service + +* fix: fix lisitng e2e tests + +* fix: fix member tests + + + + + +## [3.0.2-alpha.29](https://github.com/bloom-housing/bloom/compare/@bloom-housing/backend-core@3.0.2-alpha.28...@bloom-housing/backend-core@3.0.2-alpha.29) (2022-02-02) + + +### Bug Fixes + +* unit accordion radio button not showing default value ([#2451](https://github.com/bloom-housing/bloom/issues/2451)) ([4ed8103](https://github.com/bloom-housing/bloom/commit/4ed81039b9130d0433b11df2bdabc495ce2b9f24)) + + + + + +## [3.0.2-alpha.28](https://github.com/bloom-housing/bloom/compare/@bloom-housing/backend-core@3.0.2-alpha.27...@bloom-housing/backend-core@3.0.2-alpha.28) (2022-02-02) + +**Note:** Version bump only for package @bloom-housing/backend-core + + + + + +## [3.0.2-alpha.27](https://github.com/bloom-housing/bloom/compare/@bloom-housing/backend-core@3.0.2-alpha.26...@bloom-housing/backend-core@3.0.2-alpha.27) (2022-02-02) + + +### Bug Fixes + +* **backend:** translations input validator ([#2466](https://github.com/bloom-housing/bloom/issues/2466)) ([603c3dc](https://github.com/bloom-housing/bloom/commit/603c3dc52a400db815c4d81552a5aa74f397fe0f)) + + + + + +## [3.0.2-alpha.26](https://github.com/bloom-housing/bloom/compare/@bloom-housing/backend-core@3.0.2-alpha.25...@bloom-housing/backend-core@3.0.2-alpha.26) (2022-02-02) + +**Note:** Version bump only for package @bloom-housing/backend-core + + + + + +## [3.0.2-alpha.25](https://github.com/bloom-housing/bloom/compare/@bloom-housing/backend-core@3.0.2-alpha.24...@bloom-housing/backend-core@3.0.2-alpha.25) (2022-02-01) + + +### Bug Fixes + +* date validation issue ([#2464](https://github.com/bloom-housing/bloom/issues/2464)) ([158f7bf](https://github.com/bloom-housing/bloom/commit/158f7bf7fdc59954aebfebbd1ad3741239ed1a35)) + + + + + +## [3.0.2-alpha.24](https://github.com/bloom-housing/bloom/compare/@bloom-housing/backend-core@3.0.2-alpha.23...@bloom-housing/backend-core@3.0.2-alpha.24) (2022-02-01) + +**Note:** Version bump only for package @bloom-housing/backend-core + + + + + +## [3.0.2-alpha.23](https://github.com/bloom-housing/bloom/compare/@bloom-housing/backend-core@3.0.2-alpha.22...@bloom-housing/backend-core@3.0.2-alpha.23) (2022-02-01) + + +### Bug Fixes + +* await casbin enforcer ([4feacec](https://github.com/bloom-housing/bloom/commit/4feacec44635135bc5469c0edd02a3424a2697cc)) + + + + + +## [3.0.2-alpha.22](https://github.com/bloom-housing/bloom/compare/@bloom-housing/backend-core@3.0.2-alpha.21...@bloom-housing/backend-core@3.0.2-alpha.22) (2022-02-01) + +**Note:** Version bump only for package @bloom-housing/backend-core + + + + + +## [3.0.2-alpha.21](https://github.com/bloom-housing/bloom/compare/@bloom-housing/backend-core@3.0.2-alpha.20...@bloom-housing/backend-core@3.0.2-alpha.21) (2022-02-01) + +**Note:** Version bump only for package @bloom-housing/backend-core + + + + + +## [3.0.2-alpha.20](https://github.com/bloom-housing/bloom/compare/@bloom-housing/backend-core@3.0.2-alpha.19...@bloom-housing/backend-core@3.0.2-alpha.20) (2022-02-01) + + +### Features + +* **backend:** add publishedAt and closedAt to listing entity ([#2432](https://github.com/bloom-housing/bloom/issues/2432)) ([f3b0f86](https://github.com/bloom-housing/bloom/commit/f3b0f864a6d5d2ad3d886e828743454c3e8fca71)) + + + + + +## [3.0.2-alpha.19](https://github.com/bloom-housing/bloom/compare/@bloom-housing/backend-core@3.0.2-alpha.18...@bloom-housing/backend-core@3.0.2-alpha.19) (2022-02-01) + +**Note:** Version bump only for package @bloom-housing/backend-core + + + + + +## [3.0.2-alpha.18](https://github.com/bloom-housing/bloom/compare/@bloom-housing/backend-core@3.0.2-alpha.17...@bloom-housing/backend-core@3.0.2-alpha.18) (2022-01-31) + +**Note:** Version bump only for package @bloom-housing/backend-core + + + + + +## [3.0.2-alpha.17](https://github.com/bloom-housing/bloom/compare/@bloom-housing/backend-core@3.0.2-alpha.16...@bloom-housing/backend-core@3.0.2-alpha.17) (2022-01-27) + + +### Features + +* outdated password messaging updates ([b14e19d](https://github.com/bloom-housing/bloom/commit/b14e19d43099af2ba721d8aaaeeb2be886d05111)) + + + + + +## [3.0.2-alpha.16](https://github.com/bloom-housing/bloom/compare/@bloom-housing/backend-core@3.0.2-alpha.15...@bloom-housing/backend-core@3.0.2-alpha.16) (2022-01-24) + + +### Bug Fixes + +* ami charts without all households ([#2430](https://github.com/bloom-housing/bloom/issues/2430)) ([92dfbad](https://github.com/bloom-housing/bloom/commit/92dfbad32c90d84ee1ec3a3468c084cb110aa8be)) + + + + + +## [3.0.2-alpha.15](https://github.com/bloom-housing/bloom/compare/@bloom-housing/backend-core@3.0.2-alpha.14...@bloom-housing/backend-core@3.0.2-alpha.15) (2022-01-14) + + +### Bug Fixes + +* patches translations for preferences ([#2410](https://github.com/bloom-housing/bloom/issues/2410)) ([7906e6b](https://github.com/bloom-housing/bloom/commit/7906e6bc035fab4deea79ea51833a0ef29926d45)) + + + + +## [3.0.1](https://github.com/seanmalbert/bloom/compare/@bloom-housing/backend-core@3.0.1-alpha.23...@bloom-housing/backend-core@3.0.1) (2022-01-13) + +### Bug Fixes + +* app submission w/ no due date ([8e5a81c](https://github.com/seanmalbert/bloom/commit/8e5a81c37c4efc3404e5536bd54c10cd2962bca3)) +* cannot save custom mailing, dropoff, or pickup address ([edcb068](https://github.com/seanmalbert/bloom/commit/edcb068ca23411e0a34f1dc2ff4c77ab489ac0fc)) +* fix for csv dempgraphics and preference patch ([0ffc090](https://github.com/seanmalbert/bloom/commit/0ffc0900fee73b34fd953e5355552e2e763c239c)) +* listings management keep empty strings, remove empty objects ([3aba274](https://github.com/seanmalbert/bloom/commit/3aba274a751cdb2db55b65ade1cda5d1689ca681)) +* recalculate units available on listing update ([9c3967f](https://github.com/seanmalbert/bloom/commit/9c3967f0b74526db39df4f5dbc7ad9a52741a6ea)) +* units with invalid ami chart ([621ff02](https://github.com/seanmalbert/bloom/commit/621ff0227270861047e885467f9ddd77459adec1)) +* updates household member count ([f822713](https://github.com/seanmalbert/bloom/commit/f82271397d02025629d7ea039b40cdac95877c45)) + + +* Release 11 11 21 (#2162) ([4847469](https://github.com/seanmalbert/bloom/commit/484746982e440c1c1c87c85089d86cd5968f1cae)), closes [#2162](https://github.com/seanmalbert/bloom/issues/2162) + +### Features + +* add a phone number column to the user_accounts table ([44881da](https://github.com/seanmalbert/bloom/commit/44881da1a7ccc17b7d4db1fcf79513632c18066d)) +* add SRO unit type ([a4c1403](https://github.com/seanmalbert/bloom/commit/a4c140350a84a5bacfa65fb6714aa594e406945d)) +* adds jurisdictions to pref seeds ([8a70b68](https://github.com/seanmalbert/bloom/commit/8a70b688ec8c6eb785543d5ce91ae182f62af168)) +* adds new preferences, reserved community type ([90c0673](https://github.com/seanmalbert/bloom/commit/90c0673779eeb028041717d0b1e0e69fb0766c71)) +* adds whatToExpect to GTrans ([461961a](https://github.com/seanmalbert/bloom/commit/461961a4dd48d7a1c935e4dc03e9a62d2f455088)) +* ami chart jurisdictionalized ([b2e2537](https://github.com/seanmalbert/bloom/commit/b2e2537818d92ff41ea51fbbeb23d9d7e8c1cf52)) +* **backend:** all programs to csv export ([#2302](https://github.com/seanmalbert/bloom/issues/2302)) ([48b50f9](https://github.com/seanmalbert/bloom/commit/48b50f95be794773cc68ebee3144c1f44db26f04)) +* better seed data for ami-charts ([24eb7e4](https://github.com/seanmalbert/bloom/commit/24eb7e41512963f8dc716b74e8a8684e1272e1b7)) +* feat(backend): make use of new application confirmation codes ([8f386e8](https://github.com/seanmalbert/bloom/commit/8f386e8e656c8d498d41de947f2e5246d3c16b19)) +* new demographics sub-race questions ([910df6a](https://github.com/seanmalbert/bloom/commit/910df6ad3985980becdc2798076ed5dfeeb310b5)) +* one month rent ([319743d](https://github.com/seanmalbert/bloom/commit/319743d23268f5b55e129c0878510edb4204b668)) +* overrides fallback to english, tagalog support ([b79fd10](https://github.com/seanmalbert/bloom/commit/b79fd1018619f618bd9be8e870d35c1180b81dfb)) +* updates email confirmation for lottery ([768064a](https://github.com/seanmalbert/bloom/commit/768064a985ed858fae681caebcbcdb561319eaf9)) + + +### Reverts + +* Revert "chore: removes application program partners" ([91e22d8](https://github.com/seanmalbert/bloom/commit/91e22d891104e8d4fc024d709a6a14cec1400733)) +* Revert "chore: removes application program display" ([740cf00](https://github.com/seanmalbert/bloom/commit/740cf00dc3a729eed037d56a8dfc5988decd2651)) + + +### BREAKING CHANGES + +* preferences model and relationships changed + +* feat: feat(backend): extend UserUpdateDto to support email change + +picked from dev 3e1fdbd0ea91d4773973d5c485a5ba61303db90a + +* fix: 2056/user account edit fix + +picked from dev a15618c0cb548ff5b2ae913b802c9e08bb673f30 + +* refactor: 2085/adds top level catchAll exception filter + +picked from dev aeaa63d1af1fa3d11671e169cb3bd23d356fface + +* feat: feat: Change unit number field type to text + +picked from dev f54be7c7ba6aac8e00fee610dc86584b60cc212d + +* feat(backend): improve application flagged set saving efficiency + +* fix: fix: updates address order + +picked from dev 252e014dcbd2e4c305384ed552135f5a8e4e4767 + +* fix: sets programs to optoinal and updates versions + +* chore: chore(deps): bump electron from 13.1.7 to 13.3.0 + +* chore: chore(deps): bump axios from 0.21.1 to 0.21.2 + +* fix: adds programs service + +* fix: fix lisitng e2e tests + +* fix: fix member tests + + + + + +## [3.0.2-alpha.14](https://github.com/bloom-housing/bloom/compare/@bloom-housing/backend-core@3.0.2-alpha.13...@bloom-housing/backend-core@3.0.2-alpha.14) (2022-01-13) + + +### Bug Fixes + +* partners render issue ([#2395](https://github.com/bloom-housing/bloom/issues/2395)) ([7fb108d](https://github.com/bloom-housing/bloom/commit/7fb108d744fcafd6b9df42706d2a2f58fbc30f0a)) + + + + + +## [3.0.2-alpha.13](https://github.com/bloom-housing/bloom/compare/@bloom-housing/backend-core@3.0.2-alpha.12...@bloom-housing/backend-core@3.0.2-alpha.13) (2022-01-13) + + +### Bug Fixes + +* dates showing as invalid in send by mail section ([#2362](https://github.com/bloom-housing/bloom/issues/2362)) ([3567388](https://github.com/bloom-housing/bloom/commit/35673882d87e2b524b2c94d1fb7b40c9d777f0a3)) + + +### BREAKING CHANGES + +* remove applicationDueTime field and consolidated into applicationDueDate + + + + + +## [3.0.2-alpha.12](https://github.com/bloom-housing/bloom/compare/@bloom-housing/backend-core@3.0.2-alpha.11...@bloom-housing/backend-core@3.0.2-alpha.12) (2022-01-07) + +**Note:** Version bump only for package @bloom-housing/backend-core + + + + + +## [3.0.2-alpha.11](https://github.com/bloom-housing/bloom/compare/@bloom-housing/backend-core@3.0.2-alpha.10...@bloom-housing/backend-core@3.0.2-alpha.11) (2022-01-07) + +**Note:** Version bump only for package @bloom-housing/backend-core + + + + + +## [3.0.2-alpha.10](https://github.com/bloom-housing/bloom/compare/@bloom-housing/backend-core@3.0.2-alpha.9...@bloom-housing/backend-core@3.0.2-alpha.10) (2022-01-04) + + +### Bug Fixes + +* fix sortig on applications partner grid ([f097037](https://github.com/bloom-housing/bloom/commit/f097037afd896eec8bb90cc5e2de07f222907870)) +* fixes linting error ([aaaf858](https://github.com/bloom-housing/bloom/commit/aaaf85822e3b03224fb336bae66209a2b6b88d1d)) +* fixes some issues with the deployment ([a0042ba](https://github.com/bloom-housing/bloom/commit/a0042badc5474dde413e41a7f4f84c8ee7b2f8f1)) +* fixes tests and also issue with user grid ([da07ba4](https://github.com/bloom-housing/bloom/commit/da07ba49459f77fe77e3f72555eb50a0cbaab095)) + + + + + +## [3.0.2-alpha.9](https://github.com/bloom-housing/bloom/compare/@bloom-housing/backend-core@3.0.2-alpha.8...@bloom-housing/backend-core@3.0.2-alpha.9) (2022-01-04) + +**Note:** Version bump only for package @bloom-housing/backend-core + + + + + +## [3.0.2-alpha.8](https://github.com/bloom-housing/bloom/compare/@bloom-housing/backend-core@3.0.2-alpha.7...@bloom-housing/backend-core@3.0.2-alpha.8) (2022-01-03) + +**Note:** Version bump only for package @bloom-housing/backend-core + + + + + +## [3.0.2-alpha.7](https://github.com/bloom-housing/bloom/compare/@bloom-housing/backend-core@3.0.1...@bloom-housing/backend-core@3.0.2-alpha.7) (2022-01-03) + + +### Bug Fixes + +* bump version ([#2349](https://github.com/bloom-housing/bloom/issues/2349)) ([b9e3ba1](https://github.com/bloom-housing/bloom/commit/b9e3ba10aebd6534090f8be231a9ea77b3c929b6)) +* bump version ([#2350](https://github.com/bloom-housing/bloom/issues/2350)) ([05863f5](https://github.com/bloom-housing/bloom/commit/05863f55f3939bea4387bd7cf4eb1f34df106124)) +* check for user lastLoginAt ([d78745a](https://github.com/bloom-housing/bloom/commit/d78745a4c8b770864c4f5e6140ee602e745b8bec)) + + +### Features + +* **backend:** add appropriate http exception for password outdated login failure ([e5df66e](https://github.com/bloom-housing/bloom/commit/e5df66e4fe0f937f507d014f3b25c6c9b4b5deff)) +* **backend:** add password outdating only to users which are either admins or partners ([754546d](https://github.com/bloom-housing/bloom/commit/754546dfd5194f8c30e12963031791818566d22d)) +* **backend:** add user password expiration ([107c2f0](https://github.com/bloom-housing/bloom/commit/107c2f06e2f8367b52cb7cc8f00e6d9aef751fe0)) +* **backend:** lock failed login attempts ([a8370ce](https://github.com/bloom-housing/bloom/commit/a8370ce1516f75180796d190a9a9f2697723e181)) +* **backend:** remove activity log interceptor from update-password ([2e56b98](https://github.com/bloom-housing/bloom/commit/2e56b9878969604bec2f7694a83dbf7061af9df2)) + + + + + +## [3.0.2-alpha.6](https://github.com/bloom-housing/bloom/compare/@bloom-housing/backend-core@3.0.1...@bloom-housing/backend-core@3.0.2-alpha.6) (2022-01-03) + + +### Bug Fixes + +* bump version ([#2349](https://github.com/bloom-housing/bloom/issues/2349)) ([b9e3ba1](https://github.com/bloom-housing/bloom/commit/b9e3ba10aebd6534090f8be231a9ea77b3c929b6)) +* bump version ([#2350](https://github.com/bloom-housing/bloom/issues/2350)) ([05863f5](https://github.com/bloom-housing/bloom/commit/05863f55f3939bea4387bd7cf4eb1f34df106124)) +* check for user lastLoginAt ([d78745a](https://github.com/bloom-housing/bloom/commit/d78745a4c8b770864c4f5e6140ee602e745b8bec)) + + +### Features + +* **backend:** add appropriate http exception for password outdated login failure ([e5df66e](https://github.com/bloom-housing/bloom/commit/e5df66e4fe0f937f507d014f3b25c6c9b4b5deff)) +* **backend:** add password outdating only to users which are either admins or partners ([754546d](https://github.com/bloom-housing/bloom/commit/754546dfd5194f8c30e12963031791818566d22d)) +* **backend:** add user password expiration ([107c2f0](https://github.com/bloom-housing/bloom/commit/107c2f06e2f8367b52cb7cc8f00e6d9aef751fe0)) +* **backend:** lock failed login attempts ([a8370ce](https://github.com/bloom-housing/bloom/commit/a8370ce1516f75180796d190a9a9f2697723e181)) +* **backend:** remove activity log interceptor from update-password ([2e56b98](https://github.com/bloom-housing/bloom/commit/2e56b9878969604bec2f7694a83dbf7061af9df2)) + + + + + +## [3.0.2-alpha.1](https://github.com/seanmalbert/bloom/compare/@bloom-housing/backend-core@3.0.2-alpha.0...@bloom-housing/backend-core@3.0.2-alpha.1) (2021-12-23) + +**Note:** Version bump only for package @bloom-housing/backend-core + + + + + +## [3.0.2-alpha.0](https://github.com/seanmalbert/bloom/compare/@bloom-housing/backend-core@3.0.1...@bloom-housing/backend-core@3.0.2-alpha.0) (2021-12-23) + + +### Features + +* **backend:** lock failed login attempts ([a8370ce](https://github.com/seanmalbert/bloom/commit/a8370ce1516f75180796d190a9a9f2697723e181)) + + + + + +## [3.0.1](https://github.com/seanmalbert/bloom/compare/@bloom-housing/backend-core@3.0.1-alpha.40...@bloom-housing/backend-core@3.0.1) (2021-12-22) + +**Note:** Version bump only for package @bloom-housing/backend-core + + + + + +## [3.0.1-alpha.40](https://github.com/bloom-housing/bloom/compare/@bloom-housing/backend-core@3.0.1-alpha.39...@bloom-housing/backend-core@3.0.1-alpha.40) (2021-12-15) + + +### Features + +* **backend:** refactor applications module ([#2279](https://github.com/bloom-housing/bloom/issues/2279)) ([e0b4523](https://github.com/bloom-housing/bloom/commit/e0b4523817c7d3863c3802d8a9f61d1a1c8685d4)) + + + + + +## [3.0.1-alpha.39](https://github.com/bloom-housing/bloom/compare/@bloom-housing/backend-core@3.0.1-alpha.38...@bloom-housing/backend-core@3.0.1-alpha.39) (2021-12-14) + + +### Features + +* removes ListingLangCacheInterceptor from get by id ([7acbd82](https://github.com/bloom-housing/bloom/commit/7acbd82485edfa9a8aa5a82473d5bbe5cee571e7)) + + + + + +## [3.0.1-alpha.38](https://github.com/bloom-housing/bloom/compare/@bloom-housing/backend-core@3.0.1-alpha.37...@bloom-housing/backend-core@3.0.1-alpha.38) (2021-12-14) + + +### Features + +* **backend:** add partnerTerms to jurisdiction entity ([#2301](https://github.com/bloom-housing/bloom/issues/2301)) ([7ecf3ef](https://github.com/bloom-housing/bloom/commit/7ecf3ef24f261bf6b42fc38cf0080251a3c60e89)) + + + + + +## [3.0.1-alpha.37](https://github.com/bloom-housing/bloom/compare/@bloom-housing/backend-core@3.0.1-alpha.36...@bloom-housing/backend-core@3.0.1-alpha.37) (2021-12-13) + + +### Features + +* **backend:** all programs to csv export ([#2302](https://github.com/bloom-housing/bloom/issues/2302)) ([f4d6a62](https://github.com/bloom-housing/bloom/commit/f4d6a62920e3b859310898e3a040f8116b43cab3)) + + + + + +## [3.0.1-alpha.36](https://github.com/bloom-housing/bloom/compare/@bloom-housing/backend-core@3.0.1-alpha.35...@bloom-housing/backend-core@3.0.1-alpha.36) (2021-12-13) + + +### Features + +* **backend:** add activity logging to listings module ([#2190](https://github.com/bloom-housing/bloom/issues/2190)) ([88d60e3](https://github.com/bloom-housing/bloom/commit/88d60e32d77381d6e830158ce77c058b1cfcc022)) + + + + + +## [3.0.1-alpha.35](https://github.com/bloom-housing/bloom/compare/@bloom-housing/backend-core@3.0.1-alpha.34...@bloom-housing/backend-core@3.0.1-alpha.35) (2021-12-10) + + +### Features + +* adds whatToExpect to GTrans ([#2303](https://github.com/bloom-housing/bloom/issues/2303)) ([6d7305b](https://github.com/bloom-housing/bloom/commit/6d7305b8e3b7e1c3a9776123e8e6d370ab803af0)) + + + + + +## [3.0.1-alpha.34](https://github.com/bloom-housing/bloom/compare/@bloom-housing/backend-core@3.0.1-alpha.33...@bloom-housing/backend-core@3.0.1-alpha.34) (2021-12-09) + + +### Bug Fixes + +* units with invalid ami chart ([#2290](https://github.com/bloom-housing/bloom/issues/2290)) ([a6516e1](https://github.com/bloom-housing/bloom/commit/a6516e142ec13db5c3c8d2bb4f726be681e172e3)) + + + + + +## [3.0.1-alpha.33](https://github.com/bloom-housing/bloom/compare/@bloom-housing/backend-core@3.0.1-alpha.32...@bloom-housing/backend-core@3.0.1-alpha.33) (2021-12-07) + + +### Features + +* overrides fallback to english, tagalog support ([#2262](https://github.com/bloom-housing/bloom/issues/2262)) ([679ab9b](https://github.com/bloom-housing/bloom/commit/679ab9b1816d5934f48f02ca5f5696952ef88ae7)) + + + + + +## [3.0.1-alpha.32](https://github.com/bloom-housing/bloom/compare/@bloom-housing/backend-core@3.0.1-alpha.31...@bloom-housing/backend-core@3.0.1-alpha.32) (2021-12-06) + + +### Features + +* **backend:** add listings closing routine ([#2213](https://github.com/bloom-housing/bloom/issues/2213)) ([a747806](https://github.com/bloom-housing/bloom/commit/a747806282f80c92bd9a171a2b4d5c9b74d3b49a)) + + + + + +## [3.0.1-alpha.31](https://github.com/bloom-housing/bloom/compare/@bloom-housing/backend-core@3.0.1-alpha.30...@bloom-housing/backend-core@3.0.1-alpha.31) (2021-12-03) + +**Note:** Version bump only for package @bloom-housing/backend-core + + + + + +## [3.0.1-alpha.30](https://github.com/bloom-housing/bloom/compare/@bloom-housing/backend-core@3.0.1-alpha.29...@bloom-housing/backend-core@3.0.1-alpha.30) (2021-12-03) + +**Note:** Version bump only for package @bloom-housing/backend-core + + + + + +## [3.0.1-alpha.29](https://github.com/bloom-housing/bloom/compare/@bloom-housing/backend-core@3.0.1-alpha.28...@bloom-housing/backend-core@3.0.1-alpha.29) (2021-12-03) + + +### Features + +* **backend:** add storing listing translations ([#2215](https://github.com/bloom-housing/bloom/issues/2215)) ([6ac63ea](https://github.com/bloom-housing/bloom/commit/6ac63eae82e14ab32d541b907c7e5dc800c1971f)) + + + + + +## [3.0.1-alpha.28](https://github.com/bloom-housing/bloom/compare/@bloom-housing/backend-core@3.0.1-alpha.27...@bloom-housing/backend-core@3.0.1-alpha.28) (2021-12-01) + +**Note:** Version bump only for package @bloom-housing/backend-core + + + + + +## [3.0.1-alpha.27](https://github.com/bloom-housing/bloom/compare/@bloom-housing/backend-core@3.0.1-alpha.26...@bloom-housing/backend-core@3.0.1-alpha.27) (2021-12-01) + +**Note:** Version bump only for package @bloom-housing/backend-core + + + + + +## [3.0.1-alpha.26](https://github.com/bloom-housing/bloom/compare/@bloom-housing/backend-core@3.0.1-alpha.25...@bloom-housing/backend-core@3.0.1-alpha.26) (2021-11-30) + + +### Bug Fixes + +* **backend:** nginx with heroku configuration ([#2196](https://github.com/bloom-housing/bloom/issues/2196)) ([a1e2630](https://github.com/bloom-housing/bloom/commit/a1e26303bdd660b9ac267da55dc8d09661216f1c)) + + + + + +## [3.0.1-alpha.25](https://github.com/bloom-housing/bloom/compare/@bloom-housing/backend-core@3.0.1-alpha.24...@bloom-housing/backend-core@3.0.1-alpha.25) (2021-11-29) + +**Note:** Version bump only for package @bloom-housing/backend-core + + + + + +## [3.0.1-alpha.24](https://github.com/bloom-housing/bloom/compare/@bloom-housing/backend-core@3.0.1-alpha.23...@bloom-housing/backend-core@3.0.1-alpha.24) (2021-11-29) + + +### Bug Fixes + +* cannot save custom mailing, dropoff, or pickup address ([#2207](https://github.com/bloom-housing/bloom/issues/2207)) ([96484b5](https://github.com/bloom-housing/bloom/commit/96484b5676ecb000e492851ee12766ba9e6cd86f)) + + + + + +## [3.0.1-alpha.23](https://github.com/bloom-housing/bloom/compare/@bloom-housing/backend-core@3.0.1-alpha.22...@bloom-housing/backend-core@3.0.1-alpha.23) (2021-11-23) + + +### Features + +* updates email confirmation for lottery ([#2200](https://github.com/bloom-housing/bloom/issues/2200)) ([1a5e824](https://github.com/bloom-housing/bloom/commit/1a5e824c96d8e23674c32ea92688b9f7255528d3)) + + + + + +## [3.0.1-alpha.22](https://github.com/bloom-housing/bloom/compare/@bloom-housing/backend-core@3.0.1-alpha.21...@bloom-housing/backend-core@3.0.1-alpha.22) (2021-11-23) + + +### Features + +* new demographics sub-race questions ([#2109](https://github.com/bloom-housing/bloom/issues/2109)) ([9ab8926](https://github.com/bloom-housing/bloom/commit/9ab892694c1ad2fa8890b411b3b32af68ade1fc3)) + + + + + +## [3.0.1-alpha.21](https://github.com/bloom-housing/bloom/compare/@bloom-housing/backend-core@3.0.1-alpha.20...@bloom-housing/backend-core@3.0.1-alpha.21) (2021-11-22) + +**Note:** Version bump only for package @bloom-housing/backend-core + + + + + +## [3.0.1-alpha.20](https://github.com/bloom-housing/bloom/compare/@bloom-housing/backend-core@3.0.1-alpha.19...@bloom-housing/backend-core@3.0.1-alpha.20) (2021-11-22) + + +### Features + +* adds jurisdictions to pref seeds ([#2199](https://github.com/bloom-housing/bloom/issues/2199)) ([9e47cec](https://github.com/bloom-housing/bloom/commit/9e47cec3b1acfe769207ccbb33c07019cd742e33)) + + + + + +## [3.0.1-alpha.19](https://github.com/bloom-housing/bloom/compare/@bloom-housing/backend-core@3.0.1-alpha.18...@bloom-housing/backend-core@3.0.1-alpha.19) (2021-11-22) + +**Note:** Version bump only for package @bloom-housing/backend-core + + + + + +## [3.0.1-alpha.18](https://github.com/bloom-housing/bloom/compare/@bloom-housing/backend-core@3.0.1-alpha.17...@bloom-housing/backend-core@3.0.1-alpha.18) (2021-11-22) + +**Note:** Version bump only for package @bloom-housing/backend-core + + + + + +## [3.0.1-alpha.17](https://github.com/bloom-housing/bloom/compare/@bloom-housing/backend-core@3.0.1-alpha.16...@bloom-housing/backend-core@3.0.1-alpha.17) (2021-11-17) + + +### Bug Fixes + +* **backend:** fix view.spec.ts test ([#2175](https://github.com/bloom-housing/bloom/issues/2175)) ([324446c](https://github.com/bloom-housing/bloom/commit/324446c90138d8fac50aba445f515009b5a58bfb)) + + + + + +## [3.0.1-alpha.16](https://github.com/bloom-housing/bloom/compare/@bloom-housing/backend-core@3.0.1-alpha.15...@bloom-housing/backend-core@3.0.1-alpha.16) (2021-11-16) + +**Note:** Version bump only for package @bloom-housing/backend-core + + + + + +## [3.0.1-alpha.15](https://github.com/bloom-housing/bloom/compare/@bloom-housing/backend-core@3.0.1-alpha.14...@bloom-housing/backend-core@3.0.1-alpha.15) (2021-11-15) + +**Note:** Version bump only for package @bloom-housing/backend-core + + + + + +## [3.0.1-alpha.14](https://github.com/bloom-housing/bloom/compare/@bloom-housing/backend-core@3.0.1-alpha.13...@bloom-housing/backend-core@3.0.1-alpha.14) (2021-11-15) + +**Note:** Version bump only for package @bloom-housing/backend-core + + + + + +## [3.0.1-alpha.13](https://github.com/bloom-housing/bloom/compare/@bloom-housing/backend-core@3.0.1-alpha.12...@bloom-housing/backend-core@3.0.1-alpha.13) (2021-11-15) + + +### Reverts + +* Revert "feat(backend): add nginx proxy-cache configuration (#2119)" ([d7a8951](https://github.com/bloom-housing/bloom/commit/d7a8951bc6686d4361f7c1100f09a45b29058fd0)), closes [#2119](https://github.com/bloom-housing/bloom/issues/2119) + + + + + +## [3.0.1-alpha.12](https://github.com/bloom-housing/bloom/compare/@bloom-housing/backend-core@3.0.1-alpha.11...@bloom-housing/backend-core@3.0.1-alpha.12) (2021-11-12) + + +### Bug Fixes + +* sapp submission w/ no due date ([4af1f5a](https://github.com/bloom-housing/bloom/commit/4af1f5a8448f16d347b4a65ecb85fda4d6ed71fc)) + + + + + +## [3.0.1-alpha.11](https://github.com/bloom-housing/bloom/compare/@bloom-housing/backend-core@3.0.1-alpha.10...@bloom-housing/backend-core@3.0.1-alpha.11) (2021-11-12) + +**Note:** Version bump only for package @bloom-housing/backend-core + + + + + +## [3.0.1-alpha.10](https://github.com/bloom-housing/bloom/compare/@bloom-housing/backend-core@3.0.1-alpha.9...@bloom-housing/backend-core@3.0.1-alpha.10) (2021-11-11) + + +### Bug Fixes + +* recalculate units available on listing update ([#2150](https://github.com/bloom-housing/bloom/issues/2150)) ([f1a3dbc](https://github.com/bloom-housing/bloom/commit/f1a3dbce6478b16542ed61ab20de5dfb9b797262)) + + + + + +## [3.0.1-alpha.9](https://github.com/bloom-housing/bloom/compare/@bloom-housing/backend-core@3.0.1-alpha.8...@bloom-housing/backend-core@3.0.1-alpha.9) (2021-11-10) + + +### Features + +* **backend:** add nginx proxy-cache configuration ([#2119](https://github.com/bloom-housing/bloom/issues/2119)) ([34d32e7](https://github.com/bloom-housing/bloom/commit/34d32e75ceae378a26c57f4c9b7feec8c88339e0)) + + + + + +## [3.0.1-alpha.8](https://github.com/bloom-housing/bloom/compare/@bloom-housing/backend-core@3.0.1-alpha.7...@bloom-housing/backend-core@3.0.1-alpha.8) (2021-11-09) + + +### Bug Fixes + +* updates address order ([#2151](https://github.com/bloom-housing/bloom/issues/2151)) ([252e014](https://github.com/bloom-housing/bloom/commit/252e014dcbd2e4c305384ed552135f5a8e4e4767)) + + + + + +## [3.0.1-alpha.7](https://github.com/bloom-housing/bloom/compare/@bloom-housing/backend-core@3.0.1-alpha.6...@bloom-housing/backend-core@3.0.1-alpha.7) (2021-11-09) + +**Note:** Version bump only for package @bloom-housing/backend-core + + + + + +## [3.0.1-alpha.6](https://github.com/bloom-housing/bloom/compare/@bloom-housing/backend-core@3.0.1-alpha.5...@bloom-housing/backend-core@3.0.1-alpha.6) (2021-11-09) + +**Note:** Version bump only for package @bloom-housing/backend-core + + + + + +## [3.0.1-alpha.5](https://github.com/bloom-housing/bloom/compare/@bloom-housing/backend-core@3.0.1-alpha.4...@bloom-housing/backend-core@3.0.1-alpha.5) (2021-11-09) + + +### Features + +* **backend:** improve application flagged set saving efficiency ([#2147](https://github.com/bloom-housing/bloom/issues/2147)) ([08a064c](https://github.com/bloom-housing/bloom/commit/08a064c319adabb5385e474f5751246d92dba9a2)) + + + + + +## [3.0.1-alpha.4](https://github.com/bloom-housing/bloom/compare/@bloom-housing/backend-core@3.0.1-alpha.3...@bloom-housing/backend-core@3.0.1-alpha.4) (2021-11-09) + +**Note:** Version bump only for package @bloom-housing/backend-core + + + + + +## [3.0.1-alpha.3](https://github.com/bloom-housing/bloom/compare/@bloom-housing/backend-core@3.0.1-alpha.2...@bloom-housing/backend-core@3.0.1-alpha.3) (2021-11-08) + + +### Features + +* add Programs section to listings management ([#2093](https://github.com/bloom-housing/bloom/issues/2093)) ([9bd1fe1](https://github.com/bloom-housing/bloom/commit/9bd1fe1033dee0fb7e73756254474471bc304f5e)) + + + + + +## [3.0.1-alpha.2](https://github.com/bloom-housing/bloom/compare/@bloom-housing/backend-core@3.0.1-alpha.1...@bloom-housing/backend-core@3.0.1-alpha.2) (2021-11-08) + +**Note:** Version bump only for package @bloom-housing/backend-core + + + + + +## [3.0.1-alpha.1](https://github.com/bloom-housing/bloom/compare/@bloom-housing/backend-core@3.0.1-alpha.0...@bloom-housing/backend-core@3.0.1-alpha.1) (2021-11-08) + + +### Features + +* **backend:** extend UserUpdateDto to support email change with confirmation ([#2120](https://github.com/bloom-housing/bloom/issues/2120)) ([3e1fdbd](https://github.com/bloom-housing/bloom/commit/3e1fdbd0ea91d4773973d5c485a5ba61303db90a)) + + + + + +## [3.0.1-alpha.0](https://github.com/bloom-housing/bloom/compare/@bloom-housing/backend-core@3.0.0...@bloom-housing/backend-core@3.0.1-alpha.0) (2021-11-05) + + +* 1837/preferences cleanup 3 (#2144) ([3ce6d5e](https://github.com/bloom-housing/bloom/commit/3ce6d5eb5aac49431ec5bf4912dbfcbe9077d84e)), closes [#2144](https://github.com/bloom-housing/bloom/issues/2144) + + +### BREAKING CHANGES + +* Preferences are now M-N relation with a listing and have an intermediate table with ordinal number + +* refactor(backend): preferences deduplication + +So far each listing referenced it's own unique Preferences. This change introduces Many to Many +relationship between Preference and Listing entity and forces sharing Preferences between listings. + +* feat(backend): extend preferences migration with moving existing relations to a new intermediate tab + +* feat(backend): add Preference - Jurisdiction ManyToMany relation + +* feat: adapt frontend to backend changes + +* fix(backend): typeORM preferences select statement + +* fix(backend): connect preferences with jurisdictions in seeds, fix pref filter validator + +* fix(backend): fix missing import in preferences-filter-params.ts + +* refactor: rebase issue + +* feat: uptake jurisdictional preferences + +* fix: fixup tests + +* fix: application preferences ignore page, always separate + +* Remove page from src/migration/1633359409242-add-listing-preferences-intermediate-relation.ts + +* fix: preference fetching and ordering/pages + +* Fix code style issues with Prettier + +* fix(backend): query User__leasingAgentInListings__jurisdiction_User__leasingAgentIn specified more + +* fix: perferences cypress tests + +Co-authored-by: Michal Plebanski +Co-authored-by: Emily Jablonski +Co-authored-by: Lint Action + + + + + +# [3.0.0](https://github.com/seanmalbert/bloom/compare/@bloom-housing/backend-core@2.0.1-alpha.7...@bloom-housing/backend-core@3.0.0) (2021-11-05) + +**Note:** Version bump only for package @bloom-housing/backend-core + + + + + +## [2.0.1-alpha.7](https://github.com/bloom-housing/bloom/compare/@bloom-housing/backend-core@2.0.1-alpha.6...@bloom-housing/backend-core@2.0.1-alpha.7) (2021-11-05) + +**Note:** Version bump only for package @bloom-housing/backend-core + + + + + +## [2.0.1-alpha.6](https://github.com/bloom-housing/bloom/compare/@bloom-housing/backend-core@2.0.1-alpha.5...@bloom-housing/backend-core@2.0.1-alpha.6) (2021-11-04) + + +### Reverts + +* Revert "refactor: listing preferences and adds jurisdictional filtering" ([41f72c0](https://github.com/bloom-housing/bloom/commit/41f72c0db49cf94d7930f5cfc88f6ee9d6040986)) + + + + + +## [2.0.1-alpha.5](https://github.com/bloom-housing/bloom/compare/@bloom-housing/backend-core@2.0.1-alpha.4...@bloom-housing/backend-core@2.0.1-alpha.5) (2021-11-04) + + +### Bug Fixes + +* **backend:** make it possible to filter portal users in /users endpoint ([#2078](https://github.com/bloom-housing/bloom/issues/2078)) ([29bf714](https://github.com/bloom-housing/bloom/commit/29bf714d28755916ec8ec896366c8c32c3a227c4)) + + + + + +## [2.0.1-alpha.4](https://github.com/bloom-housing/bloom/compare/@bloom-housing/backend-core@2.0.1-alpha.3...@bloom-housing/backend-core@2.0.1-alpha.4) (2021-11-04) + + +### Features + +* Updates application confirmation numbers ([#2072](https://github.com/bloom-housing/bloom/issues/2072)) ([75cd67b](https://github.com/bloom-housing/bloom/commit/75cd67bcb62280936bdeeaee8c9b7b2583a1339d)) + + + + + +## [2.0.1-alpha.3](https://github.com/bloom-housing/bloom/compare/@bloom-housing/backend-core@2.0.1-alpha.2...@bloom-housing/backend-core@2.0.1-alpha.3) (2021-11-03) + +**Note:** Version bump only for package @bloom-housing/backend-core + + + + + +## [2.0.1-alpha.2](https://github.com/bloom-housing/bloom/compare/@bloom-housing/backend-core@2.0.1-alpha.1...@bloom-housing/backend-core@2.0.1-alpha.2) (2021-11-03) + + +### Bug Fixes + +* don't send email confirmation on paper app submission ([#2110](https://github.com/bloom-housing/bloom/issues/2110)) ([7f83b70](https://github.com/bloom-housing/bloom/commit/7f83b70327049245ecfba04ae3aea4e967929b2a)) + + + + + +## [2.0.1-alpha.1](https://github.com/bloom-housing/bloom/compare/@bloom-housing/backend-core@2.0.1-alpha.0...@bloom-housing/backend-core@2.0.1-alpha.1) (2021-11-03) + + +### Features + +* jurisdictional email signatures ([#2111](https://github.com/bloom-housing/bloom/issues/2111)) ([7a146ff](https://github.com/bloom-housing/bloom/commit/7a146ffb5de88cfa2950e2a469a99e38d71b33c8)) + + + + + +## [2.0.1-alpha.0](https://github.com/bloom-housing/bloom/compare/@bloom-housing/backend-core@2.0.0...@bloom-housing/backend-core@2.0.1-alpha.0) (2021-11-02) + + +### Features + +* two new common app questions - Household Changes and Household Student ([#2070](https://github.com/bloom-housing/bloom/issues/2070)) ([42a752e](https://github.com/bloom-housing/bloom/commit/42a752ec073c0f5b65374c7a68da1e34b0b1c949)) + + + + + +# [2.0.0](https://github.com/seanmalbert/bloom/compare/@bloom-housing/backend-core@2.0.0-pre-tailwind.16...@bloom-housing/backend-core@2.0.0) (2021-11-02) + +**Note:** Version bump only for package @bloom-housing/backend-core + + + + + +# [2.0.0-pre-tailwind.16](https://github.com/bloom-housing/bloom/compare/@bloom-housing/backend-core@2.0.0-pre-tailwind.15...@bloom-housing/backend-core@2.0.0-pre-tailwind.16) (2021-11-02) + + +### Code Refactoring + +* listing preferences and adds jurisdictional filtering ([9f661b4](https://github.com/bloom-housing/bloom/commit/9f661b43921ec939bd1bf5709c934ad6f56dd859)) + + +### BREAKING CHANGES + +* updates preference relationship with listings + + + + + +# [2.0.0-pre-tailwind.15](https://github.com/bloom-housing/bloom/compare/@bloom-housing/backend-core@2.0.0-pre-tailwind.14...@bloom-housing/backend-core@2.0.0-pre-tailwind.15) (2021-11-01) + + +### Bug Fixes + +* reverts preferences to re-add as breaking/major bump ([508078e](https://github.com/bloom-housing/bloom/commit/508078e16649e4d5f669273c50ef62407aab995f)) +* reverts preferences to re-add as breaking/major bump ([4f7d893](https://github.com/bloom-housing/bloom/commit/4f7d89327361b3b28b368c23cfd24e6e8123a0a8)) + + + + + +# [2.0.0-pre-tailwind.14](https://github.com/bloom-housing/bloom/compare/@bloom-housing/backend-core@2.0.0-pre-tailwind.13...@bloom-housing/backend-core@2.0.0-pre-tailwind.14) (2021-10-30) + + +### Bug Fixes + +* updates household member count ([#2112](https://github.com/bloom-housing/bloom/issues/2112)) ([3dee0f7](https://github.com/bloom-housing/bloom/commit/3dee0f7d676ff42d546ecf83a17659cd69d7e1bc)) + + + + + +# [2.0.0-pre-tailwind.13](https://github.com/bloom-housing/bloom/compare/@bloom-housing/backend-core@2.0.0-pre-tailwind.12...@bloom-housing/backend-core@2.0.0-pre-tailwind.13) (2021-10-30) + + +* Preferences cleanup (#1947) ([7329a58](https://github.com/bloom-housing/bloom/commit/7329a58cc9242faf647459e46de1e3cff3fe9c9d)), closes [#1947](https://github.com/bloom-housing/bloom/issues/1947) + + +### BREAKING CHANGES + +* Preferences are now M-N relation with a listing and have an intermediate table with ordinal number + +* refactor(backend): preferences deduplication + +So far each listing referenced it's own unique Preferences. This change introduces Many to Many +relationship between Preference and Listing entity and forces sharing Preferences between listings. + +* feat(backend): extend preferences migration with moving existing relations to a new intermediate tab + +* feat(backend): add Preference - Jurisdiction ManyToMany relation + +* feat: adapt frontend to backend changes + +* fix(backend): typeORM preferences select statement + +* fix(backend): connect preferences with jurisdictions in seeds, fix pref filter validator + +* fix(backend): fix missing import in preferences-filter-params.ts + +* refactor: rebase issue + +* feat: uptake jurisdictional preferences + +* fix: fixup tests + +* fix: application preferences ignore page, always separate + +* Remove page from src/migration/1633359409242-add-listing-preferences-intermediate-relation.ts + +* fix: preference fetching and ordering/pages + +* Fix code style issues with Prettier + +* fix(backend): query User__leasingAgentInListings__jurisdiction_User__leasingAgentIn specified more + +* fix: perferences cypress tests + +Co-authored-by: Emily Jablonski +Co-authored-by: Sean Albert +Co-authored-by: Lint Action + + + + + +# [2.0.0-pre-tailwind.12](https://github.com/bloom-housing/bloom/compare/@bloom-housing/backend-core@2.0.0-pre-tailwind.11...@bloom-housing/backend-core@2.0.0-pre-tailwind.12) (2021-10-29) + + +### Bug Fixes + +* fix for csv demographics and preference patch ([4768fb0](https://github.com/bloom-housing/bloom/commit/4768fb00be55957b3b1b197d149187c79374b48d)) + + + + + +# [2.0.0-pre-tailwind.11](https://github.com/bloom-housing/bloom/compare/@bloom-housing/backend-core@2.0.0-pre-tailwind.10...@bloom-housing/backend-core@2.0.0-pre-tailwind.11) (2021-10-28) + + +### Bug Fixes + +* in listings management keep empty strings, remove empty objects ([#2064](https://github.com/bloom-housing/bloom/issues/2064)) ([c4b1e83](https://github.com/bloom-housing/bloom/commit/c4b1e833ec128f457015ac7ffa421ee6047083d9)) + + + + + +# [2.0.0-pre-tailwind.10](https://github.com/bloom-housing/bloom/compare/@bloom-housing/backend-core@2.0.0-pre-tailwind.9...@bloom-housing/backend-core@2.0.0-pre-tailwind.10) (2021-10-27) + +**Note:** Version bump only for package @bloom-housing/backend-core + + + + + +# [2.0.0-pre-tailwind.9](https://github.com/bloom-housing/bloom/compare/@bloom-housing/backend-core@2.0.0-pre-tailwind.8...@bloom-housing/backend-core@2.0.0-pre-tailwind.9) (2021-10-26) + + +### Bug Fixes + +* Incorrect listing status ([#2015](https://github.com/bloom-housing/bloom/issues/2015)) ([48aa14e](https://github.com/bloom-housing/bloom/commit/48aa14eb522cb8e4d0a25fdeadcc392b30d7f1a9)) + + + + + +# [2.0.0-pre-tailwind.8](https://github.com/bloom-housing/bloom/compare/@bloom-housing/backend-core@2.0.0-pre-tailwind.7...@bloom-housing/backend-core@2.0.0-pre-tailwind.8) (2021-10-22) + + +### Bug Fixes + +* alternate contact email now validated ([#2035](https://github.com/bloom-housing/bloom/issues/2035)) ([b411695](https://github.com/bloom-housing/bloom/commit/b411695350f8f8de39c6994f2fac2fcb4678f678)) + + + + + +# [2.0.0-pre-tailwind.7](https://github.com/bloom-housing/bloom/compare/@bloom-housing/backend-core@2.0.0-pre-tailwind.6...@bloom-housing/backend-core@2.0.0-pre-tailwind.7) (2021-10-22) + + +### Bug Fixes + +* makes listing programs optional ([fbe7134](https://github.com/bloom-housing/bloom/commit/fbe7134348e59e3fdb86663cfdca7648655e7b4b)) + + + + + +# [2.0.0-pre-tailwind.6](https://github.com/bloom-housing/bloom/compare/@bloom-housing/backend-core@2.0.0-pre-tailwind.5...@bloom-housing/backend-core@2.0.0-pre-tailwind.6) (2021-10-22) + + +### Features + +* **backend:** add Program entity ([#1968](https://github.com/bloom-housing/bloom/issues/1968)) ([492ec4d](https://github.com/bloom-housing/bloom/commit/492ec4d333cf9b73af772a1aceed29813f405ba0)), closes [#2034](https://github.com/bloom-housing/bloom/issues/2034) + + + + + +# [2.0.0-pre-tailwind.5](https://github.com/bloom-housing/bloom/compare/@bloom-housing/backend-core@2.0.0-pre-tailwind.4...@bloom-housing/backend-core@2.0.0-pre-tailwind.5) (2021-10-22) + +**Note:** Version bump only for package @bloom-housing/backend-core + + + + + +# [2.0.0-pre-tailwind.4](https://github.com/bloom-housing/bloom/compare/@bloom-housing/backend-core@2.0.0-pre-tailwind.3...@bloom-housing/backend-core@2.0.0-pre-tailwind.4) (2021-10-22) + +**Note:** Version bump only for package @bloom-housing/backend-core + + + + + +# [2.0.0-pre-tailwind.3](https://github.com/bloom-housing/bloom/compare/@bloom-housing/backend-core@2.0.0-pre-tailwind.2...@bloom-housing/backend-core@2.0.0-pre-tailwind.3) (2021-10-21) + +**Note:** Version bump only for package @bloom-housing/backend-core + + + + + +# [2.0.0-pre-tailwind.2](https://github.com/bloom-housing/bloom/compare/@bloom-housing/backend-core@2.0.0-pre-tailwind.1...@bloom-housing/backend-core@2.0.0-pre-tailwind.2) (2021-10-21) + + +### Bug Fixes + +* **backend:** enforces lower casing of emails ([#1972](https://github.com/bloom-housing/bloom/issues/1972)) ([2608e82](https://github.com/bloom-housing/bloom/commit/2608e8228830a2fc7e6b522c73cb587adbb5803b)) +* migration fix ([#2043](https://github.com/bloom-housing/bloom/issues/2043)) ([ffa4d45](https://github.com/bloom-housing/bloom/commit/ffa4d45e0f53ce071fc4dcf8079c06cf5e836ed3)) + + +### Features + +* adds jurisdiction filtering to listings ([#2027](https://github.com/bloom-housing/bloom/issues/2027)) ([219696b](https://github.com/bloom-housing/bloom/commit/219696ba784cfc079dd5aec74b24c3a8479160b6)) +* **backend:** add languages (Language[]) to Jurisdiction entity ([#1998](https://github.com/bloom-housing/bloom/issues/1998)) ([9ceed24](https://github.com/bloom-housing/bloom/commit/9ceed24d48b14888e6ea59b421b409f875d12b01)) +* **backend:** Add user delete endpoint and expose leasingAgentInList… ([#1996](https://github.com/bloom-housing/bloom/issues/1996)) ([a13f735](https://github.com/bloom-housing/bloom/commit/a13f73574b470beff2f8948abb226a6786856480)) +* **backend:** make use of new application confirmation codes ([#2014](https://github.com/bloom-housing/bloom/issues/2014)) ([3c45c29](https://github.com/bloom-housing/bloom/commit/3c45c2904818200eed4568931d4cc352fd2f449e)) +* **backend:** try fixing SETEX redis e2e tests flakiness ([#2044](https://github.com/bloom-housing/bloom/issues/2044)) ([4087c53](https://github.com/bloom-housing/bloom/commit/4087c532ddba672a415a048f4362e509aba7fd7f)) + + + + + +# [2.0.0-pre-tailwind.1](https://github.com/bloom-housing/bloom/compare/@bloom-housing/backend-core@2.0.0-pre-tailwind.0...@bloom-housing/backend-core@2.0.0-pre-tailwind.1) (2021-10-19) + +**Note:** Version bump only for package @bloom-housing/backend-core + +# 2.0.0-pre-tailwind.0 (2021-10-19) + +### Bug Fixes + +- **backend:** Change tokenMissing to account already confirmed error … ([#1971](https://github.com/bloom-housing/bloom/issues/1971)) ([bc6ec92](https://github.com/bloom-housing/bloom/commit/bc6ec9243fb5be62ca8e240d96b828d418a9ee5b)) +- **backend:** totalFlagged from AFS missing in swagger documentation ([#1997](https://github.com/bloom-housing/bloom/issues/1997)) ([0abf5dd](https://github.com/bloom-housing/bloom/commit/0abf5ddefe8d4f33a895fe3faf59d43316f56003)) +- **backend:** unitCreate and UnitUpdateDto now require only IdDto for… ([#1956](https://github.com/bloom-housing/bloom/issues/1956)) ([43dcfbe](https://github.com/bloom-housing/bloom/commit/43dcfbe7493bdd654d7b898ed9650804a016065c)), closes [#1897](https://github.com/bloom-housing/bloom/issues/1897) +- Fix dev seeds with new priority types ([#1920](https://github.com/bloom-housing/bloom/issues/1920)) ([b01bd7c](https://github.com/bloom-housing/bloom/commit/b01bd7ca2c1ba3ba7948ad8213a0939375003d90)) +- Fix maps unit max occupancy to household size ([d1fefcf](https://github.com/bloom-housing/bloom/commit/d1fefcf2ea20cccf90375881c2a19d51bf986678)) +- Fixes reserved community type import ([e5b0e25](https://github.com/bloom-housing/bloom/commit/e5b0e25f556af6cdcdf05d79825736dddcd1e105)) +- Fixes unit types for max income ([87f018a](https://github.com/bloom-housing/bloom/commit/87f018a410657037a7c9a74a93ec6dbac6b42dec)) +- Fixes unit types for max income ([#2013](https://github.com/bloom-housing/bloom/issues/2013)) ([b8966a1](https://github.com/bloom-housing/bloom/commit/b8966a19ea79012456f7f28d01c34b32d6f207bb)) +- Multiple ami charts should show a max not a range ([#1925](https://github.com/bloom-housing/bloom/issues/1925)) ([142f436](https://github.com/bloom-housing/bloom/commit/142f43697bff23d2f59c7897d51ced83a2003308)) +- Plus one to maxHouseholdSize for bmr ([401c956](https://github.com/bloom-housing/bloom/commit/401c956b0e885d3485b427622b82b85fd9a5f8b1)) +- Removes 150 char limit on textarea fields ([6eb7036](https://github.com/bloom-housing/bloom/commit/6eb70364409c5910aa9b8277b37a8214c2a94358)) +- Removes nested validation from applicationAddress ([747fd83](https://github.com/bloom-housing/bloom/commit/747fd836a9b5b8333a6586727b00c5674ef87a86)) +- Update alameda's notification sign up URL ([#1874](https://github.com/bloom-housing/bloom/issues/1874)) ([3eb85fc](https://github.com/bloom-housing/bloom/commit/3eb85fccf7521e32f3d1f369e706cec0c078b536)) + +### Features + +- **backend:** Add jurisdiction relation to ami charts entity ([#1905](https://github.com/bloom-housing/bloom/issues/1905)) ([1f13985](https://github.com/bloom-housing/bloom/commit/1f13985142c7908b4c37eaf0fbbbad0ad660f014)) +- **backend:** Add jurisidction relation to ReservedCommunittType Entity ([#1889](https://github.com/bloom-housing/bloom/issues/1889)) ([9b0fe73](https://github.com/bloom-housing/bloom/commit/9b0fe73fe9ed1349584e119f235cb66f6e68785f)) +- Listings management draft and publish validation backend & frontend ([#1850](https://github.com/bloom-housing/bloom/issues/1850)) ([ef67997](https://github.com/bloom-housing/bloom/commit/ef67997a056c6f1f758d2fa67bf877d4a3d897ab)) +- Support PDF uploads or webpage links for building selection criteria ([#1893](https://github.com/bloom-housing/bloom/issues/1893)) ([8514b43](https://github.com/bloom-housing/bloom/commit/8514b43ba337d33cb877ff468bf780ff47fdc772)) + +### Performance Improvements + +- **applications and flagged sets:** Adds indexes and updates listWit… ([#2003](https://github.com/bloom-housing/bloom/issues/2003)) ([f9efb15](https://github.com/bloom-housing/bloom/commit/f9efb15b930865b517249d5dc525c11d68dc251d)) + +### Reverts + +- Revert "latest dev (#1999)" ([73a2789](https://github.com/bloom-housing/bloom/commit/73a2789d8f133f2d788e2399faa42b374d74ab15)), closes [#1999](https://github.com/bloom-housing/bloom/issues/1999) +- **backend:** Revert some listing filters ([#1984](https://github.com/bloom-housing/bloom/issues/1984)) ([14847e1](https://github.com/bloom-housing/bloom/commit/14847e1a797930f3e30bd945a2617dec2e3d679f)) + +### BREAKING CHANGES + +- POST/PUT /listings interface change +- Manually add totalFlagged until fixed diff --git a/backend/core/Dockerfile b/backend/core/Dockerfile new file mode 100644 index 0000000000..6c56939866 --- /dev/null +++ b/backend/core/Dockerfile @@ -0,0 +1,35 @@ +FROM node:14.17-alpine AS development + +WORKDIR /usr/src/app + +# Supports an optional yarn.lock +COPY package.json yarn*.lock tsconfig*.json ./ + +RUN yarn install + +COPY . . + +RUN yarn build + +FROM node:14.17-alpine AS production + +ARG NODE_ENV=production +ENV NODE_ENV=${NODE_ENV} + +WORKDIR /usr/src/app + +# Supports an optional yarn.lock +COPY package.json yarn*.lock tsconfig*.json ./ + +RUN yarn install --only=production + +COPY . . + +COPY --from=development /usr/src/app/dist ./dist + +EXPOSE ${PORT} + +# If you attempt to run this by itself, you'll need to pass in: +# PORT, DATABASE_URL, REDIS_URL, REDIS_TLS_URL +# Typically via: docker run --env-file=".env" -t my-app +CMD yarn start diff --git a/backend/core/Procfile b/backend/core/Procfile new file mode 100644 index 0000000000..6e4fd28d2b --- /dev/null +++ b/backend/core/Procfile @@ -0,0 +1,2 @@ +release: yarn herokusetup +web: yarn start diff --git a/backend/core/README.md b/backend/core/README.md new file mode 100644 index 0000000000..d459572979 --- /dev/null +++ b/backend/core/README.md @@ -0,0 +1,120 @@ +# Bloom Backend Services + +This package is a [NestJS application](https://docs.nestjs.com/) that provides a core set of backend services via REST API endpoints to apps using the Bloom Housing framework. Information is stored in a Postgres database, accessed via [TypeORM](https://typeorm.io/). + +## OpenAPI Documentation + +OpenAPI (fka Swagger) documentation is automatically generated by the server at `http://localhost:3100/docs/` for a standard local development environment. A raw JSON version of the schema is also available at `/docs-json/`, suitable for API client code generation or other code-based consumers. + +## Getting Started + +- Install Node.js 14.x `brew install node@14.` +- Install Postgres 12 `brew install postgresql` +- Install Redis `brew install redis` +- Copy the `.env.template` within `backend/core` to `.env` and edit variables appropriate to your local environment. Ensure sure the Database URL and Test Database URL match your Postgres configuration. +- Install dependencies `yarn install` within `backend/core` + +### Redis + +To start Redis: +`redis-server`. + +To launch Redis as background service and restart at login: +`brew services start redis`. + +Test if Redis is working: +`redis-cli ping` + +### Using Docker containers + +If you don't want to install Postgres and Redis on your local machine and instead want to use Docker containers, run: + +```shell script +docker-compose up redis postgres +``` + +All `psql` and `yarn` commands related to databases will then be connecting to the database in the docker container `postgres`. + +### Seeding the Database + +There are two databases used in this project: `bloom` and `bloom_test`. The first is used every time you are starting a project with `yarn dev` and second one is only used in end-to-end tests. Corresponding TypeORM configs are defined in `ormconfig.ts` and `ormconfig.test.ts`. +If you are just starting to work with the projects it's best to simply run: + +``` +yarn && yarn db:reseed +``` + +which will create the `bloom` DB for you, migrate it to the latest schema, and seed with appropriate dev data. If running the reseed command requires that you input a password for Postgres, set the following environment variables: `PGUSER` to postgres and `PGPASSWORD` to the default password you inputted for the postgres user during Postgres installation. If you get the `FATAL: database "" does not exist` error please run: `createdb ` first. + +Dropping the DB: + +```shell script +yarn db:drop +``` + +Creating the DB: + +```shell script +yarn db:create +``` + +Seeding the DB: + +```shell script +yarn db:seed +``` + +Generating a new migration: + +```shell script +yarn db:migration:generate +``` + +Applying migrations: + +```shell script +yarn db:migration:run +``` + +### Running Tests + +End-to-end tests: + +```shell script +yarn test:e2e:local +``` + +Unit tests: + +```shell script +yarn test +``` + +### Translations + +The backend keeps translations for email related content in the DB in the `translations` table. +The `/translations` endpoint exposes CRUD operations on this table (admin only). +Translations are defined for each county code and language pair e.g. (Alameda, en). To modify a particular +translation pair: + +1. Fetch `GET /translations` and list all the translations +2. Find an ID of a pair that interest you +3. Use `PUT /translations/:translationId` to modify it's content + +### Environment Variables + +| Name | Description | Default | Type | +| ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------- | ----------------------------- | +| PORT | Port number the server will listen to for incoming connections | 3100 | number | +| NODE_ENV | Controls build optimization and enables some additional logging when set to `development` | development | "development" \| "production" | +| DATABASE_URL | Database connection | postgres://localhost/bloom | string | +| TEST_DATABASE_URL | Test database connection | postgres://localhost/bloom_test | string | string | +| REDIS_TLS_URL | Secure Redis connection | rediss://127.0.0.1:6379/ | string | +| REDIS_URL | TCP Redis connection string | redis://127.0.0.1:6379/0 | string | +| REDIS_USE_TLS | Flag controlling the use of TLS or unsecure transport for Redis | 0 | 0 \| 1 | +| THROTTLE_TTL | Rate limit TTL in seconds (currently used only for application submission endpoint) | 60 | number | +| THROTTLE_LIMIT | Max number of operations in given time window THROTTLE_TTL after which HTTP 429 Too Many Requests will be returned by the server | 2 | number | +| EMAIL_API_KEY | Sendgrid API key (see [sendgrid docs for creating API keys](https://sendgrid.com/docs/ui/account-and-settings/api-keys/#managing-api-keys) | Available internally | string | +| EMAIL_FROM_ADDRESS | Controls "from" field of all the emails sent by the process | 'Bloom Dev Housing Portal ' | string | +| APP_SECRET | Secret used for signing JWT tokens (generate it with e.g. `openssl rand -hex 48`) | Available internally | string | +| PARTNERS_PORTAL_URL | URL for partners site | http://localhost:3001 | string | diff --git a/backend/core/archer.ts b/backend/core/archer.ts new file mode 100644 index 0000000000..31347a973c --- /dev/null +++ b/backend/core/archer.ts @@ -0,0 +1,1536 @@ +import { + AmiChart, + EnumJurisdictionLanguages, + Listing, + ListingMarketingTypeEnum, + ListingReviewOrder, + ListingStatus, + UnitStatus, +} from "./types" + +import { CountyCode } from "./src/shared/types/county-code" + +export const SanMateoHUD2019: AmiChart = { + id: "ami_chart_id", + createdAt: new Date(), + updatedAt: new Date(), + name: "SanMateoHUD2019", + jurisdiction: { + id: "jurisdictiion_id", + createdAt: new Date(), + updatedAt: new Date(), + name: "Alameda", + emailFromAddress: "Alameda Housing Email", + programs: [], + languages: [EnumJurisdictionLanguages.en], + preferences: [], + publicUrl: "", + }, + items: [ + { + percentOfAmi: 120, + householdSize: 1, + income: 114900, + }, + { + percentOfAmi: 120, + householdSize: 2, + income: 131300, + }, + { + percentOfAmi: 120, + householdSize: 3, + income: 147750, + }, + { + percentOfAmi: 120, + householdSize: 4, + income: 164150, + }, + { + percentOfAmi: 120, + householdSize: 5, + income: 177300, + }, + { + percentOfAmi: 100, + householdSize: 1, + income: 95750, + }, + { + percentOfAmi: 100, + householdSize: 2, + income: 109450, + }, + { + percentOfAmi: 100, + householdSize: 3, + income: 123100, + }, + { + percentOfAmi: 100, + householdSize: 4, + income: 136800, + }, + { + percentOfAmi: 100, + householdSize: 5, + income: 147750, + }, + { + percentOfAmi: 100, + householdSize: 6, + income: 158700, + }, + { + percentOfAmi: 100, + householdSize: 7, + income: 169650, + }, + { + percentOfAmi: 100, + householdSize: 8, + income: 180600, + }, + { + percentOfAmi: 80, + householdSize: 1, + income: 90450, + }, + { + percentOfAmi: 80, + householdSize: 2, + income: 103350, + }, + { + percentOfAmi: 80, + householdSize: 3, + income: 116250, + }, + { + percentOfAmi: 80, + householdSize: 4, + income: 129150, + }, + { + percentOfAmi: 80, + householdSize: 5, + income: 139500, + }, + { + percentOfAmi: 80, + householdSize: 6, + income: 149850, + }, + { + percentOfAmi: 80, + householdSize: 7, + income: 160150, + }, + { + percentOfAmi: 80, + householdSize: 8, + income: 170500, + }, + { + percentOfAmi: 60, + householdSize: 1, + income: 71170, + }, + { + percentOfAmi: 60, + householdSize: 2, + income: 81340, + }, + { + percentOfAmi: 60, + householdSize: 3, + income: 91502, + }, + { + percentOfAmi: 60, + householdSize: 4, + income: 101629, + }, + { + percentOfAmi: 60, + householdSize: 5, + income: 109833, + }, + { + percentOfAmi: 60, + householdSize: 6, + income: 117924, + }, + { + percentOfAmi: 60, + householdSize: 7, + income: 126059, + }, + { + percentOfAmi: 60, + householdSize: 8, + income: 134219, + }, + { + percentOfAmi: 50, + householdSize: 1, + income: 56450, + }, + { + percentOfAmi: 50, + householdSize: 2, + income: 64500, + }, + { + percentOfAmi: 50, + householdSize: 3, + income: 72550, + }, + { + percentOfAmi: 50, + householdSize: 4, + income: 80600, + }, + { + percentOfAmi: 50, + householdSize: 5, + income: 87050, + }, + { + percentOfAmi: 50, + householdSize: 6, + income: 93500, + }, + { + percentOfAmi: 50, + householdSize: 7, + income: 99950, + }, + { + percentOfAmi: 50, + householdSize: 8, + income: 106400, + }, + { + percentOfAmi: 30, + householdSize: 1, + income: 33850, + }, + { + percentOfAmi: 30, + householdSize: 2, + income: 38700, + }, + { + percentOfAmi: 30, + householdSize: 3, + income: 43550, + }, + { + percentOfAmi: 30, + householdSize: 4, + income: 48350, + }, + { + percentOfAmi: 30, + householdSize: 5, + income: 52250, + }, + { + percentOfAmi: 30, + householdSize: 6, + income: 56100, + }, + { + percentOfAmi: 30, + householdSize: 7, + income: 60000, + }, + { + percentOfAmi: 30, + householdSize: 8, + income: 63850, + }, + ], +} + +export const ArcherListing: Listing = { + id: "Uvbk5qurpB2WI9V6WnNdH", + applicationConfig: undefined, + applicationOpenDate: new Date("2019-12-31T15:22:57.000-07:00"), + applicationPickUpAddress: undefined, + applicationPickUpAddressOfficeHours: "", + applicationDropOffAddress: null, + applicationDropOffAddressOfficeHours: null, + applicationMailingAddress: null, + countyCode: CountyCode["San Jose"], + jurisdiction: { + id: "id", + name: "San Jose", + publicUrl: "", + }, + depositMax: "", + disableUnitsAccordion: false, + events: [], + showWaitlist: false, + reviewOrderType: ListingReviewOrder.firstComeFirstServe, + urlSlug: "listing-slug-abcdef", + whatToExpect: "Applicant will be contacted. All info will be verified. Be prepared if chosen.", + status: ListingStatus.active, + postmarkedApplicationsReceivedByDate: new Date("2019-12-05"), + applicationDueDate: new Date("2019-12-31T15:22:57.000-07:00"), + applicationMethods: [], + applicationOrganization: "98 Archer Street", + // TODO confirm not used anywhere + // applicationPhone: "(408) 217-8562", + assets: [ + { + // TODO confirm not used anywhere + // referenceType: "Listing", + // TODO confirm not used anywhere + // referenceId: "Uvbk5qurpB2WI9V6WnNdH", + label: "building", + fileId: + "https://regional-dahlia-staging.s3-us-west-1.amazonaws.com/listings/archer/archer-studios.jpg", + }, + ], + buildingSelectionCriteria: + "Tenant Selection Criteria will be available to all applicants upon request.", + costsNotIncluded: + "Resident responsible for PG&E, internet and phone. Owner pays for water, trash, and sewage. Residents encouraged to obtain renter's insurance but this is not a requirement. Rent is due by the 5th of each month. Late fee $35 and returned check fee is $35 additional.", + creditHistory: + "Applications will be rated on a score system for housing. An applicant's score may be impacted by negative tenant peformance information provided to the credit reporting agency. All applicants are expected have a passing acore of 70 points out of 100 to be considered for housing. Applicants with no credit history will receive a maximum of 80 points to fairly outweigh positive and/or negative trades as would an applicant with established credit history. Refer to Tenant Selection Criteria or Qualification Criteria for details related to the qualification process. ", + depositMin: "1140.0", + listingPrograms: [], + programRules: + "Applicants must adhere to minimum & maximum income limits. Tenant Selection Criteria applies.", + // TODO confirm not used anywhere + // externalId: null, + waitlistMaxSize: 300, + name: "Archer Studios", + waitlistCurrentSize: 300, + waitlistOpenSpots: 0, + isWaitlistOpen: true, + // Addng displayWaitListSize for #707 + displayWaitlistSize: false, + // TODO confirm not used anywhere + // prioritiesDescriptor: null, + requiredDocuments: "Completed application and government issued IDs", + // TODO confirm not used anywhere + // reservedCommunityMaximumAge: null, + // TODO confirm not used anywhere + // reservedCommunityMinimumAge: null, + // TODO confirm not used anywhere + // reservedDescriptor: null, + createdAt: new Date("2019-07-08T15:37:19.565-07:00"), + updatedAt: new Date("2019-07-09T14:35:11.142-07:00"), + // TODO confirm not used anywhere + // groupId: 1, + // TODO confirm not used anywhere + // hideUnitFeatures: false, + applicationFee: "30.0", + criminalBackground: + "A criminal background investigation will be obtained on each applicant. As criminal background checks are done county by county and will be ran for all counties in which the applicant lived, Applicants will be disqualified for tenancy if they have been convicted of a felony or misdemeanor. Refer to Tenant Selection Criteria or Qualification Criteria for details related to the qualification process. ", + leasingAgentAddress: { + id: "id", + createdAt: new Date(), + updatedAt: new Date(), + city: "San Jose", + street: "98 Archer Street", + zipCode: "95112", + state: "CA", + latitude: 37.36537, + longitude: -121.91071, + }, + leasingAgentEmail: "mbaca@charitieshousing.org", + leasingAgentName: "Marisela Baca", + leasingAgentOfficeHours: "Monday, Tuesday & Friday, 9:00AM - 5:00PM", + leasingAgentPhone: "(408) 217-8562", + leasingAgentTitle: "", + listingPreferences: [], + rentalAssistance: "Custom rental assistance", + rentalHistory: + "Two years of rental history will be verified with all applicable landlords. Household family members and/or personal friends are not acceptable landlord references. Two professional character references may be used in lieu of rental history for applicants with no prior rental history. An unlawful detainer report will be processed thourhg the U.D. Registry, Inc. Applicants will be disqualified if they have any evictions filing within the last 7 years. Refer to Tenant Selection Criteria or Qualification Criteria for details related to the qualification process.", + householdSizeMin: 2, + householdSizeMax: 3, + smokingPolicy: "Non-smoking building", + unitsAvailable: 0, + unitSummaries: undefined, + unitGroups: [], + unitAmenities: "Dishwasher", + developer: "Charities Housing ", + yearBuilt: 2012, + accessibility: + "There is a total of 5 ADA units in the complex, all others are adaptable. Exterior Wheelchair ramp (front entry)", + amenities: + "Community Room, Laundry Room, Assigned Parking, Bike Storage, Roof Top Garden, Part-time Resident Service Coordinator", + buildingTotalUnits: 35, + buildingAddress: { + id: "buildingId", + createdAt: new Date(), + updatedAt: new Date(), + city: "San Jose", + street: "98 Archer Street", + zipCode: "95112", + state: "CA", + latitude: 37.36537, + longitude: -121.91071, + }, + neighborhood: "Rosemary Gardens Park", + petPolicy: + "No pets allowed. Accommodation animals may be granted to persons with disabilities via a reasonable accommodation request.", + units: [ + { + id: "sQ19KuyILEo0uuNqti2fl", + amiPercentage: "45.0", + annualIncomeMin: "26496.0", + monthlyIncomeMin: "2208.0", + floor: 2, + annualIncomeMax: "46125.0", + maxOccupancy: 2, + minOccupancy: 1, + monthlyRent: "1104.0", + numBathrooms: null, + numBedrooms: null, + number: null, + priorityType: null, + sqFeet: "285", + status: UnitStatus.occupied, + unitType: { + id: "random_id_35edf", + createdAt: new Date(), + updatedAt: new Date(), + name: "studio", + numBedrooms: 0, + }, + createdAt: new Date("2019-07-09T21:20:05.783Z"), + updatedAt: new Date("2019-08-14T23:05:43.913Z"), + amiChart: SanMateoHUD2019, + monthlyRentAsPercentOfIncome: null, + }, + { + id: "Cq870hwYXcPxCYT4_uW_3", + amiPercentage: "45.0", + annualIncomeMin: "26496.0", + monthlyIncomeMin: "2208.0", + floor: 3, + annualIncomeMax: "46125.0", + maxOccupancy: 2, + minOccupancy: 1, + monthlyRent: "1104.0", + numBathrooms: null, + numBedrooms: null, + number: null, + priorityType: null, + sqFeet: "285", + status: UnitStatus.occupied, + unitType: { + id: "random_id_35edf", + createdAt: new Date(), + updatedAt: new Date(), + name: "studio", + numBedrooms: 0, + }, + createdAt: new Date("2019-08-14T22:53:09.982Z"), + updatedAt: new Date("2019-08-14T23:06:59.015Z"), + amiChart: SanMateoHUD2019, + monthlyRentAsPercentOfIncome: null, + }, + { + id: "9XQrfuAPOn8wtD7HlhCTR", + amiPercentage: "45.0", + annualIncomeMin: "26496.0", + monthlyIncomeMin: "2208.0", + floor: 2, + annualIncomeMax: "46125.0", + maxOccupancy: 2, + minOccupancy: 1, + monthlyRent: "1104.0", + numBathrooms: null, + numBedrooms: null, + number: null, + priorityType: null, + sqFeet: "285", + status: UnitStatus.occupied, + unitType: { + id: "random_id_35edf", + createdAt: new Date(), + updatedAt: new Date(), + name: "studio", + numBedrooms: 0, + }, + createdAt: new Date("2019-08-14T22:52:08.758Z"), + updatedAt: new Date("2019-08-14T23:06:59.023Z"), + amiChart: SanMateoHUD2019, + monthlyRentAsPercentOfIncome: null, + }, + { + id: "bamrJpZA9JmnLSMEbTlI4", + amiPercentage: "45.0", + annualIncomeMin: "26496.0", + monthlyIncomeMin: "2208.0", + floor: 2, + annualIncomeMax: "46125.0", + maxOccupancy: 2, + minOccupancy: 1, + monthlyRent: "1104.0", + numBathrooms: null, + numBedrooms: null, + number: null, + priorityType: null, + sqFeet: "285", + status: UnitStatus.occupied, + unitType: { + id: "random_id_35edf", + createdAt: new Date(), + updatedAt: new Date(), + name: "studio", + numBedrooms: 0, + }, + createdAt: new Date("2019-08-14T22:52:08.766Z"), + updatedAt: new Date("2019-08-14T23:06:59.031Z"), + amiChart: SanMateoHUD2019, + monthlyRentAsPercentOfIncome: null, + }, + { + id: "BCwOFAHJDpyPbKcVBjIUM", + amiPercentage: "45.0", + annualIncomeMin: "26496.0", + monthlyIncomeMin: "2208.0", + floor: 2, + annualIncomeMax: "46125.0", + maxOccupancy: 2, + minOccupancy: 1, + monthlyRent: "1104.0", + numBathrooms: null, + numBedrooms: null, + number: null, + priorityType: null, + sqFeet: "285", + status: UnitStatus.occupied, + unitType: { + id: "random_id_35edf", + createdAt: new Date(), + updatedAt: new Date(), + name: "studio", + numBedrooms: 0, + }, + createdAt: new Date("2019-08-14T22:52:08.771Z"), + updatedAt: new Date("2019-08-14T23:06:59.039Z"), + amiChart: SanMateoHUD2019, + monthlyRentAsPercentOfIncome: null, + }, + { + id: "5t56gXJdJLZiksBuX8BtL", + amiPercentage: "45.0", + annualIncomeMin: "26496.0", + monthlyIncomeMin: "2208.0", + floor: 2, + annualIncomeMax: "46125.0", + maxOccupancy: 2, + minOccupancy: 1, + monthlyRent: "1104.0", + numBathrooms: null, + numBedrooms: null, + number: null, + priorityType: null, + sqFeet: "285", + status: UnitStatus.occupied, + unitType: { + id: "random_id_35edf", + createdAt: new Date(), + updatedAt: new Date(), + name: "studio", + numBedrooms: 0, + }, + createdAt: new Date("2019-08-14T22:52:08.777Z"), + updatedAt: new Date("2019-08-14T23:06:59.046Z"), + amiChart: SanMateoHUD2019, + monthlyRentAsPercentOfIncome: null, + }, + { + id: "7mmAuJ0x7l_2VxJLoSzX5", + amiPercentage: "45.0", + annualIncomeMin: "26496.0", + monthlyIncomeMin: "2208.0", + floor: 2, + annualIncomeMax: "46125.0", + maxOccupancy: 2, + minOccupancy: 1, + monthlyRent: "1104.0", + numBathrooms: null, + numBedrooms: null, + number: null, + priorityType: null, + sqFeet: "285", + status: UnitStatus.occupied, + unitType: { + id: "random_id_35edf", + createdAt: new Date(), + updatedAt: new Date(), + name: "studio", + numBedrooms: 0, + }, + createdAt: new Date("2019-08-14T22:52:08.783Z"), + updatedAt: new Date("2019-08-14T23:06:59.053Z"), + amiChart: SanMateoHUD2019, + monthlyRentAsPercentOfIncome: null, + }, + { + id: "LVsJ-_PYy8x2rn5V8Deo9", + amiPercentage: "45.0", + annualIncomeMin: "26496.0", + monthlyIncomeMin: "2208.0", + floor: 3, + annualIncomeMax: "46125.0", + maxOccupancy: 2, + minOccupancy: 1, + monthlyRent: "1104.0", + numBathrooms: null, + numBedrooms: null, + number: null, + priorityType: null, + sqFeet: "285", + status: UnitStatus.occupied, + unitType: { + id: "random_id_35edf", + createdAt: new Date(), + updatedAt: new Date(), + name: "studio", + numBedrooms: 0, + }, + createdAt: new Date("2019-08-14T22:53:09.976Z"), + updatedAt: new Date("2019-08-14T23:06:59.161Z"), + amiChart: SanMateoHUD2019, + monthlyRentAsPercentOfIncome: null, + }, + { + id: "neDXHUzJkL2YZ2CQOZx1i", + amiPercentage: "45.0", + annualIncomeMin: "26496.0", + monthlyIncomeMin: "2208.0", + floor: 3, + annualIncomeMax: "46125.0", + maxOccupancy: 2, + minOccupancy: 1, + monthlyRent: "1104.0", + numBathrooms: null, + numBedrooms: null, + number: null, + priorityType: null, + sqFeet: "285", + status: UnitStatus.occupied, + unitType: { + id: "random_id_35edf", + createdAt: new Date(), + updatedAt: new Date(), + name: "studio", + numBedrooms: 0, + }, + createdAt: new Date("2019-08-14T22:53:09.976Z"), + updatedAt: new Date("2019-08-14T23:06:59.167Z"), + amiChart: SanMateoHUD2019, + monthlyRentAsPercentOfIncome: null, + }, + { + id: "3_cr3dd76rGY7tDYlvfEO", + amiPercentage: "30.0", + annualIncomeMin: "17256.0", + monthlyIncomeMin: "1438.0", + floor: 2, + annualIncomeMax: "30750.0", + maxOccupancy: 2, + minOccupancy: 1, + monthlyRent: "719.0", + numBathrooms: null, + numBedrooms: null, + number: null, + priorityType: null, + sqFeet: "285", + status: UnitStatus.occupied, + unitType: { + id: "random_id_35edf", + createdAt: new Date(), + updatedAt: new Date(), + name: "studio", + numBedrooms: 0, + }, + createdAt: new Date("2019-07-09T21:24:14.122Z"), + updatedAt: new Date("2019-08-14T23:06:59.173Z"), + amiChart: SanMateoHUD2019, + monthlyRentAsPercentOfIncome: null, + }, + { + id: "_38QsH2XMgHEzn_Sn4b2r", + amiPercentage: "30.0", + annualIncomeMin: "17256.0", + monthlyIncomeMin: "1438.0", + floor: 2, + annualIncomeMax: "30750.0", + maxOccupancy: 2, + minOccupancy: 1, + monthlyRent: "719.0", + numBathrooms: null, + numBedrooms: null, + number: null, + priorityType: null, + sqFeet: "285", + status: UnitStatus.occupied, + unitType: { + id: "random_id_35edf", + createdAt: new Date(), + updatedAt: new Date(), + name: "studio", + numBedrooms: 0, + }, + createdAt: new Date("2019-08-14T22:55:22.950Z"), + updatedAt: new Date("2019-08-14T23:06:59.179Z"), + amiChart: SanMateoHUD2019, + monthlyRentAsPercentOfIncome: null, + }, + { + id: "gTHXtJ37uP8R8zkOp7wOt", + amiPercentage: "30.0", + annualIncomeMin: "17256.0", + monthlyIncomeMin: "1438.0", + floor: 2, + annualIncomeMax: "30750.0", + maxOccupancy: 2, + minOccupancy: 1, + monthlyRent: "719.0", + numBathrooms: null, + numBedrooms: null, + number: null, + priorityType: null, + sqFeet: "285", + status: UnitStatus.occupied, + unitType: { + id: "random_id_35edf", + createdAt: new Date(), + updatedAt: new Date(), + name: "studio", + numBedrooms: 0, + }, + createdAt: new Date("2019-08-14T22:55:22.956Z"), + updatedAt: new Date("2019-08-14T23:06:59.186Z"), + amiChart: SanMateoHUD2019, + monthlyRentAsPercentOfIncome: null, + }, + { + id: "me-MRbUEn6ox-OYpzosO1", + amiPercentage: "30.0", + annualIncomeMin: "17256.0", + monthlyIncomeMin: "1438.0", + floor: 2, + annualIncomeMax: "30750.0", + maxOccupancy: 2, + minOccupancy: 1, + monthlyRent: "719.0", + numBathrooms: null, + numBedrooms: null, + number: null, + priorityType: null, + sqFeet: "285", + status: UnitStatus.occupied, + unitType: { + id: "random_id_35edf", + createdAt: new Date(), + updatedAt: new Date(), + name: "studio", + numBedrooms: 0, + }, + createdAt: new Date("2019-08-14T22:55:22.961Z"), + updatedAt: new Date("2019-08-14T23:06:59.192Z"), + amiChart: SanMateoHUD2019, + monthlyRentAsPercentOfIncome: null, + }, + { + id: "ZOtuFSb79LX7p6CVW3H4w", + amiPercentage: "30.0", + annualIncomeMin: "17256.0", + monthlyIncomeMin: "1438.0", + floor: 2, + annualIncomeMax: "30750.0", + maxOccupancy: 2, + minOccupancy: 1, + monthlyRent: "719.0", + numBathrooms: null, + numBedrooms: null, + number: null, + priorityType: null, + sqFeet: "285", + status: UnitStatus.occupied, + unitType: { + id: "random_id_35edf", + createdAt: new Date(), + updatedAt: new Date(), + name: "studio", + numBedrooms: 0, + }, + createdAt: new Date("2019-08-14T22:55:22.967Z"), + updatedAt: new Date("2019-08-14T23:06:59.198Z"), + amiChart: SanMateoHUD2019, + monthlyRentAsPercentOfIncome: null, + }, + { + id: "nISGOCiWoCzQXkMZGV5bV", + amiPercentage: "30.0", + annualIncomeMin: "17256.0", + monthlyIncomeMin: "1438.0", + floor: 2, + annualIncomeMax: "30750.0", + maxOccupancy: 2, + minOccupancy: 1, + monthlyRent: "719.0", + numBathrooms: null, + numBedrooms: null, + number: null, + priorityType: null, + sqFeet: "285", + status: UnitStatus.occupied, + unitType: { + id: "random_id_35edf", + createdAt: new Date(), + updatedAt: new Date(), + name: "studio", + numBedrooms: 0, + }, + createdAt: new Date("2019-08-14T22:55:22.972Z"), + updatedAt: new Date("2019-08-14T23:06:59.204Z"), + amiChart: SanMateoHUD2019, + monthlyRentAsPercentOfIncome: null, + }, + { + id: "Ppne-7ChrEht1HxwfO0gc", + amiPercentage: "30.0", + annualIncomeMin: "17256.0", + monthlyIncomeMin: "1438.0", + floor: 2, + annualIncomeMax: "30750.0", + maxOccupancy: 2, + minOccupancy: 1, + monthlyRent: "719.0", + numBathrooms: null, + numBedrooms: null, + number: null, + priorityType: null, + sqFeet: "285", + status: UnitStatus.occupied, + unitType: { + id: "random_id_35edf", + createdAt: new Date(), + updatedAt: new Date(), + name: "studio", + numBedrooms: 0, + }, + createdAt: new Date("2019-08-14T22:55:22.978Z"), + updatedAt: new Date("2019-08-14T23:06:59.210Z"), + amiChart: SanMateoHUD2019, + monthlyRentAsPercentOfIncome: null, + }, + { + id: "78hBgnEoHw3aW5r4Mn2Jf", + amiPercentage: "30.0", + annualIncomeMin: "17256.0", + monthlyIncomeMin: "1438.0", + floor: 2, + annualIncomeMax: "30750.0", + maxOccupancy: 2, + minOccupancy: 1, + monthlyRent: "719.0", + numBathrooms: null, + numBedrooms: null, + number: null, + priorityType: null, + sqFeet: "285", + status: UnitStatus.occupied, + unitType: { + id: "random_id_35edf", + createdAt: new Date(), + updatedAt: new Date(), + name: "studio", + numBedrooms: 0, + }, + createdAt: new Date("2019-08-14T22:55:22.984Z"), + updatedAt: new Date("2019-08-14T23:06:59.216Z"), + amiChart: SanMateoHUD2019, + monthlyRentAsPercentOfIncome: null, + }, + { + id: "0RtHf-Iogw3x643r46y-a", + amiPercentage: "30.0", + annualIncomeMin: "17256.0", + monthlyIncomeMin: "1438.0", + floor: 3, + annualIncomeMax: "30750.0", + maxOccupancy: 2, + minOccupancy: 1, + monthlyRent: "719.0", + numBathrooms: null, + numBedrooms: null, + number: null, + priorityType: null, + sqFeet: "285", + status: UnitStatus.occupied, + unitType: { + id: "random_id_35edf", + createdAt: new Date(), + updatedAt: new Date(), + name: "studio", + numBedrooms: 0, + }, + createdAt: new Date("2019-08-14T22:56:06.563Z"), + updatedAt: new Date("2019-08-14T23:06:59.222Z"), + amiChart: SanMateoHUD2019, + monthlyRentAsPercentOfIncome: null, + }, + { + id: "ENMVc3sX0kmD3G4762naM", + amiPercentage: "30.0", + annualIncomeMin: "17256.0", + monthlyIncomeMin: "1438.0", + floor: 3, + annualIncomeMax: "30750.0", + maxOccupancy: 2, + minOccupancy: 1, + monthlyRent: "719.0", + numBathrooms: null, + numBedrooms: null, + number: null, + priorityType: null, + sqFeet: "285", + status: UnitStatus.occupied, + unitType: { + id: "random_id_35edf", + createdAt: new Date(), + updatedAt: new Date(), + name: "studio", + numBedrooms: 0, + }, + createdAt: new Date("2019-08-14T22:56:06.570Z"), + updatedAt: new Date("2019-08-14T23:06:59.229Z"), + amiChart: SanMateoHUD2019, + monthlyRentAsPercentOfIncome: null, + }, + { + id: "O9OSAiIFTSA5SimFlCbd7", + amiPercentage: "30.0", + annualIncomeMin: "17256.0", + monthlyIncomeMin: "1438.0", + floor: 3, + annualIncomeMax: "30750.0", + maxOccupancy: 2, + minOccupancy: 1, + monthlyRent: "719.0", + numBathrooms: null, + numBedrooms: null, + number: null, + priorityType: null, + sqFeet: "285", + status: UnitStatus.occupied, + unitType: { + id: "random_id_35edf", + createdAt: new Date(), + updatedAt: new Date(), + name: "studio", + numBedrooms: 0, + }, + createdAt: new Date("2019-08-14T22:56:06.575Z"), + updatedAt: new Date("2019-08-14T23:06:59.235Z"), + amiChart: SanMateoHUD2019, + monthlyRentAsPercentOfIncome: null, + }, + { + id: "d_7SUFpxe1rZZ5dIgMgTG", + amiPercentage: "30.0", + annualIncomeMin: "17256.0", + monthlyIncomeMin: "1438.0", + floor: 3, + annualIncomeMax: "30750.0", + maxOccupancy: 2, + minOccupancy: 1, + monthlyRent: "719.0", + numBathrooms: null, + numBedrooms: null, + number: null, + priorityType: null, + sqFeet: "285", + status: UnitStatus.occupied, + unitType: { + id: "random_id_35edf", + createdAt: new Date(), + updatedAt: new Date(), + name: "studio", + numBedrooms: 0, + }, + createdAt: new Date("2019-08-14T22:56:06.580Z"), + updatedAt: new Date("2019-08-14T23:06:59.241Z"), + amiChart: SanMateoHUD2019, + monthlyRentAsPercentOfIncome: null, + }, + { + id: "bR17hir7729c22LyVbQ3m", + amiPercentage: "30.0", + annualIncomeMin: "17256.0", + monthlyIncomeMin: "1438.0", + floor: 3, + annualIncomeMax: "30750.0", + maxOccupancy: 2, + minOccupancy: 1, + monthlyRent: "719.0", + numBathrooms: null, + numBedrooms: null, + number: null, + priorityType: null, + sqFeet: "285", + status: UnitStatus.occupied, + unitType: { + id: "random_id_35edf", + createdAt: new Date(), + updatedAt: new Date(), + name: "studio", + numBedrooms: 0, + }, + createdAt: new Date("2019-08-14T22:56:06.587Z"), + updatedAt: new Date("2019-08-14T23:06:59.247Z"), + amiChart: SanMateoHUD2019, + monthlyRentAsPercentOfIncome: null, + }, + { + id: "B62kKSz7qwAA7aM6tzwtB", + amiPercentage: "30.0", + annualIncomeMin: "17256.0", + monthlyIncomeMin: "1438.0", + floor: 3, + annualIncomeMax: "30750.0", + maxOccupancy: 2, + minOccupancy: 1, + monthlyRent: "719.0", + numBathrooms: null, + numBedrooms: null, + number: null, + priorityType: null, + sqFeet: "285", + status: UnitStatus.occupied, + unitType: { + id: "random_id_35edf", + createdAt: new Date(), + updatedAt: new Date(), + name: "studio", + numBedrooms: 0, + }, + createdAt: new Date("2019-08-14T22:56:06.593Z"), + updatedAt: new Date("2019-08-14T23:06:59.254Z"), + amiChart: SanMateoHUD2019, + monthlyRentAsPercentOfIncome: null, + }, + { + id: "C3YePWy05Or9fDeVuRPTF", + amiPercentage: "30.0", + annualIncomeMin: "17256.0", + monthlyIncomeMin: "1438.0", + floor: 3, + annualIncomeMax: "30750.0", + maxOccupancy: 2, + minOccupancy: 1, + monthlyRent: "719.0", + numBathrooms: null, + numBedrooms: null, + number: null, + priorityType: null, + sqFeet: "285", + status: UnitStatus.occupied, + unitType: { + id: "random_id_35edf", + createdAt: new Date(), + updatedAt: new Date(), + name: "studio", + numBedrooms: 0, + }, + createdAt: new Date("2019-08-14T22:56:06.606Z"), + updatedAt: new Date("2019-08-14T23:06:59.260Z"), + amiChart: SanMateoHUD2019, + monthlyRentAsPercentOfIncome: null, + }, + { + id: "Logk3eY0iXtf3oCOctxqT", + amiPercentage: "30.0", + annualIncomeMin: "17256.0", + monthlyIncomeMin: "1438.0", + floor: 3, + annualIncomeMax: "30750.0", + maxOccupancy: 2, + minOccupancy: 1, + monthlyRent: "719.0", + numBathrooms: null, + numBedrooms: null, + number: null, + priorityType: null, + sqFeet: "285", + status: UnitStatus.occupied, + unitType: { + id: "random_id_35edf", + createdAt: new Date(), + updatedAt: new Date(), + name: "studio", + numBedrooms: 0, + }, + createdAt: new Date("2019-08-14T22:56:06.612Z"), + updatedAt: new Date("2019-08-14T23:06:59.267Z"), + amiChart: SanMateoHUD2019, + monthlyRentAsPercentOfIncome: null, + }, + { + id: "nIYojGurvtF7xelaeT0tN", + amiPercentage: "45.0", + annualIncomeMin: "26496.0", + monthlyIncomeMin: "2208.0", + floor: 2, + annualIncomeMax: "46125.0", + maxOccupancy: 2, + minOccupancy: 1, + monthlyRent: "1104.0", + numBathrooms: null, + numBedrooms: null, + number: null, + priorityType: null, + sqFeet: "285", + status: UnitStatus.occupied, + unitType: { + id: "random_id_35edf", + createdAt: new Date(), + updatedAt: new Date(), + name: "studio", + numBedrooms: 0, + }, + createdAt: new Date("2019-08-14T22:52:08.790Z"), + updatedAt: new Date("2019-08-14T23:06:59.060Z"), + amiChart: SanMateoHUD2019, + monthlyRentAsPercentOfIncome: null, + }, + { + id: "omzU7rRoirKXq8SQfaShf", + amiPercentage: "45.0", + annualIncomeMin: "26496.0", + monthlyIncomeMin: "2208.0", + floor: 2, + annualIncomeMax: "46125.0", + maxOccupancy: 2, + minOccupancy: 1, + monthlyRent: "1104.0", + numBathrooms: null, + numBedrooms: null, + number: null, + priorityType: null, + sqFeet: "285", + status: UnitStatus.occupied, + unitType: { + id: "random_id_35edf", + createdAt: new Date(), + updatedAt: new Date(), + name: "studio", + numBedrooms: 0, + }, + createdAt: new Date("2019-08-14T22:52:08.796Z"), + updatedAt: new Date("2019-08-14T23:06:59.067Z"), + amiChart: SanMateoHUD2019, + monthlyRentAsPercentOfIncome: null, + }, + { + id: "IzVtblU-KMTHf3wPGzx2g", + amiPercentage: "45.0", + annualIncomeMin: "26496.0", + monthlyIncomeMin: "2208.0", + floor: 2, + annualIncomeMax: "46125.0", + maxOccupancy: 2, + minOccupancy: 1, + monthlyRent: "1104.0", + numBathrooms: null, + numBedrooms: null, + number: null, + priorityType: null, + sqFeet: "285", + status: UnitStatus.occupied, + unitType: { + id: "random_id_35edf", + createdAt: new Date(), + updatedAt: new Date(), + name: "studio", + numBedrooms: 0, + }, + createdAt: new Date("2019-08-14T22:52:08.802Z"), + updatedAt: new Date("2019-08-14T23:06:59.074Z"), + amiChart: SanMateoHUD2019, + monthlyRentAsPercentOfIncome: null, + }, + { + id: "7g-6eFE_Q6Xi5K2xT2bE5", + amiPercentage: "45.0", + annualIncomeMin: "26496.0", + monthlyIncomeMin: "2208.0", + floor: 2, + annualIncomeMax: "46125.0", + maxOccupancy: 2, + minOccupancy: 1, + monthlyRent: "1104.0", + numBathrooms: null, + numBedrooms: null, + number: null, + priorityType: null, + sqFeet: "285", + status: UnitStatus.occupied, + unitType: { + id: "random_id_35edf", + createdAt: new Date(), + updatedAt: new Date(), + name: "studio", + numBedrooms: 0, + }, + createdAt: new Date("2019-08-14T22:52:08.807Z"), + updatedAt: new Date("2019-08-14T23:06:59.080Z"), + amiChart: SanMateoHUD2019, + monthlyRentAsPercentOfIncome: null, + }, + { + id: "4Br-28LII41R3pINIzLwe", + amiPercentage: "45.0", + annualIncomeMin: "26496.0", + monthlyIncomeMin: "2208.0", + floor: 2, + annualIncomeMax: "46125.0", + maxOccupancy: 2, + minOccupancy: 1, + monthlyRent: "1104.0", + numBathrooms: null, + numBedrooms: null, + number: null, + priorityType: null, + sqFeet: "285", + status: UnitStatus.occupied, + unitType: { + id: "random_id_35edf", + createdAt: new Date(), + updatedAt: new Date(), + name: "studio", + numBedrooms: 0, + }, + createdAt: new Date("2019-08-14T22:52:08.813Z"), + updatedAt: new Date("2019-08-14T23:06:59.086Z"), + amiChart: SanMateoHUD2019, + monthlyRentAsPercentOfIncome: null, + }, + { + id: "5bvjTW2ATEpxwsKppCh0l", + amiPercentage: "45.0", + annualIncomeMin: "26496.0", + monthlyIncomeMin: "2208.0", + floor: 2, + annualIncomeMax: "46125.0", + maxOccupancy: 2, + minOccupancy: 1, + monthlyRent: "1104.0", + numBathrooms: null, + numBedrooms: null, + number: null, + priorityType: null, + sqFeet: "285", + status: UnitStatus.occupied, + unitType: { + id: "random_id_35edf", + createdAt: new Date(), + updatedAt: new Date(), + name: "studio", + numBedrooms: 0, + }, + createdAt: new Date("2019-08-14T22:52:08.819Z"), + updatedAt: new Date("2019-08-14T23:06:59.093Z"), + amiChart: SanMateoHUD2019, + monthlyRentAsPercentOfIncome: null, + }, + { + id: "BZlMmnCXwT4bChrcaNUW3", + amiPercentage: "45.0", + annualIncomeMin: "26496.0", + monthlyIncomeMin: "2208.0", + floor: 3, + annualIncomeMax: "46125.0", + maxOccupancy: 2, + minOccupancy: 1, + monthlyRent: "1104.0", + numBathrooms: null, + numBedrooms: null, + number: null, + priorityType: null, + sqFeet: "285", + status: UnitStatus.occupied, + unitType: { + id: "random_id_35edf", + createdAt: new Date(), + updatedAt: new Date(), + name: "studio", + numBedrooms: 0, + }, + createdAt: new Date("2019-08-14T22:53:09.921Z"), + updatedAt: new Date("2019-08-14T23:06:59.099Z"), + amiChart: SanMateoHUD2019, + monthlyRentAsPercentOfIncome: null, + }, + { + id: "j2hU6Qv5ayOHMKPLQBolz", + amiPercentage: "45.0", + annualIncomeMin: "26496.0", + monthlyIncomeMin: "2208.0", + floor: 3, + annualIncomeMax: "46125.0", + maxOccupancy: 2, + minOccupancy: 1, + monthlyRent: "1104.0", + numBathrooms: null, + numBedrooms: null, + number: null, + priorityType: null, + sqFeet: "285", + status: UnitStatus.occupied, + unitType: { + id: "random_id_35edf", + createdAt: new Date(), + updatedAt: new Date(), + name: "studio", + numBedrooms: 0, + }, + createdAt: new Date("2019-08-14T22:53:09.927Z"), + updatedAt: new Date("2019-08-14T23:06:59.105Z"), + amiChart: SanMateoHUD2019, + monthlyRentAsPercentOfIncome: null, + }, + { + id: "w2-TtBySVELMWyL1cLTkA", + amiPercentage: "45.0", + annualIncomeMin: "26496.0", + monthlyIncomeMin: "2208.0", + floor: 3, + annualIncomeMax: "46125.0", + maxOccupancy: 2, + minOccupancy: 1, + monthlyRent: "1104.0", + numBathrooms: null, + numBedrooms: null, + number: null, + priorityType: null, + sqFeet: "285", + status: UnitStatus.occupied, + unitType: { + id: "random_id_35edf", + createdAt: new Date(), + updatedAt: new Date(), + name: "studio", + numBedrooms: 0, + }, + createdAt: new Date("2019-08-14T22:53:09.933Z"), + updatedAt: new Date("2019-08-14T23:06:59.111Z"), + amiChart: SanMateoHUD2019, + monthlyRentAsPercentOfIncome: null, + }, + { + id: "YhC6LoOIT6hxPfk4uKU3m", + amiPercentage: "45.0", + annualIncomeMin: "26496.0", + monthlyIncomeMin: "2208.0", + floor: 3, + annualIncomeMax: "46125.0", + maxOccupancy: 2, + minOccupancy: 1, + monthlyRent: "1104.0", + numBathrooms: null, + numBedrooms: null, + number: null, + priorityType: null, + sqFeet: "285", + status: UnitStatus.occupied, + unitType: { + id: "random_id_35edf", + createdAt: new Date(), + updatedAt: new Date(), + name: "studio", + numBedrooms: 0, + }, + createdAt: new Date("2019-08-14T22:53:09.938Z"), + updatedAt: new Date("2019-08-14T23:06:59.118Z"), + amiChart: SanMateoHUD2019, + monthlyRentAsPercentOfIncome: null, + }, + { + id: "5CuSFqgGgFX245JQsnG84", + amiPercentage: "45.0", + annualIncomeMin: "26496.0", + monthlyIncomeMin: "2208.0", + floor: 3, + annualIncomeMax: "46125.0", + maxOccupancy: 2, + minOccupancy: 1, + monthlyRent: "1104.0", + numBathrooms: null, + numBedrooms: null, + number: null, + priorityType: null, + sqFeet: "285", + status: UnitStatus.occupied, + unitType: { + id: "random_id_35edf", + createdAt: new Date(), + updatedAt: new Date(), + name: "studio", + numBedrooms: 0, + }, + createdAt: new Date("2019-08-14T22:53:09.944Z"), + updatedAt: new Date("2019-08-14T23:06:59.124Z"), + amiChart: SanMateoHUD2019, + monthlyRentAsPercentOfIncome: null, + }, + { + id: "WoD20A8q1CZm8NmGvLHUn", + amiPercentage: "45.0", + annualIncomeMin: "26496.0", + monthlyIncomeMin: "2208.0", + floor: 3, + annualIncomeMax: "46125.0", + maxOccupancy: 2, + minOccupancy: 1, + monthlyRent: "1104.0", + numBathrooms: null, + numBedrooms: null, + number: null, + priorityType: null, + sqFeet: "285", + status: UnitStatus.occupied, + unitType: { + id: "random_id_35edf", + createdAt: new Date(), + updatedAt: new Date(), + name: "studio", + numBedrooms: 0, + }, + createdAt: new Date("2019-08-14T22:53:09.950Z"), + updatedAt: new Date("2019-08-14T23:06:59.130Z"), + amiChart: SanMateoHUD2019, + monthlyRentAsPercentOfIncome: null, + }, + { + id: "srzDhzV5HQpqR5vuyHKlQ", + amiPercentage: "45.0", + annualIncomeMin: "26496.0", + monthlyIncomeMin: "2208.0", + floor: 3, + annualIncomeMax: "46125.0", + maxOccupancy: 2, + minOccupancy: 1, + monthlyRent: "1104.0", + numBathrooms: null, + numBedrooms: null, + number: null, + priorityType: null, + sqFeet: "285", + status: UnitStatus.occupied, + unitType: { + id: "random_id_35edf", + createdAt: new Date(), + updatedAt: new Date(), + name: "studio", + numBedrooms: 0, + }, + createdAt: new Date("2019-08-14T22:53:09.955Z"), + updatedAt: new Date("2019-08-14T23:06:59.136Z"), + amiChart: SanMateoHUD2019, + monthlyRentAsPercentOfIncome: null, + }, + { + id: "b9jo7kYEOQcATHWBjwJ6r", + amiPercentage: "45.0", + annualIncomeMin: "26496.0", + monthlyIncomeMin: "2208.0", + floor: 3, + annualIncomeMax: "46125.0", + maxOccupancy: 2, + minOccupancy: 1, + monthlyRent: "1104.0", + numBathrooms: null, + numBedrooms: null, + number: null, + priorityType: null, + sqFeet: "285", + status: UnitStatus.occupied, + unitType: { + id: "random_id_35edf", + createdAt: new Date(), + updatedAt: new Date(), + name: "studio", + numBedrooms: 0, + }, + createdAt: new Date("2019-08-14T22:53:09.960Z"), + updatedAt: new Date("2019-08-14T23:06:59.142Z"), + amiChart: SanMateoHUD2019, + monthlyRentAsPercentOfIncome: null, + }, + { + id: "i5tQbXCZRrU_X3ultDSii", + amiPercentage: "45.0", + annualIncomeMin: "26496.0", + monthlyIncomeMin: "2208.0", + floor: 3, + annualIncomeMax: "46125.0", + maxOccupancy: 2, + minOccupancy: 1, + monthlyRent: "1104.0", + numBathrooms: null, + numBedrooms: null, + number: null, + priorityType: null, + sqFeet: "285", + status: UnitStatus.occupied, + unitType: { + id: "random_id_35edf", + createdAt: new Date(), + updatedAt: new Date(), + name: "studio", + numBedrooms: 0, + }, + createdAt: new Date("2019-08-14T22:53:09.965Z"), + updatedAt: new Date("2019-08-14T23:06:59.148Z"), + amiChart: SanMateoHUD2019, + monthlyRentAsPercentOfIncome: null, + }, + { + id: "mrRtN0rArISKnE-PFomth", + amiPercentage: "45.0", + annualIncomeMin: "26496.0", + monthlyIncomeMin: "2208.0", + floor: 3, + annualIncomeMax: "46125.0", + maxOccupancy: 2, + minOccupancy: 1, + monthlyRent: "1104.0", + numBathrooms: null, + numBedrooms: null, + number: null, + priorityType: null, + sqFeet: "285", + status: UnitStatus.occupied, + unitType: { + id: "random_id_35edf", + createdAt: new Date(), + updatedAt: new Date(), + name: "studio", + numBedrooms: 0, + }, + createdAt: new Date("2019-08-14T22:53:09.970Z"), + updatedAt: new Date("2019-08-14T23:06:59.155Z"), + amiChart: SanMateoHUD2019, + monthlyRentAsPercentOfIncome: null, + }, + ], + marketingType: ListingMarketingTypeEnum.marketing, + // TODO confirm not used anywhere + // totalUnits: 2, +} diff --git a/backend/core/heroku.setup.js b/backend/core/heroku.setup.js new file mode 100644 index 0000000000..2c2a9c313c --- /dev/null +++ b/backend/core/heroku.setup.js @@ -0,0 +1,68 @@ +/* eslint-disable @typescript-eslint/no-var-requires */ +/* eslint-env node */ +/* eslint-env es6 */ + +const https = require("https") +if (process.env.NODE_ENV !== "production") { + require("dotenv").config() +} + +// Simplifed version of https://stackoverflow.com/a/50891354 +function httpsPost({ body, ...options }) { + return new Promise((resolve, reject) => { + const req = https.request( + { + method: "POST", + ...options, + }, + (res) => { + const chunks = [] + res.on("data", (data) => chunks.push(data)) + res.on("end", () => { + // resolve the promise + resolve(Buffer.concat(chunks)) + }) + } + ) + // reject the promise if there's an error + req.on("error", reject) + req.write(body) + req.end() + }) +} + +// Currently not used in the Heroku Review App process +// …but it might come in handy later +// eslint-disable-next-line @typescript-eslint/no-unused-vars +async function getBranchNameFromGit() { + const git = require("simple-git/promise") + const statusSummary = await git(__dirname).status() + + return statusSummary.current +} + +function getBranchNameFromHerokuEnvVar() { + return process.env.HEROKU_BRANCH +} + +// Heroku Review app subdomains are randomized, so we need to +// extrapolate it from the env var provided +function determineHerokuListingServiceUrl() { + return `https://${process.env.HEROKU_APP_NAME}.herokuapp.com` +} + +// Run the build hook on Netlify to generate a review app for the branch +async function pingNetlify(buildHook, currentBranch) { + await httpsPost({ + hostname: "api.netlify.com", + path: `/build_hooks/${buildHook}?trigger_branch=${currentBranch}&trigger_title=Heroku+Review+App+Trigger`, + body: determineHerokuListingServiceUrl(), + }) + + console.log("Netlify ping completed.") +} + +// ** Main Process ** +if (process.env.NETLIFY_BUILD_HOOK) { + void pingNetlify(process.env.NETLIFY_BUILD_HOOK, getBranchNameFromHerokuEnvVar()) +} diff --git a/backend/core/index.ts b/backend/core/index.ts new file mode 100644 index 0000000000..51f739d012 --- /dev/null +++ b/backend/core/index.ts @@ -0,0 +1 @@ +export * from "./types" diff --git a/backend/core/jest-e2e.json b/backend/core/jest-e2e.json new file mode 100644 index 0000000000..e9d912f3e3 --- /dev/null +++ b/backend/core/jest-e2e.json @@ -0,0 +1,9 @@ +{ + "moduleFileExtensions": ["js", "json", "ts"], + "rootDir": ".", + "testEnvironment": "node", + "testRegex": ".e2e-spec.ts$", + "transform": { + "^.+\\.(t|j)s$": "ts-jest" + } +} diff --git a/backend/core/jest.json b/backend/core/jest.json new file mode 100644 index 0000000000..edec4b36c2 --- /dev/null +++ b/backend/core/jest.json @@ -0,0 +1,9 @@ +{ + "moduleFileExtensions": ["js", "json", "ts"], + "rootDir": ".", + "testEnvironment": "node", + "testRegex": "\\.spec\\.ts$", + "transform": { + "^.+\\.(t|j)s$": "ts-jest" + } +} diff --git a/backend/core/nest-cli.json b/backend/core/nest-cli.json new file mode 100644 index 0000000000..70bb77d7be --- /dev/null +++ b/backend/core/nest-cli.json @@ -0,0 +1,22 @@ +{ + "collection": "@nestjs/schematics", + "sourceRoot": "src", + "compilerOptions": { + "plugins": [ + { + "name": "@nestjs/swagger", + "options": { + "dtoFileNameSuffix": ["dto.ts", "entity.ts"], + "controllerFileNameSuffix": ["controller.ts"], + "classValidatorShim": false, + "introspectComments": true + } + } + ], + "assets": [ + { "include": "auth/*.{conf,csv}", "outDir": "dist/src" }, + { "include": "shared/views/**/*.hbs", "outDir": "dist/src" }, + { "include": "locals/*.json", "outDir": "dist/src" } + ] + } +} diff --git a/backend/core/ormconfig.test.ts b/backend/core/ormconfig.test.ts new file mode 100644 index 0000000000..a459a433fc --- /dev/null +++ b/backend/core/ormconfig.test.ts @@ -0,0 +1,32 @@ +import { SnakeNamingStrategy } from "typeorm-naming-strategies" +import { join } from "path" +import { ConnectionOptions } from "typeorm" + +// dotenv is a dev dependency, so conditionally import it (don't need it in Prod). +try { + // eslint-disable-next-line @typescript-eslint/no-var-requires + require("dotenv").config() +} catch { + // Pass +} + +export = { + type: "postgres", + url: process.env.TEST_DATABASE_URL || "postgres://localhost:5432/bloom_test", + synchronize: true, + logging: false, + namingStrategy: new SnakeNamingStrategy(), + entities: [ + // Needed to get a TS context on entity imports. + // See + // https://stackoverflow.com/questions/59435293/typeorm-entity-in-nestjs-cannot-use-import-statement-outside-a-module + join(__dirname, "src/**", "*.entity.{js,ts}"), + ], + migrations: [join(__dirname, "src/migration", "*.{js,ts}")], + subscribers: [join(__dirname, "src/subscriber", "*.{js,ts}")], + cli: { + entitiesDir: "src/entity", + migrationsDir: "src/migration", + subscribersDir: "src/subscriber", + }, +} as ConnectionOptions diff --git a/backend/core/ormconfig.ts b/backend/core/ormconfig.ts new file mode 100644 index 0000000000..3381af9ad7 --- /dev/null +++ b/backend/core/ormconfig.ts @@ -0,0 +1,59 @@ +import { SnakeNamingStrategy } from "typeorm-naming-strategies" +import { join } from "path" + +// dotenv is a dev dependency, so conditionally import it (don't need it in Prod). +try { + // eslint-disable-next-line @typescript-eslint/no-var-requires + require("dotenv").config() +} catch { + // Pass +} + +const defaultConnectionForEnv = { + development: { + host: "localhost", + port: 5432, + database: "bloom", + }, +} + +const env = process.env.NODE_ENV || "development" + +// If we have a DATABASE_URL, use that +const connectionInfo = process.env.DATABASE_URL + ? { url: process.env.DATABASE_URL } + : defaultConnectionForEnv[env] + +// Require an SSL connection to the DB in production, and allow self-signed +if (process.env.NODE_ENV === "production") { + connectionInfo.ssl = { rejectUnauthorized: false } +} + +// Unfortunately, we need to use CommonJS/AMD style exports rather than ES6-style modules for this due to how +// TypeORM expects the config to be available. +export = { + type: "postgres", + ...connectionInfo, + synchronize: false, + migrationsRun: false, + logging: false, + namingStrategy: new SnakeNamingStrategy(), + entities: [ + // Needed to get a TS context on entity imports. + // See + // https://stackoverflow.com/questions/59435293/typeorm-entity-in-nestjs-cannot-use-import-statement-outside-a-module + join(__dirname, "src/**", "*.entity.{js,ts}"), + ], + migrations: [join(__dirname, "src/migration", "*.{js,ts}")], + subscribers: [join(__dirname, "src/subscriber", "*.{js,ts}")], + cli: { + entitiesDir: "src/entity", + migrationsDir: "src/migration", + subscribersDir: "src/subscriber", + }, + // extra: { + // ssl: { + // rejectUnauthorized: false, + // }, + // }, +} diff --git a/backend/core/package.json b/backend/core/package.json new file mode 100644 index 0000000000..4647df40ea --- /dev/null +++ b/backend/core/package.json @@ -0,0 +1,149 @@ +{ + "name": "@bloom-housing/backend-core", + "version": "4.2.0", + "description": "Listings service reference implementation for the Bloom affordable housing system", + "author": "Sean Albert ", + "private": false, + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org" + }, + "license": "Apache-2.0", + "main": "index.js", + "scripts": { + "clean": "rimraf dist", + "build": "rimraf dist && nest build", + "start": "node dist/src/main", + "dev": "NODE_ENV=development nest start --watch --preserveWatchOutput", + "debug": "nest start --debug --watch", + "db:drop": "psql -c 'DROP DATABASE IF EXISTS bloom_detroit;'", + "db:create": "psql -c 'CREATE DATABASE bloom_detroit;'", + "db:add-uuid-extension": "psql -d bloom_detroit -c 'CREATE EXTENSION IF NOT EXISTS \"uuid-ossp\";'", + "db:seed": "ts-node src/seeder/seed.ts", + "db:migration:run": "yarn typeorm migration:run", + "db:migration:generate": "yarn typeorm migration:generate", + "db:reseed": "yarn db:drop && yarn db:create && yarn db:add-uuid-extension && yarn db:migration:run && yarn db:seed", + "db:reseed:detroit": "yarn db:drop && yarn db:create && yarn db:add-uuid-extension && yarn db:migration:run && yarn db:seed:detroit", + "db:seed:detroit": "ts-node src/seeder/detroit-seed.ts", + "db:seed:detroit-arcgis": "yarn ts-node scripts/import-listings-from-detroit-arcgis.ts http://localhost:3100 test@example.com:abcdef https://services2.arcgis.com/qvkbeam7Wirps6zC/ArcGIS/rest/services/Affordable_Housing_Website_data_12_20/FeatureServer/0//query", + "test": "jest --config ./jest.json --runInBand --detectOpenHandles", + "test:watch": "jest --watch", + "test:cov": "jest --coverage", + "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", + "test:e2e": "jest --config ./jest-e2e.json --runInBand --forceExit", + "test:e2e:debug": "node --inspect-brk node_modules/.bin/jest --runInBand --config ./jest-e2e.json --forceExit", + "test:e2e:local": "yarn test:db:setup && yarn run test:e2e --verbose false", + "test:db:setup": "psql -c 'DROP DATABASE IF EXISTS bloom_test' && psql -c 'CREATE DATABASE bloom_test' && psql -d bloom_test -c 'CREATE EXTENSION IF NOT EXISTS \"uuid-ossp\";' && ts-node ./node_modules/.bin/typeorm --config ./ormconfig.test.ts migration:run && ts-node src/seeder/seed.ts --test", + "typeorm": "ts-node ./node_modules/.bin/typeorm", + "herokusetup": "node heroku.setup.js", + "heroku-postbuild": "rimraf dist && nest build && yarn run db:migration:run", + "generate:client": "ts-node scripts/generate-axios-client.ts && prettier -w types/src/backend-swagger.ts", + "migrator": "ts-node migrator.ts", + "import:unit-groups": "ts-node ./scripts/import-unit-groups.ts", + "import:listings": "ts-node scripts/import-listings-basic.ts" + }, + "dependencies": { + "@anchan828/nest-sendgrid": "^0.3.25", + "@google-cloud/translate": "^6.2.6", + "@nestjs/bull": "^0.4.2", + "@nestjs/cli": "^7.5.1", + "@nestjs/common": "^7.6.18", + "@nestjs/config": "^0.5.0", + "@nestjs/core": "^7.6.18", + "@nestjs/jwt": "^7.1.0", + "@nestjs/passport": "^7.1.0", + "@nestjs/platform-express": "^7.6.18", + "@nestjs/schedule": "^1.0.2", + "@nestjs/swagger": "4.7.3", + "@nestjs/throttler": "^1.1.2", + "@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", + "cache-manager": "^3.4.0", + "cache-manager-redis-store": "^2.0.0", + "casbin": "5.13.0", + "class-transformer": "0.3.1", + "class-validator": "^0.12.2", + "cloudinary": "^1.25.2", + "csv-parser": "^3.0.0", + "csv-reader": "^1.0.8", + "dayjs": "^1.10.7", + "dotenv": "^8.2.0", + "express": "^4.17.1", + "fast-xml-parser": "^4.0.0-beta.2", + "handlebars": "^4.7.6", + "ioredis": "^4.24.4", + "joi": "^17.3.0", + "jwt-simple": "^0.5.6", + "lodash": "^4.17.21", + "mapbox": "^1.0.0-beta10", + "nanoid": "^3.1.12", + "nestjs-throttler-storage-redis": "^0.1.11", + "nestjs-twilio": "^2.1.0", + "nestjs-typeorm-paginate": "^3.1.3", + "newrelic": "7.5.1", + "node-polyglot": "^2.4.0", + "passport": "^0.4.1", + "passport-custom": "^1.1.1", + "passport-jwt": "^4.0.0", + "passport-local": "^1.0.0", + "pg": "^8.4.1", + "reflect-metadata": "^0.1.13", + "rimraf": "^3.0.2", + "rxjs": "^6.6.3", + "swagger-ui-express": "^4.1.4", + "ts-node": "^9.0.0", + "twilio": "^3.71.3", + "typeorm": "0.2.34", + "typeorm-naming-strategies": "^1.1.0", + "typescript": "^3.9.7", + "uuid": "^8.3.2", + "xlsx": "^0.17.4" + }, + "devDependencies": { + "@babel/core": "^7.11.6", + "@babel/plugin-proposal-decorators": "^7.10.5", + "@nestjs/schematics": "^7.1.2", + "@nestjs/testing": "^7.4.4", + "@types/axios": "^0.14.0", + "@types/bull": "^3.15.5", + "@types/cron": "^1.7.3", + "@types/express": "^4.17.8", + "@types/node": "^12.12.67", + "@types/passport-jwt": "^3.0.3", + "@types/passport-local": "^1.0.33", + "@types/supertest": "^2.0.10", + "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", + "ts-loader": "^8.0.4", + "tsconfig-paths": "^3.9.0", + "yargs": "^16.0.3" + }, + "jest": { + "moduleFileExtensions": [ + "js", + "json", + "ts" + ], + "rootDir": "src", + "testRegex": ".spec.ts$", + "transform": { + "^.+\\.(t|j)s$": "ts-jest" + }, + "coverageDirectory": "../coverage", + "testEnvironment": "node" + }, + "engines": { + "node": "14", + "yarn": "^1.22" + } +} diff --git a/backend/core/scripts/detroit-helpers.ts b/backend/core/scripts/detroit-helpers.ts new file mode 100644 index 0000000000..34343fd0d1 --- /dev/null +++ b/backend/core/scripts/detroit-helpers.ts @@ -0,0 +1,12 @@ +import { UnitStatus } from "../src/units/types/unit-status-enum" + +export function createUnitsArray(type: string, number: number) { + const units = [] + for (let unit_index = 0; unit_index < number; unit_index++) { + units.push({ + unitType: type, + status: UnitStatus.unknown, + }) + } + return units +} diff --git a/backend/core/scripts/generate-ami-chart.sh b/backend/core/scripts/generate-ami-chart.sh new file mode 100755 index 0000000000..81cddf2925 --- /dev/null +++ b/backend/core/scripts/generate-ami-chart.sh @@ -0,0 +1,61 @@ +#!/bin/bash + +if [ -z "$1" ]; then + cat << EOF +Usage: generate-ami-chart.sh path/to/FILE + +WARNING: overwrites the file path/to/FILE.ts + +This script takes a formatted text file and writes a .ts file containing the JSON representation +of the AMI chart data. It is expecting 9 columns, the first one being the AMI percentage and then +8 columns representing the income for a household with 1..8 people corresponding to that AMI. Ex: +20% 10,000 11,000 12,000 13,000 14,000 15,000 16,000 17,000 +30% 15,000 16,000 17,000 18,000 19,000 20,000 21,000 22,000 + +This format is based on the PDF format for published MSHDA charts. Noutput ote: there must be a newline +at the end of the file or the last row will not be read in. +EOF + exit +fi + +# Get the file name and the path separately. +DIRECTORY=$(dirname "$1") +FILE=$(basename "$1") +FILENAME=${FILE%.*} +OUTPUT_FILE="$DIRECTORY/$FILENAME.ts" + + +echo "Generating $OUTPUT_FILE" + +cat << EOF > $OUTPUT_FILE +import { AmiChartCreateDto } from "../../../ami-charts/dto/ami-chart.dto" +import { BaseEntity } from "typeorm" + +// THIS FILE WAS AUTOMATICALLY GENERATED FROM $FILE. +export const $FILENAME: Omit = { + name: "$FILENAME", + items: [ +EOF + +# For each line, generate a set of JSON values +sed -e "s/%//g" -e "s/,//g" $1 | + while read -ra INCOME; do + # AMI is the first column + AMI=${INCOME[0]} + for i in $(seq 8); do + # print this AMI table value to the OUTPUT + cat << EOF >> $OUTPUT_FILE + { + percentOfAmi: $AMI, + householdSize: $i, + income: ${INCOME[$i]}, + }, +EOF + done + done + +# Finish the JSON +cat << EOF >> $OUTPUT_FILE + ], +} +EOF diff --git a/backend/core/scripts/generate-axios-client.ts b/backend/core/scripts/generate-axios-client.ts new file mode 100644 index 0000000000..243f45a35b --- /dev/null +++ b/backend/core/scripts/generate-axios-client.ts @@ -0,0 +1,18 @@ +import { codegen } from "swagger-axios-codegen" +import * as fs from "fs" + +async function codeGen() { + await codegen({ + methodNameMode: "operationId", + remoteUrl: "http://localhost:3100/docs-json", + outputDir: "types/src", + useStaticMethod: false, + fileName: "backend-swagger.ts", + useHeaderParameters: false, + strictNullChecks: true, + }) + let content = fs.readFileSync("./types/src/backend-swagger.ts", "utf-8") + content = content.replace(/(\w+)Dto/g, "$1") + fs.writeFileSync("./types/src/backend-swagger.ts", content) +} +void codeGen() diff --git a/backend/core/scripts/import-amc-waitlist-report.ts b/backend/core/scripts/import-amc-waitlist-report.ts new file mode 100644 index 0000000000..19ed53e395 --- /dev/null +++ b/backend/core/scripts/import-amc-waitlist-report.ts @@ -0,0 +1,103 @@ +import * as client from "../types/src/backend-swagger" +import yargs from "yargs" +import axios from "axios" +import { serviceOptions } from "../types/src/backend-swagger" +import { readFile, WorkBook } from "xlsx" + +// To view usage: +// $ yarn ts-node scripts/import-amc-waitlist-report.ts --help + +const args = yargs.options({ + email: { + type: "string", + demandOption: true, + describe: + "The email of the user updating the listings. Must have admin or listing agent permissions.", + }, + password: { + type: "string", + demandOption: true, + describe: "The password of the user updating the listings.", + }, + backendUrl: { type: "string", demandOption: true, describe: "The URL of the backend service." }, + reportFilePath: { + type: "string", + demandOption: true, + describe: "The file path of the AMC waitlist report (in XLSX format).", + }, + listingId: { + type: "string", + demandOption: true, + describe: "The database ID of the listing being updated.", + }, +}).argv + +async function main(): Promise { + const workBook: WorkBook = readFile(args.reportFilePath) + + // First, find the waitlist info we're looking for. + let waitlistSize = -1 + for (const name of workBook.SheetNames) { + const sheet = workBook.Sheets[name] + for (const key in sheet) { + if (Object.prototype.hasOwnProperty.call(sheet[key], "v")) { + try { + const match = sheet[key].v.match(/Total on Waitlist:\s*(\d*)/) + if (match?.length > 0) { + waitlistSize = Number.parseInt(match[1]) + break + } + } catch (err) { + continue + } + } + } + if (waitlistSize >= 0) { + break + } + } + + if (waitlistSize < 0) { + console.log("Couldn't find waitlist size. Stopping.") + return + } + + console.log(`Found waitlist size: ${waitlistSize}`) + + serviceOptions.axios = axios.create({ + baseURL: args.backendUrl, + timeout: 10000, + }) + + const { accessToken } = await new client.AuthService().login({ + body: { + email: args.email, + password: args.password, + }, + }) + + // Update the axios config so future requests include the access token in the header. + serviceOptions.axios = axios.create({ + baseURL: args.backendUrl, + timeout: 10000, + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }) + + const listingsService = new client.ListingsService() + const listing = await listingsService.retrieve({ id: args.listingId }) + if (!listing.waitlistMaxSize) { + // If there's no specified maximum waitlist size, we assume it's "unbounded". + // Max int in SQL is 2 ** 31 - 1, so we use that to represent "unbounded". + listing.waitlistMaxSize = 2 ** 31 - 1 + } + listing.waitlistCurrentSize = waitlistSize + console.log( + `Updating "${listing.name}" listing with new waitlist size ${listing.waitlistCurrentSize} (out of ${listing.waitlistMaxSize})` + ) + // TODO: Update with new unit groups model + // await listingsService.update({ id: args.listingId, body: listing }) +} + +void main() diff --git a/backend/core/scripts/import-helpers.ts b/backend/core/scripts/import-helpers.ts new file mode 100644 index 0000000000..ca38b9e616 --- /dev/null +++ b/backend/core/scripts/import-helpers.ts @@ -0,0 +1,237 @@ +import * as client from "../types/src/backend-swagger" +import axios from "axios" +import { + ListingCreate, + ListingStatus, + serviceOptions, + UnitGroupCreate, + UnitCreate, +} from "../types/src/backend-swagger" + +// NOTE: This script relies on any logged-in users having permission to create +// listings and properties (defined in backend/core/src/auth/authz_policy.csv) + +const preferencesService = new client.PreferencesService() +const listingsService = new client.ListingsService() +const authService = new client.AuthService() +const unitTypesService = new client.UnitTypesService() +const unitAccessibilityPriorityTypesService = new client.UnitAccessibilityPriorityTypesService() +const applicationMethodsService = new client.ApplicationMethodsService() +const reservedCommunityTypesService = new client.ReservedCommunityTypesService() +const jurisdictionService = new client.JurisdictionsService() + +// Create these import interfaces to mimic the format defined in backend-swagger.ts, but allow +// certain fields to have a simpler type. For example: allow listing.units.unitType to be a +// string (e.g. "oneBdrm"), and then the importListing function will look up the corresponding +// unitType object by name and use that unitType object to construct the UnitCreate. +export interface ListingImport + extends Omit { + unitsSummary?: UnitsSummaryImport[] + units?: UnitImport[] + reservedCommunityTypeName?: string + jurisdictionName?: string +} +export interface UnitsSummaryImport extends Omit { + unitType?: string +} +export interface UnitImport extends Omit { + priorityType?: string + unitType?: string +} + +async function uploadEntity(entityKey, entityService, listing) { + const newRecordsIds = await Promise.all( + listing[entityKey].map(async (obj) => { + try { + const res = await entityService.create({ + body: obj, + }) + return res + } catch (e) { + console.log(obj) + console.log(e.response.data.message) + process.exit(1) + } + }) + ) + listing[entityKey] = newRecordsIds + return listing +} + +async function uploadListing(listing: ListingCreate) { + try { + return await listingsService.create({ + body: listing, + }) + } catch (e) { + console.log(listing) + throw new Error(e.response.data.message) + } +} + +async function uploadReservedCommunityType(name: string, jurisdictions: client.Jurisdiction[]) { + try { + return await reservedCommunityTypesService.create({ + body: { + name, + jurisdiction: jurisdictions.find((jurisdiction) => jurisdiction.name == "Detroit"), + }, + }) + } catch (e) { + console.log(e.response) + process.exit(1) + } +} + +async function getReservedCommunityType(name: string) { + try { + const reservedTypes = await reservedCommunityTypesService.list() + return reservedTypes.filter((reservedType) => reservedType.name === name)[0] + } catch (e) { + console.log(e.response) + process.exit(1) + } +} + +function reformatListing(listing, relationsKeys: string[]) { + relationsKeys.forEach((relation) => { + if (!(relation in listing) || listing[relation] === null) { + listing[relation] = [] + } else { + // Replace nulls with undefined and remove id + // This is because validation @IsOptional does not allow nulls + const relationArr = listing[relation] + for (const obj of relationArr) { + try { + delete obj["id"] + } catch (e) { + console.error(e) + } + for (const key in obj) { + if (obj[key] === null) { + delete obj[key] + } + } + } + } + }) + if (!("status" in listing)) { + listing.status = ListingStatus.active + } + try { + delete listing["id"] + } catch (e) { + console.error(e) + } + return listing +} + +const findByName = (list, name: string) => { + return list.find((el) => el.name === name) +} + +export async function importListing( + apiUrl: string, + email: string, + password: string, + listing: ListingImport +) { + serviceOptions.axios = axios.create({ + baseURL: apiUrl, + timeout: 10000, + }) + + // Log in to retrieve an access token. + const { accessToken } = await authService.login({ + body: { + email: email, + password: password, + }, + }) + + // Update the axios config so future requests include the access token in the header. + serviceOptions.axios = axios.create({ + baseURL: apiUrl, + timeout: 10000, + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }) + const unitTypes = await unitTypesService.list() + const priorityTypes = await unitAccessibilityPriorityTypesService.list() + const jurisdictions = await jurisdictionService.list() + + // Tidy a few of the listing's fields. + const relationsKeys = [] + listing = reformatListing(listing, relationsKeys) + + // If a managementWebsite is provided, make sure it is a well-formed URL. + if (listing.managementWebsite) { + if (!listing.managementWebsite.startsWith("http")) { + listing.managementWebsite = "http://" + listing.managementWebsite + } + + // This next line will throw an error if managementWebsite is a malformed URL. + try { + new URL(listing.managementWebsite) + } catch (e) { + console.log( + `Error: ${listing.name} has a malformed managementWebsite (${listing.managementWebsite});` + + ` this website will be discarded and the listing will be uploaded without it.` + ) + console.log(e) + listing.managementWebsite = null + } + } + + // Upload new entities. + listing = await uploadEntity("preferences", preferencesService, listing) + listing = await uploadEntity("applicationMethods", applicationMethodsService, listing) + + // Look up the reserved community type by name, or create it if it doesn't yet exist. + let reservedCommunityType: client.ReservedCommunityType + if (listing.reservedCommunityTypeName) { + reservedCommunityType = await getReservedCommunityType(listing.reservedCommunityTypeName) + if (!reservedCommunityType) { + reservedCommunityType = await uploadReservedCommunityType( + listing.reservedCommunityTypeName, + jurisdictions + ) + } + } + + // Construct the units and unitsSummary arrays expected by the backend, by looking up the + // unitTypes and priorityTypes referenced by name. + const unitsCreate: UnitCreate[] = [] + listing.units.forEach((unit) => { + const priorityType = findByName(priorityTypes, unit.priorityType) + const unitType = findByName(unitTypes, unit.unitType) + unitsCreate.push({ ...unit, priorityType: priorityType, unitType: unitType }) + }) + const unitGroupsCreate: UnitGroupCreate[] = [] + /* if (listing.unitsSummary) { + listing.unitsSummary.forEach((summary) => { + // const unitType = findByName(unitTypes, summary.unitType) + // unitGroupsCreate.push({ ...summary }) + }) + } */ + + let jurisdiction: client.Jurisdiction = null + if (listing.jurisdictionName) { + jurisdiction = findByName(jurisdictions, listing.jurisdictionName) + } + + // Construct the ListingCreate to be sent to the backend. Its structure mostly mimics that of the + // input ListingImport, with the exception of the fields for which we had to look up referenced + // types. + const listingCreate: ListingCreate = { + ...listing, + unitGroups: unitGroupsCreate, + units: unitsCreate, + reservedCommunityType: reservedCommunityType, + jurisdiction: jurisdiction, + } + + // Upload the listing, and then return it. + return await uploadListing(listingCreate) +} diff --git a/backend/core/scripts/import-listing-from-json-file.ts b/backend/core/scripts/import-listing-from-json-file.ts new file mode 100644 index 0000000000..5383c3fca7 --- /dev/null +++ b/backend/core/scripts/import-listing-from-json-file.ts @@ -0,0 +1,33 @@ +import { importListing, ListingImport } from "./import-helpers" +import fs from "fs" +import { Listing } from "../types/src/backend-swagger" + +// Example usage (from within /backend/core): +// $ yarn ts-node scripts/import-listing-from-json-file.ts http://localhost:3100 admin@example.com:abcdef scripts/minimal-listing.json + +async function main() { + if (process.argv.length < 5) { + console.log( + "usage: yarn ts-node scripts/import-listing-from-json-file.ts api_url email:password input_listing.json" + ) + process.exit(1) + } + + const [apiUrl, userAndPassword, listingFilePath] = process.argv.slice(2) + const [email, password] = userAndPassword.split(":") + + const listing: ListingImport = JSON.parse(fs.readFileSync(listingFilePath, "utf-8")) + + let newListing: Listing + try { + newListing = await importListing(apiUrl, email, password, listing) + } catch (e) { + console.log(e) + process.exit(1) + } + + console.log(newListing) + console.log("Success! New listing created.") +} + +void main() diff --git a/backend/core/scripts/import-listings-basic.ts b/backend/core/scripts/import-listings-basic.ts new file mode 100644 index 0000000000..d9f3adcef6 --- /dev/null +++ b/backend/core/scripts/import-listings-basic.ts @@ -0,0 +1,269 @@ +import * as fs from "fs" +import CsvReadableStream from "csv-reader" +import { Connection, DeepPartial } from "typeorm" +import { Listing } from "../src/listings/entities/listing.entity" +import { Jurisdiction } from "../src/jurisdictions/entities/jurisdiction.entity" +import dbOptions = require("../ormconfig") +import { Program } from "../src/program/entities/program.entity" +import { AddressCreateDto } from "../src/shared/dto/address.dto" + +/* eslint-disable-next-line @typescript-eslint/no-var-requires */ +const getStream = require("get-stream") + +/* eslint-disable-next-line @typescript-eslint/no-var-requires */ +const 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`) +} + +export class HeaderConstants { + public static readonly TemporaryId: string = "ID" + public static readonly Name: string = "Building Name" + public static readonly Developer: string = "Developer" + public static readonly BuildingAddressStreet: string = "Building Street Address" + public static readonly BuildingAddressCity: string = "City" + public static readonly BuildingAddressState: string = "State" + public static readonly BuildingAddressZipCode: string = "Zip Code" + public static readonly Neighborhood: string = "Neighborhood 2" + public static readonly YearBuilt: string = "Year Built" + public static readonly CommunityTypePrograms: string = "Community Type" + public static readonly LeasingAgentName: string = "Leasing Agent Name (Property Mgmt Company)" + public static readonly LeasingAgentEmail: string = "Leasing Agent Email" + public static readonly LeasingAgentPhone: string = "Leasing Agent Phone" + public static readonly ManagementWebsite: string = "Management Website" + public static readonly LeasingAgentAddress: string = "Leasing Agent Address" + public static readonly ApplicationFee: string = "Application Fee" + public static readonly DepositMin: string = "Deposit Min" + public static readonly DepositMax: string = "Deposit Max" + public static readonly DepositHelperText: string = "Deposit HelperText" + public static readonly CostsNotIncluded: string = "Costs not included" + public static readonly PropertyAmenities: string = "Property Amenities" + public static readonly HeatingInUnit: string = "Heating in Unit" + public static readonly AcInUnit: string = "AC in Unit" + public static readonly LaundryInBuilding: string = "Laundry in Building" + public static readonly ParkingOnSiteElevator: string = "Parking On Site Elevator" + public static readonly ServiceAnimalsAllowed: string = "Service Animals Allowed" + public static readonly RollInShower: string = "Roll in Shower" + public static readonly WheelchairRamp: string = "Wheelchair Ramp" + public static readonly AccessibleParking: string = "Accessible Parking" + public static readonly InUnitWasherDryer: string = "In Unit Washer Dryer" + public static readonly BarrierFreeEntrance: string = "Barrier Free Entrance" + public static readonly GrabBars: string = "Grab Bars" + public static readonly Hearing: string = "Hearing" + public static readonly Visual: string = "Visual" + public static readonly Mobility: string = "Mobility" + public static readonly AdditionalAccessibility: string = "Additional Accessibility" + public static readonly RentalAssistance: string = "RentalAssistance" + public static readonly SmokingPolicy: string = "Smoking Policy" + public static readonly PetPolicy: string = "Pet Policy" + 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 { + const jurisdictionsRepository = connection.getRepository(Jurisdiction) + return await jurisdictionsRepository.findOneOrFail({ + where: { + name: "Detroit", + }, + }) +} + +async function fetchProgramsOrFail( + connection: Connection, + programsString: string +): Promise { + if (!programsString) { + return [] + } + + const programsRepository = connection.getRepository(Program) + const programTitles = programsString.split(",").map((p) => p.trim()) + + return Promise.all( + programTitles.map((programTitle) => { + return programsRepository.findOneOrFail({ where: { title: programTitle } }) + }) + ) +} + +function destructureYearBuilt(yearBuilt: string): number { + if (!yearBuilt) { + return null + } + + if (typeof yearBuilt === "number") { + return yearBuilt + } + + if (yearBuilt.includes("/")) { + const [year1, year2] = yearBuilt.split("/") + return Number.parseInt(year2) + } + + return Number.parseInt(yearBuilt) +} + +async function getLatitudeAndLongitude( + address: string +): Promise<{ latitude: number; longitude: number }> { + const res = await client.geocodeForward(address) + let latitude + let longitude + if (res.entity?.features?.length) { + latitude = res.entity.features[0].center[0] + longitude = res.entity.features[0].center[1] + } + return { latitude, longitude } +} + +async function destructureAddressString(addressString: string): Promise { + if (!addressString) { + return null + } + + const tokens = addressString.split(",").map((addressString) => addressString.trim()) + + const { latitude, longitude } = await getLatitudeAndLongitude(addressString) + + if (tokens.length === 1) { + return { + street: tokens[0], + city: undefined, + state: undefined, + zipCode: undefined, + latitude, + longitude, + } + } + + const [state, zipCode] = tokens[2].split(" ") + + return { + street: tokens[0], + city: tokens[1], + state, + zipCode, + latitude, + longitude, + } +} + +async function main() { + const connection = new Connection(dbOptions) + await connection.connect() + + const detroitJurisdiction = await fetchDetroitJurisdiction(connection) + + const listingsRepository = connection.getRepository(Listing) + + let rowsCount = 0 + let failedRowsCounts = 0 + const failedRowsIDs = [] + + const inputRows = await getStream.array( + fs.createReadStream(filePath, "utf8").pipe( + new CsvReadableStream({ + parseNumbers: true, + parseBooleans: true, + trim: true, + asObject: true, + }) + ) + ) + + for (const row of inputRows) { + rowsCount += 1 + try { + console.info(`Importing row ${row[HeaderConstants.TemporaryId]}`) + const programsString = row[HeaderConstants.CommunityTypePrograms] + const communityTypePrograms = await fetchProgramsOrFail(connection, programsString) + const newListing: DeepPartial = { + temporaryListingId: row[HeaderConstants.TemporaryId], + assets: [], + name: row[HeaderConstants.Name], + displayWaitlistSize: false, + property: { + developer: row[HeaderConstants.Developer], + accessibility: row[HeaderConstants.AdditionalAccessibility], + smokingPolicy: row[HeaderConstants.SmokingPolicy], + petPolicy: row[HeaderConstants.PetPolicy], + amenities: row[HeaderConstants.PropertyAmenities], + buildingAddress: { + street: row[HeaderConstants.BuildingAddressStreet], + city: row[HeaderConstants.BuildingAddressCity], + state: row[HeaderConstants.BuildingAddressState], + zipCode: row[HeaderConstants.BuildingAddressZipCode], + ...(await getLatitudeAndLongitude( + [ + row[HeaderConstants.BuildingAddressStreet], + row[HeaderConstants.BuildingAddressCity], + row[HeaderConstants.BuildingAddressState], + row[HeaderConstants.BuildingAddressZipCode], + ].join(" ") + )), + }, + neighborhood: row[HeaderConstants.Neighborhood], + yearBuilt: destructureYearBuilt(row[HeaderConstants.YearBuilt]), + }, + jurisdiction: detroitJurisdiction, + listingPrograms: communityTypePrograms.map((program) => { + return { + program: program, + ordinal: null, + } + }), + leasingAgentName: row[HeaderConstants.LeasingAgentName], + leasingAgentEmail: row[HeaderConstants.LeasingAgentEmail], + leasingAgentPhone: row[HeaderConstants.LeasingAgentPhone], + managementWebsite: row[HeaderConstants.ManagementWebsite], + leasingAgentAddress: await destructureAddressString( + row[HeaderConstants.LeasingAgentAddress] + ), + applicationFee: row[HeaderConstants.ApplicationFee], + depositMin: row[HeaderConstants.DepositMin], + depositMax: row[HeaderConstants.DepositMax], + depositHelperText: row[HeaderConstants.DepositHelperText], + costsNotIncluded: row[HeaderConstants.CostsNotIncluded], + features: { + heatingInUnit: row[HeaderConstants.HeatingInUnit] === "Yes", + acInUnit: row[HeaderConstants.AcInUnit] === "Yes", + laundryInBuilding: row[HeaderConstants.LaundryInBuilding] === "Yes", + parkingOnSite: row[HeaderConstants.ParkingOnSiteElevator] === "Yes", + serviceAnimalsAllowed: row[HeaderConstants.ServiceAnimalsAllowed] === "Yes", + rollInShower: row[HeaderConstants.RollInShower] === "Yes", + wheelchairRamp: row[HeaderConstants.WheelchairRamp] === "Yes", + accessibleParking: row[HeaderConstants.AccessibleParking] === "Yes", + inUnitWasherDryer: row[HeaderConstants.InUnitWasherDryer] === "Yes", + barrierFreeEntrance: row[HeaderConstants.BarrierFreeEntrance] === "Yes", + grabBars: row[HeaderConstants.GrabBars] === "Yes", + }, + requiredDocuments: row[HeaderConstants.RequiredDocuments], + programRules: row[HeaderConstants.ImportantProgramRules], + specialNotes: row[HeaderConstants.SpecialNotes], + rentalAssistance: row[HeaderConstants.RentalAssistance], + } + await listingsRepository.save(newListing) + } catch (e) { + console.error(`skipping row: ${row[HeaderConstants.TemporaryId]}`) + console.error(e) + failedRowsCounts += 1 + failedRowsIDs.push(row[HeaderConstants.TemporaryId]) + } + } + console.log(`${failedRowsCounts}/${rowsCount} rows failed`) + console.log("IDs:") + console.log(failedRowsIDs) +} + +void main() diff --git a/backend/core/scripts/import-listings-from-csv.ts b/backend/core/scripts/import-listings-from-csv.ts new file mode 100644 index 0000000000..09bcddd376 --- /dev/null +++ b/backend/core/scripts/import-listings-from-csv.ts @@ -0,0 +1,235 @@ +import csv from "csv-parser" +import fs from "fs" +import axios from "axios" +import { importListing, ListingImport, UnitsSummaryImport } from "./import-helpers" +import * as client from "../types/src/backend-swagger" +import { + AddressCreate, + ListingMarketingTypeEnum, + ListingStatus, + serviceOptions, +} from "../types/src/backend-swagger" +import { ListingReviewOrder } from "../src/listings/types/listing-review-order-enum" + +// This script reads in listing data from a CSV file and sends requests to the backend to create +// the corresponding Listings. A few notes: +// - This script does not delete or modify any existing listings. +// - If one listing fails to be uploaded, the script will still attempt all the rest. At the end, +// it will report how many failed (with error messages) and how many succeeded. +// - Each line in the CSV file is assumed to correspond to a distinct listing. +// - This script assumes particular heading names in the input CSV file (see listingFields["..."] +// below). + +// Sample usage: +// $ yarn ts-node scripts/import-listings-from-csv.ts http://localhost:3100 admin@example.com:abcdef path/to/file.csv + +async function main() { + if (process.argv.length < 5) { + console.log( + "usage: yarn ts-node scripts/import-listings-from-csv.ts import_api_url email:password csv_file_path" + ) + process.exit(1) + } + + const [importApiUrl, userAndPassword, csvFilePath] = process.argv.slice(2) + const [email, password] = userAndPassword.split(":") + + serviceOptions.axios = axios.create({ + baseURL: importApiUrl, + timeout: 10000, + }) + + const hrdIds: Set = new Set( + (await new client.ListingsService().list({ limit: "all" })).items.map( + (listing) => listing.hrdId + ) + ) + console.log(`Got ${hrdIds.size} HRD ids.`) + + // Regex used to parse the AMI from an AMI column name + const amiColumnRegex = /(\d+) Pct AMI/ // e.g. 30 Pct AMI + + // Read raw CSV data into memory. + // Note: createReadStream creates ReadStream's whose on("data", ...) methods are called + // asynchronously. To ensure that all CSV lines are read in before we start trying to upload + // listings from it, we wrap this step in a Promise. + const rawListingFields = [] + const promise = new Promise((resolve, reject) => { + fs.createReadStream(csvFilePath) + .pipe(csv()) + .on("data", (listingFields) => { + const listingName: string = listingFields["Project Name"].trim() + // Exclude listings that are not "regulated" affordable housing + const affordabilityStatus: string = listingFields["Affordability status"] + if (affordabilityStatus?.toLowerCase() !== "regulated") { + console.log( + `Skipping listing because it is not *regulated* affordable housing: ${listingName}` + ) + return + } + + // Exclude listings that are already present in the db, based on HRD id. + if (hrdIds.has(listingFields["HRDID"])) { + console.log(`Skipping ${listingName} because it's already in the database.`) + return + } + + // Exclude listings that are not at the stage of housing people. + // Some listings are in the "development pipeline" and should not yet be shown to + // housing seekers. The "Development Pipeline Bucket" below is a code that is meaningful + // within HRD. + const projectType: string = listingFields["Project Type"] + const developmentPipelineBucket: number = parseInt( + listingFields["Development Pipeline Bucket"] + ) + if (projectType?.toLowerCase() !== "existing occupied" && developmentPipelineBucket < 3) { + console.log( + `Skipping listing because it is not far enough along in the development pipeline: ${listingName}` + ) + return + } + + rawListingFields.push(listingFields) + }) + .on("end", resolve) + .on("error", reject) + }) + await promise + + console.log(`CSV file successfully read in; ${rawListingFields.length} listings to upload`) + + const uploadFailureMessages = [] + let numListingsSuccessfullyUploaded = 0 + for (const listingFields of rawListingFields) { + const address: AddressCreate = { + street: listingFields["Project Address"], + zipCode: listingFields["Zip Code"], + city: "Detroit", + state: "MI", + longitude: listingFields["Longitude"], + latitude: listingFields["Latitude"], + } + + // Add data about unitsSummaries + const unitsSummaries: UnitsSummaryImport[] = [] + // TODO: Update with new unit groups model + // if (listingFields["Number 0BR"]) { + // unitsSummaries.push({ + // unitType: "studio", + // totalCount: Number(listingFields["Number 0BR"]), + // }) + // } + // if (listingFields["Number 1BR"]) { + // unitsSummaries.push({ + // unitType: "oneBdrm", + // totalCount: Number(listingFields["Number 1BR"]), + // }) + // } + // if (listingFields["Number 2BR"]) { + // unitsSummaries.push({ + // unitType: "twoBdrm", + // totalCount: Number(listingFields["Number 2BR"]), + // }) + // } + // if (listingFields["Number 3BR"]) { + // unitsSummaries.push({ + // unitType: "threeBdrm", + // totalCount: Number(listingFields["Number 3BR"]), + // }) + // } + // // Lump 4BR and 5BR together as "fourBdrm" + // const numberFourBdrm = listingFields["Number 4BR"] ? parseInt(listingFields["Number 4BR"]) : 0 + // const numberFiveBdrm = listingFields["Number 5BR"] ? parseInt(listingFields["Number 5BR"]) : 0 + // if (numberFourBdrm + numberFiveBdrm > 0) { + // unitsSummaries.push({ + // unitType: "fourBdrm", + // totalCount: numberFourBdrm + numberFiveBdrm, + // }) + // } + + // Listing affordability details + let amiPercentageMin, amiPercentageMax + const listingFieldsArray = Object.entries(listingFields) + const colStart = listingFieldsArray.findIndex((element) => element[0] === "15 Pct AMI") + const colEnd = listingFieldsArray.findIndex((element) => element[0] === "80 Pct AMI") + for (const [key, value] of listingFieldsArray.slice(colStart, colEnd + 1)) { + if (!value) continue + if (!amiPercentageMin) { + amiPercentageMin = parseInt(amiColumnRegex.exec(key)[1]) + } + amiPercentageMax = parseInt(amiColumnRegex.exec(key)[1]) + } + + let leasingAgentEmail = null + if (listingFields["Manager Email"]) { + leasingAgentEmail = listingFields["Manager Email"] + } + + let reservedCommunityTypeName: string = null + const hudClientGroup = listingFields["HUD Client group"].toLowerCase() + if (["wholly physically handicapped", "wholly physically disabled"].includes(hudClientGroup)) { + reservedCommunityTypeName = "specialNeeds" + } else if (hudClientGroup === "wholly elderly housekeeping") { + reservedCommunityTypeName = "senior62" + } + + const listing: ListingImport = { + name: listingFields["Project Name"], + hrdId: listingFields["HRDID"], + buildingAddress: address, + region: listingFields["Region"], + ownerCompany: listingFields["Owner Company"], + managementCompany: listingFields["Management Company"], + leasingAgentName: listingFields["Manager Contact"], + leasingAgentPhone: listingFields["Manager Phone"], + managementWebsite: listingFields["Management Website"], + leasingAgentEmail: leasingAgentEmail, + phoneNumber: listingFields["Property Phone"], + amiPercentageMin: amiPercentageMin, + amiPercentageMax: amiPercentageMax, + status: ListingStatus.active, + // unitsSummary: unitsSummaries, + jurisdictionName: "Detroit", + reservedCommunityTypeName: reservedCommunityTypeName, + neighborhood: listingFields["Neighborhood"], + + // The following fields are only set because they are required + units: [], + applicationMethods: [], + applicationDropOffAddress: null, + applicationMailingAddress: null, + events: [], + assets: [], + displayWaitlistSize: false, + depositMin: "", + depositMax: "", + developer: "", + digitalApplication: false, + images: [], + isWaitlistOpen: true, + paperApplication: false, + referralOpportunity: false, + rentalAssistance: "", + reviewOrderType: ListingReviewOrder.firstComeFirstServe, + listingPreferences: [], + marketingType: ListingMarketingTypeEnum.marketing, + } + + try { + const newListing = await importListing(importApiUrl, email, password, listing) + console.log(`New listing uploaded successfully: ${newListing.name}`) + numListingsSuccessfullyUploaded++ + } catch (e) { + console.log(e) + uploadFailureMessages.push(`Upload failed for ${listing.name}: ${e}`) + } + } + + console.log(`\nNumber of listings successfully uploaded: ${numListingsSuccessfullyUploaded}`) + console.log(`Number of failed listing uploads: ${uploadFailureMessages.length}\n`) + for (const failureMessage of uploadFailureMessages) { + console.log(failureMessage) + } +} + +void main() diff --git a/backend/core/scripts/import-realpages-availability-report.ts b/backend/core/scripts/import-realpages-availability-report.ts new file mode 100644 index 0000000000..5f04a3f08c --- /dev/null +++ b/backend/core/scripts/import-realpages-availability-report.ts @@ -0,0 +1,165 @@ +import * as client from "../types/src/backend-swagger" +import fs from "fs" +import yargs from "yargs" +import axios from "axios" +import { XMLParser } from "fast-xml-parser" +import { serviceOptions } from "../types/src/backend-swagger" + +// To view usage: +// $ yarn ts-node scripts/import-realpages-availability-report.ts --help + +const args = yargs.options({ + email: { + type: "string", + demandOption: true, + describe: + "The email of the user updating the listings. Must have admin or listing agent permissions.", + }, + password: { + type: "string", + demandOption: true, + describe: "The password of the user updating the listings.", + }, + backendUrl: { type: "string", demandOption: true, describe: "The URL of the backend service." }, + reportFilePath: { + type: "string", + demandOption: true, + describe: "The file path of the Realpages availability report (in XML format).", + }, + listingId: { + type: "string", + demandOption: true, + describe: "The database ID of the listing being updated.", + }, + mapping: { + type: "array", + describe: + 'The mapping from floorplan code to unit type. E.g. "1.5 B:oneBdrm". Must be repeated for every floorplan code value.', + }, +}).argv + +function attributeFetcher(unitXmlData, attribute: string): string { + return unitXmlData[`@_${attribute}`] +} + +function tagContentsFetcher(unitXmlData, tag: string): string { + return unitXmlData[tag] +} + +async function main(): Promise { + const fpCodeUnitTypeMap: Record = args.mapping.reduce((a, m: string) => { + return { ...a, [m.split(":")[0]]: m.split(":")[1] } + }, {}) + + const reportUnitData: any[] = new XMLParser({ ignoreAttributes: false }).parse( + fs.readFileSync(args.reportFilePath, "utf-8") + ).root.Response.FileContents.root.LeaseVariance.Row + + if (reportUnitData.length == 0) { + console.log("No unit data to process. Aborting.") + process.exit(0) + } + + // Some reports contain the data in attributes, and others contain it in sub-tags. + let infoFetcher: (unit: any, key: string) => string + if (reportUnitData.every((u) => "@_fpCode" in u && "@_UnitAvailableBit" in u)) { + infoFetcher = attributeFetcher + } else if (reportUnitData.every((u) => "fpCode" in u && "UnitAvailableBit" in u)) { + infoFetcher = tagContentsFetcher + } else { + throw "Missing fpCode or UnitAvailableBit information from unit data." + } + + // Make sure there's a mapping for every fpCode in the XML before proceeding. + const reportFpCodes = new Set( + reportUnitData + .map((u) => infoFetcher(u, "fpCode")) + .filter((fpCode: string) => fpCode.length > 0) + ) + const mappedFpCodes = new Set(Object.keys(fpCodeUnitTypeMap)) + for (const fpCode of reportFpCodes) { + if (!mappedFpCodes.has(fpCode)) { + throw `Missing fpCode "${fpCode}" from mapping.` + } + } + + serviceOptions.axios = axios.create({ + baseURL: args.backendUrl, + timeout: 10000, + }) + + const { accessToken } = await new client.AuthService().login({ + body: { + email: args.email, + password: args.password, + }, + }) + + // Update the axios config so future requests include the access token in the header. + serviceOptions.axios = axios.create({ + baseURL: args.backendUrl, + timeout: 10000, + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }) + + const unitTypes = await new client.UnitTypesService().list() + + // Make sure there's a unit type for every mapped unit type before proceeding. + const unitTypeNames = new Set(unitTypes.map((u) => u.name)) + const mappedUnitTypeNames = new Set(Object.values(fpCodeUnitTypeMap)) + for (const mappedUnitTypeName of mappedUnitTypeNames) { + if (!unitTypeNames.has(mappedUnitTypeName)) { + throw `Unknown unit type "${mappedUnitTypeName}"` + } + } + + const listingsService = new client.ListingsService() + const listing = await listingsService.retrieve({ id: args.listingId }) + // TODO: Update with new unit groups model + // const listingUnitTypeNameSummaryMap = listing.unitsSummary.reduce((a, s) => { + // return { ...a, [s.unitType.name]: s } + // }, {}) + const listingUnitTypeNameSummaryMap = {} + + // Make sure that the listing has all specified mapped unit type names. + /* const listingUnitTypeNames = new Set(Object.keys(listingUnitTypeNameSummaryMap)) + for (const mappedUnitTypeName of mappedUnitTypeNames) { + if (!listingUnitTypeNames.has(mappedUnitTypeName)) { + throw `Listing "${listing.name}" is missing unit type ${mappedUnitTypeName} from unit summaries.` + } + } */ + + let newUnitTypeNameAvailabilityMap = {} + for (const mappedUnitTypeName of mappedUnitTypeNames) { + newUnitTypeNameAvailabilityMap = { + ...newUnitTypeNameAvailabilityMap, + [mappedUnitTypeName]: reportUnitData.filter( + (u) => + fpCodeUnitTypeMap[infoFetcher(u, "fpCode")] === mappedUnitTypeName && + infoFetcher(u, "UnitAvailableBit") === "1" + ).length, + } + } + + // Make sure that the availability count is < the total count. + /* for (const unitTypeName in newUnitTypeNameAvailabilityMap) { + if ( + newUnitTypeNameAvailabilityMap[unitTypeName] > + listingUnitTypeNameSummaryMap[unitTypeName].totalCount + ) { + throw `New availability (${newUnitTypeNameAvailabilityMap[unitTypeName]}) for unit type ${unitTypeName} is greater than total (${listingUnitTypeNameSummaryMap[unitTypeName].totalCount})` + } + } + + // TODO: Update with new unit groups model + // for (const unitSummary of listing.unitsSummary) { + // unitSummary.totalAvailable = newUnitTypeNameAvailabilityMap[unitSummary.unitType.name] || 0 + // } + console.log(`Updating listing "${listing.name}" with new availabilities:`) + console.log(newUnitTypeNameAvailabilityMap) + await listingsService.update({ id: args.listingId, body: listing }) */ +} + +void main() diff --git a/backend/core/scripts/import-unit-groups.ts b/backend/core/scripts/import-unit-groups.ts new file mode 100644 index 0000000000..223d2e76f1 --- /dev/null +++ b/backend/core/scripts/import-unit-groups.ts @@ -0,0 +1,285 @@ +import * as fs from "fs" +import CsvReadableStream from "csv-reader" +import { Connection, DeepPartial } from "typeorm" +import { Listing } from "../src/listings/entities/listing.entity" +import { UnitGroup } from "../src/units-summary/entities/unit-group.entity" +import { UnitGroupAmiLevel } from "../src/units-summary/entities/unit-group-ami-level.entity" +import { UnitType } from "../src/unit-types/entities/unit-type.entity" +import { AmiChart } from "../src/ami-charts/entities/ami-chart.entity" +import { HUD2021 } from "../src/seeder/seeds/ami-charts/HUD2021" +import { MSHDA2021 } from "../src/seeder/seeds/ami-charts/MSHDA2021" +import { MonthlyRentDeterminationType } from "../src/units-summary/types/monthly-rent-determination.enum" +import dbOptions = require("../ormconfig") + +type AmiChartNameType = "MSHDA" | "HUD" + +const args = process.argv.slice(2) + +const filePath = args[0] +if (typeof filePath !== "string" && !fs.existsSync(filePath)) { + throw new Error(`usage: ts-node import-unit-groups.ts csv-file-path`) +} + +export class HeaderConstants { + public static readonly TemporaryListingId: string = "ID" + public static readonly UnitTypeName: string = "Unit Types" + public static readonly MinOccupancy: string = "Min Occupancy" + public static readonly MaxOccupancy: string = "Max Occupancy" + public static readonly TotalCount: string = "Unit Type Quantity (Affordable)" + public static readonly TotalAvailable: string = "Vacant Units" + public static readonly WaitlistClosed: string = "Waitlist Closed" + public static readonly WaitlistOpen: string = "Waitlist Open" + public static readonly AMIChart: string = "AMI Chart" + public static readonly AmiChartPercentage: string = "Percent AMIs" + public static readonly Type20: string = "20% (Flat / Percent)" + public static readonly Type25: string = "25% (Flat / Percent)" + public static readonly Type30: string = "30% (Flat / Percent)" + public static readonly Type35: string = "35% (Flat / Percent)" + public static readonly Type40: string = "40% (Flat / Percent)" + public static readonly Type45: string = "45% (Flat / Percent)" + public static readonly Type50: string = "50% (Flat / Percent)" + public static readonly Type55: string = "55% (Flat / Percent)" + public static readonly Type60: string = "60% (Flat / Percent)" + public static readonly Type70: string = "70% (Flat / Percent)" + public static readonly Type80: string = "80% (Flat / Percent)" + public static readonly Type100: string = "100% (Flat / Percent)" + public static readonly Type120: string = "120% (Flat / Percent)" + public static readonly Type125: string = "125% (Flat / Percent)" + public static readonly Type140: string = "140% (Flat / Percent)" + public static readonly Type150: string = "150% (Flat / Percent)" + 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( + amiCharts: Array, + spreadSheetAmiChartName: AmiChartNameType +): AmiChart { + const SpreadSheetAmiChartNameToDbChartNameMapping: Record = { + MSHDA: MSHDA2021.name, + HUD: HUD2021.name, + } + return amiCharts.find( + (amiChart) => + amiChart.name === SpreadSheetAmiChartNameToDbChartNameMapping[spreadSheetAmiChartName] + ) +} + +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) { + // This is case where $ is added by google spreadsheet because it's a single non % value + if (type === "flat" && value.toString().includes("$")) { + return Number.parseInt(value.replace(/\$/, "").replace(/,/, "")) + } + + const splitValues = value.toString().split(",") + + if (splitValues.length === 1) { + return Number.parseInt(value) + } else if (splitValues.length === 2) { + return type === "flat" ? Number.parseInt(splitValues[0]) : Number.parseInt(splitValues[1]) + } + + throw new Error("This part should not be reached") + } +} + +function getAmiTypeFromColumn(row, amiPercentage: number) { + const mapAmiPercentageToColumnName = { + 20: HeaderConstants.Type20, + 25: HeaderConstants.Type25, + 30: HeaderConstants.Type30, + 35: HeaderConstants.Type35, + 40: HeaderConstants.Type40, + 45: HeaderConstants.Type45, + 50: HeaderConstants.Type50, + 55: HeaderConstants.Type55, + 60: HeaderConstants.Type60, + 70: HeaderConstants.Type70, + 80: HeaderConstants.Type80, + 100: HeaderConstants.Type100, + 120: HeaderConstants.Type120, + 125: HeaderConstants.Type125, + 140: HeaderConstants.Type140, + 150: HeaderConstants.Type150, + } + const type = row[mapAmiPercentageToColumnName[amiPercentage]] + return type +} + +function generateUnitsSummaryAmiLevels( + 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 amiChartName of amiCharts) { + const amiChartEntity = findAmiChartByName(amiChartEntities, amiChartName as AmiChartNameType) + + for (const amiPercentage of amiPercentages) { + const type = getAmiTypeFromColumn(row, amiPercentage) + const splitTypes = type.split(", ") + + splitTypes.forEach((monthlyRentDeterminationType) => { + amiChartLevels.push({ + amiChart: amiChartEntity, + amiPercentage, + percentageOfIncomeValue: + monthlyRentDeterminationType === "Percent" + ? getAmiValueFromColumn(row, amiPercentage, "percentage") + : null, + monthlyRentDeterminationType: + monthlyRentDeterminationType === "Flat" + ? MonthlyRentDeterminationType.flatRent + : MonthlyRentDeterminationType.percentageOfIncome, + flatRentValue: + monthlyRentDeterminationType === "Flat" + ? getAmiValueFromColumn(row, amiPercentage, "flat") + : null, + }) + }) + } + } + + return amiChartLevels +} + +function getOpenWaitlistValue(row): boolean { + const waitlistClosedColumn = row[HeaderConstants.WaitlistClosed] + if (waitlistClosedColumn === "Closed") { + return false + } + + const waitlistOpenColumn = row[HeaderConstants.WaitlistOpen] + if (waitlistOpenColumn === "Open") { + return true + } + + return true +} + +async function main() { + const connection = new Connection(dbOptions) + await connection.connect() + + const listingsRepository = connection.getRepository(Listing) + const unitTypesRepository = connection.getRepository(UnitType) + const amiChartsRepository = connection.getRepository(AmiChart) + + const amiCharts = await amiChartsRepository.find() + + const inputStream = fs.createReadStream(filePath, "utf8") + inputStream + .pipe( + new CsvReadableStream({ parseNumbers: true, parseBooleans: true, trim: true, asObject: true }) + ) + // eslint-disable-next-line @typescript-eslint/no-misused-promises + .on("data", async (row) => { + try { + const listing: DeepPartial = await listingsRepository.findOne({ + where: { + temporaryListingId: row[HeaderConstants.TemporaryListingId], + }, + }) + if (!listing) { + throw new Error(`Listing with ID: ${row[HeaderConstants.TemporaryListingId]} not found.`) + } + + const unitTypes = [] + if (row[HeaderConstants.UnitTypeName]) { + const spreadsheetUnitTypeNameToDbUnitTypeName = { + "1BR": "oneBdrm", + "2BR": "twoBdrm", + "3BR": "threeBdrm", + "4+BR": "fourBdrm", + "4BR": "fourBdrm", + Studio: "studio", + } + + const unitType = await unitTypesRepository.findOneOrFail({ + where: { + name: spreadsheetUnitTypeNameToDbUnitTypeName[row[HeaderConstants.UnitTypeName]], + }, + }) + unitTypes.push(unitType) + } + + const newUnitsSummary: DeepPartial = { + minOccupancy: row[HeaderConstants.MinOccupancy] + ? row[HeaderConstants.MinOccupancy] + : null, + maxOccupancy: row[HeaderConstants.MaxOccupancy] + ? row[HeaderConstants.MaxOccupancy] + : null, + totalCount: row[HeaderConstants.TotalCount] ? row[HeaderConstants.TotalCount] : null, + totalAvailable: row[HeaderConstants.TotalAvailable] + ? row[HeaderConstants.TotalAvailable] + : null, + openWaitlist: getOpenWaitlistValue(row), + unitType: unitTypes, + amiLevels: generateUnitsSummaryAmiLevels( + row, + amiCharts, + row[HeaderConstants.AMIChart], + row[HeaderConstants.AmiChartPercentage] + ), + } + listing.unitGroups.push(newUnitsSummary) + + await listingsRepository.save(listing) + } catch (e) { + console.error(row) + console.error(e) + } + }) +} + +void main() diff --git a/backend/core/scripts/listings-update-schema.ts b/backend/core/scripts/listings-update-schema.ts new file mode 100644 index 0000000000..b3ea5fb5b2 --- /dev/null +++ b/backend/core/scripts/listings-update-schema.ts @@ -0,0 +1,80 @@ +import fs from "fs" +import { plainToClass } from "class-transformer" +import { ApplicationMethodDto } from "../src/application-methods/dto/application-method.dto" +import { ApplicationMethodType } from "../src/application-methods/types/application-method-type-enum" + +if (process.argv.length < 3) { + console.log("usage: listings-update-schema input_listing.json") + process.exit(1) +} + +const [listingFilePath] = process.argv.slice(2) + +function convertApplicationMethods(listing: any) { + const applicationMethods: Array = [] + + if ("attachments" in listing) { + listing.attachments.forEach((attachment) => { + if (attachment.type === 1) { + applicationMethods.push( + plainToClass(ApplicationMethodDto, { + type: ApplicationMethodType.FileDownload, + acceptsPostmarkedApplications: false, + label: attachment.label, + fileId: attachment.fileUrl, + }) + ) + } else if (attachment.type === 2) { + applicationMethods.push( + plainToClass(ApplicationMethodDto, { + type: ApplicationMethodType.ExternalLink, + acceptsPostmarkedApplications: false, + label: attachment.label, + fileId: attachment.fileUrl, + }) + ) + } + }) + delete listing["attachments"] + } + + ;[ + "acceptingApplicationsAtLeasingAgent", + "acceptingOnlineApplications", + "acceptsPostmarkedApplications", + "blankPaperApplicationCanBePickedUp", + ].forEach((key) => { + delete listing[key] + }) + + listing.applicationMethods = applicationMethods + + return listing +} + +function convertImageUrl(listing) { + if (!("assets" in listing)) { + listing.assets = [] + } + + if (listing.imageUrl !== null && listing.imageUrl != undefined) { + listing.assets.push({ + referenceType: "Listing", + referenceId: "", + label: "building", + fileId: listing.imageUrl, + }) + } + + delete listing["imageUrl"] + return listing +} + +function main() { + const listing = JSON.parse(fs.readFileSync(listingFilePath, "utf-8")) + let newListing = convertApplicationMethods(listing) + newListing = convertImageUrl(newListing) + console.log(JSON.stringify(newListing, null, 2)) +} + +main() diff --git a/backend/core/scripts/minimal-listing.json b/backend/core/scripts/minimal-listing.json new file mode 100644 index 0000000000..a79e8f246f --- /dev/null +++ b/backend/core/scripts/minimal-listing.json @@ -0,0 +1,41 @@ +{ + "name": "Minimal Listing", + "status": "active", + "countyCode": "Alameda", + "listingPreferences": [], + "buildingAddress": { + "city": "Oakland", + "state": "CA", + "street": "Main St", + "zipCode": "94501", + "latitude": "37.80", + "longitude": "122.27" + }, + "units": [ + { + "unitType": "oneBdrm", + "status": "available" + } + ], + "assets": [], + "applicationMethods": [], + "events": [], + "displayWaitlistSize": false, + "jurisdictionName": "Alameda", + "depositMin": "min", + "depositMax": "max", + "developer": "developer", + "digitalApplication": false, + "image": { + "fileId": "file_id", + "label": "label" + }, + "isWaitlistOpen": false, + "leasingAgentEmail": "leasing_agent@example.com", + "leasingAgentName": "leasing agent name", + "leasingAgentPhone": "(202) 555-0194", + "paperApplication": false, + "referralOpportunity": false, + "rentalAssistance": "rental_assistance", + "reviewOrderType": "lottery" +} diff --git a/backend/core/src/activity-log/activity-log.module.ts b/backend/core/src/activity-log/activity-log.module.ts new file mode 100644 index 0000000000..4fc610fad1 --- /dev/null +++ b/backend/core/src/activity-log/activity-log.module.ts @@ -0,0 +1,13 @@ +import { forwardRef, Module } from "@nestjs/common" +import { TypeOrmModule } from "@nestjs/typeorm" +import { AuthModule } from "../auth/auth.module" +import { ActivityLog } from "./entities/activity-log.entity" +import { ActivityLogService } from "./services/activity-log.service" + +@Module({ + imports: [TypeOrmModule.forFeature([ActivityLog]), forwardRef(() => AuthModule)], + controllers: [], + providers: [ActivityLogService], + exports: [ActivityLogService], +}) +export class ActivityLogModule {} diff --git a/backend/core/src/activity-log/decorators/activity-log-metadata.decorator.ts b/backend/core/src/activity-log/decorators/activity-log-metadata.decorator.ts new file mode 100644 index 0000000000..4d82260dcc --- /dev/null +++ b/backend/core/src/activity-log/decorators/activity-log-metadata.decorator.ts @@ -0,0 +1,5 @@ +import { SetMetadata } from "@nestjs/common" +import { ActivityLogMetadataType } from "../types/activity-log-metadata-type" + +export const ActivityLogMetadata = (metadata: ActivityLogMetadataType) => + SetMetadata("activity_log_metadata", metadata) diff --git a/backend/core/src/activity-log/entities/activity-log.entity.ts b/backend/core/src/activity-log/entities/activity-log.entity.ts new file mode 100644 index 0000000000..9fbb0632e1 --- /dev/null +++ b/backend/core/src/activity-log/entities/activity-log.entity.ts @@ -0,0 +1,29 @@ +import { Column, Entity, JoinColumn, ManyToOne } from "typeorm" +import { Expose, Type } from "class-transformer" +import { AbstractEntity } from "../../shared/entities/abstract.entity" +import { User } from "../../auth/entities/user.entity" + +@Entity({ name: "activity_logs" }) +export class ActivityLog extends AbstractEntity { + @Column() + @Expose() + module: string + + @Column("uuid") + @Expose() + recordId: string + + @Column() + @Expose() + action: string + + @ManyToOne(() => User, { nullable: true }) + @JoinColumn() + @Expose() + @Type(() => User) + user: User + + @Column({ type: "jsonb", nullable: true }) + @Expose() + metadata?: any +} diff --git a/backend/core/src/activity-log/interceptors/activity-log.interceptor.ts b/backend/core/src/activity-log/interceptors/activity-log.interceptor.ts new file mode 100644 index 0000000000..140d1813df --- /dev/null +++ b/backend/core/src/activity-log/interceptors/activity-log.interceptor.ts @@ -0,0 +1,92 @@ +import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from "@nestjs/common" +import { ActivityLogService } from "../services/activity-log.service" +import { Reflector } from "@nestjs/core" +import { httpMethodsToAction } from "../../shared/http-methods-to-actions" +import { User } from "../../auth/entities/user.entity" +import { authzActions } from "../../auth/enum/authz-actions.enum" +import { endWith, ignoreElements, mergeMap } from "rxjs/operators" +import { from } from "rxjs" +import { ActivityLogMetadataType } from "../types/activity-log-metadata-type" +import { deepFind } from "../../shared/utils/deep-find" + +@Injectable() +export class ActivityLogInterceptor implements NestInterceptor { + constructor( + protected readonly activityLogService: ActivityLogService, + protected reflector: Reflector + ) {} + + getBasicRequestInfo( + context: ExecutionContext + ): { + module?: string + action?: string + resourceId?: string + user?: User + activityLogMetadata: ActivityLogMetadataType + } { + const req = context.switchToHttp().getRequest() + const module = this.reflector.getAllAndOverride("authz_type", [ + context.getClass(), + context.getHandler(), + ]) + const action = + this.reflector.get("authz_action", context.getHandler()) || + httpMethodsToAction[req.method] + const user: User | null = req.user + const activityLogMetadata = this.reflector.getAllAndOverride( + "activity_log_metadata", + [context.getClass(), context.getHandler()] + ) + return { module, action, user, activityLogMetadata } + } + + extractMetadata(body: any, activityLogMetadata: ActivityLogMetadataType) { + let metadata + if (activityLogMetadata) { + metadata = {} + for (const trackPropertiesMetadata of activityLogMetadata) { + metadata[trackPropertiesMetadata.targetPropertyName] = deepFind( + body, + trackPropertiesMetadata.propertyPath + ) + } + } + return metadata + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + intercept(context: ExecutionContext, next: CallHandler) { + const { module, action, user, activityLogMetadata } = this.getBasicRequestInfo(context) + + if (action === authzActions.read) { + return next.handle() + } + + const metadata = this.extractMetadata( + context.switchToHttp().getRequest().body, + activityLogMetadata + ) + + return next.handle().pipe( + mergeMap((value) => + // NOTE: Resource ID is taken from the response value because it does not exist for e.g. create endpoints + { + const req = context.switchToHttp().getRequest() + let resourceId + if (req.method === "DELETE") { + resourceId = req.params.id + } else if (req.method === "POST") { + resourceId = value?.id + } else { + resourceId = req.body.id + } + return from(this.activityLogService.log(module, action, resourceId, user, metadata)).pipe( + ignoreElements(), + endWith(value) + ) + } + ) + ) + } +} diff --git a/backend/core/src/activity-log/services/activity-log.service.ts b/backend/core/src/activity-log/services/activity-log.service.ts new file mode 100644 index 0000000000..2b8f0472db --- /dev/null +++ b/backend/core/src/activity-log/services/activity-log.service.ts @@ -0,0 +1,17 @@ +import { Injectable } from "@nestjs/common" +import { User } from "../../auth/entities/user.entity" +import { ActivityLog } from "../entities/activity-log.entity" +import { InjectRepository } from "@nestjs/typeorm" +import { Repository } from "typeorm" + +@Injectable() +export class ActivityLogService { + constructor( + @InjectRepository(ActivityLog) + private readonly repository: Repository + ) {} + + public async log(module: string, action: string, recordId: string, user: User, metadata?: any) { + return await this.repository.save({ module, action, recordId, user, metadata }) + } +} diff --git a/backend/core/src/activity-log/types/activity-log-metadata-type.ts b/backend/core/src/activity-log/types/activity-log-metadata-type.ts new file mode 100644 index 0000000000..fbeb4e4d68 --- /dev/null +++ b/backend/core/src/activity-log/types/activity-log-metadata-type.ts @@ -0,0 +1 @@ +export type ActivityLogMetadataType = Array<{ targetPropertyName: string; propertyPath: string }> diff --git a/backend/core/src/ami-charts/ami-charts.controller.ts b/backend/core/src/ami-charts/ami-charts.controller.ts new file mode 100644 index 0000000000..bd06b3a979 --- /dev/null +++ b/backend/core/src/ami-charts/ami-charts.controller.ts @@ -0,0 +1,62 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + Post, + Put, + Query, + UseGuards, + UsePipes, + ValidationPipe, +} from "@nestjs/common" +import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger" +import { DefaultAuthGuard } from "../auth/guards/default.guard" +import { AuthzGuard } from "../auth/guards/authz.guard" +import { ResourceType } from "../auth/decorators/resource-type.decorator" +import { mapTo } from "../shared/mapTo" +import { AmiChartsService } from "./ami-charts.service" +import { AmiChartCreateDto, AmiChartDto, AmiChartUpdateDto } from "./dto/ami-chart.dto" +import { defaultValidationPipeOptions } from "../shared/default-validation-pipe-options" +import { AmiChartListQueryParams } from "./dto/ami-chart-list-query-params" + +@Controller("/amiCharts") +@ApiTags("amiCharts") +@ApiBearerAuth() +@ResourceType("amiChart") +@UseGuards(DefaultAuthGuard, AuthzGuard) +@UsePipes(new ValidationPipe(defaultValidationPipeOptions)) +export class AmiChartsController { + constructor(private readonly amiChartsService: AmiChartsService) {} + + @Get() + @ApiOperation({ summary: "List amiCharts", operationId: "list" }) + async list(@Query() queryParams: AmiChartListQueryParams): Promise { + return mapTo(AmiChartDto, await this.amiChartsService.list(queryParams)) + } + + @Post() + @ApiOperation({ summary: "Create amiChart", operationId: "create" }) + async create(@Body() amiChart: AmiChartCreateDto): Promise { + return mapTo(AmiChartDto, await this.amiChartsService.create(amiChart)) + } + + @Put(`:amiChartId`) + @ApiOperation({ summary: "Update amiChart", operationId: "update" }) + async update(@Body() amiChart: AmiChartUpdateDto): Promise { + return mapTo(AmiChartDto, await this.amiChartsService.update(amiChart)) + } + + @Get(`:amiChartId`) + @ApiOperation({ summary: "Get amiChart by id", operationId: "retrieve" }) + async retrieve(@Param("amiChartId") amiChartId: string): Promise { + return mapTo(AmiChartDto, await this.amiChartsService.findOne({ where: { id: amiChartId } })) + } + + @Delete(`:amiChartId`) + @ApiOperation({ summary: "Delete amiChart by id", operationId: "delete" }) + async delete(@Param("amiChartId") amiChartId: string): Promise { + return await this.amiChartsService.delete(amiChartId) + } +} diff --git a/backend/core/src/ami-charts/ami-charts.module.ts b/backend/core/src/ami-charts/ami-charts.module.ts new file mode 100644 index 0000000000..4feb88d6bc --- /dev/null +++ b/backend/core/src/ami-charts/ami-charts.module.ts @@ -0,0 +1,13 @@ +import { Module } from "@nestjs/common" +import { AmiChartsController } from "./ami-charts.controller" +import { AmiChartsService } from "./ami-charts.service" +import { TypeOrmModule } from "@nestjs/typeorm" +import { AmiChart } from "./entities/ami-chart.entity" +import { AuthModule } from "../auth/auth.module" + +@Module({ + imports: [TypeOrmModule.forFeature([AmiChart]), AuthModule], + controllers: [AmiChartsController], + providers: [AmiChartsService], +}) +export class AmiChartsModule {} diff --git a/backend/core/src/ami-charts/ami-charts.service.ts b/backend/core/src/ami-charts/ami-charts.service.ts new file mode 100644 index 0000000000..0875dec137 --- /dev/null +++ b/backend/core/src/ami-charts/ami-charts.service.ts @@ -0,0 +1,60 @@ +import { AmiChart } from "./entities/ami-chart.entity" +import { AmiChartCreateDto, AmiChartUpdateDto } from "./dto/ami-chart.dto" +import { InjectRepository } from "@nestjs/typeorm" +import { FindOneOptions, Repository } from "typeorm" +import { NotFoundException } from "@nestjs/common" +import { AmiChartListQueryParams } from "./dto/ami-chart-list-query-params" +import { assignDefined } from "../shared/utils/assign-defined" + +export class AmiChartsService { + constructor( + @InjectRepository(AmiChart) + private readonly repository: Repository + ) {} + + list(queryParams?: AmiChartListQueryParams): Promise { + return this.repository.find({ + join: { + alias: "amiChart", + leftJoinAndSelect: { jurisdiction: "amiChart.jurisdiction" }, + }, + where: (qb) => { + if (queryParams.jurisdictionName) { + qb.where("jurisdiction.name = :jurisdictionName", queryParams) + } else if (queryParams.jurisdictionId) { + qb.where("jurisdiction.id = :jurisdictionId", queryParams) + } + }, + }) + } + + async create(dto: AmiChartCreateDto): Promise { + return await this.repository.save(dto) + } + + async findOne(findOneOptions: FindOneOptions): Promise { + const obj = await this.repository.findOne(findOneOptions) + if (!obj) { + throw new NotFoundException() + } + return obj + } + + async delete(objId: string) { + await this.repository.delete(objId) + } + + async update(dto: AmiChartUpdateDto) { + const obj = await this.repository.findOne({ + where: { + id: dto.id, + }, + }) + if (!obj) { + throw new NotFoundException() + } + assignDefined(obj, dto) + await this.repository.save(obj) + return obj + } +} diff --git a/backend/core/src/ami-charts/dto/ami-chart-list-query-params.ts b/backend/core/src/ami-charts/dto/ami-chart-list-query-params.ts new file mode 100644 index 0000000000..9c7faa020e --- /dev/null +++ b/backend/core/src/ami-charts/dto/ami-chart-list-query-params.ts @@ -0,0 +1,26 @@ +import { Expose } from "class-transformer" +import { ApiProperty } from "@nestjs/swagger" +import { IsOptional, IsString } from "class-validator" +import { ValidationsGroupsEnum } from "../../shared/types/validations-groups-enum" + +export class AmiChartListQueryParams { + @Expose() + @ApiProperty({ + name: "jurisdictionName", + required: false, + type: String, + }) + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + jurisdictionName?: string + + @Expose() + @ApiProperty({ + name: "jurisdictionId", + required: false, + type: String, + }) + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + jurisdictionId?: string +} diff --git a/backend/core/src/ami-charts/dto/ami-chart.dto.ts b/backend/core/src/ami-charts/dto/ami-chart.dto.ts new file mode 100644 index 0000000000..87d34f06ce --- /dev/null +++ b/backend/core/src/ami-charts/dto/ami-chart.dto.ts @@ -0,0 +1,76 @@ +import { Expose, Type } from "class-transformer" +import { IsDate, IsDefined, IsOptional, IsUUID, ValidateNested } from "class-validator" +import { OmitType } from "@nestjs/swagger" +import { AmiChart } from "../entities/ami-chart.entity" +import { AmiChartItem } from "../entities/ami-chart-item.entity" +import { ValidationsGroupsEnum } from "../../shared/types/validations-groups-enum" +import { JurisdictionDto } from "../../jurisdictions/dto/jurisdiction.dto" +import { IdDto } from "../../shared/dto/id.dto" + +export class AmiChartDto extends OmitType(AmiChart, ["items", "jurisdiction"] as const) { + @Expose() + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => AmiChartItem) + @ValidateNested({ groups: [ValidationsGroupsEnum.default], each: true }) + items: AmiChartItem[] + + @Expose() + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => JurisdictionDto) + jurisdiction: JurisdictionDto +} + +export class AmiChartCreateDto extends OmitType(AmiChartDto, [ + "id", + "createdAt", + "updatedAt", + "items", + "jurisdiction", +] as const) { + @Expose() + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => AmiChartItem) + @ValidateNested({ groups: [ValidationsGroupsEnum.default], each: true }) + items: AmiChartItem[] + + @Expose() + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => IdDto) + jurisdiction: IdDto +} + +export class AmiChartUpdateDto extends OmitType(AmiChartDto, [ + "id", + "createdAt", + "updatedAt", + "items", + "jurisdiction", +]) { + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsUUID(4, { groups: [ValidationsGroupsEnum.default] }) + id?: string + + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsDate({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => Date) + createdAt?: Date + + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsDate({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => Date) + updatedAt?: Date + + @Expose() + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => AmiChartItem) + @ValidateNested({ groups: [ValidationsGroupsEnum.default], each: true }) + items: AmiChartItem[] + + @Expose() + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => IdDto) + jurisdiction: IdDto +} diff --git a/backend/core/src/ami-charts/entities/ami-chart-item.entity.ts b/backend/core/src/ami-charts/entities/ami-chart-item.entity.ts new file mode 100644 index 0000000000..15cf2490b3 --- /dev/null +++ b/backend/core/src/ami-charts/entities/ami-chart-item.entity.ts @@ -0,0 +1,17 @@ +import { Expose } from "class-transformer" +import { IsNumber } from "class-validator" +import { ValidationsGroupsEnum } from "../../shared/types/validations-groups-enum" + +export class AmiChartItem { + @Expose() + @IsNumber({}, { groups: [ValidationsGroupsEnum.default] }) + percentOfAmi: number + + @Expose() + @IsNumber({}, { groups: [ValidationsGroupsEnum.default] }) + householdSize: number + + @Expose() + @IsNumber({}, { groups: [ValidationsGroupsEnum.default] }) + income: number +} diff --git a/backend/core/src/ami-charts/entities/ami-chart.entity.ts b/backend/core/src/ami-charts/entities/ami-chart.entity.ts new file mode 100644 index 0000000000..d1ce1678a5 --- /dev/null +++ b/backend/core/src/ami-charts/entities/ami-chart.entity.ts @@ -0,0 +1,52 @@ +import { + Column, + CreateDateColumn, + Entity, + ManyToOne, + PrimaryGeneratedColumn, + UpdateDateColumn, +} from "typeorm" +import { Expose, Type } from "class-transformer" +import { IsDate, IsDefined, IsString, IsUUID, ValidateNested } from "class-validator" +import { AmiChartItem } from "./ami-chart-item.entity" +import { ValidationsGroupsEnum } from "../../shared/types/validations-groups-enum" +import { Jurisdiction } from "../../jurisdictions/entities/jurisdiction.entity" + +@Entity() +export class AmiChart { + @PrimaryGeneratedColumn("uuid") + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @IsUUID(4, { groups: [ValidationsGroupsEnum.default] }) + id: string + + @CreateDateColumn() + @Expose() + @IsDate({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => Date) + createdAt: Date + + @UpdateDateColumn() + @Expose() + @IsDate({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => Date) + updatedAt: Date + + @Column("jsonb") + @Expose() + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default], each: true }) + @Type(() => AmiChartItem) + items: AmiChartItem[] + + @Column() + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + name: string + + @ManyToOne(() => Jurisdiction, { eager: true, nullable: false }) + @Expose() + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => Jurisdiction) + jurisdiction: Jurisdiction +} diff --git a/backend/core/src/app.module.ts b/backend/core/src/app.module.ts new file mode 100644 index 0000000000..b22aa0470c --- /dev/null +++ b/backend/core/src/app.module.ts @@ -0,0 +1,134 @@ +// dotenv is a dev dependency, so conditionally import it (don't need it in Prod). +try { + // eslint-disable-next-line @typescript-eslint/no-var-requires + require("dotenv").config() +} catch { + // Pass +} +if (process.env.NEW_RELIC_APP_NAME && process.env.NEW_RELIC_LICENSE_KEY) { + require("newrelic") +} +import { ClassSerializerInterceptor, DynamicModule, INestApplication, Module } from "@nestjs/common" +import { TypeOrmModule } from "@nestjs/typeorm" +// Use require because of the CommonJS/AMD style export. +// See https://www.typescriptlang.org/docs/handbook/modules.html#export--and-import--require +import { AuthModule } from "./auth/auth.module" +import { ListingsModule } from "./listings/listings.module" +import { ApplicationsModule } from "./applications/applications.module" +import { PreferencesModule } from "./preferences/preferences.module" +import { UnitsModule } from "./units/units.module" +import { PropertyGroupsModule } from "./property-groups/property-groups.module" +import { PropertiesModule } from "./property/properties.module" +import { AmiChartsModule } from "./ami-charts/ami-charts.module" +import { ApplicationFlaggedSetsModule } from "./application-flagged-sets/application-flagged-sets.module" +import * as bodyParser from "body-parser" +import { ThrottlerModule } from "@nestjs/throttler" +import { ThrottlerStorageRedisService } from "nestjs-throttler-storage-redis" +import Redis from "ioredis" +import { SharedModule } from "./shared/shared.module" +import { ConfigModule, ConfigService } from "@nestjs/config" +import { TranslationsModule } from "./translations/translations.module" +import { HttpAdapterHost, Reflector } from "@nestjs/core" +import { AssetsModule } from "./assets/assets.module" +import { JurisdictionsModule } from "./jurisdictions/jurisdictions.module" +import { ReservedCommunityTypesModule } from "./reserved-community-type/reserved-community-types.module" +import { UnitTypesModule } from "./unit-types/unit-types.module" +import { UnitRentTypesModule } from "./unit-rent-types/unit-rent-types.module" +import { UnitAccessibilityPriorityTypesModule } from "./unit-accessbility-priority-types/unit-accessibility-priority-types.module" +import { ApplicationMethodsModule } from "./application-methods/applications-methods.module" +import { PaperApplicationsModule } from "./paper-applications/paper-applications.module" +import { SmsModule } from "./sms/sms.module" +import { ScheduleModule } from "@nestjs/schedule" +import { CronModule } from "./cron/cron.module" +import { BullModule } from "@nestjs/bull" +import { ProgramsModule } from "./program/programs.module" +import { ActivityLogModule } from "./activity-log/activity-log.module" +import { logger } from "./shared/middlewares/logger.middleware" +import { CatchAllFilter } from "./shared/filters/catch-all-filter" + +export function applicationSetup(app: INestApplication) { + const { httpAdapter } = app.get(HttpAdapterHost) + app.enableCors() + app.use(logger) + app.useGlobalFilters(new CatchAllFilter(httpAdapter)) + app.use(bodyParser.json({ limit: "50mb" })) + app.use(bodyParser.urlencoded({ limit: "50mb", extended: true })) + app.useGlobalInterceptors( + new ClassSerializerInterceptor(app.get(Reflector), { excludeExtraneousValues: true }) + ) + return app +} + +@Module({ + imports: [ActivityLogModule], +}) +export class AppModule { + static register(dbOptions): DynamicModule { + /** + * DEV NOTE: + * This configuration is required due to issues with + * self signed certificates in Redis 6. + * + * { rejectUnauthorized: false } option is intentional and required + * + * Read more: + * https://help.heroku.com/HC0F8CUS/redis-connection-issues + * https://devcenter.heroku.com/articles/heroku-redis#ioredis-module + */ + const redis = + "0" === process.env.REDIS_USE_TLS + ? new Redis(process.env.REDIS_URL, { connectTimeout: 60000 }) + : new Redis(process.env.REDIS_TLS_URL, { + tls: { + rejectUnauthorized: false, + }, + connectTimeout: 60000, + }) + + return { + module: AppModule, + imports: [ + AmiChartsModule, + ApplicationFlaggedSetsModule, + ApplicationMethodsModule, + ApplicationsModule, + AssetsModule, + AuthModule, + BullModule.forRoot({ + redis: redis.options, + }), + JurisdictionsModule, + ListingsModule, + CronModule, + PaperApplicationsModule, + PreferencesModule, + ProgramsModule, + PropertiesModule, + PropertyGroupsModule, + ProgramsModule, + ReservedCommunityTypesModule, + ScheduleModule.forRoot(), + SharedModule, + SmsModule, + TranslationsModule, + TypeOrmModule.forRoot({ + ...dbOptions, + autoLoadEntities: true, + }), + ThrottlerModule.forRootAsync({ + imports: [ConfigModule], + inject: [ConfigService], + useFactory: (config: ConfigService) => ({ + ttl: config.get("THROTTLE_TTL"), + limit: config.get("THROTTLE_LIMIT"), + storage: new ThrottlerStorageRedisService(redis), + }), + }), + UnitsModule, + UnitTypesModule, + UnitRentTypesModule, + UnitAccessibilityPriorityTypesModule, + ], + } + } +} diff --git a/backend/core/src/application-flagged-sets/application-flagged-sets.controller.ts b/backend/core/src/application-flagged-sets/application-flagged-sets.controller.ts new file mode 100644 index 0000000000..08a63fd51c --- /dev/null +++ b/backend/core/src/application-flagged-sets/application-flagged-sets.controller.ts @@ -0,0 +1,67 @@ +import { + Body, + Controller, + Get, + Param, + Post, + Query, + Request, + UseGuards, + UsePipes, + ValidationPipe, +} from "@nestjs/common" +import { Request as ExpressRequest } from "express" +import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger" +import { ResourceType } from "../auth/decorators/resource-type.decorator" +import { OptionalAuthGuard } from "../auth/guards/optional-auth.guard" +import { AuthzGuard } from "../auth/guards/authz.guard" +import { defaultValidationPipeOptions } from "../shared/default-validation-pipe-options" +import { mapTo } from "../shared/mapTo" +import { ApplicationFlaggedSetsService } from "./application-flagged-sets.service" +import { ApplicationFlaggedSetDto } from "./dto/application-flagged-set.dto" +import { PaginatedApplicationFlaggedSetDto } from "./dto/paginated-application-flagged-set.dto" +import { ApplicationFlaggedSetResolveDto } from "./dto/application-flagged-set-resolve.dto" +import { PaginatedApplicationFlaggedSetQueryParams } from "./paginated-application-flagged-set-query-params" + +@Controller("/applicationFlaggedSets") +@ApiTags("applicationFlaggedSets") +@ApiBearerAuth() +@ResourceType("applicationFlaggedSet") +@UseGuards(OptionalAuthGuard, AuthzGuard) +@UsePipes( + new ValidationPipe({ + ...defaultValidationPipeOptions, + }) +) +export class ApplicationFlaggedSetsController { + constructor(private readonly applicationFlaggedSetsService: ApplicationFlaggedSetsService) {} + + @Get() + @ApiOperation({ summary: "List application flagged sets", operationId: "list" }) + async list( + @Query() queryParams: PaginatedApplicationFlaggedSetQueryParams + ): Promise { + return mapTo( + PaginatedApplicationFlaggedSetDto, + await this.applicationFlaggedSetsService.listPaginated(queryParams) + ) + } + + @Get(`:afsId`) + @ApiOperation({ summary: "Retrieve application flagged set by id", operationId: "retrieve" }) + async retrieve(@Param("afsId") afsId: string): Promise { + return mapTo( + ApplicationFlaggedSetDto, + await this.applicationFlaggedSetsService.findOneById(afsId) + ) + } + + @Post("resolve") + @ApiOperation({ summary: "Resolve application flagged set", operationId: "resolve" }) + async resolve( + @Request() req: ExpressRequest, + @Body() dto: ApplicationFlaggedSetResolveDto + ): Promise { + return mapTo(ApplicationFlaggedSetDto, await this.applicationFlaggedSetsService.resolve(dto)) + } +} diff --git a/backend/core/src/application-flagged-sets/application-flagged-sets.module.ts b/backend/core/src/application-flagged-sets/application-flagged-sets.module.ts new file mode 100644 index 0000000000..2f2c960d31 --- /dev/null +++ b/backend/core/src/application-flagged-sets/application-flagged-sets.module.ts @@ -0,0 +1,15 @@ +import { Module } from "@nestjs/common" +import { ApplicationFlaggedSetsController } from "./application-flagged-sets.controller" +import { ApplicationFlaggedSetsService } from "./application-flagged-sets.service" +import { TypeOrmModule } from "@nestjs/typeorm" +import { AuthModule } from "../auth/auth.module" +import { ApplicationFlaggedSet } from "./entities/application-flagged-set.entity" +import { Application } from "../applications/entities/application.entity" + +@Module({ + imports: [TypeOrmModule.forFeature([ApplicationFlaggedSet, Application]), AuthModule], + controllers: [ApplicationFlaggedSetsController], + providers: [ApplicationFlaggedSetsService], + exports: [ApplicationFlaggedSetsService], +}) +export class ApplicationFlaggedSetsModule {} diff --git a/backend/core/src/application-flagged-sets/application-flagged-sets.service.ts b/backend/core/src/application-flagged-sets/application-flagged-sets.service.ts new file mode 100644 index 0000000000..400ecb3472 --- /dev/null +++ b/backend/core/src/application-flagged-sets/application-flagged-sets.service.ts @@ -0,0 +1,367 @@ +import { BadRequestException, Inject, Injectable, NotFoundException, Scope } from "@nestjs/common" +import { AuthzService } from "../auth/services/authz.service" +import { ApplicationFlaggedSet } from "./entities/application-flagged-set.entity" +import { InjectRepository } from "@nestjs/typeorm" +import { + Brackets, + DeepPartial, + EntityManager, + getManager, + getMetadataArgsStorage, + In, + QueryRunner, + Repository, + SelectQueryBuilder, +} from "typeorm" +import { paginate } from "nestjs-typeorm-paginate" +import { Application } from "../applications/entities/application.entity" +import { REQUEST } from "@nestjs/core" +import { Request as ExpressRequest } from "express" +import { User } from "../auth/entities/user.entity" +import { FlaggedSetStatus } from "./types/flagged-set-status-enum" +import { Rule } from "./types/rule-enum" +import { ApplicationFlaggedSetResolveDto } from "./dto/application-flagged-set-resolve.dto" +import { PaginatedApplicationFlaggedSetQueryParams } from "./paginated-application-flagged-set-query-params" +import { ListingStatus } from "../listings/types/listing-status-enum" + +@Injectable({ scope: Scope.REQUEST }) +export class ApplicationFlaggedSetsService { + constructor( + @Inject(REQUEST) private request: ExpressRequest, + private readonly authzService: AuthzService, + @InjectRepository(Application) + private readonly applicationsRepository: Repository, + @InjectRepository(ApplicationFlaggedSet) + private readonly afsRepository: Repository + ) {} + async listPaginated(queryParams: PaginatedApplicationFlaggedSetQueryParams) { + const results = await paginate( + this.afsRepository, + { limit: queryParams.limit, page: queryParams.page }, + { + relations: ["listing", "applications"], + where: { + ...(queryParams.listingId && { listingId: queryParams.listingId }), + }, + } + ) + const countTotalFlagged = await this.afsRepository.count({ + where: { + status: FlaggedSetStatus.flagged, + ...(queryParams.listingId && { listingId: queryParams.listingId }), + }, + }) + return { + ...results, + meta: { + ...results.meta, + totalFlagged: countTotalFlagged, + }, + } + } + + async findOneById(afsId: string) { + return await this.afsRepository.findOneOrFail({ + relations: ["listing", "applications"], + where: { + id: afsId, + }, + }) + } + + async resolve(dto: ApplicationFlaggedSetResolveDto) { + return await getManager().transaction("SERIALIZABLE", async (transactionalEntityManager) => { + const transAfsRepository = transactionalEntityManager.getRepository(ApplicationFlaggedSet) + const transApplicationsRepository = transactionalEntityManager.getRepository(Application) + const afs = await transAfsRepository.findOne({ + where: { id: dto.afsId }, + relations: ["applications", "listing"], + }) + if (!afs) { + throw new NotFoundException() + } + + if (afs.listing.status !== ListingStatus.closed) { + throw new BadRequestException("Listing must be closed before resolving any duplicates.") + } + + // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion + afs.resolvingUser = this.request.user as User + afs.resolvedTime = new Date() + afs.status = FlaggedSetStatus.resolved + const appsToBeResolved = afs.applications.filter((afsApp) => + dto.applications.map((appIdDto) => appIdDto.id).includes(afsApp.id) + ) + + const appsNotToBeResolved = afs.applications.filter( + (afsApp) => !dto.applications.map((appIdDto) => appIdDto.id).includes(afsApp.id) + ) + + for (const appToBeResolved of appsToBeResolved) { + appToBeResolved.markedAsDuplicate = true + } + + for (const appNotToBeResolved of appsNotToBeResolved) { + appNotToBeResolved.markedAsDuplicate = false + } + + await transApplicationsRepository.save([...appsToBeResolved, ...appsNotToBeResolved]) + + appsToBeResolved.forEach((app) => (app.markedAsDuplicate = true)) + await transAfsRepository.save(afs) + + return afs + }) + } + + async onApplicationSave(newApplication: Application, transactionalEntityManager: EntityManager) { + for (const rule of [Rule.email, Rule.nameAndDOB]) { + await this.updateApplicationFlaggedSetsForRule( + transactionalEntityManager, + newApplication, + rule + ) + } + } + + private async _getAfsesContainingApplicationId( + queryRunnery: QueryRunner, + applicationId: string + ): Promise> { + const metadataArgsStorage = getMetadataArgsStorage().findJoinTable( + ApplicationFlaggedSet, + "applications" + ) + const applicationsJunctionTableName = metadataArgsStorage.name + const query = ` + SELECT DISTINCT application_flagged_set_id FROM ${applicationsJunctionTableName} + WHERE applications_id = $1 + ` + return await queryRunnery.query(query, [applicationId]) + } + + async onApplicationUpdate( + newApplication: Application, + transactionalEntityManager: EntityManager + ) { + const transApplicationsRepository = transactionalEntityManager.getRepository(Application) + newApplication.markedAsDuplicate = false + await transApplicationsRepository.save(newApplication) + + const transAfsRepository = transactionalEntityManager.getRepository(ApplicationFlaggedSet) + + const afsIds = await this._getAfsesContainingApplicationId( + transAfsRepository.queryRunner, + newApplication.id + ) + const afses = await transAfsRepository.find({ + where: { id: In(afsIds.map((afs) => afs.application_flagged_set_id)) }, + relations: ["applications"], + }) + const afsesToBeSaved: Array = [] + const afsesToBeRemoved: Array = [] + for (const afs of afses) { + afs.status = FlaggedSetStatus.flagged + afs.resolvedTime = null + afs.resolvingUser = null + const applicationIndex = afs.applications.findIndex( + (application) => application.id === newApplication.id + ) + afs.applications.splice(applicationIndex, 1) + if (afs.applications.length > 1) { + afsesToBeSaved.push(afs) + } else { + afsesToBeRemoved.push(afs) + } + } + await transAfsRepository.save(afsesToBeSaved) + await transAfsRepository.remove(afsesToBeRemoved) + + await this.onApplicationSave(newApplication, transactionalEntityManager) + } + + async fetchDuplicatesMatchingRule( + transactionalEntityManager: EntityManager, + application: Application, + rule: Rule + ) { + switch (rule) { + case Rule.nameAndDOB: + return await this.fetchDuplicatesMatchingNameAndDOBRule( + transactionalEntityManager, + application + ) + case Rule.email: + return await this.fetchDuplicatesMatchingEmailRule(transactionalEntityManager, application) + } + } + + async updateApplicationFlaggedSetsForRule( + transactionalEntityManager: EntityManager, + newApplication: Application, + rule: Rule + ) { + const applicationsMatchingRule = await this.fetchDuplicatesMatchingRule( + transactionalEntityManager, + newApplication, + rule + ) + const transAfsRepository = transactionalEntityManager.getRepository(ApplicationFlaggedSet) + const visitedAfses = [] + const afses = await transAfsRepository + .createQueryBuilder("afs") + .leftJoin("afs.applications", "applications") + .select(["afs", "applications.id"]) + .where(`afs.listing_id = :listingId`, { listingId: newApplication.listing.id }) + .andWhere(`rule = :rule`, { rule }) + .getMany() + + for (const matchedApplication of applicationsMatchingRule) { + const afsesMatchingRule = afses.filter((afs) => + afs.applications.map((app) => app.id).includes(matchedApplication.id) + ) + + if (afsesMatchingRule.length === 0) { + const newAfs: DeepPartial = { + rule: rule, + resolvedTime: null, + resolvingUser: null, + status: FlaggedSetStatus.flagged, + applications: [newApplication, matchedApplication], + listing: newApplication.listing, + } + await transAfsRepository.save(newAfs) + } else if (afsesMatchingRule.length === 1) { + for (const afs of afsesMatchingRule) { + if (visitedAfses.includes(afs.id)) { + return + } + visitedAfses.push(afs.id) + afs.applications.push(newApplication) + await transAfsRepository.save(afs) + } + } else { + console.error( + "There should be up to one AFS matching a rule for given application, " + + "probably a logic error when creating AFSes" + ) + } + } + } + + private async fetchDuplicatesMatchingEmailRule( + transactionalEntityManager: EntityManager, + newApplication: Application + ) { + const transApplicationsRepository = transactionalEntityManager.getRepository(Application) + return await transApplicationsRepository.find({ + select: ["id"], + where: (qb: SelectQueryBuilder) => { + qb.where("Application.id != :id", { + id: newApplication.id, + }) + .andWhere("Application.listing.id = :listingId", { + listingId: newApplication.listing.id, + }) + .andWhere("Application__applicant.emailAddress = :emailAddress", { + emailAddress: newApplication.applicant.emailAddress, + }) + .andWhere("Application.status = :status", { status: "submitted" }) + }, + }) + } + + private async fetchDuplicatesMatchingNameAndDOBRule( + transactionalEntityManager: EntityManager, + newApplication: Application + ) { + const transApplicationsRepository = transactionalEntityManager.getRepository(Application) + const firstNames = [ + newApplication.applicant.firstName, + ...newApplication.householdMembers.map((householdMember) => householdMember.firstName), + ] + + const lastNames = [ + newApplication.applicant.lastName, + ...newApplication.householdMembers.map((householdMember) => householdMember.lastName), + ] + + const birthMonths = [ + newApplication.applicant.birthMonth, + ...newApplication.householdMembers.map((householdMember) => householdMember.birthMonth), + ] + + const birthDays = [ + newApplication.applicant.birthDay, + ...newApplication.householdMembers.map((householdMember) => householdMember.birthDay), + ] + + const birthYears = [ + newApplication.applicant.birthYear, + ...newApplication.householdMembers.map((householdMember) => householdMember.birthYear), + ] + + return await transApplicationsRepository.find({ + select: ["id"], + where: (qb: SelectQueryBuilder) => { + qb.where("Application.id != :id", { + id: newApplication.id, + }) + .andWhere("Application.listing.id = :listingId", { + listingId: newApplication.listing.id, + }) + .andWhere("Application.status = :status", { status: "submitted" }) + .andWhere( + new Brackets((subQb) => { + subQb.where("Application__householdMembers.firstName IN (:...firstNames)", { + firstNames: firstNames, + }) + subQb.orWhere("Application__applicant.firstName IN (:...firstNames)", { + firstNames: firstNames, + }) + }) + ) + .andWhere( + new Brackets((subQb) => { + subQb.where("Application__householdMembers.lastName IN (:...lastNames)", { + lastNames: lastNames, + }) + subQb.orWhere("Application__applicant.lastName IN (:...lastNames)", { + lastNames: lastNames, + }) + }) + ) + .andWhere( + new Brackets((subQb) => { + subQb.where("Application__householdMembers.birthMonth IN (:...birthMonths)", { + birthMonths: birthMonths, + }) + subQb.orWhere("Application__applicant.birthMonth IN (:...birthMonths)", { + birthMonths: birthMonths, + }) + }) + ) + .andWhere( + new Brackets((subQb) => { + subQb.where("Application__householdMembers.birthDay IN (:...birthDays)", { + birthDays: birthDays, + }) + subQb.orWhere("Application__applicant.birthDay IN (:...birthDays)", { + birthDays: birthDays, + }) + }) + ) + .andWhere( + new Brackets((subQb) => { + subQb.where("Application__householdMembers.birthYear IN (:...birthYears)", { + birthYears: birthYears, + }) + subQb.orWhere("Application__applicant.birthYear IN (:...birthYears)", { + birthYears: birthYears, + }) + }) + ) + }, + }) + } +} diff --git a/backend/core/src/application-flagged-sets/dto/application-flagged-set-pagination-meta.ts b/backend/core/src/application-flagged-sets/dto/application-flagged-set-pagination-meta.ts new file mode 100644 index 0000000000..6eb793cb70 --- /dev/null +++ b/backend/core/src/application-flagged-sets/dto/application-flagged-set-pagination-meta.ts @@ -0,0 +1,9 @@ +import { PaginationMeta } from "../../shared/dto/pagination.dto" +import { Expose } from "class-transformer" +import { ApiProperty } from "@nestjs/swagger" + +export class ApplicationFlaggedSetPaginationMeta extends PaginationMeta { + @Expose() + @ApiProperty() + totalFlagged: number +} diff --git a/backend/core/src/application-flagged-sets/dto/application-flagged-set-resolve.dto.ts b/backend/core/src/application-flagged-sets/dto/application-flagged-set-resolve.dto.ts new file mode 100644 index 0000000000..7cfeae050c --- /dev/null +++ b/backend/core/src/application-flagged-sets/dto/application-flagged-set-resolve.dto.ts @@ -0,0 +1,18 @@ +import { Expose, Type } from "class-transformer" +import { ArrayMaxSize, IsArray, IsDefined, IsUUID, ValidateNested } from "class-validator" +import { ValidationsGroupsEnum } from "../../shared/types/validations-groups-enum" +import { IdDto } from "../../shared/dto/id.dto" + +export class ApplicationFlaggedSetResolveDto { + @Expose() + @IsUUID(4, { groups: [ValidationsGroupsEnum.default] }) + afsId: string + + @Expose() + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @IsArray({ groups: [ValidationsGroupsEnum.default] }) + @ArrayMaxSize(512, { groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default], each: true }) + @Type(() => IdDto) + applications: IdDto[] +} diff --git a/backend/core/src/application-flagged-sets/dto/application-flagged-set.dto.ts b/backend/core/src/application-flagged-sets/dto/application-flagged-set.dto.ts new file mode 100644 index 0000000000..65c1ffa728 --- /dev/null +++ b/backend/core/src/application-flagged-sets/dto/application-flagged-set.dto.ts @@ -0,0 +1,28 @@ +import { OmitType } from "@nestjs/swagger" +import { ApplicationFlaggedSet } from "../entities/application-flagged-set.entity" +import { Expose, Type } from "class-transformer" +import { ValidateNested } from "class-validator" +import { ValidationsGroupsEnum } from "../../shared/types/validations-groups-enum" +import { ApplicationDto } from "../../applications/dto/application.dto" +import { IdDto } from "../../shared/dto/id.dto" + +export class ApplicationFlaggedSetDto extends OmitType(ApplicationFlaggedSet, [ + "resolvingUser", + "applications", + "listing", +] as const) { + @Expose() + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => IdDto) + resolvingUser: IdDto + + @Expose() + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => ApplicationDto) + applications: ApplicationDto[] + + @Expose() + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => IdDto) + listing: IdDto +} diff --git a/backend/core/src/application-flagged-sets/dto/paginated-application-flagged-set.dto.ts b/backend/core/src/application-flagged-sets/dto/paginated-application-flagged-set.dto.ts new file mode 100644 index 0000000000..6a352fc361 --- /dev/null +++ b/backend/core/src/application-flagged-sets/dto/paginated-application-flagged-set.dto.ts @@ -0,0 +1,11 @@ +import { PaginationFactory } from "../../shared/dto/pagination.dto" +import { Expose } from "class-transformer" +import { ApplicationFlaggedSetPaginationMeta } from "./application-flagged-set-pagination-meta" +import { ApplicationFlaggedSetDto } from "./application-flagged-set.dto" + +export class PaginatedApplicationFlaggedSetDto extends PaginationFactory( + ApplicationFlaggedSetDto +) { + @Expose() + meta: ApplicationFlaggedSetPaginationMeta +} diff --git a/backend/core/src/application-flagged-sets/entities/application-flagged-set.entity.ts b/backend/core/src/application-flagged-sets/entities/application-flagged-set.entity.ts new file mode 100644 index 0000000000..fa69f0706f --- /dev/null +++ b/backend/core/src/application-flagged-sets/entities/application-flagged-set.entity.ts @@ -0,0 +1,51 @@ +import { Column, Entity, Index, JoinColumn, JoinTable, ManyToMany, ManyToOne } from "typeorm" +import { AbstractEntity } from "../../shared/entities/abstract.entity" +import { IsDate, IsEnum, IsOptional, IsString, ValidateNested } from "class-validator" +import { Expose, Type } from "class-transformer" +import { ValidationsGroupsEnum } from "../../shared/types/validations-groups-enum" +import { Application } from "../../applications/entities/application.entity" +import { User } from "../../auth/entities/user.entity" +import { Listing } from "../../listings/entities/listing.entity" +import { FlaggedSetStatus } from "../types/flagged-set-status-enum" +import { Rule } from "../types/rule-enum" + +@Entity() +@Index(["listing"]) +export class ApplicationFlaggedSet extends AbstractEntity { + @Column({ enum: Rule, nullable: false }) + @Expose() + @IsEnum(Rule, { groups: [ValidationsGroupsEnum.default] }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + rule: string + + @Column({ type: "timestamptz", nullable: true }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsDate({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => Date) + resolvedTime?: Date | null + + @ManyToOne(() => User, { eager: true, nullable: true, cascade: false }) + @JoinColumn() + @Expose() + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => User) + resolvingUser: User + + @Column({ enum: FlaggedSetStatus, nullable: false, default: FlaggedSetStatus.flagged }) + @Expose() + @IsEnum(FlaggedSetStatus, { groups: [ValidationsGroupsEnum.default] }) + status: FlaggedSetStatus + + @ManyToMany(() => Application) + @JoinTable({ name: "application_flagged_set_applications_applications" }) + @Expose() + @ValidateNested({ groups: [ValidationsGroupsEnum.default], each: true }) + applications: Application[] + + @ManyToOne(() => Listing) + listing: Listing + + @Column() + listingId: string +} diff --git a/backend/core/src/application-flagged-sets/paginated-application-flagged-set-query-params.ts b/backend/core/src/application-flagged-sets/paginated-application-flagged-set-query-params.ts new file mode 100644 index 0000000000..8068126294 --- /dev/null +++ b/backend/core/src/application-flagged-sets/paginated-application-flagged-set-query-params.ts @@ -0,0 +1,16 @@ +import { PaginationQueryParams } from "../shared/dto/pagination.dto" +import { Expose } from "class-transformer" +import { ApiProperty } from "@nestjs/swagger" +import { IsUUID } from "class-validator" +import { ValidationsGroupsEnum } from "../shared/types/validations-groups-enum" + +export class PaginatedApplicationFlaggedSetQueryParams extends PaginationQueryParams { + @Expose() + @ApiProperty({ + type: String, + example: "listingId", + required: true, + }) + @IsUUID(4, { groups: [ValidationsGroupsEnum.default] }) + listingId: string +} diff --git a/backend/core/src/application-flagged-sets/types/flagged-set-status-enum.ts b/backend/core/src/application-flagged-sets/types/flagged-set-status-enum.ts new file mode 100644 index 0000000000..e8a1e34e6d --- /dev/null +++ b/backend/core/src/application-flagged-sets/types/flagged-set-status-enum.ts @@ -0,0 +1,4 @@ +export enum FlaggedSetStatus { + flagged = "flagged", + resolved = "resolved", +} diff --git a/backend/core/src/application-flagged-sets/types/rule-enum.ts b/backend/core/src/application-flagged-sets/types/rule-enum.ts new file mode 100644 index 0000000000..72eb6ffdd5 --- /dev/null +++ b/backend/core/src/application-flagged-sets/types/rule-enum.ts @@ -0,0 +1,4 @@ +export enum Rule { + nameAndDOB = "Name and DOB", + email = "Email", +} diff --git a/backend/core/src/application-methods/application-methods.controller.ts b/backend/core/src/application-methods/application-methods.controller.ts new file mode 100644 index 0000000000..9b2cff9bc1 --- /dev/null +++ b/backend/core/src/application-methods/application-methods.controller.ts @@ -0,0 +1,79 @@ +import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger" +import { DefaultAuthGuard } from "../auth/guards/default.guard" +import { AuthzGuard } from "../auth/guards/authz.guard" +import { ResourceType } from "../auth/decorators/resource-type.decorator" +import { mapTo } from "../shared/mapTo" +import { + Body, + Controller, + Delete, + Get, + Param, + Post, + Put, + UseGuards, + UsePipes, + ValidationPipe, +} from "@nestjs/common" +import { defaultValidationPipeOptions } from "../shared/default-validation-pipe-options" +import { ApplicationMethodsService } from "./application-methods.service" +import { + ApplicationMethodCreateDto, + ApplicationMethodDto, + ApplicationMethodUpdateDto, +} from "./dto/application-method.dto" + +@Controller("applicationMethods") +@ApiTags("applicationMethods") +@ApiBearerAuth() +@ResourceType("applicationMethod") +@UseGuards(DefaultAuthGuard, AuthzGuard) +@UsePipes(new ValidationPipe(defaultValidationPipeOptions)) +export class ApplicationMethodsController { + constructor(private readonly applicationMethodsService: ApplicationMethodsService) {} + + @Get() + @ApiOperation({ summary: "List applicationMethods", operationId: "list" }) + async list(): Promise { + return mapTo(ApplicationMethodDto, await this.applicationMethodsService.list()) + } + + @Post() + @ApiOperation({ summary: "Create applicationMethod", operationId: "create" }) + async create( + @Body() applicationMethod: ApplicationMethodCreateDto + ): Promise { + return mapTo( + ApplicationMethodDto, + await this.applicationMethodsService.create(applicationMethod) + ) + } + + @Put(`:applicationMethodId`) + @ApiOperation({ summary: "Update applicationMethod", operationId: "update" }) + async update( + @Body() applicationMethod: ApplicationMethodUpdateDto + ): Promise { + return mapTo( + ApplicationMethodDto, + await this.applicationMethodsService.update(applicationMethod) + ) + } + + @Get(`:applicationMethodId`) + @ApiOperation({ summary: "Get applicationMethod by id", operationId: "retrieve" }) + async retrieve( + @Param("applicationMethodId") applicationMethodId: string + ): Promise { + return mapTo( + ApplicationMethodDto, + await this.applicationMethodsService.findOne({ where: { id: applicationMethodId } }) + ) + } + + @Delete(`:applicationMethodId`) + @ApiOperation({ summary: "Delete applicationMethod by id", operationId: "delete" }) + async delete(@Param("applicationMethodId") applicationMethodId: string): Promise { + return await this.applicationMethodsService.delete(applicationMethodId) + } +} diff --git a/backend/core/src/application-methods/application-methods.service.ts b/backend/core/src/application-methods/application-methods.service.ts new file mode 100644 index 0000000000..5d4c0c40f4 --- /dev/null +++ b/backend/core/src/application-methods/application-methods.service.ts @@ -0,0 +1,14 @@ +import { AbstractServiceFactory } from "../shared/services/abstract-service" +import { Injectable } from "@nestjs/common" +import { ApplicationMethod } from "./entities/application-method.entity" +import { + ApplicationMethodCreateDto, + ApplicationMethodUpdateDto, +} from "./dto/application-method.dto" + +@Injectable() +export class ApplicationMethodsService extends AbstractServiceFactory< + ApplicationMethod, + ApplicationMethodCreateDto, + ApplicationMethodUpdateDto +>(ApplicationMethod) {} diff --git a/backend/core/src/application-methods/applications-methods.module.ts b/backend/core/src/application-methods/applications-methods.module.ts new file mode 100644 index 0000000000..b11cc3254c --- /dev/null +++ b/backend/core/src/application-methods/applications-methods.module.ts @@ -0,0 +1,13 @@ +import { Module } from "@nestjs/common" +import { TypeOrmModule } from "@nestjs/typeorm" +import { AuthModule } from "../auth/auth.module" +import { ApplicationMethod } from "./entities/application-method.entity" +import { ApplicationMethodsController } from "./application-methods.controller" +import { ApplicationMethodsService } from "./application-methods.service" + +@Module({ + imports: [TypeOrmModule.forFeature([ApplicationMethod]), AuthModule], + controllers: [ApplicationMethodsController], + providers: [ApplicationMethodsService], +}) +export class ApplicationMethodsModule {} diff --git a/backend/core/src/application-methods/dto/application-method.dto.ts b/backend/core/src/application-methods/dto/application-method.dto.ts new file mode 100644 index 0000000000..c7f0dcfa04 --- /dev/null +++ b/backend/core/src/application-methods/dto/application-method.dto.ts @@ -0,0 +1,72 @@ +import { Expose, Type } from "class-transformer" +import { IsDate, IsOptional, IsUUID, ValidateNested } from "class-validator" +import { OmitType } from "@nestjs/swagger" +import { IdDto } from "../../shared/dto/id.dto" +import { ValidationsGroupsEnum } from "../../shared/types/validations-groups-enum" +import { ApplicationMethod } from "../entities/application-method.entity" +import { + PaperApplicationCreateDto, + PaperApplicationDto, + PaperApplicationUpdateDto, +} from "../../paper-applications/dto/paper-application.dto" + +export class ApplicationMethodDto extends OmitType(ApplicationMethod, [ + "listing", + "paperApplications", + "listing", +] as const) { + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default], each: true }) + @Type(() => PaperApplicationDto) + paperApplications?: PaperApplicationDto[] | null + + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default], each: true }) + @Type(() => IdDto) + listing: IdDto +} + +export class ApplicationMethodCreateDto extends OmitType(ApplicationMethodDto, [ + "id", + "createdAt", + "updatedAt", + "paperApplications", +] as const) { + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default], each: true }) + @Type(() => PaperApplicationCreateDto) + paperApplications?: PaperApplicationCreateDto[] | null +} + +export class ApplicationMethodUpdateDto extends OmitType(ApplicationMethodDto, [ + "id", + "createdAt", + "updatedAt", + "paperApplications", +] as const) { + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsUUID(4, { groups: [ValidationsGroupsEnum.default] }) + id?: string + + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsDate({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => Date) + createdAt?: Date + + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsDate({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => Date) + updatedAt?: Date + + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default], each: true }) + @Type(() => PaperApplicationUpdateDto) + paperApplications?: PaperApplicationUpdateDto[] | null +} diff --git a/backend/core/src/application-methods/entities/application-method.entity.ts b/backend/core/src/application-methods/entities/application-method.entity.ts new file mode 100644 index 0000000000..620386252e --- /dev/null +++ b/backend/core/src/application-methods/entities/application-method.entity.ts @@ -0,0 +1,59 @@ +import { Column, Entity, ManyToOne, OneToMany } from "typeorm" +import { Expose, Type } from "class-transformer" +import { IsBoolean, IsEnum, IsOptional, IsString, MaxLength, ValidateNested } from "class-validator" +import { ValidationsGroupsEnum } from "../../shared/types/validations-groups-enum" +import { AbstractEntity } from "../../shared/entities/abstract.entity" +import { ApiProperty } from "@nestjs/swagger" +import { Listing } from "../../listings/entities/listing.entity" +import { ApplicationMethodType } from "../types/application-method-type-enum" +import { PaperApplication } from "../../paper-applications/entities/paper-application.entity" + +@Entity({ name: "application_methods" }) +export class ApplicationMethod extends AbstractEntity { + @Column({ type: "enum", enum: ApplicationMethodType }) + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @IsEnum(ApplicationMethodType, { groups: [ValidationsGroupsEnum.default] }) + @ApiProperty({ enum: ApplicationMethodType, enumName: "ApplicationMethodType" }) + type: ApplicationMethodType + + @Column({ type: "text", nullable: true }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @MaxLength(256, { groups: [ValidationsGroupsEnum.default] }) + label?: string | null + + @Column({ type: "text", nullable: true }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @MaxLength(4096, { groups: [ValidationsGroupsEnum.default] }) + externalReference?: string | null + + @Column({ type: "bool", nullable: true }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsBoolean({ groups: [ValidationsGroupsEnum.default] }) + acceptsPostmarkedApplications?: boolean | null + + @Column({ type: "text", nullable: true }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @MaxLength(16, { groups: [ValidationsGroupsEnum.default] }) + phoneNumber?: string | null + + @OneToMany(() => PaperApplication, (paperApplication) => paperApplication.applicationMethod, { + cascade: true, + eager: true, + }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default], each: true }) + @Type(() => PaperApplication) + paperApplications?: PaperApplication[] | null + + @ManyToOne(() => Listing, (listing) => listing.applicationMethods) + listing: Listing +} diff --git a/backend/core/src/application-methods/types/application-method-type-enum.ts b/backend/core/src/application-methods/types/application-method-type-enum.ts new file mode 100644 index 0000000000..bc4a69bd5b --- /dev/null +++ b/backend/core/src/application-methods/types/application-method-type-enum.ts @@ -0,0 +1,9 @@ +export enum ApplicationMethodType { + Internal = "Internal", + FileDownload = "FileDownload", + ExternalLink = "ExternalLink", + PaperPickup = "PaperPickup", + POBox = "POBox", + LeasingAgent = "LeasingAgent", + Referral = "Referral", +} diff --git a/backend/core/src/applications/application-preference-api-extra-models.ts b/backend/core/src/applications/application-preference-api-extra-models.ts new file mode 100644 index 0000000000..d8efd396a4 --- /dev/null +++ b/backend/core/src/applications/application-preference-api-extra-models.ts @@ -0,0 +1,5 @@ +import { BooleanInput } from "./types/form-metadata/boolean-input" +import { TextInput } from "./types/form-metadata/text-input" +import { AddressInput } from "./types/form-metadata/address-input" + +export const applicationPreferenceApiExtraModels = [BooleanInput, TextInput, AddressInput] diff --git a/backend/core/src/applications/applications-submission.controller.ts b/backend/core/src/applications/applications-submission.controller.ts new file mode 100644 index 0000000000..eb6a6e1917 --- /dev/null +++ b/backend/core/src/applications/applications-submission.controller.ts @@ -0,0 +1,39 @@ +import { Body, Controller, Post, UseGuards, UsePipes, ValidationPipe } from "@nestjs/common" +import { ApiBearerAuth, ApiExtraModels, ApiOperation, ApiTags } from "@nestjs/swagger" +import { ResourceType } from "../auth/decorators/resource-type.decorator" +import { OptionalAuthGuard } from "../auth/guards/optional-auth.guard" +import { ApplicationDto } from "./dto/application.dto" +import { mapTo } from "../shared/mapTo" +import { defaultValidationPipeOptions } from "../shared/default-validation-pipe-options" +import { ResourceAction } from "../auth/decorators/resource-action.decorator" +import { AuthzGuard } from "../auth/guards/authz.guard" +import { ValidationsGroupsEnum } from "../shared/types/validations-groups-enum" +import { ThrottlerGuard } from "@nestjs/throttler" +import { applicationPreferenceApiExtraModels } from "./types/application-preference-api-extra-models" +import { authzActions } from "../auth/enum/authz-actions.enum" +import { ApplicationsService } from "./services/applications.service" +import { ApplicationCreateDto } from "./dto/application-create.dto" + +@Controller("applications") +@ApiTags("applications") +@ApiBearerAuth() +@ResourceType("application") +@UseGuards(OptionalAuthGuard, AuthzGuard, ThrottlerGuard) +@UsePipes( + new ValidationPipe({ + ...defaultValidationPipeOptions, + groups: [ValidationsGroupsEnum.default, ValidationsGroupsEnum.applicants], + }) +) +@ApiExtraModels(...applicationPreferenceApiExtraModels) +export class ApplicationsSubmissionController { + constructor(private readonly applicationsService: ApplicationsService) {} + + @Post(`submit`) + @ApiOperation({ summary: "Submit application", operationId: "submit" }) + @ResourceAction(authzActions.submit) + async submit(@Body() applicationCreateDto: ApplicationCreateDto): Promise { + const application = await this.applicationsService.submit(applicationCreateDto) + return mapTo(ApplicationDto, application) + } +} diff --git a/backend/core/src/applications/applications.controller.ts b/backend/core/src/applications/applications.controller.ts new file mode 100644 index 0000000000..d123b4a30f --- /dev/null +++ b/backend/core/src/applications/applications.controller.ts @@ -0,0 +1,105 @@ +import { + Body, + Controller, + Delete, + Get, + Header, + Param, + Post, + Put, + Query, + UseGuards, + UseInterceptors, + UsePipes, + ValidationPipe, +} from "@nestjs/common" +import { ApiBearerAuth, ApiExtraModels, ApiOperation, ApiTags } from "@nestjs/swagger" +import { OptionalAuthGuard } from "../auth/guards/optional-auth.guard" +import { ResourceType } from "../auth/decorators/resource-type.decorator" +import { mapTo } from "../shared/mapTo" +import { ApplicationDto } from "./dto/application.dto" +import { ValidationsGroupsEnum } from "../shared/types/validations-groups-enum" +import { defaultValidationPipeOptions } from "../shared/default-validation-pipe-options" +import { applicationPreferenceApiExtraModels } from "./types/application-preference-api-extra-models" +import { ListingsService } from "../listings/listings.service" +import { ApplicationCsvExporterService } from "./services/application-csv-exporter.service" +import { ApplicationsService } from "./services/applications.service" +import { ActivityLogInterceptor } from "../activity-log/interceptors/activity-log.interceptor" +import { PaginatedApplicationListQueryParams } from "./dto/paginated-application-list-query-params" +import { ApplicationsCsvListQueryParams } from "./dto/applications-csv-list-query-params" +import { ApplicationsApiExtraModel } from "./types/applications-api-extra-model" +import { PaginatedApplicationDto } from "./dto/paginated-application.dto" +import { ApplicationCreateDto } from "./dto/application-create.dto" +import { ApplicationUpdateDto } from "./dto/application-update.dto" + +@Controller("applications") +@ApiTags("applications") +@ApiBearerAuth() +@ResourceType("application") +@UseGuards(OptionalAuthGuard) +@UseInterceptors(ActivityLogInterceptor) +@UsePipes( + new ValidationPipe({ + ...defaultValidationPipeOptions, + groups: [ValidationsGroupsEnum.default, ValidationsGroupsEnum.partners], + }) +) +@ApiExtraModels(...applicationPreferenceApiExtraModels, ApplicationsApiExtraModel) +export class ApplicationsController { + constructor( + private readonly applicationsService: ApplicationsService, + private readonly listingsService: ListingsService, + private readonly applicationCsvExporter: ApplicationCsvExporterService + ) {} + + @Get() + @ApiOperation({ summary: "List applications", operationId: "list" }) + async list( + @Query() queryParams: PaginatedApplicationListQueryParams + ): Promise { + return mapTo(PaginatedApplicationDto, await this.applicationsService.listPaginated(queryParams)) + } + + @Get(`csv`) + @ApiOperation({ summary: "List applications as csv", operationId: "listAsCsv" }) + @Header("Content-Type", "text/csv") + async listAsCsv( + @Query(new ValidationPipe(defaultValidationPipeOptions)) + queryParams: ApplicationsCsvListQueryParams + ): Promise { + const applications = await this.applicationsService.rawListWithFlagged(queryParams) + return this.applicationCsvExporter.exportFromObject( + applications, + queryParams.includeDemographics + ) + } + + @Post() + @ApiOperation({ summary: "Create application", operationId: "create" }) + async create(@Body() applicationCreateDto: ApplicationCreateDto): Promise { + const application = await this.applicationsService.create(applicationCreateDto) + return mapTo(ApplicationDto, application) + } + + @Get(`:id`) + @ApiOperation({ summary: "Get application by id", operationId: "retrieve" }) + async retrieve(@Param("id") applicationId: string): Promise { + const app = await this.applicationsService.findOne(applicationId) + return mapTo(ApplicationDto, app) + } + + @Put(`:id`) + @ApiOperation({ summary: "Update application by id", operationId: "update" }) + async update( + @Param("id") applicationId: string, + @Body() applicationUpdateDto: ApplicationUpdateDto + ): Promise { + return mapTo(ApplicationDto, await this.applicationsService.update(applicationUpdateDto)) + } + + @Delete(`:id`) + @ApiOperation({ summary: "Delete application by id", operationId: "delete" }) + async delete(@Param("id") applicationId: string) { + await this.applicationsService.delete(applicationId) + } +} diff --git a/backend/core/src/applications/applications.module.ts b/backend/core/src/applications/applications.module.ts new file mode 100644 index 0000000000..36db6b78fd --- /dev/null +++ b/backend/core/src/applications/applications.module.ts @@ -0,0 +1,37 @@ +import { Module } from "@nestjs/common" +import { TypeOrmModule } from "@nestjs/typeorm" +import { Application } from "./entities/application.entity" +import { ApplicationsController } from "./applications.controller" +import { AuthModule } from "../auth/auth.module" +import { SharedModule } from "../shared/shared.module" +import { ListingsModule } from "../listings/listings.module" +import { Address } from "../shared/entities/address.entity" +import { Applicant } from "./entities/applicant.entity" +import { ApplicationsSubmissionController } from "./applications-submission.controller" +import { ApplicationFlaggedSetsModule } from "../application-flagged-sets/application-flagged-sets.module" +import { TranslationsModule } from "../translations/translations.module" +import { Listing } from "../listings/entities/listing.entity" +import { ScheduleModule } from "@nestjs/schedule" +import { ApplicationsService } from "./services/applications.service" +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" + +@Module({ + imports: [ + TypeOrmModule.forFeature([Application, Applicant, Address, Listing]), + AuthModule, + ActivityLogModule, + SharedModule, + ListingsModule, + ApplicationFlaggedSetsModule, + TranslationsModule, + EmailModule, + ScheduleModule.forRoot(), + ], + providers: [ApplicationsService, CsvBuilder, ApplicationCsvExporterService], + exports: [ApplicationsService], + controllers: [ApplicationsController, ApplicationsSubmissionController], +}) +export class ApplicationsModule {} diff --git a/backend/core/src/applications/dto/accessibility.dto.ts b/backend/core/src/applications/dto/accessibility.dto.ts new file mode 100644 index 0000000000..3486049b3c --- /dev/null +++ b/backend/core/src/applications/dto/accessibility.dto.ts @@ -0,0 +1,36 @@ +import { OmitType } from "@nestjs/swagger" +import { Accessibility } from "../entities/accessibility.entity" +import { Expose, Type } from "class-transformer" +import { IsDate, IsOptional, IsUUID } from "class-validator" +import { ValidationsGroupsEnum } from "../../shared/types/validations-groups-enum" + +export class AccessibilityDto extends Accessibility {} + +export class AccessibilityCreateDto extends OmitType(AccessibilityDto, [ + "id", + "createdAt", + "updatedAt", +] as const) {} + +export class AccessibilityUpdateDto extends OmitType(AccessibilityDto, [ + "id", + "createdAt", + "updatedAt", +]) { + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsUUID(4, { groups: [ValidationsGroupsEnum.default] }) + id?: string + + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsDate({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => Date) + createdAt?: Date + + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsDate({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => Date) + updatedAt?: Date +} diff --git a/backend/core/src/applications/dto/alternate-contact.dto.ts b/backend/core/src/applications/dto/alternate-contact.dto.ts new file mode 100644 index 0000000000..1a5d7d2983 --- /dev/null +++ b/backend/core/src/applications/dto/alternate-contact.dto.ts @@ -0,0 +1,57 @@ +import { OmitType } from "@nestjs/swagger" +import { AlternateContact } from "../entities/alternate-contact.entity" +import { Expose, Type } from "class-transformer" +import { IsDate, IsDefined, IsOptional, IsUUID, ValidateNested } from "class-validator" +import { AddressCreateDto, AddressDto, AddressUpdateDto } from "../../shared/dto/address.dto" +import { ValidationsGroupsEnum } from "../../shared/types/validations-groups-enum" + +export class AlternateContactDto extends OmitType(AlternateContact, ["mailingAddress"]) { + @Expose() + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => AddressDto) + mailingAddress: AddressDto +} + +export class AlternateContactCreateDto extends OmitType(AlternateContactDto, [ + "id", + "createdAt", + "updatedAt", + "mailingAddress", +]) { + @Expose() + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => AddressCreateDto) + mailingAddress: AddressCreateDto +} + +export class AlternateContactUpdateDto extends OmitType(AlternateContactDto, [ + "id", + "createdAt", + "updatedAt", + "mailingAddress", +]) { + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsUUID(4, { groups: [ValidationsGroupsEnum.default] }) + id?: string + + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsDate({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => Date) + createdAt?: Date + + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsDate({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => Date) + updatedAt?: Date + + @Expose() + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => AddressUpdateDto) + mailingAddress: AddressUpdateDto +} diff --git a/backend/core/src/applications/dto/applicant.dto.ts b/backend/core/src/applications/dto/applicant.dto.ts new file mode 100644 index 0000000000..cf58035677 --- /dev/null +++ b/backend/core/src/applications/dto/applicant.dto.ts @@ -0,0 +1,77 @@ +import { OmitType } from "@nestjs/swagger" +import { Applicant } from "../entities/applicant.entity" +import { Expose, Type } from "class-transformer" +import { IsDate, IsDefined, IsOptional, IsUUID, ValidateNested } from "class-validator" +import { AddressCreateDto, AddressDto, AddressUpdateDto } from "../../shared/dto/address.dto" +import { ValidationsGroupsEnum } from "../../shared/types/validations-groups-enum" + +export class ApplicantDto extends OmitType(Applicant, ["address", "workAddress"] as const) { + @Expose() + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => AddressDto) + address: AddressDto + + @Expose() + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => AddressDto) + workAddress: AddressDto +} + +export class ApplicantCreateDto extends OmitType(ApplicantDto, [ + "id", + "createdAt", + "updatedAt", + "address", + "workAddress", +] as const) { + @Expose() + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => AddressCreateDto) + address: AddressCreateDto + + @Expose() + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => AddressCreateDto) + workAddress: AddressCreateDto +} + +export class ApplicantUpdateDto extends OmitType(ApplicantDto, [ + "id", + "createdAt", + "updatedAt", + "address", + "workAddress", +] as const) { + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsUUID(4, { groups: [ValidationsGroupsEnum.default] }) + id?: string + + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsDate({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => Date) + createdAt?: Date + + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsDate({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => Date) + updatedAt?: Date + + @Expose() + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => AddressUpdateDto) + address: AddressUpdateDto + + @Expose() + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => AddressUpdateDto) + workAddress: AddressUpdateDto +} diff --git a/backend/core/src/applications/dto/application-create.dto.ts b/backend/core/src/applications/dto/application-create.dto.ts new file mode 100644 index 0000000000..0de263e30b --- /dev/null +++ b/backend/core/src/applications/dto/application-create.dto.ts @@ -0,0 +1,86 @@ +import { OmitType } from "@nestjs/swagger" +import { Expose, Type } from "class-transformer" +import { ArrayMaxSize, IsDefined, ValidateNested } from "class-validator" +import { ValidationsGroupsEnum } from "../../shared/types/validations-groups-enum" +import { IdDto } from "../../shared/dto/id.dto" +import { ApplicantCreateDto } from "./applicant.dto" +import { AddressCreateDto } from "../../shared/dto/address.dto" +import { AlternateContactCreateDto } from "./alternate-contact.dto" +import { AccessibilityCreateDto } from "./accessibility.dto" +import { DemographicsCreateDto } from "./demographics.dto" +import { HouseholdMemberCreateDto } from "./household-member.dto" +import { ApplicationDto } from "./application.dto" + +export class ApplicationCreateDto extends OmitType(ApplicationDto, [ + "id", + "createdAt", + "updatedAt", + "deletedAt", + "applicant", + "listing", + "user", + "mailingAddress", + "alternateAddress", + "alternateContact", + "accessibility", + "demographics", + "householdMembers", + "markedAsDuplicate", + "preferredUnit", + "confirmationCode", +] as const) { + @Expose() + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => IdDto) + listing: IdDto + + @Expose() + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => ApplicantCreateDto) + applicant: ApplicantCreateDto + + @Expose() + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => AddressCreateDto) + mailingAddress: AddressCreateDto + + @Expose() + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => AddressCreateDto) + alternateAddress: AddressCreateDto + + @Expose() + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => AlternateContactCreateDto) + alternateContact: AlternateContactCreateDto + + @Expose() + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => AccessibilityCreateDto) + accessibility: AccessibilityCreateDto + + @Expose() + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => DemographicsCreateDto) + demographics: DemographicsCreateDto + + @Expose() + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default], each: true }) + @ArrayMaxSize(32, { groups: [ValidationsGroupsEnum.default] }) + @Type(() => HouseholdMemberCreateDto) + householdMembers: HouseholdMemberCreateDto[] + + @Expose() + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default], each: true }) + @Type(() => IdDto) + preferredUnit: IdDto[] +} diff --git a/backend/core/src/applications/dto/application-update.dto.ts b/backend/core/src/applications/dto/application-update.dto.ts new file mode 100644 index 0000000000..99268080f6 --- /dev/null +++ b/backend/core/src/applications/dto/application-update.dto.ts @@ -0,0 +1,115 @@ +import { OmitType } from "@nestjs/swagger" +import { Expose, Type } from "class-transformer" +import { + ArrayMaxSize, + IsDate, + IsDefined, + IsOptional, + IsUUID, + ValidateNested, +} from "class-validator" +import { ValidationsGroupsEnum } from "../../shared/types/validations-groups-enum" +import { IdDto } from "../../shared/dto/id.dto" +import { ApplicantUpdateDto } from "./applicant.dto" +import { AddressUpdateDto } from "../../shared/dto/address.dto" +import { AlternateContactUpdateDto } from "./alternate-contact.dto" +import { AccessibilityUpdateDto } from "./accessibility.dto" +import { DemographicsUpdateDto } from "./demographics.dto" +import { HouseholdMemberUpdateDto } from "./household-member.dto" +import { ApplicationDto } from "./application.dto" + +export class ApplicationUpdateDto extends OmitType(ApplicationDto, [ + "id", + "createdAt", + "updatedAt", + "deletedAt", + "applicant", + "listing", + "user", + "mailingAddress", + "alternateAddress", + "alternateContact", + "accessibility", + "demographics", + "householdMembers", + "markedAsDuplicate", + "preferredUnit", + "confirmationCode", +] as const) { + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsUUID(4, { groups: [ValidationsGroupsEnum.default] }) + id?: string + + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsDate({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => Date) + createdAt?: Date + + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsDate({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => Date) + updatedAt?: Date + + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsDate({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => Date) + deletedAt?: Date + + @Expose() + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => IdDto) + listing: IdDto + + @Expose() + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => ApplicantUpdateDto) + applicant: ApplicantUpdateDto + + @Expose() + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => AddressUpdateDto) + mailingAddress: AddressUpdateDto + + @Expose() + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => AddressUpdateDto) + alternateAddress: AddressUpdateDto + + @Expose() + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => AlternateContactUpdateDto) + alternateContact: AlternateContactUpdateDto + + @Expose() + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => AccessibilityUpdateDto) + accessibility: AccessibilityUpdateDto + + @Expose() + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => DemographicsUpdateDto) + demographics: DemographicsUpdateDto + + @Expose() + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default], each: true }) + @ArrayMaxSize(32, { groups: [ValidationsGroupsEnum.default] }) + @Type(() => HouseholdMemberUpdateDto) + householdMembers: HouseholdMemberUpdateDto[] + + @Expose() + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default], each: true }) + @Type(() => IdDto) + preferredUnit: IdDto[] +} diff --git a/backend/core/src/applications/dto/application.dto.ts b/backend/core/src/applications/dto/application.dto.ts new file mode 100644 index 0000000000..052d227418 --- /dev/null +++ b/backend/core/src/applications/dto/application.dto.ts @@ -0,0 +1,101 @@ +import { OmitType } from "@nestjs/swagger" +import { ArrayMaxSize, IsDefined, ValidateNested } from "class-validator" +import { Application } from "../entities/application.entity" +import { Expose, plainToClass, Transform, Type } from "class-transformer" +import { IdDto } from "../../shared/dto/id.dto" +import { ApplicantDto } from "./applicant.dto" +import { AddressDto } from "../../shared/dto/address.dto" +import { AlternateContactDto } from "./alternate-contact.dto" +import { DemographicsDto } from "./demographics.dto" +import { HouseholdMemberDto } from "./household-member.dto" +import { AccessibilityDto } from "./accessibility.dto" +import { ValidationsGroupsEnum } from "../../shared/types/validations-groups-enum" +import { UnitTypeDto } from "../../unit-types/dto/unit-type.dto" + +export class ApplicationDto extends OmitType(Application, [ + "listing", + "listingId", + "user", + "userId", + "applicant", + "mailingAddress", + "alternateAddress", + "alternateContact", + "accessibility", + "demographics", + "householdMembers", + "flagged", + "preferredUnit", +] as const) { + @Expose() + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => ApplicantDto) + applicant: ApplicantDto + + @Expose() + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => IdDto) + @Transform( + (value, obj) => { + return plainToClass(IdDto, { id: obj.listingId }) + }, + { toClassOnly: true } + ) + listing: IdDto + + @Expose() + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => IdDto) + @Transform( + (value, obj) => { + return obj.userId ? plainToClass(IdDto, { id: obj.userId }) : undefined + }, + { toClassOnly: true } + ) + user?: IdDto + + @Expose() + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => AddressDto) + mailingAddress: AddressDto + + @Expose() + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => AddressDto) + alternateAddress: AddressDto + + @Expose() + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => AlternateContactDto) + alternateContact: AlternateContactDto + + @Expose() + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => AccessibilityDto) + accessibility: AccessibilityDto + + @Expose() + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => DemographicsDto) + demographics: DemographicsDto + + @Expose() + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default], each: true }) + @ArrayMaxSize(32, { groups: [ValidationsGroupsEnum.default] }) + @Type(() => HouseholdMemberDto) + householdMembers: HouseholdMemberDto[] + + @Expose() + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default], each: true }) + @Type(() => UnitTypeDto) + preferredUnit: UnitTypeDto[] +} diff --git a/backend/core/src/applications/dto/applications-csv-list-query-params.ts b/backend/core/src/applications/dto/applications-csv-list-query-params.ts new file mode 100644 index 0000000000..6e4b8c8efc --- /dev/null +++ b/backend/core/src/applications/dto/applications-csv-list-query-params.ts @@ -0,0 +1,28 @@ +import { PaginatedApplicationListQueryParams } from "./paginated-application-list-query-params" +import { Expose, Transform } from "class-transformer" +import { ApiProperty, OmitType } from "@nestjs/swagger" +import { IsBoolean, IsOptional, IsUUID } from "class-validator" +import { ValidationsGroupsEnum } from "../../shared/types/validations-groups-enum" + +export class ApplicationsCsvListQueryParams extends OmitType(PaginatedApplicationListQueryParams, [ + "listingId", +]) { + @Expose() + @ApiProperty({ + type: String, + required: true, + }) + @IsUUID() + listingId: string + + @Expose() + @ApiProperty({ + type: Boolean, + example: true, + required: false, + }) + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsBoolean({ groups: [ValidationsGroupsEnum.default] }) + @Transform((value: string | undefined) => value === "true", { toClassOnly: true }) + includeDemographics?: boolean +} diff --git a/backend/core/src/applications/dto/demographics.dto.ts b/backend/core/src/applications/dto/demographics.dto.ts new file mode 100644 index 0000000000..c91897ecfc --- /dev/null +++ b/backend/core/src/applications/dto/demographics.dto.ts @@ -0,0 +1,36 @@ +import { OmitType } from "@nestjs/swagger" +import { Demographics } from "../entities/demographics.entity" +import { Expose, Type } from "class-transformer" +import { IsDate, IsOptional, IsUUID } from "class-validator" +import { ValidationsGroupsEnum } from "../../shared/types/validations-groups-enum" + +export class DemographicsDto extends OmitType(Demographics, [] as const) {} + +export class DemographicsCreateDto extends OmitType(DemographicsDto, [ + "id", + "createdAt", + "updatedAt", +] as const) {} + +export class DemographicsUpdateDto extends OmitType(DemographicsDto, [ + "id", + "createdAt", + "updatedAt", +]) { + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsUUID(4, { groups: [ValidationsGroupsEnum.default] }) + id?: string + + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsDate({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => Date) + createdAt?: Date + + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsDate({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => Date) + updatedAt?: Date +} diff --git a/backend/core/src/applications/dto/household-member.dto.ts b/backend/core/src/applications/dto/household-member.dto.ts new file mode 100644 index 0000000000..c9bbc63111 --- /dev/null +++ b/backend/core/src/applications/dto/household-member.dto.ts @@ -0,0 +1,81 @@ +import { OmitType } from "@nestjs/swagger" +import { Expose, Type } from "class-transformer" +import { IsDate, IsDefined, IsOptional, IsUUID, ValidateNested } from "class-validator" +import { AddressCreateDto, AddressDto, AddressUpdateDto } from "../../shared/dto/address.dto" +import { HouseholdMember } from "../entities/household-member.entity" +import { ValidationsGroupsEnum } from "../../shared/types/validations-groups-enum" + +export class HouseholdMemberDto extends OmitType(HouseholdMember, [ + "address", + "workAddress", + "application", +] as const) { + @Expose() + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => AddressDto) + address: AddressDto + + @Expose() + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => AddressDto) + workAddress: AddressDto +} + +export class HouseholdMemberCreateDto extends OmitType(HouseholdMemberDto, [ + "id", + "createdAt", + "updatedAt", + "address", + "workAddress", +] as const) { + @Expose() + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => AddressCreateDto) + address: AddressCreateDto + + @Expose() + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => AddressCreateDto) + workAddress: AddressCreateDto +} + +export class HouseholdMemberUpdateDto extends OmitType(HouseholdMemberDto, [ + "id", + "createdAt", + "updatedAt", + "address", + "workAddress", +] as const) { + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsUUID(4, { groups: [ValidationsGroupsEnum.default] }) + id?: string + + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsDate({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => Date) + createdAt?: Date + + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsDate({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => Date) + updatedAt?: Date + + @Expose() + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => AddressUpdateDto) + address: AddressUpdateDto + + @Expose() + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => AddressUpdateDto) + workAddress: AddressUpdateDto +} diff --git a/backend/core/src/applications/dto/housing-counselor.dto.ts b/backend/core/src/applications/dto/housing-counselor.dto.ts new file mode 100644 index 0000000000..457fe8eb92 --- /dev/null +++ b/backend/core/src/applications/dto/housing-counselor.dto.ts @@ -0,0 +1,40 @@ +import { Expose } from "class-transformer" +import { ArrayMaxSize, IsOptional, IsString, MaxLength } from "class-validator" +import { ValidationsGroupsEnum } from "../../shared/types/validations-groups-enum" + +export class HousingCounselor { + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @MaxLength(64, { groups: [ValidationsGroupsEnum.default] }) + name: string + + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default], each: true }) + @MaxLength(64, { groups: [ValidationsGroupsEnum.default], each: true }) + @ArrayMaxSize(16, { groups: [ValidationsGroupsEnum.default] }) + languages: string[] + + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @MaxLength(64, { groups: [ValidationsGroupsEnum.default] }) + address?: string | null + + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @MaxLength(16, { groups: [ValidationsGroupsEnum.default] }) + citystate?: string | null + + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @MaxLength(16, { groups: [ValidationsGroupsEnum.default] }) + phone?: string | null + + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @MaxLength(256, { groups: [ValidationsGroupsEnum.default] }) + website?: string | null +} diff --git a/backend/core/src/applications/dto/paginated-application-list-query-params.ts b/backend/core/src/applications/dto/paginated-application-list-query-params.ts new file mode 100644 index 0000000000..42e0a058e3 --- /dev/null +++ b/backend/core/src/applications/dto/paginated-application-list-query-params.ts @@ -0,0 +1,90 @@ +import { PaginationQueryParams } from "../../shared/dto/pagination.dto" +import { Expose, Transform } from "class-transformer" +import { ApiProperty } from "@nestjs/swagger" +import { IsBoolean, IsIn, IsOptional, IsString } from "class-validator" +import { ValidationsGroupsEnum } from "../../shared/types/validations-groups-enum" +import { OrderByParam } from "../types/order-by-param" +import { OrderParam } from "../types/order-param" + +export class PaginatedApplicationListQueryParams extends PaginationQueryParams { + @Expose() + @ApiProperty({ + type: String, + example: "listingId", + required: false, + }) + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + listingId?: string + + @Expose() + @ApiProperty({ + type: String, + example: "search", + required: false, + }) + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + search?: string + + @Expose() + @ApiProperty({ + type: String, + example: "userId", + required: false, + }) + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + userId?: string + + @Expose() + @ApiProperty({ + enum: Object.keys(OrderByParam), + example: "createdAt", + default: "createdAt", + required: false, + }) + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @IsIn(Object.values(OrderByParam), { groups: [ValidationsGroupsEnum.default] }) + @Transform((value: string | undefined) => + value ? (OrderByParam[value] ? OrderByParam[value] : value) : OrderByParam.createdAt + ) + orderBy?: OrderByParam + + @Expose() + @ApiProperty({ + enum: OrderParam, + example: "DESC", + default: "DESC", + required: false, + }) + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @IsIn(Object.keys(OrderParam), { groups: [ValidationsGroupsEnum.default] }) + @Transform((value: string | undefined) => (value ? value : OrderParam.DESC)) + order?: OrderParam + + @Expose() + @ApiProperty({ + type: Boolean, + example: true, + required: false, + }) + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsBoolean({ groups: [ValidationsGroupsEnum.default] }) + @Transform( + (value: string | undefined) => { + switch (value) { + case "true": + return true + case "false": + return false + default: + return undefined + } + }, + { toClassOnly: true } + ) + markedAsDuplicate?: boolean +} diff --git a/backend/core/src/applications/dto/paginated-application.dto.ts b/backend/core/src/applications/dto/paginated-application.dto.ts new file mode 100644 index 0000000000..74e7447d6d --- /dev/null +++ b/backend/core/src/applications/dto/paginated-application.dto.ts @@ -0,0 +1,4 @@ +import { PaginationFactory } from "../../shared/dto/pagination.dto" +import { ApplicationDto } from "./application.dto" + +export class PaginatedApplicationDto extends PaginationFactory(ApplicationDto) {} diff --git a/backend/core/src/applications/entities/accessibility.entity.ts b/backend/core/src/applications/entities/accessibility.entity.ts new file mode 100644 index 0000000000..5fe71bd120 --- /dev/null +++ b/backend/core/src/applications/entities/accessibility.entity.ts @@ -0,0 +1,26 @@ +import { Column, Entity } from "typeorm" +import { AbstractEntity } from "../../shared/entities/abstract.entity" +import { Expose } from "class-transformer" +import { IsBoolean, IsOptional } from "class-validator" +import { ValidationsGroupsEnum } from "../../shared/types/validations-groups-enum" + +@Entity() +export class Accessibility extends AbstractEntity { + @Column({ type: "boolean", nullable: true }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsBoolean({ groups: [ValidationsGroupsEnum.default] }) + mobility?: boolean | null + + @Column({ type: "boolean", nullable: true }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsBoolean({ groups: [ValidationsGroupsEnum.default] }) + vision?: boolean | null + + @Column({ type: "boolean", nullable: true }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsBoolean({ groups: [ValidationsGroupsEnum.default] }) + hearing?: boolean | null +} diff --git a/backend/core/src/applications/entities/alternate-contact.entity.ts b/backend/core/src/applications/entities/alternate-contact.entity.ts new file mode 100644 index 0000000000..72ec7e10f9 --- /dev/null +++ b/backend/core/src/applications/entities/alternate-contact.entity.ts @@ -0,0 +1,75 @@ +import { Column, Entity, JoinColumn, OneToOne } from "typeorm" +import { AbstractEntity } from "../../shared/entities/abstract.entity" +import { Expose, Type } from "class-transformer" +import { + IsDefined, + IsOptional, + IsString, + MaxLength, + ValidateNested, + IsEmail, +} from "class-validator" +import { Address } from "../../shared/entities/address.entity" +import { ValidationsGroupsEnum } from "../../shared/types/validations-groups-enum" +import { EnforceLowerCase } from "../../shared/decorators/enforceLowerCase.decorator" + +@Entity() +export class AlternateContact extends AbstractEntity { + @Column({ type: "text", nullable: true }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.partners] }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @MaxLength(16, { groups: [ValidationsGroupsEnum.default] }) + type?: string | null + + @Column({ type: "text", nullable: true }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @MaxLength(64, { groups: [ValidationsGroupsEnum.default] }) + otherType?: string | null + + @Column({ type: "text", nullable: true }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.partners] }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @MaxLength(64, { groups: [ValidationsGroupsEnum.default] }) + firstName?: string | null + + @Column({ type: "text", nullable: true }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.partners] }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @MaxLength(64, { groups: [ValidationsGroupsEnum.default] }) + lastName?: string | null + + @Column({ type: "text", nullable: true }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @MaxLength(128, { groups: [ValidationsGroupsEnum.default] }) + agency?: string | null + + @Column({ type: "text", nullable: true }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.partners] }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @MaxLength(16, { groups: [ValidationsGroupsEnum.default] }) + phoneNumber?: string | null + + @Column({ type: "text", nullable: true }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @IsEmail({}, { groups: [ValidationsGroupsEnum.default] }) + @EnforceLowerCase() + emailAddress?: string | null + + @OneToOne(() => Address, { eager: true, cascade: true }) + @JoinColumn() + @Expose() + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => Address) + mailingAddress: Address +} diff --git a/backend/core/src/applications/entities/applicant.entity.ts b/backend/core/src/applications/entities/applicant.entity.ts new file mode 100644 index 0000000000..1a86d85312 --- /dev/null +++ b/backend/core/src/applications/entities/applicant.entity.ts @@ -0,0 +1,119 @@ +import { Column, Entity, JoinColumn, OneToOne } from "typeorm" +import { AbstractEntity } from "../../shared/entities/abstract.entity" +import { Expose, Type } from "class-transformer" +import { + IsBoolean, + IsDefined, + IsIn, + IsOptional, + IsString, + MaxLength, + ValidateNested, + IsEmail, + MinLength, +} from "class-validator" +import { Address } from "../../shared/entities/address.entity" +import { ValidationsGroupsEnum } from "../../shared/types/validations-groups-enum" +import { EnforceLowerCase } from "../../shared/decorators/enforceLowerCase.decorator" + +@Entity() +export class Applicant extends AbstractEntity { + @Column({ type: "text", nullable: true }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.partners] }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @MaxLength(64, { groups: [ValidationsGroupsEnum.default] }) + @MinLength(1, { groups: [ValidationsGroupsEnum.default] }) + firstName?: string | null + + @Column({ type: "text", nullable: true }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.partners] }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @MaxLength(64, { groups: [ValidationsGroupsEnum.default] }) + middleName?: string | null + + @Column({ type: "text", nullable: true }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.partners] }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @MaxLength(64, { groups: [ValidationsGroupsEnum.default] }) + @MinLength(1, { groups: [ValidationsGroupsEnum.default] }) + lastName?: string | null + + @Column({ type: "text", nullable: true }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.partners] }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @MaxLength(8, { groups: [ValidationsGroupsEnum.default] }) + birthMonth?: string | null + + @Column({ type: "text", nullable: true }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.partners] }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @MaxLength(8, { groups: [ValidationsGroupsEnum.default] }) + birthDay?: string | null + + @Column({ type: "text", nullable: true }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.partners] }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @MaxLength(8, { groups: [ValidationsGroupsEnum.default] }) + birthYear?: string | null + + @Column({ type: "text", nullable: true }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsEmail({}, { groups: [ValidationsGroupsEnum.default] }) + @EnforceLowerCase() + emailAddress?: string | null + + @Column({ type: "bool", nullable: true }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsBoolean({ groups: [ValidationsGroupsEnum.default] }) + noEmail?: boolean | null + + @Column({ type: "text", nullable: true }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.partners] }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @MaxLength(16, { groups: [ValidationsGroupsEnum.default] }) + phoneNumber?: string | null + + @Column({ type: "text", nullable: true }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.partners] }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @MaxLength(16, { groups: [ValidationsGroupsEnum.default] }) + phoneNumberType?: string | null + + @Column({ type: "bool", nullable: true }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsBoolean({ groups: [ValidationsGroupsEnum.default] }) + noPhone?: boolean | null + + @Column({ type: "text", nullable: true }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsIn(["yes", "no"], { groups: [ValidationsGroupsEnum.default] }) + workInRegion?: string | null + + @OneToOne(() => Address, { eager: true, cascade: true }) + @JoinColumn() + @Expose() + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => Address) + workAddress: Address + + @OneToOne(() => Address, { eager: true, cascade: true }) + @JoinColumn() + @Expose() + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => Address) + address: Address +} diff --git a/backend/core/src/applications/entities/application-preferences.entity.ts b/backend/core/src/applications/entities/application-preferences.entity.ts new file mode 100644 index 0000000000..6cad2625da --- /dev/null +++ b/backend/core/src/applications/entities/application-preferences.entity.ts @@ -0,0 +1,21 @@ +import { Expose, Type } from "class-transformer" +import { ArrayMaxSize, IsBoolean, IsString, MaxLength, ValidateNested } from "class-validator" +import { ValidationsGroupsEnum } from "../../shared/types/validations-groups-enum" +import { ApplicationPreferenceOption } from "../types/application-preference-option" + +export class ApplicationPreference { + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @MaxLength(128, { groups: [ValidationsGroupsEnum.default] }) + key: string + + @Expose() + @IsBoolean({ groups: [ValidationsGroupsEnum.default] }) + claimed: boolean + + @Expose() + @ArrayMaxSize(64, { groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default], each: true }) + @Type(() => ApplicationPreferenceOption) + options: Array +} diff --git a/backend/core/src/applications/entities/application-program.entity.ts b/backend/core/src/applications/entities/application-program.entity.ts new file mode 100644 index 0000000000..b26a16fec1 --- /dev/null +++ b/backend/core/src/applications/entities/application-program.entity.ts @@ -0,0 +1,21 @@ +import { Expose, Type } from "class-transformer" +import { ArrayMaxSize, IsBoolean, IsString, MaxLength, ValidateNested } from "class-validator" +import { ValidationsGroupsEnum } from "../../shared/types/validations-groups-enum" +import { ApplicationProgramOption } from "../types/application-program-option" + +export class ApplicationProgram { + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @MaxLength(128, { groups: [ValidationsGroupsEnum.default] }) + key: string + + @Expose() + @IsBoolean({ groups: [ValidationsGroupsEnum.default] }) + claimed: boolean + + @Expose() + @ArrayMaxSize(64, { groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default], each: true }) + @Type(() => ApplicationProgramOption) + options: Array +} diff --git a/backend/core/src/applications/entities/application.entity.ts b/backend/core/src/applications/entities/application.entity.ts new file mode 100644 index 0000000000..4d1ed0ade5 --- /dev/null +++ b/backend/core/src/applications/entities/application.entity.ts @@ -0,0 +1,282 @@ +import { + Column, + DeleteDateColumn, + Entity, + Index, + JoinColumn, + JoinTable, + ManyToMany, + ManyToOne, + OneToMany, + OneToOne, + RelationId, + Unique, +} from "typeorm" +import { User } from "../../auth/entities/user.entity" +import { Listing } from "../../listings/entities/listing.entity" +import { + ArrayMaxSize, + IsBoolean, + IsDate, + IsDefined, + IsEnum, + IsNumber, + IsOptional, + IsString, + MaxLength, + ValidateNested, +} from "class-validator" +import { Expose, Type } from "class-transformer" +import { AbstractEntity } from "../../shared/entities/abstract.entity" +import { Applicant } from "./applicant.entity" +import { Address } from "../../shared/entities/address.entity" +import { AlternateContact } from "./alternate-contact.entity" +import { Accessibility } from "./accessibility.entity" +import { Demographics } from "./demographics.entity" +import { HouseholdMember } from "./household-member.entity" +import { ApiProperty } from "@nestjs/swagger" +import { ValidationsGroupsEnum } from "../../shared/types/validations-groups-enum" +import { ApplicationPreference } from "./application-preferences.entity" +import { Language } from "../../shared/types/language-enum" +import { ApplicationStatus } from "../types/application-status-enum" +import { ApplicationSubmissionType } from "../types/application-submission-type-enum" +import { IncomePeriod } from "../types/income-period-enum" +import { UnitType } from "../../unit-types/entities/unit-type.entity" +import { ApplicationProgram } from "./application-program.entity" + +@Entity({ name: "applications" }) +@Unique(["listing", "confirmationCode"]) +@Index(["listing"]) +export class Application extends AbstractEntity { + @DeleteDateColumn() + @Expose() + @IsOptional() + @IsDate({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => Date) + deletedAt?: Date | null + + @Column({ type: "text", nullable: true }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.partners] }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @MaxLength(256, { groups: [ValidationsGroupsEnum.default] }) + appUrl?: string | null + + @ManyToOne(() => User, { nullable: true, onDelete: "SET NULL" }) + user?: User | null + + @RelationId((application: Application) => application.user) + @Expose() + userId?: string + + @ManyToOne(() => Listing, (listing) => listing.applications) + listing: Listing + + @RelationId((application: Application) => application.listing) + @Expose() + listingId: string + + @OneToOne(() => Applicant, { eager: true, cascade: true }) + @JoinColumn() + @Expose() + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => Applicant) + applicant: Applicant + + @Column({ type: "bool", nullable: true }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.partners] }) + @IsBoolean({ groups: [ValidationsGroupsEnum.default] }) + additionalPhone?: boolean | null + + @Column({ type: "text", nullable: true }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.partners] }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @MaxLength(16, { groups: [ValidationsGroupsEnum.default] }) + additionalPhoneNumber?: string | null + + @Column({ type: "text", nullable: true }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.partners] }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @MaxLength(16, { groups: [ValidationsGroupsEnum.default] }) + additionalPhoneNumberType?: string | null + + @Column("text", { array: true }) + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default], each: true }) + @ArrayMaxSize(8, { groups: [ValidationsGroupsEnum.default] }) + @MaxLength(64, { groups: [ValidationsGroupsEnum.default], each: true }) + contactPreferences: string[] + + @Column({ type: "integer", nullable: true }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.partners] }) + @IsNumber({}, { groups: [ValidationsGroupsEnum.default] }) + householdSize?: number | null + + @Column({ type: "text", nullable: true }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.partners] }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @MaxLength(16, { groups: [ValidationsGroupsEnum.default] }) + housingStatus?: string | null + + @Column({ type: "bool", nullable: true }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.partners] }) + @IsBoolean({ groups: [ValidationsGroupsEnum.default] }) + sendMailToMailingAddress?: boolean | null + + @OneToOne(() => Address, { eager: true, cascade: true }) + @JoinColumn() + @Expose() + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => Address) + mailingAddress: Address + + @OneToOne(() => Address, { eager: true, cascade: true }) + @JoinColumn() + @Expose() + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => Address) + alternateAddress: Address + + @OneToOne(() => AlternateContact, { eager: true, cascade: true }) + @JoinColumn() + @Expose() + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => AlternateContact) + alternateContact: AlternateContact + + @OneToOne(() => Accessibility, { eager: true, cascade: true }) + @JoinColumn() + @Expose() + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => Accessibility) + accessibility: Accessibility + + @OneToOne(() => Demographics, { eager: true, cascade: true }) + @JoinColumn() + @Expose() + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => Demographics) + demographics: Demographics + + @Column({ type: "bool", nullable: true }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.partners] }) + @IsBoolean({ groups: [ValidationsGroupsEnum.default] }) + householdExpectingChanges?: boolean | null + + @Column({ type: "bool", nullable: true }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.partners] }) + @IsBoolean({ groups: [ValidationsGroupsEnum.default] }) + householdStudent?: boolean | null + + @Column({ type: "bool", nullable: true }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.partners] }) + @IsBoolean({ groups: [ValidationsGroupsEnum.default] }) + incomeVouchers?: boolean | null + + @Column({ type: "text", nullable: true }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.partners] }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @MaxLength(64, { groups: [ValidationsGroupsEnum.default] }) + income?: string | null + + @Column({ enum: IncomePeriod, nullable: true }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.partners] }) + @IsEnum(IncomePeriod, { groups: [ValidationsGroupsEnum.default] }) + @ApiProperty({ enum: IncomePeriod, enumName: "IncomePeriod" }) + incomePeriod?: IncomePeriod | null + + @OneToMany(() => HouseholdMember, (householdMember) => householdMember.application, { + eager: true, + cascade: true, + }) + @Expose() + @ValidateNested({ groups: [ValidationsGroupsEnum.default], each: true }) + @ArrayMaxSize(32, { groups: [ValidationsGroupsEnum.default] }) + @Type(() => HouseholdMember) + householdMembers: HouseholdMember[] + + @ManyToMany(() => UnitType, { eager: true, cascade: true }) + @JoinTable() + @Type(() => UnitType) + preferredUnit: UnitType[] + + @Column({ type: "jsonb" }) + @Expose() + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ArrayMaxSize(64, { groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default], each: true }) + @Type(() => ApplicationPreference) + preferences: ApplicationPreference[] + + @Column({ type: "jsonb", nullable: true }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @ArrayMaxSize(64, { groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default], each: true }) + @Type(() => ApplicationProgram) + programs?: ApplicationProgram[] + + @Column({ enum: ApplicationStatus }) + @Expose() + @IsEnum(ApplicationStatus, { groups: [ValidationsGroupsEnum.default] }) + @ApiProperty({ enum: ApplicationStatus, enumName: "ApplicationStatus" }) + status: ApplicationStatus + + @Column({ enum: Language, nullable: true }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.partners] }) + @IsEnum(Language, { groups: [ValidationsGroupsEnum.default] }) + @ApiProperty({ enum: Language, enumName: "Language" }) + language?: Language | null + + @Column({ enum: ApplicationSubmissionType }) + @Expose() + @IsEnum(ApplicationSubmissionType, { groups: [ValidationsGroupsEnum.default] }) + @ApiProperty({ enum: ApplicationSubmissionType, enumName: "ApplicationSubmissionType" }) + submissionType: ApplicationSubmissionType + + @Column({ type: "bool", nullable: true }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsBoolean({ groups: [ValidationsGroupsEnum.default] }) + acceptedTerms?: boolean | null + + @Column({ type: "timestamptz", nullable: true }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsDate({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => Date) + submissionDate?: Date | null + + @Column({ type: "bool", default: false }) + @Expose() + @IsBoolean({ groups: [ValidationsGroupsEnum.default] }) + markedAsDuplicate: boolean + + // This is a 'virtual field' needed for CSV export + @Expose() + @IsBoolean({ groups: [ValidationsGroupsEnum.default] }) + @IsOptional({ groups: [ValidationsGroupsEnum.partners] }) + flagged?: boolean + + @Column({ type: "text", nullable: false }) + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + confirmationCode: string +} diff --git a/backend/core/src/applications/entities/demographics.entity.ts b/backend/core/src/applications/entities/demographics.entity.ts new file mode 100644 index 0000000000..939a0081bc --- /dev/null +++ b/backend/core/src/applications/entities/demographics.entity.ts @@ -0,0 +1,43 @@ +import { Column, Entity } from "typeorm" +import { AbstractEntity } from "../../shared/entities/abstract.entity" +import { Expose } from "class-transformer" +import { ArrayMaxSize, IsOptional, IsString, MaxLength } from "class-validator" +import { ValidationsGroupsEnum } from "../../shared/types/validations-groups-enum" + +@Entity() +export class Demographics extends AbstractEntity { + @Column({ type: "text", nullable: true }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.partners] }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @MaxLength(64, { groups: [ValidationsGroupsEnum.default] }) + ethnicity?: string | null + + @Column({ type: "text", nullable: true }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.partners] }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @MaxLength(64, { groups: [ValidationsGroupsEnum.default] }) + gender?: string | null + + @Column({ type: "text", nullable: true }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.partners] }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @MaxLength(64, { groups: [ValidationsGroupsEnum.default] }) + sexualOrientation?: string | null + + @Column({ array: true, type: "text" }) + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default], each: true }) + @ArrayMaxSize(64, { groups: [ValidationsGroupsEnum.default] }) + @MaxLength(64, { groups: [ValidationsGroupsEnum.default], each: true }) + howDidYouHear: string[] + + @Column({ array: true, type: "text", nullable: true }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.partners] }) + @ArrayMaxSize(64, { groups: [ValidationsGroupsEnum.default] }) + @IsString({ groups: [ValidationsGroupsEnum.default], each: true }) + race?: string[] | null +} diff --git a/backend/core/src/applications/entities/household-member.entity.ts b/backend/core/src/applications/entities/household-member.entity.ts new file mode 100644 index 0000000000..6663147d44 --- /dev/null +++ b/backend/core/src/applications/entities/household-member.entity.ts @@ -0,0 +1,139 @@ +import { Column, Entity, Index, JoinColumn, ManyToOne, OneToOne } from "typeorm" +import { AbstractEntity } from "../../shared/entities/abstract.entity" +import { Expose, Type } from "class-transformer" +import { + IsBoolean, + IsDefined, + IsIn, + IsNumber, + IsOptional, + IsString, + MaxLength, + ValidateNested, +} from "class-validator" +import { Address } from "../../shared/entities/address.entity" +import { Application } from "./application.entity" +import { ValidationsGroupsEnum } from "../../shared/types/validations-groups-enum" +import { EnforceLowerCase } from "../../shared/decorators/enforceLowerCase.decorator" +@Entity() +@Index(["application"]) +export class HouseholdMember extends AbstractEntity { + @Column({ nullable: true }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsNumber({}, { groups: [ValidationsGroupsEnum.default] }) + orderId?: number | null + + @OneToOne(() => Address, { eager: true, cascade: true }) + @JoinColumn() + @Expose() + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => Address) + address: Address + + @Column({ type: "text", nullable: true }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.partners] }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @MaxLength(64, { groups: [ValidationsGroupsEnum.default] }) + firstName?: string | null + + @Column({ type: "text", nullable: true }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.partners] }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @MaxLength(64, { groups: [ValidationsGroupsEnum.default] }) + middleName?: string | null + + @Column({ type: "text", nullable: true }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.partners] }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @MaxLength(64, { groups: [ValidationsGroupsEnum.default] }) + lastName?: string | null + + @Column({ type: "text", nullable: true }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.partners] }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @MaxLength(8, { groups: [ValidationsGroupsEnum.default] }) + birthMonth?: string | null + + @Column({ type: "text", nullable: true }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.partners] }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @MaxLength(8, { groups: [ValidationsGroupsEnum.default] }) + birthDay?: string | null + + @Column({ type: "text", nullable: true }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.partners] }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @MaxLength(8, { groups: [ValidationsGroupsEnum.default] }) + birthYear?: string | null + + @Column({ type: "text", nullable: true }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.partners] }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @EnforceLowerCase() + emailAddress?: string | null + + @Column({ nullable: true, type: "boolean" }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsBoolean({ groups: [ValidationsGroupsEnum.default] }) + noEmail?: boolean | null + + @Column({ type: "text", nullable: true }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.partners] }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @MaxLength(16, { groups: [ValidationsGroupsEnum.default] }) + phoneNumber?: string | null + + @Column({ type: "text", nullable: true }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.partners] }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @MaxLength(16, { groups: [ValidationsGroupsEnum.default] }) + phoneNumberType?: string | null + + @Column({ type: "boolean", nullable: true }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsBoolean({ groups: [ValidationsGroupsEnum.default] }) + noPhone?: boolean | null + + @Column({ type: "text", nullable: true }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsIn(["yes", "no"], { groups: [ValidationsGroupsEnum.default] }) + sameAddress?: string | null + + @Column({ type: "text", nullable: true }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @MaxLength(64, { groups: [ValidationsGroupsEnum.default] }) + relationship?: string | null + + @Column({ type: "text", nullable: true }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsIn(["yes", "no"], { groups: [ValidationsGroupsEnum.default] }) + workInRegion?: string | null + + @OneToOne(() => Address, { eager: true, cascade: true }) + @JoinColumn() + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => Address) + workAddress?: Address | null + + @ManyToOne(() => Application, (application) => application.householdMembers) + application: Application +} diff --git a/backend/core/src/applications/services/application-csv-exporter.service.ts b/backend/core/src/applications/services/application-csv-exporter.service.ts new file mode 100644 index 0000000000..a64a5a4b46 --- /dev/null +++ b/backend/core/src/applications/services/application-csv-exporter.service.ts @@ -0,0 +1,265 @@ +import { Injectable, Scope } from "@nestjs/common" +import dayjs from "dayjs" +import { CsvBuilder, KeyNumber } from "./csv-builder.service" +import { getBirthday } from "../../shared/utils/get-birthday" +import { formatBoolean } from "../../shared/utils/format-boolean" +import { capitalizeFirstLetter } from "../../shared/utils/capitalize-first-letter" +import { capAndSplit } from "../../shared/utils/cap-and-split" +import { ApplicationProgram } from "../entities/application-program.entity" +import { ApplicationPreference } from "../entities/application-preferences.entity" +import { AddressCreateDto } from "../../shared/dto/address.dto" + +@Injectable({ scope: Scope.REQUEST }) +export class ApplicationCsvExporterService { + constructor(private readonly csvBuilder: CsvBuilder) {} + + mapHouseholdMembers(app) { + const obj = { + "First Name": app.householdMembers_first_name, + "Middle Name": app.householdMembers_middle_name, + "Last Name": app.householdMembers_last_name, + Birthday: getBirthday( + app.householdMembers_birth_day, + app.householdMembers_birth_month, + app.householdMembers_birth_year + ), + "Same Address as Primary Applicant": formatBoolean(app.householdMembers_same_address), + Relationship: app.householdMembers_relationship, + "Work in Region": formatBoolean(app.householdMembers_work_in_region), + Street: app.householdMembers_address_street, + "Street 2": app.householdMembers_address_street2, + City: app.householdMembers_address_city, + State: app.householdMembers_address_state, + "Zip Code": app.householdMembers_address_zip_code, + } + return obj + } + + // could use translations + unitTypeToReadable(type) { + const typeMap = { + SRO: "SRO", + studio: "Studio", + oneBdrm: "One Bedroom", + twoBdrm: "Two Bedroom", + threeBdrm: "Three Bedroom", + fourBdrm: "Four+ Bedroom", + } + return typeMap[type] ?? type + } + + raceToReadable(type) { + const [rootKey, customValue = ""] = type.split(":") + const typeMap = { + americanIndianAlaskanNative: "American Indian / Alaskan Native", + asian: "Asian", + "asian-asianIndian": "Asian[Asian Indian]", + "asian-otherAsian": `Asian[Other Asian:${customValue}]`, + blackAfricanAmerican: "Black / African American", + "asian-chinese": "Asian[Chinese]", + declineToRespond: "Decline to Respond", + "asian-filipino": "Asian[Filipino]", + "nativeHawaiianOtherPacificIslander-guamanianOrChamorro": + "Native Hawaiian / Other Pacific Islander[Guamanian or Chamorro]", + "asian-japanese": "Asian[Japanese]", + "asian-korean": "Asian[Korean]", + "nativeHawaiianOtherPacificIslander-nativeHawaiian": + "Native Hawaiian / Other Pacific Islander[Native Hawaiian]", + nativeHawaiianOtherPacificIslander: "Native Hawaiian / Other Pacific Islander", + otherMultiracial: `Other / Multiracial:${customValue}`, + "nativeHawaiianOtherPacificIslander-otherPacificIslander": `Native Hawaiian / Other Pacific Islander[Other Pacific Islander:${customValue}]`, + "nativeHawaiianOtherPacificIslander-samoan": + "Native Hawaiian / Other Pacific Islander[Samoan]", + "asian-vietnamese": "Asian[Vietnamese]", + white: "White", + } + return typeMap[rootKey] ?? rootKey + } + + buildProgram(items: ApplicationProgram[], programKeys: KeyNumber) { + return this.buildPreference(items, programKeys) + } + + buildPreference( + items: ApplicationPreference[] | ApplicationProgram[], + preferenceKeys: KeyNumber + ) { + if (!items) { + return {} + } + + return items.reduce((obj, preference) => { + const root = capAndSplit(preference.key) + preference.options.forEach((option) => { + // TODO: remove temporary patch + if (option.key === "residencyNoColiseum") { + option.key = "residency" + } + const key = `${root}: ${capAndSplit(option.key)}` + preferenceKeys[key] = 1 + if (option.checked) { + obj[key] = "claimed" + } + if (option.extraData?.length) { + const extraKey = `${key} - ${option.extraData.map((obj) => obj.key).join(" and ")}` + let extraString = "" + option.extraData.forEach((extra) => { + if (extra.type === "text") { + extraString += `${capitalizeFirstLetter(extra.key)}: ${extra.value as string}, ` + } else if (extra.type === "address") { + extraString += `Street: ${(extra.value as AddressCreateDto).street}, Street 2: ${ + (extra.value as AddressCreateDto).street2 + }, City: ${(extra.value as AddressCreateDto).city}, State: ${ + (extra.value as AddressCreateDto).state + }, Zip Code: ${(extra.value as AddressCreateDto).zipCode}` + } + }) + preferenceKeys[extraKey] = 1 + obj[extraKey] = extraString + } + }) + return obj + }, {}) + } + + exportFromObject(applications: { [key: string]: any }, includeDemographics?: boolean): string { + const extraHeaders: KeyNumber = { + "Household Members": 1, + Preference: 1, + Program: 1, + } + const preferenceKeys: KeyNumber = {} + const programKeys: KeyNumber = {} + const applicationsObj = applications.reduce((obj, app) => { + let demographics = {} + + if (obj[app.application_id] === undefined) { + if (includeDemographics) { + demographics = { + Ethnicity: app.demographics_ethnicity, + Race: app.demographics_race.map((race) => this.raceToReadable(race)), + "How Did You Hear": app.demographics_how_did_you_hear.join(", "), + } + } + + obj[app.application_id] = { + "Application Id": app.application_id, + "Application Confirmation Code": app.application_confirmation_code, + "Application Type": + app.application_submission_type === "electronical" + ? "electronic" + : app.application_submission_type, + "Application Submission Date": dayjs(app.application_submission_date).format( + "MM-DD-YYYY h:mm:ssA" + ), + "Primary Applicant First Name": app.applicant_first_name, + "Primary Applicant Middle Name": app.applicant_middle_name, + "Primary Applicant Last Name": app.applicant_last_name, + "Primary Applicant Birthday": getBirthday( + app.applicant_birth_day, + app.applicant_birth_month, + app.applicant_birth_year + ), + "Primary Applicant Email Address": app.applicant_email_address, + "Primary Applicant Phone Number": app.applicant_phone_number, + "Primary Applicant Phone Type": app.applicant_phone_number_type, + "Primary Applicant Additional Phone Number": app.application_additional_phone_number, + "Primary Applicant Preferred Contact Type": app.application_contact_preferences.join(","), + "Primary Applicant Street": app.applicant_address_street, + "Primary Applicant Street 2": app.applicant_address_street2, + "Primary Applicant City": app.applicant_address_city, + "Primary Applicant State": app.applicant_address_state, + "Primary Applicant Zip Code": app.applicant_address_zip_code, + "Primary Applicant Mailing Street": app.mailingAddress_street, + "Primary Applicant Mailing Street 2": app.mailingAddress_street2, + "Primary Applicant Mailing City": app.mailingAddress_city, + "Primary Applicant Mailing State": app.mailingAddress_state, + "Primary Applicant Mailing Zip Code": app.mailingAddress_zip_code, + "Primary Applicant Work Street": app.applicant_workAddress_street, + "Primary Applicant Work Street 2": app.applicant_workAddress_street2, + "Primary Applicant Work City": app.applicant_workAddress_city, + "Primary Applicant Work State": app.applicant_workAddress_state, + "Primary Applicant Work Zip Code": app.applicant_workAddress_zip_code, + "Alternate Contact First Name": app.alternateContact_first_name, + "Alternate Contact Middle Name": app.alternateContact_middle_name, + "Alternate Contact Last Name": app.alternateContact_last_name, + "Alternate Contact Type": app.alternateContact_type, + "Alternate Contact Agency": app.alternateContact_agency, + "Alternate Contact Other Type": app.alternateContact_other_type, + "Alternate Contact Email Address": app.alternateContact_email_address, + "Alternate Contact Phone Number": app.alternateContact_phone_number, + "Alternate Contact Street": app.alternateContact_mailingAddress_street, + "Alternate Contact Street 2": app.alternateContact_mailingAddress_street2, + "Alternate Contact City": app.alternateContact_mailingAddress_city, + "Alternate Contact State": app.alternateContact_mailingAddress_state, + "Alternate Contact Zip Code": app.alternateContact_mailingAddress_zip_code, + Income: app.application_income, + "Income Period": app.application_income_period === "perMonth" ? "per month" : "per year", + "Accessibility Mobility": formatBoolean(app.accessibility_mobility), + "Accessibility Vision": formatBoolean(app.accessibility_vision), + "Accessibility Hearing": formatBoolean(app.accessibility_hearing), + "Expecting Household Changes": formatBoolean(app.application_household_expecting_changes), + "Household Includes Student or Member Nearing 18": formatBoolean( + app.application_household_student + ), + "Vouchers or Subsidies": formatBoolean(app.application_income_vouchers), + "Requested Unit Types": { + [app.preferredUnit_id]: this.unitTypeToReadable(app.preferredUnit_name), + }, + Preference: this.buildPreference(app.application_preferences, preferenceKeys), + Program: this.buildProgram(app.application_programs, programKeys), + "Household Size": app.application_household_size, + "Household Members": { + [app.householdMembers_id]: this.mapHouseholdMembers(app), + }, + "Marked As Duplicate": formatBoolean(app.application_marked_as_duplicate), + "Flagged As Duplicate": formatBoolean(app.flagged), + ...demographics, + } + /** + * For all conditionals below, these are for mapping the n-many relationships that applications have (since we're getting the raw query). + * While we're going through here, keep track of the extra keys created, so we don't have to loop through an extra time to create the headers + */ + } else if ( + obj[app.application_id]["Household Members"][app.householdMembers_id] === undefined + ) { + obj[app.application_id]["Household Members"][ + app.householdMembers_id + ] = this.mapHouseholdMembers(app) + extraHeaders["Household Members"] = Math.max( + extraHeaders["Household Members"], + Object.keys(obj[app.application_id]["Household Members"]).length + ) + } else if ( + obj[app.application_id]["Requested Unit Types"][app.preferredUnit_id] === undefined + ) { + obj[app.application_id]["Requested Unit Types"][ + app.preferredUnit_id + ] = this.unitTypeToReadable(app.preferredUnit_name) + } + return obj + }, {}) + + // eslint-disable-next-line @typescript-eslint/no-this-alias + const self = this + function extraGroupKeys(group, obj) { + const groups = { + "Household Members": { + nested: true, + keys: Object.keys(self.mapHouseholdMembers(obj)), + }, + Preference: { + nested: false, + keys: Object.keys(preferenceKeys), + }, + Program: { + nested: false, + keys: Object.keys(programKeys), + }, + } + return groups[group] + } + + return this.csvBuilder.buildFromIdIndex(applicationsObj, extraHeaders, extraGroupKeys) + } +} diff --git a/backend/core/src/applications/services/applications.service.ts b/backend/core/src/applications/services/applications.service.ts new file mode 100644 index 0000000000..a33c1e6323 --- /dev/null +++ b/backend/core/src/applications/services/applications.service.ts @@ -0,0 +1,336 @@ +import { + BadRequestException, + HttpException, + HttpStatus, + Inject, + Injectable, + NotFoundException, + Scope, +} from "@nestjs/common" +import { InjectRepository } from "@nestjs/typeorm" +import { DeepPartial, QueryFailedError, Repository } from "typeorm" +import { paginate, Pagination, PaginationTypeEnum } from "nestjs-typeorm-paginate" +import { Request as ExpressRequest } from "express" +import { REQUEST } from "@nestjs/core" +import retry from "async-retry" +import crypto from "crypto" +import { ApplicationFlaggedSetsService } from "../../application-flagged-sets/application-flagged-sets.service" +import { AuthzService } from "../../auth/services/authz.service" +import { ListingsService } from "../../listings/listings.service" +import { Application } from "../entities/application.entity" +import { Listing } from "../../listings/entities/listing.entity" +import { authzActions } from "../../auth/enum/authz-actions.enum" +import { assignDefined } from "../../shared/utils/assign-defined" +import { EmailService } from "../../email/email.service" +import { getView } from "../views/view" +import { PaginatedApplicationListQueryParams } from "../dto/paginated-application-list-query-params" +import { ApplicationCreateDto } from "../dto/application-create.dto" +import { ApplicationUpdateDto } from "../dto/application-update.dto" +import { ApplicationsCsvListQueryParams } from "../dto/applications-csv-list-query-params" + +@Injectable({ scope: Scope.REQUEST }) +export class ApplicationsService { + constructor( + @Inject(REQUEST) private req: ExpressRequest, + private readonly applicationFlaggedSetsService: ApplicationFlaggedSetsService, + private readonly authzService: AuthzService, + private readonly listingsService: ListingsService, + private readonly emailService: EmailService, + @InjectRepository(Application) private readonly repository: Repository, + @InjectRepository(Listing) private readonly listingsRepository: Repository+ ) {} + + public async list(params: PaginatedApplicationListQueryParams) { + const qb = this._getQb(params) + const result = await qb.getMany() + await Promise.all( + result.map(async (application) => { + await this.authorizeUserAction(this.req.user, application, authzActions.read) + }) + ) + return result + } + + public async rawListWithFlagged(params: ApplicationsCsvListQueryParams) { + await this.authorizeCSVExport(this.req.user, params.listingId) + const qb = this._getQb(params) + qb.leftJoin( + "application_flagged_set_applications_applications", + "application_flagged_set_applications_applications", + "application_flagged_set_applications_applications.applications_id = application.id" + ) + qb.addSelect( + "count(application_flagged_set_applications_applications.applications_id) > 0 as flagged" + ) + qb.groupBy( + "application.id, applicant.id, applicant_address.id, applicant_workAddress.id, alternateAddress.id, mailingAddress.id, alternateContact.id, alternateContact_mailingAddress.id, accessibility.id, demographics.id, householdMembers.id, householdMembers_address.id, householdMembers_workAddress.id, preferredUnit.id" + ) + const applications = await qb.getRawMany() + + return applications + } + + async listPaginated( + params: PaginatedApplicationListQueryParams + ): Promise> { + const qb = this._getQb(params, params.listingId ? "partnerList" : undefined) + + const applicationIDQB = this._getQb(params, params.listingId ? "partnerList" : undefined, false) + applicationIDQB.select("application.id") + applicationIDQB.groupBy("application.id") + if (params.orderBy) { + applicationIDQB.addSelect(params.orderBy) + applicationIDQB.addGroupBy(params.orderBy) + } + const applicationIDResult = await paginate(applicationIDQB, { + limit: params.limit, + page: params.page, + paginationType: PaginationTypeEnum.TAKE_AND_SKIP, + }) + + if (applicationIDResult.items.length) { + qb.andWhere("application.id IN (:...applicationIDs)", { + applicationIDs: applicationIDResult.items.map((elem) => elem.id), + }) + } + + const result = await qb.getMany() + + await Promise.all( + result.map(async (application) => { + await this.authorizeUserAction(this.req.user, application, authzActions.read) + }) + ) + return { + ...applicationIDResult, + items: result, + } + } + + async submit(applicationCreateDto: ApplicationCreateDto) { + applicationCreateDto.submissionDate = new Date() + const listing = await this.listingsRepository + .createQueryBuilder("listings") + .where(`listings.id = :listingId`, { listingId: applicationCreateDto.listing.id }) + .select("listings.applicationDueDate") + .getOne() + if ( + listing && + listing.applicationDueDate && + applicationCreateDto.submissionDate > listing.applicationDueDate + ) { + throw new BadRequestException("Listing is not open for application submission.") + } + await this.authorizeUserAction(this.req.user, applicationCreateDto, authzActions.submit) + return await this._create( + { + ...applicationCreateDto, + user: this.req.user, + }, + true + ) + } + + async create(applicationCreateDto: ApplicationCreateDto) { + await this.authorizeUserAction(this.req.user, applicationCreateDto, authzActions.create) + return this._create(applicationCreateDto, false) + } + + async findOne(applicationId: string) { + const application = await this.repository.findOneOrFail({ + where: { + id: applicationId, + }, + relations: ["user"], + }) + await this.authorizeUserAction(this.req.user, application, authzActions.read) + return application + } + + async update(applicationUpdateDto: ApplicationUpdateDto) { + const application = await this.repository.findOne({ + where: { id: applicationUpdateDto.id }, + }) + if (!application) { + throw new NotFoundException() + } + await this.authorizeUserAction(this.req.user, application, authzActions.update) + assignDefined(application, { + ...applicationUpdateDto, + id: application.id, + }) + + return await this.repository.manager.transaction( + "SERIALIZABLE", + async (transactionalEntityManager) => { + const applicationsRepository = transactionalEntityManager.getRepository(Application) + const newApplication = await applicationsRepository.save(application) + await this.applicationFlaggedSetsService.onApplicationUpdate( + application, + transactionalEntityManager + ) + + return await applicationsRepository.findOne({ id: newApplication.id }) + } + ) + } + + async delete(applicationId: string) { + const application = await this.findOne(applicationId) + await this.authorizeUserAction(this.req.user, application, authzActions.delete) + return await this.repository.softRemove({ id: applicationId }) + } + + private _getQb(params: PaginatedApplicationListQueryParams, view = "base", withSelect = true) { + /** + * Map used to generate proper parts + * of query builder. + */ + const paramsMap = { + markedAsDuplicate: (qb, { markedAsDuplicate }) => + qb.andWhere("application.markedAsDuplicate = :markedAsDuplicate", { + markedAsDuplicate: markedAsDuplicate, + }), + userId: (qb, { userId }) => qb.andWhere("application.user_id = :uid", { uid: userId }), + listingId: (qb, { listingId }) => + qb.andWhere("application.listing_id = :lid", { lid: listingId }), + orderBy: (qb, { orderBy, order }) => qb.orderBy(orderBy, order, "NULLS LAST"), + search: (qb, { search }) => + qb.andWhere( + `to_tsvector('english', REGEXP_REPLACE(concat_ws(' ', applicant, alternateContact.emailAddress), '[_]|[-]', '/', 'g')) @@ to_tsquery(CONCAT(CAST(plainto_tsquery(REGEXP_REPLACE(:search, '[_]|[-]', '/', 'g')) as text), ':*'))`, + { + search, + } + ), + } + + // --> Build main query + const qbView = getView(this.repository.createQueryBuilder("application"), view) + const qb = qbView.getViewQb(withSelect) + qb.where("application.id IS NOT NULL") + + // --> Build additional query builder parts + Object.keys(paramsMap).forEach((paramKey) => { + // e.g. markedAsDuplicate can be false and wouldn't be applied here + if (params[paramKey] !== undefined) { + paramsMap[paramKey](qb, params) + } + }) + return qb + } + + private async _createApplication(applicationCreateDto: DeepPartial) { + return await this.repository.manager.transaction( + "SERIALIZABLE", + async (transactionalEntityManager) => { + const applicationsRepository = transactionalEntityManager.getRepository(Application) + const application = await applicationsRepository.save({ + ...applicationCreateDto, + confirmationCode: ApplicationsService.generateConfirmationCode(), + }) + await this.applicationFlaggedSetsService.onApplicationSave( + application, + transactionalEntityManager + ) + return await applicationsRepository.findOne({ id: application.id }) + } + ) + } + + private async _create( + applicationCreateDto: DeepPartial, + shouldSendConfirmation: boolean + ) { + let application: Application + + try { + await retry( + async (bail) => { + try { + application = await this._createApplication(applicationCreateDto) + } catch (e) { + console.error(e.message) + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + if ( + !( + e instanceof QueryFailedError && + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + // NOTE: 40001 could not serialize access due to read/write dependencies among transactions + (e.code === "40001" || + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + // NOTE: constraint UQ_556c258a4439f1b7f53de2ed74f checks whether listing.id & confirmationCode is a unique combination + // it does make sense here to retry because it's a randomly generated 8 character string value + (e.code === "23505" && e.constraint === "UQ_556c258a4439f1b7f53de2ed74f")) + ) + ) { + bail(e) + return + } + throw e + } + }, + { retries: 6, minTimeout: 200 } + ) + } catch (e) { + console.log("Create application error = ", e) + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + if (e instanceof QueryFailedError && e.code === "40001") { + throw new HttpException( + { + statusCode: HttpStatus.TOO_MANY_REQUESTS, + error: "Too Many Requests", + message: "Please try again later.", + }, + 429 + ) + } + throw e + } + + // Listing is not eagerly joined on application entity so let's use the one provided with + // create dto + const listing = await this.listingsService.findOne(applicationCreateDto.listing.id) + if (application.applicant.emailAddress && shouldSendConfirmation) { + await this.emailService.confirmation(listing, application, applicationCreateDto.appUrl) + } + return application + } + + private async authorizeUserAction( + user, + app: T, + action + ) { + let resource: T = app + + if (app instanceof Application) { + resource = { + ...app, + listing_id: app.listingId, + } + } else if (app instanceof ApplicationCreateDto) { + resource = { + ...app, + listing_id: app.listing.id, + } + } + return this.authzService.canOrThrow(user, "application", action, resource) + } + + private async authorizeCSVExport(user, listingId) { + /** + * Checking authorization for each application is very expensive. By making lisitngId required, we can check if the user has update permissions for the listing, since right now if a user has that they also can run the export for that listing + */ + return await this.authzService.canOrThrow(user, "listing", authzActions.update, { + id: listingId, + }) + } + + public static generateConfirmationCode(): string { + return crypto.randomBytes(4).toString("hex").toUpperCase() + } +} diff --git a/backend/core/src/applications/services/csv-builder.service.ts b/backend/core/src/applications/services/csv-builder.service.ts new file mode 100644 index 0000000000..47b8b653ab --- /dev/null +++ b/backend/core/src/applications/services/csv-builder.service.ts @@ -0,0 +1,115 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { Injectable, Scope } from "@nestjs/common" + +export interface KeyNumber { + [key: string]: number +} + +@Injectable({ scope: Scope.REQUEST }) +export class CsvBuilder { + /** + * this assumes a flat file structure since it's getting fed data from a raw query + * relational data should be handled with the use of extraHeaders and extraGroupKeys, + * see application-csv-exporter Household Members for an example of this + * All formatting should be done before passing in + */ + public buildFromIdIndex( + obj: { [key: string]: any }, + extraHeaders?: { [key: string]: number }, + extraGroupKeys?: ( + group: string, + obj?: { [key: string]: any } + ) => { nested: boolean; keys: string[] } + ): string { + const headerIndex: { [key: string]: number } = {} + // rootKeys should be the ids + const rootKeys = Object.keys(obj) + + if (rootKeys.length === 0) return "" + /** + * initialApp should have all possible keys. + * If it can't, use extraHeaders + */ + const initialApp = obj[rootKeys[0]] + let index = 0 + // set headerIndex + Object.keys(initialApp).forEach((key) => { + // if the key is in extra headers, we want to group them all together + if (extraHeaders && extraHeaders[key] && extraGroupKeys) { + const groupKeys = extraGroupKeys(key, initialApp) + for (let i = 1; i < extraHeaders[key] + 1; i++) { + const headerGroup = groupKeys.nested ? `${key} (${i})` : key + groupKeys.keys.forEach((groupKey) => { + headerIndex[`${headerGroup} ${groupKey}`] = index + index++ + }) + } + } else { + headerIndex[key] = index + index++ + } + }) + const headers = Object.keys(headerIndex) + + // initiate arrays to insert data + const rows = Array.from({ length: rootKeys.length }, () => Array(headers.length)) + + // set rows (a row is a record) + rootKeys.forEach((obj_id, row) => { + const thisObj = obj[obj_id] + Object.keys(thisObj).forEach((key) => { + const val = thisObj[key] + const groupKeys = extraGroupKeys && extraGroupKeys(key, initialApp) + if (extraHeaders && extraHeaders[key] && groupKeys) { + // val in this case is an object with ids as the keys + const ids = Object.keys(val) + if (groupKeys.nested && ids.length) { + Object.keys(val).forEach((sub_id, i) => { + const headerGroup = `${key} (${i + 1})` + groupKeys.keys.forEach((groupKey) => { + const column = headerIndex[`${headerGroup} ${groupKey}`] + const sub_val = val[sub_id][groupKey] + rows[row][column] = + sub_val !== undefined && sub_val !== null ? JSON.stringify(sub_val) : "" + }) + }) + } else if (groupKeys.nested === false) { + Object.keys(val).forEach((sub_key) => { + const column = headerIndex[`${key} ${sub_key}`] + const sub_val = val[sub_key] + rows[row][column] = + sub_val !== undefined && sub_val !== null ? JSON.stringify(sub_val) : "" + }) + } + } else { + const column = headerIndex[key] + let value + if (Array.isArray(val)) { + value = val.join(", ") + } else if (val instanceof Object) { + value = Object.keys(val) + .map((key) => val[key]) + .sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)) + .join(", ") + } else { + value = val + } + rows[row][column] = value !== undefined && value !== null ? JSON.stringify(value) : "" + } + }) + }) + + let csvString = headers.join(",") + csvString += "\n" + + // turn rows into csv format + rows.forEach((row) => { + if (row.length) { + csvString += row.join(",") + csvString += "\n" + } + }) + + return csvString + } +} diff --git a/backend/core/src/applications/services/csv-builder.spec.ts b/backend/core/src/applications/services/csv-builder.spec.ts new file mode 100644 index 0000000000..d2b50c071d --- /dev/null +++ b/backend/core/src/applications/services/csv-builder.spec.ts @@ -0,0 +1,205 @@ +import { Test, TestingModule } from "@nestjs/testing" +import { CsvBuilder } from "./csv-builder.service" +/* import { ApplicationCsvExporter } from "./application-csv-exporter" +import { ApplicationStatus } from "../applications/types/application-status-enum" +import { ApplicationSubmissionType } from "../applications/types/application-submission-type-enum" */ + +// Cypress brings in Chai types for the global expect, but we want to use jest +// expect here so we need to re-declare it. +// see: https://github.com/cypress-io/cypress/issues/1319#issuecomment-593500345 +declare const expect: jest.Expect + +describe("CSVBuilder", () => { + let service: CsvBuilder + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [CsvBuilder], + }).compile() + service = await module.resolve(CsvBuilder) + }) + + it("should be defined", () => { + expect(service).toBeDefined() + }) + + it("create empty respsone", () => { + const response = service.buildFromIdIndex({}) + expect(response).toBe("") + }) + + it("create correctly escaped CSV for correct data", () => { + const response = service.buildFromIdIndex({ 1: { foo: "bar", bar: "foo" }, 2: { bar: "baz" } }) + expect(response).toBe('foo,bar\n"bar","foo"\n,"baz"\n') + }) + + it("create correct CSV for correct data with undefined value", () => { + const response = service.buildFromIdIndex({ + 1: { foo: "bar", bar: undefined }, + 2: { bar: "baz" }, + }) + expect(response).toBe('foo,bar\n"bar",\n,"baz"\n') + }) + + it("create correct CSV for correct data with null value", () => { + const response = service.buildFromIdIndex({ + 1: { foo: "bar", bar: null }, + 2: { bar: "baz" }, + }) + expect(response).toBe('foo,bar\n"bar",\n,"baz"\n') + }) + + it("create CSV with escaped double quotes", () => { + const response = service.buildFromIdIndex({ 1: { foo: '"', bar: "foo" } }) + expect(response).toBe('foo,bar\n"\\"","foo"\n') + }) + + it("create CSV with comma in value", () => { + const response = service.buildFromIdIndex({ 1: { foo: "with, comma", bar: "should work," } }) + expect(response).toBe('foo,bar\n"with, comma","should work,"\n') + }) + + it("create a CSV with an array of strings", () => { + const response = service.buildFromIdIndex({ 1: { foo: ["foo", "bar"] } }) + expect(response).toBe('foo\n"foo, bar"\n') + }) + + it("create a CSV with a nested object of key: string pairs that converts it to an array", () => { + const response = service.buildFromIdIndex({ + 1: { foo: "bar", bar: { 1: "bar-sub-1", 2: "bar-sub-2" } }, + }) + expect(response).toBe('foo,bar\n"bar","bar-sub-1, bar-sub-2"\n') + }) + + it("create CSV with extraHeaders and nested groupKeys", () => { + const response = service.buildFromIdIndex( + { + 1: { foo: "bar", bar: "foo", baz: { 1: { sub: "sub-foo" }, 2: { sub: "sub-bar" } } }, + }, + { baz: 2 }, + (group) => { + const groups = { + baz: { + nested: true, + keys: ["sub"], + }, + } + + return groups[group] + } + ) + expect(response).toBe('foo,bar,baz (1) sub,baz (2) sub\n"bar","foo","sub-foo","sub-bar"\n') + }) + + it("create CSV with extraHeaders and non nested groupKeys", () => { + const response = service.buildFromIdIndex( + { + 1: { foo: "bar", bar: "foo", baz: { sub: "baz-sub", bus: "baz-bus" } }, + }, + { baz: 1 }, + (group) => { + const groups = { + baz: { + nested: false, + keys: ["sub", "bus"], + }, + } + + return groups[group] + } + ) + expect(response).toBe('foo,bar,baz sub,baz bus\n"bar","foo","baz-sub","baz-bus"\n') + }) +}) + +// TODO: add tests specific to ApplicationCsvExporter +/* describe("ApplicationCsvExporter", () => { + let service: ApplicationCsvExporter + const now = new Date() + + const BASE_ADDRESS = { + city: "city", + state: "state", + street: "street", + zipCode: "zipcode", + } + + const BASE_APPLICATIONS = [ + { + id: "app_1", + listingId: "listing_1", + applicant: { + id: "applicant_1", + firstName: "first name", + middleName: "middle name", + lastName: "last name", + address: { + id: "address_1", + createdAt: now, + updatedAt: now, + ...BASE_ADDRESS, + }, + workAddress: { + id: "work_address_1", + createdAt: now, + updatedAt: now, + ...BASE_ADDRESS, + }, + createdAt: now, + updatedAt: now, + }, + contactPreferences: [], + updatedAt: now, + createdAt: now, + mailingAddress: { + id: "mailing_address_1", + createdAt: now, + updatedAt: now, + ...BASE_ADDRESS, + }, + alternateAddress: { + id: "alternate_address_1", + createdAt: now, + updatedAt: now, + ...BASE_ADDRESS, + }, + alternateContact: { + id: "alternate_contact_1", + createdAt: now, + updatedAt: now, + mailingAddress: { + id: "mailing_address_2", + createdAt: now, + updatedAt: now, + ...BASE_ADDRESS, + }, + }, + accessibility: { + id: "accessibility_1", + createdAt: now, + updatedAt: now, + }, + demographics: { + howDidYouHear: ["ears"], + id: "demographics_1", + createdAt: now, + updatedAt: now, + }, + householdMembers: [], + preferredUnit: [], + preferences: [], + status: ApplicationStatus.submitted, + submissionType: ApplicationSubmissionType.electronical, + markedAsDuplicate: false, + flagged: false, + confirmationCode: "code_1", + }, + ] + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [CsvBuilder, ApplicationCsvExporter], + }).compile() + service = module.get(ApplicationCsvExporter) + }) +}) */ diff --git a/backend/core/src/applications/types/application-preference-api-extra-models.ts b/backend/core/src/applications/types/application-preference-api-extra-models.ts new file mode 100644 index 0000000000..e216c4b615 --- /dev/null +++ b/backend/core/src/applications/types/application-preference-api-extra-models.ts @@ -0,0 +1,5 @@ +import { BooleanInput } from "./form-metadata/boolean-input" +import { TextInput } from "./form-metadata/text-input" +import { AddressInput } from "./form-metadata/address-input" + +export const applicationPreferenceApiExtraModels = [BooleanInput, TextInput, AddressInput] diff --git a/backend/core/src/applications/types/application-preference-option.ts b/backend/core/src/applications/types/application-preference-option.ts new file mode 100644 index 0000000000..e489181759 --- /dev/null +++ b/backend/core/src/applications/types/application-preference-option.ts @@ -0,0 +1,49 @@ +import { Expose, Type } from "class-transformer" +import { ArrayMaxSize, IsBoolean, IsString, MaxLength, 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" +import { TextInput } from "./form-metadata/text-input" +import { AddressInput } from "./form-metadata/address-input" +import { FormMetadataExtraData } from "./form-metadata/form-metadata-extra-data" +import { InputType } from "../../shared/types/input-type" + +export class ApplicationPreferenceOption { + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @MaxLength(128, { groups: [ValidationsGroupsEnum.default] }) + @ApiProperty() + key: string + + @Expose() + @IsBoolean({ groups: [ValidationsGroupsEnum.default] }) + @ApiProperty() + checked: boolean + + @Expose() + @ApiProperty({ + type: "array", + required: false, + items: { + oneOf: [ + { $ref: getSchemaPath(BooleanInput) }, + { $ref: getSchemaPath(TextInput) }, + { $ref: getSchemaPath(AddressInput) }, + ], + }, + }) + @ArrayMaxSize(64, { groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default], each: true }) + @Type(() => FormMetadataExtraData, { + keepDiscriminatorProperty: true, + discriminator: { + property: "type", + subTypes: [ + { value: BooleanInput, name: InputType.boolean }, + { value: TextInput, name: InputType.text }, + { value: AddressInput, name: InputType.address }, + ], + }, + }) + extraData: Array +} diff --git a/backend/core/src/applications/types/application-program-option.ts b/backend/core/src/applications/types/application-program-option.ts new file mode 100644 index 0000000000..305168813e --- /dev/null +++ b/backend/core/src/applications/types/application-program-option.ts @@ -0,0 +1,3 @@ +import { ApplicationPreferenceOption } from "./application-preference-option" + +export class ApplicationProgramOption extends ApplicationPreferenceOption {} diff --git a/backend/core/src/applications/types/application-status-enum.ts b/backend/core/src/applications/types/application-status-enum.ts new file mode 100644 index 0000000000..b5924d3fc6 --- /dev/null +++ b/backend/core/src/applications/types/application-status-enum.ts @@ -0,0 +1,5 @@ +export enum ApplicationStatus { + draft = "draft", + submitted = "submitted", + removed = "removed", +} diff --git a/backend/core/src/applications/types/application-submission-type-enum.ts b/backend/core/src/applications/types/application-submission-type-enum.ts new file mode 100644 index 0000000000..9c3bcebbb0 --- /dev/null +++ b/backend/core/src/applications/types/application-submission-type-enum.ts @@ -0,0 +1,4 @@ +export enum ApplicationSubmissionType { + paper = "paper", + electronical = "electronical", +} diff --git a/backend/core/src/applications/types/applications-api-extra-model.ts b/backend/core/src/applications/types/applications-api-extra-model.ts new file mode 100644 index 0000000000..10a1abf871 --- /dev/null +++ b/backend/core/src/applications/types/applications-api-extra-model.ts @@ -0,0 +1,24 @@ +import { Expose } from "class-transformer" +import { ApiProperty } from "@nestjs/swagger" +import { OrderByParam } from "./order-by-param" +import { OrderParam } from "./order-param" + +export class ApplicationsApiExtraModel { + @Expose() + @ApiProperty({ + enum: Object.keys(OrderByParam), + example: "createdAt", + default: "createdAt", + required: false, + }) + orderBy?: OrderByParam + + @Expose() + @ApiProperty({ + enum: OrderParam, + example: "DESC", + default: "DESC", + required: false, + }) + order?: OrderParam +} diff --git a/backend/core/src/applications/types/form-metadata/address-input.ts b/backend/core/src/applications/types/form-metadata/address-input.ts new file mode 100644 index 0000000000..a7bb08769d --- /dev/null +++ b/backend/core/src/applications/types/form-metadata/address-input.ts @@ -0,0 +1,15 @@ +import { FormMetadataExtraData } from "./form-metadata-extra-data" +import { Expose, Type } from "class-transformer" +import { IsDefined, ValidateNested } from "class-validator" +import { ValidationsGroupsEnum } from "../../../shared/types/validations-groups-enum" +import { AddressCreateDto } from "../../../shared/dto/address.dto" +import { ApiProperty } from "@nestjs/swagger" + +export class AddressInput extends FormMetadataExtraData { + @Expose() + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => AddressCreateDto) + @ApiProperty() + value: AddressCreateDto +} diff --git a/backend/core/src/applications/types/form-metadata/boolean-input.ts b/backend/core/src/applications/types/form-metadata/boolean-input.ts new file mode 100644 index 0000000000..bea42e1434 --- /dev/null +++ b/backend/core/src/applications/types/form-metadata/boolean-input.ts @@ -0,0 +1,12 @@ +import { FormMetadataExtraData } from "./form-metadata-extra-data" +import { Expose } from "class-transformer" +import { IsBoolean } from "class-validator" +import { ValidationsGroupsEnum } from "../../../shared/types/validations-groups-enum" +import { ApiProperty } from "@nestjs/swagger" + +export class BooleanInput extends FormMetadataExtraData { + @Expose() + @IsBoolean({ groups: [ValidationsGroupsEnum.default] }) + @ApiProperty() + value: boolean +} diff --git a/backend/core/src/applications/types/form-metadata/form-metadata-extra-data.ts b/backend/core/src/applications/types/form-metadata/form-metadata-extra-data.ts new file mode 100644 index 0000000000..01d82d6b03 --- /dev/null +++ b/backend/core/src/applications/types/form-metadata/form-metadata-extra-data.ts @@ -0,0 +1,19 @@ +import { Expose } from "class-transformer" +import { IsEnum, IsString, MaxLength } from "class-validator" +import { ValidationsGroupsEnum } from "../../../shared/types/validations-groups-enum" +import { InputType } from "../../../shared/types/input-type" +import { ApiProperty } from "@nestjs/swagger" + +export class FormMetadataExtraData { + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @IsEnum(InputType, { groups: [ValidationsGroupsEnum.default] }) + @ApiProperty({ enum: InputType, enumName: "InputType" }) + type: InputType + + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @MaxLength(128, { groups: [ValidationsGroupsEnum.default] }) + @ApiProperty() + key: string +} diff --git a/backend/core/src/applications/types/form-metadata/form-metadata-options.ts b/backend/core/src/applications/types/form-metadata/form-metadata-options.ts new file mode 100644 index 0000000000..97e6d68304 --- /dev/null +++ b/backend/core/src/applications/types/form-metadata/form-metadata-options.ts @@ -0,0 +1,40 @@ +import { Expose, Type } from "class-transformer" +import { + ArrayMaxSize, + IsBoolean, + IsOptional, + IsString, + MaxLength, + ValidateNested, +} from "class-validator" +import { ValidationsGroupsEnum } from "../../../shared/types/validations-groups-enum" +import { FormMetadataExtraData } from "./form-metadata-extra-data" +import { ApiProperty } from "@nestjs/swagger" + +export class FormMetadataOptions { + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @MaxLength(128, { groups: [ValidationsGroupsEnum.default] }) + @ApiProperty() + key: string + + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @ArrayMaxSize(64, { groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default], each: true }) + @Type(() => FormMetadataExtraData) + @ApiProperty({ type: [FormMetadataExtraData], required: false }) + extraData?: FormMetadataExtraData[] | null + + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsBoolean({ groups: [ValidationsGroupsEnum.default] }) + @ApiProperty() + description?: boolean + + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsBoolean({ groups: [ValidationsGroupsEnum.default] }) + @ApiProperty() + exclusive?: boolean +} diff --git a/backend/core/src/applications/types/form-metadata/form-metadata.ts b/backend/core/src/applications/types/form-metadata/form-metadata.ts new file mode 100644 index 0000000000..daa1c93c2c --- /dev/null +++ b/backend/core/src/applications/types/form-metadata/form-metadata.ts @@ -0,0 +1,55 @@ +import { Expose, Type } from "class-transformer" +import { + ArrayMaxSize, + IsString, + MaxLength, + ValidateNested, + IsOptional, + IsBoolean, +} from "class-validator" +import { ValidationsGroupsEnum } from "../../../shared/types/validations-groups-enum" +import { FormMetadataOptions } from "./form-metadata-options" +import { ApiProperty } from "@nestjs/swagger" + +export enum FormMetaDataType { + radio = "radio", + checkbox = "checkbox", +} + +export class FormMetadata { + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @MaxLength(128, { groups: [ValidationsGroupsEnum.default] }) + @ApiProperty() + key: string + + @Expose() + @ArrayMaxSize(64, { groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default], each: true }) + @Type(() => FormMetadataOptions) + @ApiProperty({ type: [FormMetadataOptions], nullable: true }) + options: FormMetadataOptions[] + + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsBoolean({ groups: [ValidationsGroupsEnum.default] }) + @ApiProperty() + hideGenericDecline?: boolean + + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @ApiProperty() + customSelectText?: string + + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsBoolean({ groups: [ValidationsGroupsEnum.default] }) + @ApiProperty() + hideFromListing?: boolean + + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @ApiProperty({ enum: FormMetaDataType, enumName: "FormMetaDataType" }) + type?: FormMetaDataType +} diff --git a/backend/core/src/applications/types/form-metadata/text-input.ts b/backend/core/src/applications/types/form-metadata/text-input.ts new file mode 100644 index 0000000000..d68448bba7 --- /dev/null +++ b/backend/core/src/applications/types/form-metadata/text-input.ts @@ -0,0 +1,13 @@ +import { FormMetadataExtraData } from "./form-metadata-extra-data" +import { Expose } from "class-transformer" +import { IsString, MaxLength } from "class-validator" +import { ValidationsGroupsEnum } from "../../../shared/types/validations-groups-enum" +import { ApiProperty } from "@nestjs/swagger" + +export class TextInput extends FormMetadataExtraData { + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @MaxLength(4096, { groups: [ValidationsGroupsEnum.default] }) + @ApiProperty() + value: string +} diff --git a/backend/core/src/applications/types/income-period-enum.ts b/backend/core/src/applications/types/income-period-enum.ts new file mode 100644 index 0000000000..c0d91c8a1a --- /dev/null +++ b/backend/core/src/applications/types/income-period-enum.ts @@ -0,0 +1,4 @@ +export enum IncomePeriod { + perMonth = "perMonth", + perYear = "perYear", +} diff --git a/backend/core/src/applications/types/order-by-param.ts b/backend/core/src/applications/types/order-by-param.ts new file mode 100644 index 0000000000..610b165139 --- /dev/null +++ b/backend/core/src/applications/types/order-by-param.ts @@ -0,0 +1,6 @@ +export enum OrderByParam { + firstName = "applicant.firstName", + lastName = "applicant.lastName", + submissionDate = "application.submissionDate", + createdAt = "application.createdAt", +} diff --git a/backend/core/src/applications/types/order-param.ts b/backend/core/src/applications/types/order-param.ts new file mode 100644 index 0000000000..36e26d1d0b --- /dev/null +++ b/backend/core/src/applications/types/order-param.ts @@ -0,0 +1,4 @@ +export enum OrderParam { + ASC = "ASC", + DESC = "DESC", +} diff --git a/backend/core/src/applications/views/config.ts b/backend/core/src/applications/views/config.ts new file mode 100644 index 0000000000..f02c58955d --- /dev/null +++ b/backend/core/src/applications/views/config.ts @@ -0,0 +1,37 @@ +import { Views } from "./types" + +const views: Views = { + base: { + leftJoinAndSelect: [ + ["application.applicant", "applicant"], + ["applicant.address", "applicant_address"], + ["applicant.workAddress", "applicant_workAddress"], + ["application.alternateAddress", "alternateAddress"], + ["application.mailingAddress", "mailingAddress"], + ["application.alternateContact", "alternateContact"], + ["alternateContact.mailingAddress", "alternateContact_mailingAddress"], + ["application.accessibility", "accessibility"], + ["application.demographics", "demographics"], + ["application.householdMembers", "householdMembers"], + ["householdMembers.address", "householdMembers_address"], + ["householdMembers.workAddress", "householdMembers_workAddress"], + ["application.preferredUnit", "preferredUnit"], + ], + }, +} + +views.partnerList = { + leftJoinAndSelect: [ + ["application.applicant", "applicant"], + ["application.householdMembers", "householdMembers"], + ["application.accessibility", "accessibility"], + ["applicant.address", "applicant_address"], + ["application.mailingAddress", "mailingAddress"], + ["applicant.workAddress", "applicant_workAddress"], + ["application.alternateContact", "alternateContact"], + ["application.alternateAddress", "alternateAddress"], + ["alternateContact.mailingAddress", "alternateContact_mailingAddress"], + ], +} + +export { views } diff --git a/backend/core/src/applications/views/types.ts b/backend/core/src/applications/views/types.ts new file mode 100644 index 0000000000..f5161809ce --- /dev/null +++ b/backend/core/src/applications/views/types.ts @@ -0,0 +1,10 @@ +import { View } from "../../views/base.view" + +export enum ApplicationViewEnum { + base = "base", + partnerList = "partnerList", +} + +export type Views = { + [key in ApplicationViewEnum]?: View +} diff --git a/backend/core/src/applications/views/view.ts b/backend/core/src/applications/views/view.ts new file mode 100644 index 0000000000..3a161fe6f6 --- /dev/null +++ b/backend/core/src/applications/views/view.ts @@ -0,0 +1,39 @@ +import { SelectQueryBuilder } from "typeorm" +import { Application } from "../entities/application.entity" +import { views } from "./config" +import { View, BaseView } from "../../views/base.view" + +export function getView(qb: SelectQueryBuilder, view?: string) { + switch (views[view]) { + case views.partnerList: + return new PartnerList(qb) + default: + return new BaseApplicationView(qb) + } +} + +export class BaseApplicationView extends BaseView { + qb: SelectQueryBuilder + view: View + constructor(qb: SelectQueryBuilder) { + super(qb) + this.view = views.base + } + + getViewQb(withSelect = true): SelectQueryBuilder { + if (withSelect) { + this.view.leftJoinAndSelect.forEach((tuple) => this.qb.leftJoinAndSelect(...tuple)) + } else { + this.view.leftJoinAndSelect.forEach((tuple) => this.qb.leftJoin(...tuple)) + } + + return this.qb + } +} + +export class PartnerList extends BaseApplicationView { + constructor(qb: SelectQueryBuilder) { + super(qb) + this.view = views.partnerList + } +} diff --git a/backend/core/src/assets/assets.controller.spec.ts b/backend/core/src/assets/assets.controller.spec.ts new file mode 100644 index 0000000000..419d7e1daa --- /dev/null +++ b/backend/core/src/assets/assets.controller.spec.ts @@ -0,0 +1,24 @@ +import { Test, TestingModule } from "@nestjs/testing" +import { AssetsController } from "./assets.controller" +import { AuthModule } from "../auth/auth.module" +import dbOptions = require("../../ormconfig.test") +import { TypeOrmModule } from "@nestjs/typeorm" +import { AssetsService } from "./services/assets.service" + +describe("AssetsController", () => { + let controller: AssetsController + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [AssetsController], + imports: [TypeOrmModule.forRoot(dbOptions), AuthModule], + providers: [{ provide: AssetsService, useValue: {} }], + }).compile() + + controller = module.get(AssetsController) + }) + + it("should be defined", () => { + expect(controller).toBeDefined() + }) +}) diff --git a/backend/core/src/assets/assets.controller.ts b/backend/core/src/assets/assets.controller.ts new file mode 100644 index 0000000000..35929e9db3 --- /dev/null +++ b/backend/core/src/assets/assets.controller.ts @@ -0,0 +1,75 @@ +import { + Body, + Controller, + Get, + Param, + Post, + Query, + UseGuards, + UsePipes, + ValidationPipe, +} from "@nestjs/common" +import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger" +import { mapTo } from "../shared/mapTo" +import { ResourceType } from "../auth/decorators/resource-type.decorator" +import { AuthzGuard } from "../auth/guards/authz.guard" +import { defaultValidationPipeOptions } from "../shared/default-validation-pipe-options" +import { DefaultAuthGuard } from "../auth/guards/default.guard" +import { AssetsService } from "./services/assets.service" +import { + AssetCreateDto, + AssetDto, + CreatePresignedUploadMetadataDto, + CreatePresignedUploadMetadataResponseDto, +} from "./dto/asset.dto" +import { PaginationFactory, PaginationQueryParams } from "../shared/dto/pagination.dto" + +export class PaginatedAssetsDto extends PaginationFactory(AssetDto) {} + +@Controller("assets") +@ApiTags("assets") +@ApiBearerAuth() +@ResourceType("asset") +@UseGuards(DefaultAuthGuard, AuthzGuard) +@UsePipes( + new ValidationPipe({ + ...defaultValidationPipeOptions, + }) +) +export class AssetsController { + constructor(private readonly assetsService: AssetsService) {} + + @Post() + @ApiOperation({ summary: "Create asset", operationId: "create" }) + async create(@Body() assetCreateDto: AssetCreateDto): Promise { + const asset = await this.assetsService.create(assetCreateDto) + return mapTo(AssetDto, asset) + } + + @Post("/presigned-upload-metadata") + @ApiOperation({ + summary: "Create presigned upload metadata", + operationId: "createPresignedUploadMetadata", + }) + async createPresignedUploadMetadata( + @Body() createPresignedUploadMetadataDto: CreatePresignedUploadMetadataDto + ): Promise { + return mapTo( + CreatePresignedUploadMetadataResponseDto, + await this.assetsService.createPresignedUploadMetadata(createPresignedUploadMetadataDto) + ) + } + + @Get() + @ApiOperation({ summary: "List assets", operationId: "list" }) + async list(@Query() queryParams: PaginationQueryParams): Promise { + return mapTo(PaginatedAssetsDto, await this.assetsService.list(queryParams)) + } + + @Get(`:assetId`) + @ApiOperation({ summary: "Get asset by id", operationId: "retrieve" }) + async retrieve(@Param("assetId") assetId: string): Promise { + const app = await this.assetsService.findOne(assetId) + return mapTo(AssetDto, app) + } +} diff --git a/backend/core/src/assets/assets.module.ts b/backend/core/src/assets/assets.module.ts new file mode 100644 index 0000000000..56f0a53c2a --- /dev/null +++ b/backend/core/src/assets/assets.module.ts @@ -0,0 +1,15 @@ +import { Module } from "@nestjs/common" +import { AssetsController } from "./assets.controller" +import { AssetsService } from "./services/assets.service" +import { CloudinaryService, UploadService } from "./services/upload.service" +import { TypeOrmModule } from "@nestjs/typeorm" +import { SharedModule } from "../shared/shared.module" +import { Asset } from "./entities/asset.entity" +import { AuthModule } from "../auth/auth.module" + +@Module({ + controllers: [AssetsController], + providers: [AssetsService, { provide: UploadService, useClass: CloudinaryService }], + imports: [TypeOrmModule.forFeature([Asset]), AuthModule, SharedModule], +}) +export class AssetsModule {} diff --git a/backend/core/src/assets/dto/asset.dto.ts b/backend/core/src/assets/dto/asset.dto.ts new file mode 100644 index 0000000000..68302a7751 --- /dev/null +++ b/backend/core/src/assets/dto/asset.dto.ts @@ -0,0 +1,38 @@ +import { OmitType } from "@nestjs/swagger" +import { Asset } from "../entities/asset.entity" +import { Expose, Type } from "class-transformer" +import { IsDate, IsDefined, IsOptional, IsUUID } from "class-validator" +import { ValidationsGroupsEnum } from "../../shared/types/validations-groups-enum" + +export class AssetDto extends OmitType(Asset, [] as const) {} + +export class AssetCreateDto extends OmitType(AssetDto, ["id", "createdAt", "updatedAt"] as const) {} +export class AssetUpdateDto extends OmitType(AssetDto, ["id", "createdAt", "updatedAt"] as const) { + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsUUID(4, { groups: [ValidationsGroupsEnum.default] }) + id?: string + + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsDate({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => Date) + createdAt?: Date + + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsDate({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => Date) + updatedAt?: Date +} + +export class CreatePresignedUploadMetadataDto { + @Expose() + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + parametersToSign: Record +} + +export class CreatePresignedUploadMetadataResponseDto { + @Expose() + signature: string +} diff --git a/backend/core/src/assets/entities/asset.entity.ts b/backend/core/src/assets/entities/asset.entity.ts new file mode 100644 index 0000000000..937898eb6d --- /dev/null +++ b/backend/core/src/assets/entities/asset.entity.ts @@ -0,0 +1,20 @@ +import { AbstractEntity } from "../../shared/entities/abstract.entity" +import { Expose } from "class-transformer" +import { IsString, MaxLength } from "class-validator" +import { ValidationsGroupsEnum } from "../../shared/types/validations-groups-enum" +import { Column, Entity } from "typeorm" + +@Entity({ name: "assets" }) +export class Asset extends AbstractEntity { + @Column({ type: "text" }) + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @MaxLength(128, { groups: [ValidationsGroupsEnum.default] }) + fileId: string + + @Column({ type: "text" }) + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @MaxLength(128, { groups: [ValidationsGroupsEnum.default] }) + label: string +} diff --git a/backend/core/src/assets/services/assets.service.spec.ts b/backend/core/src/assets/services/assets.service.spec.ts new file mode 100644 index 0000000000..bdf62274bb --- /dev/null +++ b/backend/core/src/assets/services/assets.service.spec.ts @@ -0,0 +1,25 @@ +import { Test, TestingModule } from "@nestjs/testing" +import { AssetsService } from "./assets.service" +import { getRepositoryToken } from "@nestjs/typeorm" +import { Asset } from "../entities/asset.entity" +import { UploadService } from "./upload.service" + +describe("AssetsService", () => { + let service: AssetsService + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + AssetsService, + { provide: getRepositoryToken(Asset), useValue: {} }, + { provide: UploadService, useValue: {} }, + ], + }).compile() + + service = module.get(AssetsService) + }) + + it("should be defined", () => { + expect(service).toBeDefined() + }) +}) diff --git a/backend/core/src/assets/services/assets.service.ts b/backend/core/src/assets/services/assets.service.ts new file mode 100644 index 0000000000..46b85df1eb --- /dev/null +++ b/backend/core/src/assets/services/assets.service.ts @@ -0,0 +1,50 @@ +import { Injectable, NotFoundException } from "@nestjs/common" +import { + AssetCreateDto, + CreatePresignedUploadMetadataDto, + CreatePresignedUploadMetadataResponseDto, +} from "../dto/asset.dto" +import { InjectRepository } from "@nestjs/typeorm" +import { Repository } from "typeorm" +import { Asset } from "../entities/asset.entity" +import { UploadService } from "./upload.service" +import { paginate } from "nestjs-typeorm-paginate" +import { PaginationQueryParams } from "../../shared/dto/pagination.dto" + +@Injectable() +export class AssetsService { + constructor( + @InjectRepository(Asset) private readonly repository: Repository, + private readonly uploadService: UploadService + ) {} + + async create(assetCreateDto: AssetCreateDto) { + return await this.repository.save(assetCreateDto) + } + + createPresignedUploadMetadata( + createUploadUrlDto: CreatePresignedUploadMetadataDto + ): Promise { + return Promise.resolve( + this.uploadService.createPresignedUploadMetadata(createUploadUrlDto.parametersToSign) + ) + } + + async list(queryParams: PaginationQueryParams) { + const qb = this._getQb() + return await paginate(qb, { limit: queryParams.limit, page: queryParams.page }) + } + + async findOne(id: string): Promise { + const asset = await this.repository.findOne({ where: { id } }) + if (!asset) { + throw new NotFoundException() + } + return asset + } + + private _getQb() { + const qb = this.repository.createQueryBuilder("assets") + return qb + } +} diff --git a/backend/core/src/assets/services/upload.service.ts b/backend/core/src/assets/services/upload.service.ts new file mode 100644 index 0000000000..ed905077ce --- /dev/null +++ b/backend/core/src/assets/services/upload.service.ts @@ -0,0 +1,31 @@ +import { Injectable } from "@nestjs/common" +import { ConfigService } from "@nestjs/config" +import { v2 as cloudinary } from "cloudinary" + +export abstract class UploadService { + abstract createPresignedUploadMetadata( + parametersToSign: Record + ): { signature: string } +} + +@Injectable() +export class CloudinaryService implements UploadService { + constructor(private readonly configService: ConfigService) {} + + createPresignedUploadMetadata(parametersToSign: Record): { signature: string } { + // Based on https://cloudinary.com/documentation/upload_images#signed_upload_video_tutorial + + const parametersToSignWithTimestamp = { + timestamp: parseInt(parametersToSign.timestamp), + ...parametersToSign, + } + + const signature = cloudinary.utils.api_sign_request( + parametersToSignWithTimestamp, + this.configService.get("CLOUDINARY_SECRET") + ) + return { + signature, + } + } +} diff --git a/backend/core/src/auth/auth.module.ts b/backend/core/src/auth/auth.module.ts new file mode 100644 index 0000000000..5d08bf16e7 --- /dev/null +++ b/backend/core/src/auth/auth.module.ts @@ -0,0 +1,68 @@ +import { forwardRef, Module } from "@nestjs/common" +import { JwtModule } from "@nestjs/jwt" +import { LocalMfaStrategy } from "./passport-strategies/local-mfa.strategy" +import { JwtStrategy } from "./passport-strategies/jwt.strategy" +import { PassportModule } from "@nestjs/passport" +import { TypeOrmModule } from "@nestjs/typeorm" +import { TwilioModule } from "nestjs-twilio" +import { RevokedToken } from "./entities/revoked-token.entity" +import { SharedModule } from "../shared/shared.module" +import { ConfigModule, ConfigService } from "@nestjs/config" +import { AuthService } from "./services/auth.service" +import { AuthzService } from "./services/authz.service" +import { AuthController } from "./controllers/auth.controller" +import { User } from "./entities/user.entity" +import { UserService } from "./services/user.service" +import { UserController } from "./controllers/user.controller" +import { PasswordService } from "./services/password.service" +import { JurisdictionsModule } from "../jurisdictions/jurisdictions.module" +import { Application } from "../applications/entities/application.entity" +import { UserProfileController } from "./controllers/user-profile.controller" +import { ActivityLogModule } from "../activity-log/activity-log.module" +import { EmailModule } from "../email/email.module" +import { SmsMfaService } from "./services/sms-mfa.service" +import { UserPreferencesController } from "./controllers/user-preferences.controller" +import { UserPreferencesService } from "./services/user-preferences.services" +import { UserPreferences } from "./entities/user-preferences.entity" + +@Module({ + imports: [ + PassportModule.register({ defaultStrategy: "jwt" }), + JwtModule.registerAsync({ + imports: [ConfigModule], + inject: [ConfigService], + useFactory: (configService: ConfigService) => ({ + secret: configService.get("APP_SECRET"), + signOptions: { + expiresIn: "10m", + }, + }), + }), + TwilioModule.forRootAsync({ + imports: [ConfigModule], + useFactory: (configService: ConfigService) => ({ + accountSid: configService.get("TWILIO_ACCOUNT_SID"), + authToken: configService.get("TWILIO_AUTH_TOKEN"), + }), + inject: [ConfigService], + }), + TypeOrmModule.forFeature([RevokedToken, User, Application, UserPreferences]), + SharedModule, + JurisdictionsModule, + EmailModule, + forwardRef(() => ActivityLogModule), + ], + providers: [ + LocalMfaStrategy, + JwtStrategy, + AuthService, + AuthzService, + UserService, + PasswordService, + SmsMfaService, + UserPreferencesService, + ], + exports: [AuthzService, AuthService, UserService, UserPreferencesService], + controllers: [AuthController, UserController, UserProfileController, UserPreferencesController], +}) +export class AuthModule {} diff --git a/backend/core/src/auth/authz_model.conf b/backend/core/src/auth/authz_model.conf new file mode 100644 index 0000000000..eb21cf3bf1 --- /dev/null +++ b/backend/core/src/auth/authz_model.conf @@ -0,0 +1,14 @@ +[request_definition] +r = sub, type, act, obj + +[policy_definition] +p = role, type, sub_rule, act + +[role_definition] +g = _, _ + +[policy_effect] +e = some(where (p.eft == allow)) + +[matchers] +m = g(r.sub, p.role) && r.type == p.type && regexMatch(r.act, p.act) && eval(p.sub_rule) diff --git a/backend/core/src/auth/authz_policy.csv b/backend/core/src/auth/authz_policy.csv new file mode 100644 index 0000000000..9a44ba1b50 --- /dev/null +++ b/backend/core/src/auth/authz_policy.csv @@ -0,0 +1,74 @@ +p, admin, application, true, .* +p, user, application, true, submit +p, user, application, !r.obj || (r.sub == r.obj.userId), read +p, anonymous, application, true, submit + +p, admin, user, true, .* +p, admin, userProfile, true, .* +p, user, user, !!r.obj && (r.sub == r.obj.id), read +p, user, userProfile, !!r.obj && (r.sub == r.obj.id), (read|update) +p, anonymous, user, true, create + +p, admin, asset, true, .* +p, partner, asset, true, .* + +p, admin, program, true, .* +p, partner, program, true, .* +p, anonymous, program, true, read + +p, admin, preference, true, .* +p, partner, preference, true, .* + +p, admin, applicationMethod, true, .* +p, partner, applicationMethod, true, read + +p, admin, unit, true, .* +p, partner, unit, true, read + +p, admin, listingEvent, true, .* +p, partner, listingEvent, true, read + +p, admin, property, true, .* +p, partner, property, true, read + +p, admin, propertyGroup, true, .* +p, partner, propertyGroup, true, read + +p, admin, amiChart, true, .* +p, anonymous, amiChart, true, read + +p, admin, applicationFlaggedSet, true, .* +p, partner, applicationFlaggedSet, true, .* + +p, admin, translation, true, .* + +p, admin, jurisdiction, true, .* +p, anonymous, jurisdiction, true, read + +p, admin, listing, true, .* +p, anonymous, listing, true, read + +p, admin, reservedCommunityType, true, .* +p, anonymous, reservedCommunityType, true, read + +p, admin, unitType, true, .* +p, anonymous, unitType, true, read + +p, admin, unitRentType, true, .* +p, anonymous, unitRentType, true, read + +p, admin, unitAccessibilityPriorityType, true, .* +p, anonymous, unitAccessibilityPriorityType, true, read + +p, admin, applicationMethod, true, .* +p, anonymous, applicationMethod, true, read + +p, admin, paperApplication, true, .* +p, anonymous, paperApplication, true, read + +p, admin, userPreference, true, .* +p, user, userPreference, !!r.obj && (r.sub == r.obj.id), (read|update) + +g, admin, partner +g, partner, user +g, user, anonymous diff --git a/backend/core/src/auth/constants.ts b/backend/core/src/auth/constants.ts new file mode 100644 index 0000000000..f191cbf452 --- /dev/null +++ b/backend/core/src/auth/constants.ts @@ -0,0 +1,3 @@ +// Length of hashed key, in bytes +export const SCRYPT_KEYLEN = 64 +export const SALT_SIZE = SCRYPT_KEYLEN diff --git a/backend/core/src/auth/controllers/auth.controller.spec.ts b/backend/core/src/auth/controllers/auth.controller.spec.ts new file mode 100644 index 0000000000..b5e0fe7160 --- /dev/null +++ b/backend/core/src/auth/controllers/auth.controller.spec.ts @@ -0,0 +1,32 @@ +import { Test, TestingModule } from "@nestjs/testing" +import { AuthController } from "./auth.controller" +import { AuthService } from "../services/auth.service" +import { UserService } from "../services/user.service" +import { EmailService } from "../../email/email.service" + +// Cypress brings in Chai types for the global expect, but we want to use jest +// expect here so we need to re-declare it. +// see: https://github.com/cypress-io/cypress/issues/1319#issuecomment-593500345 +declare const expect: jest.Expect + +describe("Auth Controller", () => { + let controller: AuthController + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + // Add mock implementations here as needed for tests. + providers: [ + { provide: AuthService, useValue: {} }, + { provide: UserService, useValue: {} }, + { provide: EmailService, useValue: {} }, + ], + controllers: [AuthController], + }).compile() + + controller = module.get(AuthController) + }) + + it("should be defined", () => { + expect(controller).toBeDefined() + }) +}) diff --git a/backend/core/src/auth/controllers/auth.controller.ts b/backend/core/src/auth/controllers/auth.controller.ts new file mode 100644 index 0000000000..62c051edd1 --- /dev/null +++ b/backend/core/src/auth/controllers/auth.controller.ts @@ -0,0 +1,67 @@ +import { + Controller, + Request, + Post, + UseGuards, + UsePipes, + ValidationPipe, + Body, +} from "@nestjs/common" +import { LocalMfaAuthGuard } from "../guards/local-mfa-auth.guard" +import { AuthService } from "../services/auth.service" +import { DefaultAuthGuard } from "../guards/default.guard" +import { ApiBody, ApiExtraModels, ApiOperation, ApiTags } from "@nestjs/swagger" +import { LoginDto } from "../dto/login.dto" +import { mapTo } from "../../shared/mapTo" +import { defaultValidationPipeOptions } from "../../shared/default-validation-pipe-options" +import { LoginResponseDto } from "../dto/login-response.dto" +import { RequestMfaCodeDto } from "../dto/request-mfa-code.dto" +import { RequestMfaCodeResponseDto } from "../dto/request-mfa-code-response.dto" +import { UserService } from "../services/user.service" +import { GetMfaInfoDto } from "../dto/get-mfa-info.dto" +import { GetMfaInfoResponseDto } from "../dto/get-mfa-info-response.dto" +import { UserErrorExtraModel } from "../user-errors" + +@Controller("auth") +@ApiTags("auth") +@UsePipes(new ValidationPipe(defaultValidationPipeOptions)) +@ApiExtraModels(UserErrorExtraModel) +export class AuthController { + constructor( + private readonly authService: AuthService, + private readonly userService: UserService + ) {} + + @UseGuards(LocalMfaAuthGuard) + @Post("login") + @ApiBody({ type: LoginDto }) + @ApiOperation({ summary: "Login", operationId: "login" }) + login(@Request() req): LoginResponseDto { + const accessToken = this.authService.generateAccessToken(req.user) + return mapTo(LoginResponseDto, { accessToken }) + } + + @UseGuards(DefaultAuthGuard) + @Post("token") + @ApiOperation({ summary: "Token", operationId: "token" }) + token(@Request() req): LoginResponseDto { + const accessToken = this.authService.generateAccessToken(req.user) + return mapTo(LoginResponseDto, { accessToken }) + } + + @Post("request-mfa-code") + @ApiOperation({ summary: "Request mfa code", operationId: "requestMfaCode" }) + async requestMfaCode( + @Body() requestMfaCodeDto: RequestMfaCodeDto + ): Promise { + const requestMfaCodeResponse = await this.userService.requestMfaCode(requestMfaCodeDto) + return mapTo(RequestMfaCodeResponseDto, requestMfaCodeResponse) + } + + @Post("mfa-info") + @ApiOperation({ summary: "Get mfa info", operationId: "getMfaInfo" }) + async getMfaInfo(@Body() getMfaInfoDto: GetMfaInfoDto): Promise { + const getMfaInfoResponseDto = await this.userService.getMfaInfo(getMfaInfoDto) + return mapTo(GetMfaInfoResponseDto, getMfaInfoResponseDto) + } +} diff --git a/backend/core/src/auth/controllers/user-preferences.controller.ts b/backend/core/src/auth/controllers/user-preferences.controller.ts new file mode 100644 index 0000000000..315aab7888 --- /dev/null +++ b/backend/core/src/auth/controllers/user-preferences.controller.ts @@ -0,0 +1,44 @@ +import { + Body, + Controller, + Put, + UseGuards, + UsePipes, + ValidationPipe, + Request, + Param, +} from "@nestjs/common" +import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger" +import { ResourceType } from "../decorators/resource-type.decorator" +import { mapTo } from "../../shared/mapTo" +import { UserPreferencesService } from "../services/user-preferences.services" +import { UserPreferencesDto } from "../dto/user-preferences.dto" +import { defaultValidationPipeOptions } from "../../shared/default-validation-pipe-options" +import { AuthContext } from "../types/auth-context" +import { User } from "../entities/user.entity" +import { Request as ExpressRequest } from "express" +import { OptionalAuthGuard } from "../guards/optional-auth.guard" +import { UserPreferencesAuthzGuard } from "../guards/user-preferences-authz.guard" + +@Controller("/userPreferences") +@ApiTags("userPreferences") +@ApiBearerAuth() +@ResourceType("userPreference") +@UseGuards(OptionalAuthGuard, UserPreferencesAuthzGuard) +@UsePipes(new ValidationPipe(defaultValidationPipeOptions)) +export class UserPreferencesController { + constructor(private readonly userPreferencesService: UserPreferencesService) {} + + @Put(`:id`) + @ApiOperation({ summary: "Update user preferences", operationId: "update" }) + async update( + @Request() req: ExpressRequest, + @Param("id") userId: string, + @Body() userPrefence: UserPreferencesDto + ): Promise { + return mapTo( + UserPreferencesDto, + await this.userPreferencesService.update(userPrefence, new AuthContext(req.user as User)) + ) + } +} diff --git a/backend/core/src/auth/controllers/user-profile.controller.ts b/backend/core/src/auth/controllers/user-profile.controller.ts new file mode 100644 index 0000000000..7d4a52248d --- /dev/null +++ b/backend/core/src/auth/controllers/user-profile.controller.ts @@ -0,0 +1,31 @@ +import { Body, Controller, Put, Request, UseGuards, UsePipes, ValidationPipe } from "@nestjs/common" +import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger" +import { Request as ExpressRequest } from "express" +import { ResourceType } from "../decorators/resource-type.decorator" +import { defaultValidationPipeOptions } from "../../shared/default-validation-pipe-options" +import { UserService } from "../services/user.service" +import { AuthzGuard } from "../guards/authz.guard" +import { UserDto } from "../dto/user.dto" +import { mapTo } from "../../shared/mapTo" +import { AuthContext } from "../types/auth-context" +import { User } from "../entities/user.entity" +import { DefaultAuthGuard } from "../guards/default.guard" +import { UserProfileUpdateDto } from "../dto/user-profile.dto" + +@Controller("userProfile") +@ApiBearerAuth() +@ApiTags("userProfile") +@ResourceType("userProfile") +@UsePipes(new ValidationPipe(defaultValidationPipeOptions)) +export class UserProfileController { + constructor(private readonly userService: UserService) {} + @Put(":id") + @UseGuards(DefaultAuthGuard, AuthzGuard) + @ApiOperation({ summary: "Update profile user", operationId: "update" }) + async update( + @Request() req: ExpressRequest, + @Body() dto: UserProfileUpdateDto + ): Promise { + return mapTo(UserDto, await this.userService.update(dto, new AuthContext(req.user as User))) + } +} diff --git a/backend/core/src/auth/controllers/user.controller.spec.ts b/backend/core/src/auth/controllers/user.controller.spec.ts new file mode 100644 index 0000000000..ed9f2f49b2 --- /dev/null +++ b/backend/core/src/auth/controllers/user.controller.spec.ts @@ -0,0 +1,37 @@ +import { Test, TestingModule } from "@nestjs/testing" +import { UserController } from "./user.controller" +import { PassportModule } from "@nestjs/passport" +import { AuthService } from "../services/auth.service" +import { UserService } from "../services/user.service" +import { AuthzService } from "../services/authz.service" +import { ActivityLogService } from "../../activity-log/services/activity-log.service" +import { EmailService } from "../../email/email.service" + +// Cypress brings in Chai types for the global expect, but we want to use jest +// expect here so we need to re-declare it. +// see: https://github.com/cypress-io/cypress/issues/1319#issuecomment-593500345 +declare const expect: jest.Expect + +describe("User Controller", () => { + let controller: UserController + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + imports: [PassportModule], + providers: [ + { provide: AuthService, useValue: {} }, + { provide: AuthzService, useValue: {} }, + { provide: UserService, useValue: {} }, + { provide: EmailService, useValue: {} }, + { provide: ActivityLogService, useValue: {} }, + ], + controllers: [UserController], + }).compile() + + controller = module.get(UserController) + }) + + it("should be defined", () => { + expect(controller).toBeDefined() + }) +}) diff --git a/backend/core/src/auth/controllers/user.controller.ts b/backend/core/src/auth/controllers/user.controller.ts new file mode 100644 index 0000000000..bbac5e7203 --- /dev/null +++ b/backend/core/src/auth/controllers/user.controller.ts @@ -0,0 +1,175 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + Post, + Put, + Query, + Request, + UseGuards, + UseInterceptors, + UsePipes, + ValidationPipe, +} from "@nestjs/common" +import { ApiBearerAuth, ApiExtraModels, ApiOperation, ApiTags } from "@nestjs/swagger" +import { Request as ExpressRequest } from "express" +import { ResourceType } from "../decorators/resource-type.decorator" +import { defaultValidationPipeOptions } from "../../shared/default-validation-pipe-options" +import { UserService } from "../services/user.service" +import { OptionalAuthGuard } from "../guards/optional-auth.guard" +import { AuthzGuard } from "../guards/authz.guard" +import { UserDto } from "../dto/user.dto" +import { mapTo } from "../../shared/mapTo" +import { StatusDto } from "../../shared/dto/status.dto" +import { ConfirmDto } from "../dto/confirm.dto" +import { ForgotPasswordDto } from "../dto/forgot-password.dto" +import { UpdatePasswordDto } from "../dto/update-password.dto" +import { AuthContext } from "../types/auth-context" +import { User } from "../entities/user.entity" +import { ResourceAction } from "../decorators/resource-action.decorator" +import { UserBasicDto } from "../dto/user-basic.dto" +import { EmailDto } from "../dto/email.dto" +import { UserCreateDto } from "../dto/user-create.dto" +import { UserUpdateDto } from "../dto/user-update.dto" +import { UserListQueryParams } from "../dto/user-list-query-params" +import { PaginatedUserListDto } from "../dto/paginated-user-list.dto" +import { UserInviteDto } from "../dto/user-invite.dto" +import { ForgotPasswordResponseDto } from "../dto/forgot-password-response.dto" +import { LoginResponseDto } from "../dto/login-response.dto" +import { authzActions } from "../enum/authz-actions.enum" +import { UserCreateQueryParams } from "../dto/user-create-query-params" +import { UserFilterParams } from "../dto/user-filter-params" +import { DefaultAuthGuard } from "../guards/default.guard" +import { UserProfileAuthzGuard } from "../guards/user-profile-authz.guard" +import { ActivityLogInterceptor } from "../../activity-log/interceptors/activity-log.interceptor" + +@Controller("user") +@ApiBearerAuth() +@ApiTags("user") +@ResourceType("user") +@UsePipes(new ValidationPipe(defaultValidationPipeOptions)) +export class UserController { + constructor(private readonly userService: UserService) {} + + @Get() + @UseGuards(DefaultAuthGuard, UserProfileAuthzGuard) + profile(@Request() req): UserDto { + return mapTo(UserDto, req.user) + } + + @Post() + @UseGuards(OptionalAuthGuard, AuthzGuard) + @ApiOperation({ summary: "Create user", operationId: "create" }) + async create( + @Request() req: ExpressRequest, + @Body() dto: UserCreateDto, + @Query() queryParams: UserCreateQueryParams + ): Promise { + return mapTo( + UserBasicDto, + await this.userService.createPublicUser( + dto, + new AuthContext(req.user as User), + queryParams.noWelcomeEmail !== true + ) + ) + } + + @Post("is-confirmation-token-valid") + @UseGuards(OptionalAuthGuard, AuthzGuard) + @ApiOperation({ + summary: "Verifies token is valid", + operationId: "isUserConfirmationTokenValid", + }) + async isUserConfirmationTokenValid(@Body() dto: ConfirmDto): Promise { + return await this.userService.isUserConfirmationTokenValid(dto) + } + + @Post("resend-confirmation") + @UseGuards(OptionalAuthGuard, AuthzGuard) + @ApiOperation({ summary: "Resend confirmation", operationId: "resendConfirmation" }) + async confirmation(@Body() dto: EmailDto): Promise { + await this.userService.resendPublicConfirmation(dto) + return mapTo(StatusDto, { status: "ok" }) + } + + @Post("resend-partner-confirmation") + @UseGuards(OptionalAuthGuard, AuthzGuard) + @ApiOperation({ summary: "Resend confirmation", operationId: "resendPartnerConfirmation" }) + async resendPartnerConfirmation(@Body() dto: EmailDto): Promise { + await this.userService.resendPartnerConfirmation(dto) + return mapTo(StatusDto, { status: "ok" }) + } + + @Put("confirm") + @ApiOperation({ summary: "Confirm email", operationId: "confirm" }) + async confirm(@Body() dto: ConfirmDto): Promise { + const accessToken = await this.userService.confirm(dto) + return mapTo(LoginResponseDto, { accessToken }) + } + + @Put("forgot-password") + @ApiOperation({ summary: "Forgot Password", operationId: "forgot-password" }) + async forgotPassword(@Body() dto: ForgotPasswordDto): Promise { + await this.userService.forgotPassword(dto) + return mapTo(ForgotPasswordResponseDto, { message: "Email was sent" }) + } + + @Put("update-password") + @ApiOperation({ summary: "Update Password", operationId: "update-password" }) + async updatePassword(@Body() dto: UpdatePasswordDto): Promise { + const accessToken = await this.userService.updatePassword(dto) + return mapTo(LoginResponseDto, { accessToken }) + } + + @Put(":id") + @UseGuards(DefaultAuthGuard, AuthzGuard) + @ApiOperation({ summary: "Update user", operationId: "update" }) + @UseInterceptors(ActivityLogInterceptor) + async update(@Request() req: ExpressRequest, @Body() dto: UserUpdateDto): Promise { + return mapTo(UserDto, await this.userService.update(dto, new AuthContext(req.user as User))) + } + + @Get("/list") + @UseGuards(OptionalAuthGuard, AuthzGuard) + @ApiExtraModels(UserFilterParams) + @ApiOperation({ summary: "List users", operationId: "list" }) + async list( + @Query() queryParams: UserListQueryParams, + @Request() req: ExpressRequest + ): Promise { + return mapTo( + PaginatedUserListDto, + await this.userService.list(queryParams, new AuthContext(req.user as User)) + ) + } + + @Post("/invite") + @UseGuards(OptionalAuthGuard, AuthzGuard) + @ApiOperation({ summary: "Invite user", operationId: "invite" }) + @ResourceAction(authzActions.invite) + @UseInterceptors(ActivityLogInterceptor) + async invite(@Request() req: ExpressRequest, @Body() dto: UserInviteDto): Promise { + return mapTo( + UserBasicDto, + await this.userService.invitePartnersPortalUser(dto, new AuthContext(req.user as User)) + ) + } + + @Get(`:id`) + @ApiOperation({ summary: "Get user by id", operationId: "retrieve" }) + @UseGuards(DefaultAuthGuard, AuthzGuard) + async retrieve(@Param("id") userId: string): Promise { + return mapTo(UserDto, await this.userService.findOneOrFail({ id: userId })) + } + + @Delete(`:id`) + @UseGuards(OptionalAuthGuard, AuthzGuard) + @ApiOperation({ summary: "Delete user by id", operationId: "delete" }) + @UseInterceptors(ActivityLogInterceptor) + async delete(@Param("id") userId: string): Promise { + return await this.userService.delete(userId) + } +} diff --git a/backend/core/src/auth/decorators/resource-action.decorator.ts b/backend/core/src/auth/decorators/resource-action.decorator.ts new file mode 100644 index 0000000000..bfebbbebbf --- /dev/null +++ b/backend/core/src/auth/decorators/resource-action.decorator.ts @@ -0,0 +1,3 @@ +import { SetMetadata } from "@nestjs/common" + +export const ResourceAction = (action: string) => SetMetadata("authz_action", action) diff --git a/backend/core/src/auth/decorators/resource-type.decorator.ts b/backend/core/src/auth/decorators/resource-type.decorator.ts new file mode 100644 index 0000000000..36c5e18b6f --- /dev/null +++ b/backend/core/src/auth/decorators/resource-type.decorator.ts @@ -0,0 +1,3 @@ +import { SetMetadata } from "@nestjs/common" + +export const ResourceType = (type: string) => SetMetadata("authz_type", type) diff --git a/backend/core/src/auth/dto/confirm.dto.ts b/backend/core/src/auth/dto/confirm.dto.ts new file mode 100644 index 0000000000..0f07b3ac0d --- /dev/null +++ b/backend/core/src/auth/dto/confirm.dto.ts @@ -0,0 +1,20 @@ +import { IsOptional, IsString, Matches, MaxLength } from "class-validator" +import { Expose } from "class-transformer" +import { ValidationsGroupsEnum } from "../../shared/types/validations-groups-enum" +import { passwordRegex } from "../../shared/password-regex" + +export class ConfirmDto { + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @MaxLength(256, { groups: [ValidationsGroupsEnum.default] }) + token: string + + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @Matches(passwordRegex, { + message: "passwordTooWeak", + groups: [ValidationsGroupsEnum.default], + }) + password?: string +} diff --git a/backend/core/src/auth/dto/email.dto.ts b/backend/core/src/auth/dto/email.dto.ts new file mode 100644 index 0000000000..9457c55b58 --- /dev/null +++ b/backend/core/src/auth/dto/email.dto.ts @@ -0,0 +1,17 @@ +import { Expose } from "class-transformer" +import { IsEmail, IsOptional, IsString, MaxLength } from "class-validator" +import { ValidationsGroupsEnum } from "../../shared/types/validations-groups-enum" +import { EnforceLowerCase } from "../../shared/decorators/enforceLowerCase.decorator" + +export class EmailDto { + @Expose() + @IsEmail({}, { groups: [ValidationsGroupsEnum.default] }) + @EnforceLowerCase() + email: string + + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @MaxLength(256, { groups: [ValidationsGroupsEnum.default] }) + appUrl?: string | null +} diff --git a/backend/core/src/auth/dto/forgot-password-response.dto.ts b/backend/core/src/auth/dto/forgot-password-response.dto.ts new file mode 100644 index 0000000000..16bc637f19 --- /dev/null +++ b/backend/core/src/auth/dto/forgot-password-response.dto.ts @@ -0,0 +1,6 @@ +import { Expose } from "class-transformer" + +export class ForgotPasswordResponseDto { + @Expose() + message: string +} diff --git a/backend/core/src/auth/dto/forgot-password.dto.ts b/backend/core/src/auth/dto/forgot-password.dto.ts new file mode 100644 index 0000000000..b61932dfee --- /dev/null +++ b/backend/core/src/auth/dto/forgot-password.dto.ts @@ -0,0 +1,18 @@ +import { IsEmail, IsOptional, IsString, MaxLength } from "class-validator" +import { Expose } from "class-transformer" +import { ValidationsGroupsEnum } from "../../shared/types/validations-groups-enum" +import { EnforceLowerCase } from "../../shared/decorators/enforceLowerCase.decorator" + +export class ForgotPasswordDto { + @Expose() + @IsEmail({}, { groups: [ValidationsGroupsEnum.default] }) + @MaxLength(256, { groups: [ValidationsGroupsEnum.default] }) + @EnforceLowerCase() + email: string + + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @MaxLength(256, { groups: [ValidationsGroupsEnum.default] }) + appUrl?: string | null +} diff --git a/backend/core/src/auth/dto/get-mfa-info-response.dto.ts b/backend/core/src/auth/dto/get-mfa-info-response.dto.ts new file mode 100644 index 0000000000..feeb789b3e --- /dev/null +++ b/backend/core/src/auth/dto/get-mfa-info-response.dto.ts @@ -0,0 +1,15 @@ +import { Expose } from "class-transformer" + +export class GetMfaInfoResponseDto { + @Expose() + phoneNumber?: string + + @Expose() + email?: string + + @Expose() + isMfaEnabled: boolean + + @Expose() + mfaUsedInThePast: boolean +} diff --git a/backend/core/src/auth/dto/get-mfa-info.dto.ts b/backend/core/src/auth/dto/get-mfa-info.dto.ts new file mode 100644 index 0000000000..dcee16253c --- /dev/null +++ b/backend/core/src/auth/dto/get-mfa-info.dto.ts @@ -0,0 +1,15 @@ +import { Expose } from "class-transformer" +import { IsEmail, IsString } from "class-validator" +import { ValidationsGroupsEnum } from "../../shared/types/validations-groups-enum" +import { EnforceLowerCase } from "../../shared/decorators/enforceLowerCase.decorator" + +export class GetMfaInfoDto { + @Expose() + @IsEmail({}, { groups: [ValidationsGroupsEnum.default] }) + @EnforceLowerCase() + email: string + + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + password: string +} diff --git a/backend/core/src/auth/dto/login-response.dto.ts b/backend/core/src/auth/dto/login-response.dto.ts new file mode 100644 index 0000000000..c4a06b1d08 --- /dev/null +++ b/backend/core/src/auth/dto/login-response.dto.ts @@ -0,0 +1,6 @@ +import { Expose } from "class-transformer" + +export class LoginResponseDto { + @Expose() + accessToken: string +} diff --git a/backend/core/src/auth/dto/login.dto.ts b/backend/core/src/auth/dto/login.dto.ts new file mode 100644 index 0000000000..9c462567b7 --- /dev/null +++ b/backend/core/src/auth/dto/login.dto.ts @@ -0,0 +1,27 @@ +import { IsEmail, IsOptional, IsString, MaxLength, IsEnum } from "class-validator" +import { Expose } from "class-transformer" +import { ValidationsGroupsEnum } from "../../shared/types/validations-groups-enum" +import { EnforceLowerCase } from "../../shared/decorators/enforceLowerCase.decorator" +import { MfaType } from "../types/mfa-type" + +export class LoginDto { + @Expose() + @IsEmail({}, { groups: [ValidationsGroupsEnum.default] }) + @EnforceLowerCase() + email: string + + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + password: string + + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @MaxLength(16, { groups: [ValidationsGroupsEnum.default] }) + mfaCode?: string + + @Expose() + @IsEnum(MfaType, { groups: [ValidationsGroupsEnum.default] }) + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + mfaType?: MfaType +} diff --git a/backend/core/src/auth/dto/paginated-user-list.dto.ts b/backend/core/src/auth/dto/paginated-user-list.dto.ts new file mode 100644 index 0000000000..f5e0dd1799 --- /dev/null +++ b/backend/core/src/auth/dto/paginated-user-list.dto.ts @@ -0,0 +1,4 @@ +import { PaginationFactory } from "../../shared/dto/pagination.dto" +import { UserDto } from "./user.dto" + +export class PaginatedUserListDto extends PaginationFactory(UserDto) {} diff --git a/backend/core/src/auth/dto/request-mfa-code-response.dto.ts b/backend/core/src/auth/dto/request-mfa-code-response.dto.ts new file mode 100644 index 0000000000..e7de50987c --- /dev/null +++ b/backend/core/src/auth/dto/request-mfa-code-response.dto.ts @@ -0,0 +1,12 @@ +import { Expose } from "class-transformer" + +export class RequestMfaCodeResponseDto { + @Expose() + phoneNumber?: string + + @Expose() + email?: string + + @Expose() + phoneNumberVerified?: boolean +} diff --git a/backend/core/src/auth/dto/request-mfa-code.dto.ts b/backend/core/src/auth/dto/request-mfa-code.dto.ts new file mode 100644 index 0000000000..64d0f35192 --- /dev/null +++ b/backend/core/src/auth/dto/request-mfa-code.dto.ts @@ -0,0 +1,25 @@ +import { Expose } from "class-transformer" +import { IsEmail, IsEnum, IsOptional, IsString } from "class-validator" +import { ValidationsGroupsEnum } from "../../shared/types/validations-groups-enum" +import { EnforceLowerCase } from "../../shared/decorators/enforceLowerCase.decorator" +import { MfaType } from "../types/mfa-type" + +export class RequestMfaCodeDto { + @Expose() + @IsEmail({}, { groups: [ValidationsGroupsEnum.default] }) + @EnforceLowerCase() + email: string + + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + password: string + + @Expose() + @IsEnum(MfaType, { groups: [ValidationsGroupsEnum.default] }) + mfaType: MfaType + + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + phoneNumber?: string +} diff --git a/backend/core/src/auth/dto/update-password.dto.ts b/backend/core/src/auth/dto/update-password.dto.ts new file mode 100644 index 0000000000..700378593f --- /dev/null +++ b/backend/core/src/auth/dto/update-password.dto.ts @@ -0,0 +1,26 @@ +import { IsString, Matches, MaxLength } from "class-validator" +import { Expose } from "class-transformer" +import { ValidationsGroupsEnum } from "../../shared/types/validations-groups-enum" +import { Match } from "../../shared/decorators/match.decorator" +import { passwordRegex } from "../../shared/password-regex" + +export class UpdatePasswordDto { + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @Matches(passwordRegex, { + message: "passwordTooWeak", + groups: [ValidationsGroupsEnum.default], + }) + password: string + + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @MaxLength(64, { groups: [ValidationsGroupsEnum.default] }) + @Match("password") + passwordConfirmation: string + + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @MaxLength(256, { groups: [ValidationsGroupsEnum.default] }) + token: string +} diff --git a/backend/core/src/auth/dto/user-basic.dto.ts b/backend/core/src/auth/dto/user-basic.dto.ts new file mode 100644 index 0000000000..5c11eeebd2 --- /dev/null +++ b/backend/core/src/auth/dto/user-basic.dto.ts @@ -0,0 +1,46 @@ +import { OmitType } from "@nestjs/swagger" +import { User } from "../entities/user.entity" +import { Expose, Type } from "class-transformer" +import { IsDefined, IsOptional, ValidateNested } from "class-validator" +import { ValidationsGroupsEnum } from "../../shared/types/validations-groups-enum" +import { UserRolesDto } from "./user-roles.dto" +import { JurisdictionDto } from "../../jurisdictions/dto/jurisdiction.dto" +import { IdDto } from "../../shared/dto/id.dto" +import { UserPreferencesDto } from "./user-preferences.dto" + +export class UserBasicDto extends OmitType(User, [ + "leasingAgentInListings", + "passwordHash", + "confirmationToken", + "resetToken", + "roles", + "jurisdictions", + "mfaCode", + "mfaCodeUpdatedAt", + "preferences", +] as const) { + @Expose() + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default], each: true }) + @Type(() => UserRolesDto) + roles: UserRolesDto + + @Expose() + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default], each: true }) + @Type(() => JurisdictionDto) + jurisdictions: JurisdictionDto[] + + @Expose() + @IsOptional() + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default], each: true }) + @Type(() => IdDto) + leasingAgentInListings?: IdDto[] | null + + @Expose() + @IsOptional() + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => UserPreferencesDto) + preferences?: UserPreferencesDto | null +} diff --git a/backend/core/src/auth/dto/user-create-query-params.ts b/backend/core/src/auth/dto/user-create-query-params.ts new file mode 100644 index 0000000000..a4aae94b88 --- /dev/null +++ b/backend/core/src/auth/dto/user-create-query-params.ts @@ -0,0 +1,17 @@ +import { Expose, Transform } from "class-transformer" +import { ApiProperty } from "@nestjs/swagger" +import { IsBoolean, IsOptional } from "class-validator" +import { ValidationsGroupsEnum } from "../../shared/types/validations-groups-enum" + +export class UserCreateQueryParams { + @Expose() + @ApiProperty({ + type: Boolean, + example: true, + required: false, + }) + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsBoolean({ groups: [ValidationsGroupsEnum.default] }) + @Transform((value: string | undefined) => value === "true", { toClassOnly: true }) + noWelcomeEmail?: boolean +} diff --git a/backend/core/src/auth/dto/user-create.dto.ts b/backend/core/src/auth/dto/user-create.dto.ts new file mode 100644 index 0000000000..ea26ecdbe6 --- /dev/null +++ b/backend/core/src/auth/dto/user-create.dto.ts @@ -0,0 +1,61 @@ +import { OmitType } from "@nestjs/swagger" +import { Expose, Type } from "class-transformer" +import { IsEmail, IsOptional, IsString, Matches, MaxLength, ValidateNested } from "class-validator" +import { ValidationsGroupsEnum } from "../../shared/types/validations-groups-enum" +import { passwordRegex } from "../../shared/password-regex" +import { Match } from "../../shared/decorators/match.decorator" +import { EnforceLowerCase } from "../../shared/decorators/enforceLowerCase.decorator" +import { UserDto } from "./user.dto" +import { IdDto } from "../../shared/dto/id.dto" + +export class UserCreateDto extends OmitType(UserDto, [ + "id", + "createdAt", + "updatedAt", + "leasingAgentInListings", + "roles", + "jurisdictions", + "email", + "mfaEnabled", + "passwordUpdatedAt", + "passwordValidForDays", + "lastLoginAt", + "failedLoginAttemptsCount", +] as const) { + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @Matches(passwordRegex, { + message: "passwordTooWeak", + groups: [ValidationsGroupsEnum.default], + }) + password: string + + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @MaxLength(64, { groups: [ValidationsGroupsEnum.default] }) + @Match("password", { groups: [ValidationsGroupsEnum.default] }) + passwordConfirmation: string + + @Expose() + @IsEmail({}, { groups: [ValidationsGroupsEnum.default] }) + @Match("email", { groups: [ValidationsGroupsEnum.default] }) + @EnforceLowerCase() + emailConfirmation: string + + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @MaxLength(256, { groups: [ValidationsGroupsEnum.default] }) + appUrl?: string | null + + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default], each: true }) + @Type(() => IdDto) + jurisdictions?: IdDto[] + + @Expose() + @IsEmail({}, { groups: [ValidationsGroupsEnum.default] }) + @EnforceLowerCase() + email: string +} diff --git a/backend/core/src/auth/dto/user-filter-params.ts b/backend/core/src/auth/dto/user-filter-params.ts new file mode 100644 index 0000000000..f3ba81bb49 --- /dev/null +++ b/backend/core/src/auth/dto/user-filter-params.ts @@ -0,0 +1,28 @@ +import { BaseFilter } from "../../shared/dto/filter.dto" +import { Expose } from "class-transformer" +import { ApiProperty } from "@nestjs/swagger" +import { IsOptional, IsString } from "class-validator" +import { ValidationsGroupsEnum } from "../../shared/types/validations-groups-enum" +import { UserFilterKeys } from "../types/user-filter-keys" + +export class UserFilterParams extends BaseFilter { + @Expose() + @ApiProperty({ + type: Boolean, + example: true, + required: false, + }) + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + [UserFilterKeys.isPartner]?: boolean; + + @Expose() + @ApiProperty({ + type: Boolean, + example: true, + required: false, + }) + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + [UserFilterKeys.isPortalUser]?: boolean +} diff --git a/backend/core/src/auth/dto/user-filter-type-to-field-map.ts b/backend/core/src/auth/dto/user-filter-type-to-field-map.ts new file mode 100644 index 0000000000..750354fea8 --- /dev/null +++ b/backend/core/src/auth/dto/user-filter-type-to-field-map.ts @@ -0,0 +1,6 @@ +import { UserFilterKeys } from "../types/user-filter-keys" + +export const userFilterTypeToFieldMap: Record = { + isPartner: "user_roles.isPartner", + isPortalUser: "user_roles", +} diff --git a/backend/core/src/auth/dto/user-invite.dto.ts b/backend/core/src/auth/dto/user-invite.dto.ts new file mode 100644 index 0000000000..675a468f49 --- /dev/null +++ b/backend/core/src/auth/dto/user-invite.dto.ts @@ -0,0 +1,40 @@ +import { OmitType } from "@nestjs/swagger" +import { UserDto } from "./user.dto" +import { Expose, Type } from "class-transformer" +import { ArrayMinSize, IsArray, IsDefined, IsOptional, ValidateNested } from "class-validator" +import { ValidationsGroupsEnum } from "../../shared/types/validations-groups-enum" +import { IdDto } from "../../shared/dto/id.dto" +import { UserRolesCreateDto } from "./user-roles-create.dto" + +export class UserInviteDto extends OmitType(UserDto, [ + "id", + "createdAt", + "updatedAt", + "roles", + "jurisdictions", + "leasingAgentInListings", + "mfaEnabled", + "passwordUpdatedAt", + "passwordValidForDays", + "lastLoginAt", + "failedLoginAttemptsCount", +] as const) { + @Expose() + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => UserRolesCreateDto) + roles: UserRolesCreateDto | null + + @Expose() + @IsArray({ groups: [ValidationsGroupsEnum.default] }) + @ArrayMinSize(1, { groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default], each: true }) + @Type(() => IdDto) + jurisdictions: IdDto[] + + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default], each: true }) + @Type(() => IdDto) + leasingAgentInListings?: IdDto[] | null +} diff --git a/backend/core/src/auth/dto/user-list-query-params.ts b/backend/core/src/auth/dto/user-list-query-params.ts new file mode 100644 index 0000000000..d10bd42096 --- /dev/null +++ b/backend/core/src/auth/dto/user-list-query-params.ts @@ -0,0 +1,25 @@ +import { PaginationAllowsAllQueryParams } from "../../shared/dto/pagination.dto" +import { Expose, Type } from "class-transformer" +import { ApiProperty, getSchemaPath } from "@nestjs/swagger" +import { ArrayMaxSize, IsArray, IsOptional, ValidateNested } from "class-validator" +import { ValidationsGroupsEnum } from "../../shared/types/validations-groups-enum" +import { UserFilterParams } from "./user-filter-params" + +export class UserListQueryParams extends PaginationAllowsAllQueryParams { + @Expose() + @ApiProperty({ + name: "filter", + required: false, + type: [String], + items: { + $ref: getSchemaPath(UserFilterParams), + }, + example: { $comparison: "=", isPartner: true }, + }) + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsArray({ groups: [ValidationsGroupsEnum.default] }) + @ArrayMaxSize(16, { groups: [ValidationsGroupsEnum.default] }) + @Type(() => UserFilterParams) + @ValidateNested({ groups: [ValidationsGroupsEnum.default], each: true }) + filter?: UserFilterParams[] +} diff --git a/backend/core/src/auth/dto/user-preferences.dto.ts b/backend/core/src/auth/dto/user-preferences.dto.ts new file mode 100644 index 0000000000..91b3da8a62 --- /dev/null +++ b/backend/core/src/auth/dto/user-preferences.dto.ts @@ -0,0 +1,4 @@ +import { OmitType } from "@nestjs/swagger" +import { UserPreferences } from "../entities/user-preferences.entity" + +export class UserPreferencesDto extends OmitType(UserPreferences, ["user"] as const) {} diff --git a/backend/core/src/auth/dto/user-profile.dto.ts b/backend/core/src/auth/dto/user-profile.dto.ts new file mode 100644 index 0000000000..3cd4473b95 --- /dev/null +++ b/backend/core/src/auth/dto/user-profile.dto.ts @@ -0,0 +1,68 @@ +import { PickType } from "@nestjs/swagger" +import { User } from "../entities/user.entity" +import { Expose, Type } from "class-transformer" +import { + IsDefined, + IsEmail, + IsNotEmpty, + IsOptional, + IsString, + Matches, + MaxLength, + ValidateIf, + ValidateNested, +} from "class-validator" +import { ValidationsGroupsEnum } from "../../shared/types/validations-groups-enum" +import { passwordRegex } from "../../shared/password-regex" +import { IdDto } from "../../shared/dto/id.dto" +import { EnforceLowerCase } from "../../shared/decorators/enforceLowerCase.decorator" +import { UserPreferencesDto } from "./user-preferences.dto" + +export class UserProfileUpdateDto extends PickType(User, [ + "id", + "firstName", + "middleName", + "lastName", + "dob", + "createdAt", + "updatedAt", + "language", + "phoneNumber", +] as const) { + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @Matches(passwordRegex, { + message: "passwordTooWeak", + groups: [ValidationsGroupsEnum.default], + }) + password?: string + + @Expose() + @ValidateIf((o) => o.password, { groups: [ValidationsGroupsEnum.default] }) + @IsNotEmpty({ groups: [ValidationsGroupsEnum.default] }) + currentPassword?: string + + @Expose() + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default], each: true }) + @Type(() => IdDto) + jurisdictions: IdDto[] + + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsEmail({}, { groups: [ValidationsGroupsEnum.default] }) + @EnforceLowerCase() + newEmail?: string + + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @MaxLength(256, { groups: [ValidationsGroupsEnum.default] }) + appUrl?: string | null + + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => UserPreferencesDto) + preferences?: UserPreferencesDto +} diff --git a/backend/core/src/auth/dto/user-roles-create.dto.ts b/backend/core/src/auth/dto/user-roles-create.dto.ts new file mode 100644 index 0000000000..58faf42d37 --- /dev/null +++ b/backend/core/src/auth/dto/user-roles-create.dto.ts @@ -0,0 +1,4 @@ +import { OmitType } from "@nestjs/swagger" +import { UserRolesDto } from "./user-roles.dto" + +export class UserRolesCreateDto extends OmitType(UserRolesDto, ["user"] as const) {} diff --git a/backend/core/src/auth/dto/user-roles-update.dto.ts b/backend/core/src/auth/dto/user-roles-update.dto.ts new file mode 100644 index 0000000000..1cddf68cc9 --- /dev/null +++ b/backend/core/src/auth/dto/user-roles-update.dto.ts @@ -0,0 +1,4 @@ +import { OmitType } from "@nestjs/swagger" +import { UserRolesDto } from "./user-roles.dto" + +export class UserRolesUpdateDto extends OmitType(UserRolesDto, ["user"] as const) {} diff --git a/backend/core/src/auth/dto/user-roles.dto.ts b/backend/core/src/auth/dto/user-roles.dto.ts new file mode 100644 index 0000000000..e4da74e67a --- /dev/null +++ b/backend/core/src/auth/dto/user-roles.dto.ts @@ -0,0 +1,10 @@ +import { OmitType } from "@nestjs/swagger" +import { Expose, Type } from "class-transformer" +import { IdDto } from "../../shared/dto/id.dto" +import { UserRoles } from "../entities/user-roles.entity" + +export class UserRolesDto extends OmitType(UserRoles, ["user"] as const) { + @Expose() + @Type(() => IdDto) + user: IdDto +} diff --git a/backend/core/src/auth/dto/user-update.dto.ts b/backend/core/src/auth/dto/user-update.dto.ts new file mode 100644 index 0000000000..68160176dc --- /dev/null +++ b/backend/core/src/auth/dto/user-update.dto.ts @@ -0,0 +1,104 @@ +import { OmitType } from "@nestjs/swagger" +import { Expose, Type } from "class-transformer" +import { + IsDate, + IsDefined, + IsEmail, + IsNotEmpty, + IsOptional, + IsString, + IsUUID, + Matches, + MaxLength, + ValidateIf, + ValidateNested, +} from "class-validator" +import { ValidationsGroupsEnum } from "../../shared/types/validations-groups-enum" +import { passwordRegex } from "../../shared/password-regex" +import { IdDto } from "../../shared/dto/id.dto" +import { UserDto } from "./user.dto" +import { EnforceLowerCase } from "../../shared/decorators/enforceLowerCase.decorator" +import { UserRolesUpdateDto } from "./user-roles-update.dto" + +export class UserUpdateDto extends OmitType(UserDto, [ + "id", + "email", + "createdAt", + "updatedAt", + "leasingAgentInListings", + "roles", + "jurisdictions", + "mfaEnabled", + "passwordUpdatedAt", + "passwordValidForDays", + "lastLoginAt", + "failedLoginAttemptsCount", +] as const) { + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsUUID(4, { groups: [ValidationsGroupsEnum.default] }) + id?: string + + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsEmail({}, { groups: [ValidationsGroupsEnum.default] }) + @EnforceLowerCase() + email?: string + + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsDate({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => Date) + createdAt?: Date + + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsDate({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => Date) + updatedAt?: Date + + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @Matches(passwordRegex, { + message: "passwordTooWeak", + groups: [ValidationsGroupsEnum.default], + }) + password?: string + + @Expose() + @ValidateIf((o) => o.password, { groups: [ValidationsGroupsEnum.default] }) + @IsNotEmpty({ groups: [ValidationsGroupsEnum.default] }) + currentPassword?: string + + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => UserRolesUpdateDto) + roles?: UserRolesUpdateDto | null + + @Expose() + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default], each: true }) + @Type(() => IdDto) + jurisdictions: IdDto[] + + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default], each: true }) + @Type(() => IdDto) + leasingAgentInListings?: IdDto[] | null + + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsEmail({}, { groups: [ValidationsGroupsEnum.default] }) + @EnforceLowerCase() + newEmail?: string + + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @MaxLength(256, { groups: [ValidationsGroupsEnum.default] }) + appUrl?: string | null +} diff --git a/backend/core/src/auth/dto/user-with-access-token.dto.ts b/backend/core/src/auth/dto/user-with-access-token.dto.ts new file mode 100644 index 0000000000..ee4b77115a --- /dev/null +++ b/backend/core/src/auth/dto/user-with-access-token.dto.ts @@ -0,0 +1,7 @@ +import { Expose } from "class-transformer" +import { UserDto } from "./user.dto" + +export class UserWithAccessTokenDto extends UserDto { + @Expose() + accessToken: string +} diff --git a/backend/core/src/auth/dto/user.dto.ts b/backend/core/src/auth/dto/user.dto.ts new file mode 100644 index 0000000000..430411e0b9 --- /dev/null +++ b/backend/core/src/auth/dto/user.dto.ts @@ -0,0 +1,45 @@ +import { OmitType } from "@nestjs/swagger" +import { User } from "../entities/user.entity" +import { Expose, Type } from "class-transformer" +import { IsDefined, IsOptional, ValidateNested } from "class-validator" +import { ValidationsGroupsEnum } from "../../shared/types/validations-groups-enum" +import { IdNameDto } from "../../shared/dto/idName.dto" +import { UserRolesDto } from "./user-roles.dto" +import { JurisdictionDto } from "../../jurisdictions/dto/jurisdiction.dto" +import { UserPreferencesDto } from "./user-preferences.dto" + +export class UserDto extends OmitType(User, [ + "leasingAgentInListings", + "passwordHash", + "resetToken", + "confirmationToken", + "roles", + "jurisdictions", + "mfaCode", + "mfaCodeUpdatedAt", + "preferences", +] as const) { + @Expose() + @IsOptional() + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default], each: true }) + @Type(() => IdNameDto) + leasingAgentInListings?: IdNameDto[] | null + + @Expose() + @IsOptional() + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => UserRolesDto) + roles?: UserRolesDto | null + + @Expose() + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default], each: true }) + @Type(() => JurisdictionDto) + jurisdictions: JurisdictionDto[] + + @Expose() + @IsOptional() + @Type(() => UserPreferencesDto) + preferences?: UserPreferencesDto | null +} diff --git a/backend/core/src/auth/entities/revoked-token.entity.ts b/backend/core/src/auth/entities/revoked-token.entity.ts new file mode 100644 index 0000000000..02185a0b03 --- /dev/null +++ b/backend/core/src/auth/entities/revoked-token.entity.ts @@ -0,0 +1,18 @@ +import { PrimaryColumn, CreateDateColumn, Entity } from "typeorm" +import { ValidationsGroupsEnum } from "../../shared/types/validations-groups-enum" +import { Type } from "class-transformer" +import { IsDate, IsString } from "class-validator" + +@Entity({ name: "revoked_tokens" }) +class RevokedToken { + @PrimaryColumn("varchar") + @IsString({ groups: [ValidationsGroupsEnum.default] }) + token: string + + @CreateDateColumn() + @IsDate({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => Date) + revokedAt: Date +} + +export { RevokedToken as default, RevokedToken } diff --git a/backend/core/src/auth/entities/user-preferences.entity.ts b/backend/core/src/auth/entities/user-preferences.entity.ts new file mode 100644 index 0000000000..d58e188b2b --- /dev/null +++ b/backend/core/src/auth/entities/user-preferences.entity.ts @@ -0,0 +1,27 @@ +import { User } from "./user.entity" +import { Column, Entity, JoinColumn, OneToOne } from "typeorm" +import { Expose } from "class-transformer" +import { IsString } from "class-validator" +import { ValidationsGroupsEnum } from "../../shared/types/validations-groups-enum" + +@Entity({ name: "user_preferences" }) +export class UserPreferences { + @OneToOne(() => User, (user) => user.preferences, { + primary: true, + }) + @JoinColumn() + user: User + + @Column("boolean", { default: false }) + @Expose() + sendEmailNotifications?: boolean + + @Column("boolean", { default: false }) + @Expose() + sendSmsNotifications?: boolean + + @Column("text", { array: true, default: [] }) + @IsString({ groups: [ValidationsGroupsEnum.default], each: true }) + @Expose() + favoriteIds?: string[] +} diff --git a/backend/core/src/auth/entities/user-roles.entity.ts b/backend/core/src/auth/entities/user-roles.entity.ts new file mode 100644 index 0000000000..f07c9b318c --- /dev/null +++ b/backend/core/src/auth/entities/user-roles.entity.ts @@ -0,0 +1,22 @@ +import { Expose } from "class-transformer" +import { Column, Entity, JoinColumn, OneToOne } from "typeorm" +import { User } from "./user.entity" + +@Entity({ name: "user_roles" }) +export class UserRoles { + @OneToOne(() => User, (user) => user.roles, { + primary: true, + onDelete: "CASCADE", + onUpdate: "CASCADE", + }) + @JoinColumn() + user: User + + @Column("boolean", { default: false }) + @Expose() + isAdmin?: boolean + + @Column("boolean", { default: false }) + @Expose() + isPartner?: boolean +} diff --git a/backend/core/src/auth/entities/user.entity.ts b/backend/core/src/auth/entities/user.entity.ts new file mode 100644 index 0000000000..7a1761faa6 --- /dev/null +++ b/backend/core/src/auth/entities/user.entity.ts @@ -0,0 +1,190 @@ +import { + Column, + CreateDateColumn, + Entity, + Index, + JoinTable, + ManyToMany, + OneToOne, + PrimaryGeneratedColumn, + Unique, + UpdateDateColumn, +} from "typeorm" +import { Listing } from "../../listings/entities/listing.entity" +import { Expose, Type } from "class-transformer" +import { + IsBoolean, + IsDate, + IsEmail, + IsEnum, + IsOptional, + IsPhoneNumber, + IsString, + IsUUID, + MaxLength, +} from "class-validator" +import { ValidationsGroupsEnum } from "../../shared/types/validations-groups-enum" +import { ApiProperty } from "@nestjs/swagger" +import { Language } from "../../shared/types/language-enum" +import { UserRoles } from "./user-roles.entity" +import { Jurisdiction } from "../../jurisdictions/entities/jurisdiction.entity" +import { EnforceLowerCase } from "../../shared/decorators/enforceLowerCase.decorator" +import { UserPreferences } from "./user-preferences.entity" + +@Entity({ name: "user_accounts" }) +@Unique(["email"]) +@Index("user_accounts_email_unique_idx", { synchronize: false }) +export class User { + @PrimaryGeneratedColumn("uuid") + @Expose() + @IsUUID(4, { groups: [ValidationsGroupsEnum.default] }) + id: string + + @Column("varchar", { select: false }) + passwordHash: string + + @Column({ default: () => "NOW()" }) + @Expose() + @Type(() => Date) + passwordUpdatedAt: Date + + @Column({ default: 180 }) + @Expose() + passwordValidForDays: number + + @Column("varchar", { nullable: true }) + resetToken: string + + @Column("varchar", { nullable: true }) + confirmationToken?: string + + @Column({ type: "timestamptz", nullable: true }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsDate({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => Date) + confirmedAt?: Date | null + + @Column("varchar") + @Expose() + @IsEmail({}, { groups: [ValidationsGroupsEnum.default] }) + @EnforceLowerCase() + email: string + + @Column("varchar") + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @MaxLength(64, { groups: [ValidationsGroupsEnum.default] }) + firstName: string + + @Column("varchar", { nullable: true }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @MaxLength(64, { groups: [ValidationsGroupsEnum.default] }) + middleName?: string + + @Column("varchar") + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @MaxLength(64, { groups: [ValidationsGroupsEnum.default] }) + lastName: string + + @Column("timestamp without time zone", { nullable: true }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsDate({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => Date) + dob?: Date | null + + @Column("varchar", { nullable: true }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsPhoneNumber(null, { groups: [ValidationsGroupsEnum.default] }) + phoneNumber?: string + + @CreateDateColumn() + @Expose() + @IsDate({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => Date) + createdAt: Date + + @UpdateDateColumn() + @Expose() + @IsDate({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => Date) + updatedAt: Date + + @ManyToMany(() => Listing, (listing) => listing.leasingAgents, { nullable: true }) + leasingAgentInListings?: Listing[] | null + + @OneToOne(() => UserRoles, (roles) => roles.user, { + eager: true, + cascade: true, + nullable: true, + onDelete: "CASCADE", + onUpdate: "CASCADE", + }) + @Expose() + roles?: UserRoles + + @Column({ enum: Language, nullable: true }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsEnum(Language, { groups: [ValidationsGroupsEnum.default] }) + @ApiProperty({ enum: Language, enumName: "Language" }) + language?: Language | null + + @ManyToMany(() => Jurisdiction, { cascade: true, eager: true }) + @JoinTable() + jurisdictions: Jurisdiction[] + + @Column({ type: "bool", default: false }) + @Expose() + @IsBoolean({ groups: [ValidationsGroupsEnum.default] }) + mfaEnabled?: boolean + + @Column("varchar", { nullable: true }) + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @MaxLength(16, { groups: [ValidationsGroupsEnum.default] }) + mfaCode?: string + + @Column({ type: "timestamptz", nullable: true }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsDate({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => Date) + mfaCodeUpdatedAt?: Date | null + + @Column({ default: () => "NOW()" }) + @Expose() + @Type(() => Date) + lastLoginAt?: Date + + @Column({ default: 0 }) + @Expose() + @Type(() => Date) + failedLoginAttemptsCount?: number + + @Column({ type: "bool", nullable: true, default: false }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsBoolean({ groups: [ValidationsGroupsEnum.default] }) + phoneNumberVerified?: boolean + + @Column({ type: "timestamptz", nullable: true }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsDate({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => Date) + hitConfirmationURL?: Date | null + + @OneToOne(() => UserPreferences, (preferences) => preferences.user, { + eager: true, + cascade: true, + nullable: true, + }) + @Expose() + preferences?: UserPreferences +} diff --git a/backend/core/src/auth/enum/authz-actions.enum.ts b/backend/core/src/auth/enum/authz-actions.enum.ts new file mode 100644 index 0000000000..877c60fd7d --- /dev/null +++ b/backend/core/src/auth/enum/authz-actions.enum.ts @@ -0,0 +1,9 @@ +export enum authzActions { + create = "create", + read = "read", + update = "update", + delete = "delete", + submit = "submit", + confirm = "confirm", + invite = "invite", +} diff --git a/backend/core/src/auth/enum/user-role-enum.ts b/backend/core/src/auth/enum/user-role-enum.ts new file mode 100644 index 0000000000..33964ee4b7 --- /dev/null +++ b/backend/core/src/auth/enum/user-role-enum.ts @@ -0,0 +1,5 @@ +export enum UserRoleEnum { + user = "user", + partner = "partner", + admin = "admin", +} diff --git a/backend/core/src/auth/filters/user-query-filter.ts b/backend/core/src/auth/filters/user-query-filter.ts new file mode 100644 index 0000000000..85dd74a36d --- /dev/null +++ b/backend/core/src/auth/filters/user-query-filter.ts @@ -0,0 +1,80 @@ +import { BaseQueryFilter } from "../../shared/query-filter/base-query-filter" +import { Brackets, WhereExpression } from "typeorm" +import { UserFilterKeys } from "../types/user-filter-keys" +import { userFilterTypeToFieldMap } from "../dto/user-filter-type-to-field-map" + +// UseQueryFilter isn't used here anymore +export class UserQueryFilter extends BaseQueryFilter { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + addFilters( + filters: FilterParams, + filterTypeToFieldMap: FilterFieldMap, + qb: WhereExpression + ) { + for (const [index, filter] of filters.entries()) { + for (const filterKey in filter) { + if (BaseQueryFilter._shouldSkipKey(filter, filterKey)) { + continue + } + BaseQueryFilter._isSupportedFilterTypeOrThrow(filterKey, filterTypeToFieldMap) + const filterValue = BaseQueryFilter._getFilterValue(filter, filterKey) + switch (filterKey) { + case UserFilterKeys.isPortalUser: + this.addIsPortalUserQuery(qb, filterValue) + continue + } + BaseQueryFilter._compare(qb, filter, filterKey, filterTypeToFieldMap, index) + } + } + } + + private addIsPortalUserQuery(qb: WhereExpression, filterValue: string) { + const userRolesColumnName = userFilterTypeToFieldMap[UserFilterKeys.isPortalUser] + if (filterValue == "true") { + qb.andWhere( + new Brackets((subQb) => { + subQb.where(`${userRolesColumnName}.isPartner = true`) + subQb.orWhere(`${userRolesColumnName}.isAdmin = true`) + }) + ) + } else if (filterValue == "false") { + qb.andWhere( + new Brackets((subQb) => { + subQb.where(`${userRolesColumnName}.isPartner IS NULL`) + subQb.orWhere(`${userRolesColumnName}.isPartner = false`) + }) + ) + qb.andWhere( + new Brackets((subQb) => { + subQb.where(`${userRolesColumnName}.isAdmin IS NULL`) + subQb.orWhere(`${userRolesColumnName}.isAdmin = false`) + }) + ) + } + } +} + +export function addIsPortalUserQuery(qb: WhereExpression, filterValue: string) { + const userRolesColumnName = userFilterTypeToFieldMap[UserFilterKeys.isPortalUser] + if (filterValue == "true") { + qb.andWhere( + new Brackets((subQb) => { + subQb.where(`${userRolesColumnName}.isPartner = true`) + subQb.orWhere(`${userRolesColumnName}.isAdmin = true`) + }) + ) + } else if (filterValue == "false") { + qb.andWhere( + new Brackets((subQb) => { + subQb.where(`${userRolesColumnName}.isPartner IS NULL`) + subQb.orWhere(`${userRolesColumnName}.isPartner = false`) + }) + ) + qb.andWhere( + new Brackets((subQb) => { + subQb.where(`${userRolesColumnName}.isAdmin IS NULL`) + subQb.orWhere(`${userRolesColumnName}.isAdmin = false`) + }) + ) + } +} diff --git a/backend/core/src/auth/guards/authz.guard.ts b/backend/core/src/auth/guards/authz.guard.ts new file mode 100644 index 0000000000..4c4320f411 --- /dev/null +++ b/backend/core/src/auth/guards/authz.guard.ts @@ -0,0 +1,34 @@ +import { CanActivate, ExecutionContext, Injectable } from "@nestjs/common" +import { Reflector } from "@nestjs/core" +import { AuthzService } from "../services/authz.service" +import { httpMethodsToAction } from "../../shared/http-methods-to-actions" + +@Injectable() +export class AuthzGuard implements CanActivate { + constructor(private authzService: AuthzService, private reflector: Reflector) {} + + async canActivate(context: ExecutionContext) { + const req = context.switchToHttp().getRequest() + const authUser = req.user + const type = this.reflector.getAllAndOverride("authz_type", [ + context.getClass(), + context.getHandler(), + ]) + const action = + this.reflector.get("authz_action", context.getHandler()) || + httpMethodsToAction[req.method] + + let resource + if (req.params.id) { + // NOTE: implicit assumption that if request.params contains an ID it also means that for requests other + // than GET and DELETE body also contains one too and it should be the same + // This prevents a security hole where user specifies params.id different than dto.id to pass authorization + // but actually edits a different resource + resource = ["GET", "DELETE"].includes(req.method) + ? { id: req.params.id } + : { id: req.body.id } + } + + return this.authzService.can(authUser, type, action, resource) + } +} diff --git a/backend/core/src/auth/guards/default.guard.ts b/backend/core/src/auth/guards/default.guard.ts new file mode 100644 index 0000000000..63302ac525 --- /dev/null +++ b/backend/core/src/auth/guards/default.guard.ts @@ -0,0 +1,3 @@ +import { JwtAuthGuard } from "./jwt.guard" + +export { JwtAuthGuard as DefaultAuthGuard } diff --git a/backend/core/src/auth/guards/jwt.guard.ts b/backend/core/src/auth/guards/jwt.guard.ts new file mode 100644 index 0000000000..3ac73fff12 --- /dev/null +++ b/backend/core/src/auth/guards/jwt.guard.ts @@ -0,0 +1,5 @@ +import { Injectable } from "@nestjs/common" +import { AuthGuard } from "@nestjs/passport" + +@Injectable() +export class JwtAuthGuard extends AuthGuard("jwt") {} diff --git a/backend/core/src/auth/guards/local-auth.guard.ts b/backend/core/src/auth/guards/local-auth.guard.ts new file mode 100644 index 0000000000..b96aadaa50 --- /dev/null +++ b/backend/core/src/auth/guards/local-auth.guard.ts @@ -0,0 +1,5 @@ +import { Injectable } from "@nestjs/common" +import { AuthGuard } from "@nestjs/passport" + +@Injectable() +export class LocalAuthGuard extends AuthGuard("local") {} diff --git a/backend/core/src/auth/guards/local-mfa-auth.guard.ts b/backend/core/src/auth/guards/local-mfa-auth.guard.ts new file mode 100644 index 0000000000..378df8c5ea --- /dev/null +++ b/backend/core/src/auth/guards/local-mfa-auth.guard.ts @@ -0,0 +1,5 @@ +import { Injectable } from "@nestjs/common" +import { AuthGuard } from "@nestjs/passport" + +@Injectable() +export class LocalMfaAuthGuard extends AuthGuard("localMfa") {} diff --git a/backend/core/src/auth/guards/optional-auth.guard.ts b/backend/core/src/auth/guards/optional-auth.guard.ts new file mode 100644 index 0000000000..6b94468510 --- /dev/null +++ b/backend/core/src/auth/guards/optional-auth.guard.ts @@ -0,0 +1,11 @@ +import { DefaultAuthGuard } from "./default.guard" +import { Injectable } from "@nestjs/common" + +@Injectable() +export class OptionalAuthGuard extends DefaultAuthGuard { + handleRequest(err, user) { + // User is literally "false" here when not logged in - return `undefined` instead so that req.user will be + // undefined in the not-logged-in case. + return user ? user : undefined + } +} diff --git a/backend/core/src/auth/guards/user-preferences-authz.guard.ts b/backend/core/src/auth/guards/user-preferences-authz.guard.ts new file mode 100644 index 0000000000..1ea1d62ae7 --- /dev/null +++ b/backend/core/src/auth/guards/user-preferences-authz.guard.ts @@ -0,0 +1,21 @@ +import { CanActivate, ExecutionContext, Injectable } from "@nestjs/common" +import { AuthzService } from "../services/authz.service" +import { Reflector } from "@nestjs/core" +import { httpMethodsToAction } from "../../shared/http-methods-to-actions" + +@Injectable() +export class UserPreferencesAuthzGuard implements CanActivate { + constructor(private authzService: AuthzService, private reflector: Reflector) {} + + async canActivate(context: ExecutionContext) { + const req = context.switchToHttp().getRequest() + const authUser = req.user + const action = + this.reflector.get("authz_action", context.getHandler()) || + httpMethodsToAction[req.method] + + return await this.authzService.can(authUser, "userPreference", action, { + id: req.params.id, + }) + } +} diff --git a/backend/core/src/auth/guards/user-profile-authz.guard.ts b/backend/core/src/auth/guards/user-profile-authz.guard.ts new file mode 100644 index 0000000000..708931d57a --- /dev/null +++ b/backend/core/src/auth/guards/user-profile-authz.guard.ts @@ -0,0 +1,19 @@ +import { CanActivate, ExecutionContext, Injectable } from "@nestjs/common" +import { AuthzService } from "../services/authz.service" +import { Reflector } from "@nestjs/core" +import { authzActions } from "../enum/authz-actions.enum" + +@Injectable() +export class UserProfileAuthzGuard implements CanActivate { + constructor(private authzService: AuthzService, private reflector: Reflector) {} + + async canActivate(context: ExecutionContext) { + const req = context.switchToHttp().getRequest() + const authUser = req.user + const type = this.reflector.getAllAndOverride("authz_type", [ + context.getClass(), + context.getHandler(), + ]) + return this.authzService.can(authUser, type, authzActions.read, { id: authUser.id }) + } +} diff --git a/backend/core/src/auth/passport-strategies/jwt.strategy.ts b/backend/core/src/auth/passport-strategies/jwt.strategy.ts new file mode 100644 index 0000000000..88a0bc7cb2 --- /dev/null +++ b/backend/core/src/auth/passport-strategies/jwt.strategy.ts @@ -0,0 +1,54 @@ +import { ExtractJwt, Strategy } from "passport-jwt" +import { PassportStrategy } from "@nestjs/passport" +import { HttpException, Injectable, UnauthorizedException } from "@nestjs/common" +import { Request } from "express" +import { ConfigService } from "@nestjs/config" +import { AuthService } from "../services/auth.service" +import { InjectRepository } from "@nestjs/typeorm" +import { User } from "../entities/user.entity" +import { Repository } from "typeorm" +import { UserService } from "../services/user.service" +import { USER_ERRORS } from "../user-errors" + +function extractTokenFromAuthHeader(req: Request) { + const authHeader = req.get("Authorization") + return authHeader.split(" ")[1] +} + +@Injectable() +export class JwtStrategy extends PassportStrategy(Strategy) { + constructor( + @InjectRepository(User) private readonly userRepository: Repository, + private authService: AuthService, + private configService: ConfigService + ) { + super({ + jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), + passReqToCallback: true, + ignoreExpiration: false, + secretOrKey: configService.get("APP_SECRET"), + }) + } + + async validate(req, payload) { + const rawToken = extractTokenFromAuthHeader(req) + const isRevoked = await this.authService.isRevokedToken(rawToken) + if (isRevoked) { + throw new UnauthorizedException() + } + const userId = payload.sub + const user = await this.userRepository.findOne({ + where: { id: userId }, + relations: ["leasingAgentInListings"], + }) + + if (user && UserService.isPasswordOutdated(user)) { + throw new HttpException( + USER_ERRORS.PASSWORD_OUTDATED.message, + USER_ERRORS.PASSWORD_OUTDATED.status + ) + } + + return user + } +} diff --git a/backend/core/src/auth/passport-strategies/local-mfa.strategy.ts b/backend/core/src/auth/passport-strategies/local-mfa.strategy.ts new file mode 100644 index 0000000000..e4d5a44121 --- /dev/null +++ b/backend/core/src/auth/passport-strategies/local-mfa.strategy.ts @@ -0,0 +1,120 @@ +import { Strategy } from "passport-custom" +import { PassportStrategy } from "@nestjs/passport" +import { + HttpException, + HttpStatus, + Injectable, + UnauthorizedException, + ValidationPipe, +} from "@nestjs/common" +import { User } from "../entities/user.entity" +import { InjectRepository } from "@nestjs/typeorm" +import { Repository } from "typeorm" +import { PasswordService } from "../services/password.service" +import { defaultValidationPipeOptions } from "../../shared/default-validation-pipe-options" +import { LoginDto } from "../dto/login.dto" +import { ConfigService } from "@nestjs/config" +import { UserService } from "../services/user.service" +import { USER_ERRORS } from "../user-errors" +import { MfaType } from "../types/mfa-type" + +@Injectable() +export class LocalMfaStrategy extends PassportStrategy(Strategy, "localMfa") { + constructor( + @InjectRepository(User) private readonly userRepository: Repository, + private readonly passwordService: PasswordService, + private readonly configService: ConfigService + ) { + super() + } + + async validate(req: Request): Promise { + const validationPipe = new ValidationPipe(defaultValidationPipeOptions) + const loginDto: LoginDto = await validationPipe.transform(req.body, { + type: "body", + metatype: LoginDto, + }) + + const user = await this.userRepository.findOne({ + where: { email: loginDto.email.toLowerCase() }, + relations: ["leasingAgentInListings"], + }) + + if (user) { + if (user.lastLoginAt) { + const retryAfter = new Date( + // eslint-disable-next-line @typescript-eslint/restrict-plus-operands + user.lastLoginAt.getTime() + this.configService.get("AUTH_LOCK_LOGIN_COOLDOWN_MS") + ) + if ( + user.failedLoginAttemptsCount >= + this.configService.get("AUTH_LOCK_LOGIN_AFTER_FAILED_ATTEMPTS") && + retryAfter > new Date() + ) { + throw new HttpException( + { + statusCode: HttpStatus.TOO_MANY_REQUESTS, + error: "Too Many Requests", + message: "Failed login attempts exceeded.", + retryAfter, + }, + 429 + ) + } + } + + if (!user.confirmedAt) { + throw new HttpException( + USER_ERRORS.ACCOUNT_NOT_CONFIRMED.message, + USER_ERRORS.ACCOUNT_NOT_CONFIRMED.status + ) + } + + if (UserService.isPasswordOutdated(user)) { + throw new HttpException( + USER_ERRORS.PASSWORD_OUTDATED.message, + USER_ERRORS.PASSWORD_OUTDATED.status + ) + } + + const validPassword = await this.passwordService.isPasswordValid(user, loginDto.password) + + let mfaAuthSuccessful = true + if (validPassword && user.mfaEnabled) { + if (!loginDto.mfaCode || !user.mfaCode || !user.mfaCodeUpdatedAt) { + throw new UnauthorizedException({ name: "mfaCodeIsMissing" }) + } + if ( + new Date( + user.mfaCodeUpdatedAt.getTime() + this.configService.get("MFA_CODE_VALID_MS") + ) < new Date() || + user.mfaCode !== loginDto.mfaCode + ) { + mfaAuthSuccessful = false + } else { + user.mfaCode = null + user.mfaCodeUpdatedAt = new Date() + } + } + + if (validPassword && mfaAuthSuccessful) { + user.failedLoginAttemptsCount = 0 + if (!user.phoneNumberVerified && loginDto.mfaType === MfaType.sms) { + user.phoneNumberVerified = true + } + } else { + user.failedLoginAttemptsCount += 1 + } + + await this.userRepository.save(user) + + if (validPassword && mfaAuthSuccessful) { + return user + } else if (validPassword && user.mfaEnabled && !mfaAuthSuccessful) { + throw new UnauthorizedException({ message: "mfaUnauthorized" }) + } + } + + throw new UnauthorizedException() + } +} diff --git a/backend/core/src/auth/passport-strategies/local.strategy.spec.ts b/backend/core/src/auth/passport-strategies/local.strategy.spec.ts new file mode 100644 index 0000000000..b9a7010498 --- /dev/null +++ b/backend/core/src/auth/passport-strategies/local.strategy.spec.ts @@ -0,0 +1,81 @@ +import { Test, TestingModule } from "@nestjs/testing" +import { getRepositoryToken } from "@nestjs/typeorm" +import { User } from "../entities/user.entity" +import { ConfigModule, ConfigService } from "@nestjs/config" +import Joi from "joi" +import { PasswordService } from "../services/password.service" +import { LocalStrategy } from "./local.strategy" +import { HttpException, UnauthorizedException } from "@nestjs/common" + +// Cypress brings in Chai types for the global expect, but we want to use jest +// expect here so we need to re-declare it. +// see: https://github.com/cypress-io/cypress/issues/1319#issuecomment-593500345 +declare const expect: jest.Expect + +describe("LocalStrategy", () => { + let configService: ConfigService + let module: TestingModule + let mockUser + let mockUserRepository + let mockPasswordService + + beforeEach(async () => { + mockPasswordService = { isPasswordValid: jest.fn() } + + mockUserRepository = { + findOne: jest.fn(), + save: jest.fn(), + } + + module = await Test.createTestingModule({ + imports: [ + ConfigModule.forRoot({ + validationSchema: Joi.object({ + AUTH_LOCK_LOGIN_AFTER_FAILED_ATTEMPTS: Joi.number().default(5), + AUTH_LOCK_LOGIN_COOLDOWN_MS: Joi.number().default(1000 * 60 * 30), + }), + }), + ], + providers: [ + { provide: PasswordService, useValue: mockPasswordService }, + { provide: getRepositoryToken(User), useValue: mockUserRepository }, + LocalStrategy, + ], + }).compile() + configService = module.get(ConfigService) + }) + + describe("user login", () => { + it("should block 5 consecutive failed login attempts", async () => { + mockUser = { + lastLoginAt: new Date(), + failedLoginAttemptsCount: 0, + passwordUpdatedAt: new Date(), + confirmedAt: new Date(), + } + mockUserRepository.findOne.mockResolvedValue(mockUser) + mockPasswordService.isPasswordValid.mockResolvedValue(false) + + const localStrategy = module.get(LocalStrategy) + for (let i = configService.get("AUTH_LOCK_LOGIN_AFTER_FAILED_ATTEMPTS"); i > 0; i--) { + await expect(localStrategy.validate("", "")).rejects.toThrow(UnauthorizedException) + } + // Next attempt should throw a different exception + await expect(localStrategy.validate("", "")).rejects.toThrow(HttpException) + + // Reset cooldown + mockUser.lastLoginAt = new Date(0) + await expect(localStrategy.validate("", "")).rejects.toThrow(UnauthorizedException) + // Failed login attempts count should not be reset so next login attempt should lock out an + // account for next cooldown period + await expect(localStrategy.validate("", "")).rejects.toThrow(HttpException) + expect(mockUser.failedLoginAttemptsCount).toBe(6) + + // Check if login with valid credentials is still possible after cooldown + mockUser.lastLoginAt = new Date(0) + mockPasswordService.isPasswordValid.mockResolvedValue(true) + await expect(localStrategy.validate("", "")).resolves.toBe(mockUser) + expect(mockUser.failedLoginAttemptsCount).toBe(0) + }) + }) +}) diff --git a/backend/core/src/auth/passport-strategies/local.strategy.ts b/backend/core/src/auth/passport-strategies/local.strategy.ts new file mode 100644 index 0000000000..e167137e98 --- /dev/null +++ b/backend/core/src/auth/passport-strategies/local.strategy.ts @@ -0,0 +1,75 @@ +import { Strategy } from "passport-local" +import { PassportStrategy } from "@nestjs/passport" +import { HttpException, HttpStatus, Injectable, UnauthorizedException } from "@nestjs/common" +import { ConfigService } from "@nestjs/config" +import { InjectRepository } from "@nestjs/typeorm" +import { IsNull, Not, Repository } from "typeorm" +import { User } from "../entities/user.entity" +import { PasswordService } from "../services/password.service" +import { UserService } from "../services/user.service" +import { USER_ERRORS } from "../user-errors" + +@Injectable() +export class LocalStrategy extends PassportStrategy(Strategy) { + constructor( + @InjectRepository(User) private readonly userRepository: Repository, + private readonly passwordService: PasswordService, + private readonly configService: ConfigService + ) { + super({ + usernameField: "email", + }) + } + + async validate(email: string, password: string): Promise { + const user = await this.userRepository.findOne({ + where: { email: email.toLowerCase(), confirmedAt: Not(IsNull()) }, + relations: ["leasingAgentInListings"], + }) + + if (user) { + if (user.lastLoginAt) { + const retryAfter = new Date( + // eslint-disable-next-line @typescript-eslint/restrict-plus-operands + user.lastLoginAt.getTime() + this.configService.get("AUTH_LOCK_LOGIN_COOLDOWN_MS") + ) + if ( + user.failedLoginAttemptsCount >= + this.configService.get("AUTH_LOCK_LOGIN_AFTER_FAILED_ATTEMPTS") && + retryAfter > new Date() + ) { + throw new HttpException( + { + statusCode: HttpStatus.TOO_MANY_REQUESTS, + error: "Too Many Requests", + message: "Failed login attempts exceeded.", + retryAfter, + }, + 429 + ) + } + } + + user.lastLoginAt = new Date() + + const validPassword = await this.passwordService.isPasswordValid(user, password) + if (UserService.isPasswordOutdated(user)) { + throw new HttpException( + USER_ERRORS.PASSWORD_OUTDATED.message, + USER_ERRORS.PASSWORD_OUTDATED.status + ) + } + if (validPassword && user.confirmedAt) { + user.failedLoginAttemptsCount = 0 + } else { + user.failedLoginAttemptsCount += 1 + } + await this.userRepository.save(user) + + if (validPassword) { + return user + } + } + throw new UnauthorizedException() + } +} diff --git a/backend/core/src/auth/services/auth.service.spec.ts b/backend/core/src/auth/services/auth.service.spec.ts new file mode 100644 index 0000000000..050c535719 --- /dev/null +++ b/backend/core/src/auth/services/auth.service.spec.ts @@ -0,0 +1,52 @@ +import { Test, TestingModule } from "@nestjs/testing" +import { AuthService } from "./auth.service" +import { JwtModule, JwtService } from "@nestjs/jwt" +import { getRepositoryToken } from "@nestjs/typeorm" +import { RevokedToken } from "../entities/revoked-token.entity" +import { User } from "../entities/user.entity" + +// Cypress brings in Chai types for the global expect, but we want to use jest +// expect here so we need to re-declare it. +// see: https://github.com/cypress-io/cypress/issues/1319#issuecomment-593500345 +declare const expect: jest.Expect + +describe("AuthService", () => { + let service: AuthService + let jwtService: JwtService + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + imports: [JwtModule.register({ secret: "secret" })], + providers: [ + AuthService, + { + provide: getRepositoryToken(RevokedToken), + useValue: {}, + }, + ], + }).compile() + + service = module.get(AuthService) + jwtService = module.get(JwtService) + }) + + it("should be defined", () => { + expect(service).toBeDefined() + }) + + describe("generateAccessToken", () => { + const user = new User() + it("should generate a JWT", () => { + const jwt = service.generateAccessToken(user) + expect(jwt).toBeDefined() + const decoded = jwtService.decode(jwt) + expect(decoded).toBeDefined() + }) + + it("should assign the userId to the token sub", () => { + const jwt = service.generateAccessToken(user) + const { sub } = jwtService.decode(jwt) as { sub?: string } + expect(sub).toBe(user.id) + }) + }) +}) diff --git a/backend/core/src/auth/services/auth.service.ts b/backend/core/src/auth/services/auth.service.ts new file mode 100644 index 0000000000..93ad99ac38 --- /dev/null +++ b/backend/core/src/auth/services/auth.service.ts @@ -0,0 +1,26 @@ +import { Injectable } from "@nestjs/common" +import { JwtService } from "@nestjs/jwt" +import { InjectRepository } from "@nestjs/typeorm" +import { Repository } from "typeorm" +import { RevokedToken } from "../entities/revoked-token.entity" +import { User } from "../entities/user.entity" + +@Injectable() +export class AuthService { + constructor( + private jwtService: JwtService, + @InjectRepository(RevokedToken) private readonly revokedTokenRepo: Repository + ) {} + + generateAccessToken(user: User) { + const payload = { + sub: user.id, + } + return this.jwtService.sign(payload) + } + + async isRevokedToken(jwt: string) { + const revoked = await this.revokedTokenRepo.findOne({ token: jwt }) + return Boolean(revoked) + } +} diff --git a/backend/core/src/auth/services/authz.service.ts b/backend/core/src/auth/services/authz.service.ts new file mode 100644 index 0000000000..97b90875e8 --- /dev/null +++ b/backend/core/src/auth/services/authz.service.ts @@ -0,0 +1,83 @@ +import { HttpException, HttpStatus, Injectable } from "@nestjs/common" +import { newEnforcer } from "casbin" +import path from "path" +import { User } from "../entities/user.entity" +import { Listing } from "../../listings/entities/listing.entity" +import { UserRoleEnum } from "../enum/user-role-enum" +import { authzActions } from "../enum/authz-actions.enum" + +@Injectable() +export class AuthzService { + /** + * Check whether this is an authorized action based on the authz rules. + * @param user User making the request. If not specified, the request will be authorized against a user with role + * "visitor" and id "anonymous" + * @param type Type of resource to verify access to + * @param action Action (e.g. "read", "edit", etc.) requested + * @param obj Optional resource object to check request against. If provided this can be used by the rule to perform + * ABAC logic. Note that a limitation in casbin seems to only allows for property retrieval one level deep on this + * object (e.g. obj.prop.value wouldn't work). + */ + public async can( + user: User | undefined, + type: string, + action: string, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + obj?: any + ): Promise { + const e = await newEnforcer( + path.join(__dirname, "..", "authz_model.conf"), + path.join(__dirname, "..", "authz_policy.csv") + ) + + // Get User roles and add them to our enforcer + if (user) { + if (user.roles?.isAdmin) { + await e.addRoleForUser(user.id, UserRoleEnum.admin) + } + if (user.roles?.isPartner) { + await e.addRoleForUser(user.id, UserRoleEnum.partner) + } + await e.addRoleForUser(user.id, UserRoleEnum.user) + + // NOTE This normally should be in authz_policy.csv, but casbin does not support expressions on arrays. + // Permissions for a leasing agent on applications are there defined here programatically. + // A User becomes a leasing agent for a given listing if he has a relation (M:N) with it. + // User side this is expressed by 'leasingAgentInListings' property. + await Promise.all( + user?.leasingAgentInListings.map((listing: Listing) => { + void e.addPermissionForUser( + user.id, + "application", + `!r.obj || r.obj.listing_id == '${listing.id}'`, + `(${authzActions.read}|${authzActions.create}|${authzActions.update}|${authzActions.delete})` + ) + void e.addPermissionForUser( + user.id, + "listing", + `!r.obj || r.obj.id == '${listing.id}'`, + `(${authzActions.read}|${authzActions.update})` + ) + }) + ) + } + + return await e.enforce(user ? user.id : "anonymous", type, action, obj) + } + + /** + * Check whether this is an authorized action based on the authz rules. + * @param user User making the request. If not specified, the request will be authorized against a user with role + * "visitor" and id "anonymous" + * @param type Type of resource to verify access to + * @param action Action (e.g. "read", "edit", etc.) requested + * @param obj Optional resource object to check request against. If provided this can be used by the rule to perform + * ABAC logic. Note that a limitation in casbin seems to only allows for property retrieval one level deep on this + * object (e.g. obj.prop.value wouldn't work). + */ + public async canOrThrow(user: User | undefined, type: string, action: string, obj?: unknown) { + if (!(await this.can(user, type, action, obj))) { + throw new HttpException("Forbidden", HttpStatus.FORBIDDEN) + } + } +} diff --git a/backend/core/src/auth/services/password.service.ts b/backend/core/src/auth/services/password.service.ts new file mode 100644 index 0000000000..30ab78a2de --- /dev/null +++ b/backend/core/src/auth/services/password.service.ts @@ -0,0 +1,46 @@ +import { Injectable } from "@nestjs/common" +import { User } from "../../auth/entities/user.entity" +import { randomBytes, scrypt } from "crypto" +import { SALT_SIZE, SCRYPT_KEYLEN } from "../constants" +import { InjectRepository } from "@nestjs/typeorm" +import { Repository } from "typeorm" + +@Injectable() +export class PasswordService { + constructor(@InjectRepository(User) private readonly userRepository: Repository) {} + + // passwordHash is a hidden field - we need to build a query to get it directly + public async getUserWithPassword(user: User) { + return await this.userRepository + .createQueryBuilder() + .addSelect("user.passwordHash") + .from(User, "user") + .where("user.id = :id", { id: user.id }) + .getOne() + } + + public async isPasswordValid(user: User, password: string) { + const userWithPassword = await this.getUserWithPassword(user) + const [salt, savedPasswordHash] = userWithPassword.passwordHash.split("#") + const verifyPasswordHash = await this.hashPassword(password, Buffer.from(salt, "hex")) + return savedPasswordHash === verifyPasswordHash + } + + public async passwordToHash(password: string) { + const salt = this.generateSalt() + const hash = await this.hashPassword(password, salt) + return `${salt.toString("hex")}#${hash}` + } + + private async hashPassword(password: string, salt: Buffer) { + return new Promise((resolve, reject) => + scrypt(password, salt, SCRYPT_KEYLEN, (err, key) => + err ? reject(err) : resolve(key.toString("hex")) + ) + ) + } + + private generateSalt(size = SALT_SIZE) { + return randomBytes(size) + } +} diff --git a/backend/core/src/auth/services/sms-mfa.service.ts b/backend/core/src/auth/services/sms-mfa.service.ts new file mode 100644 index 0000000000..7fae784ea2 --- /dev/null +++ b/backend/core/src/auth/services/sms-mfa.service.ts @@ -0,0 +1,19 @@ +import { Injectable } from "@nestjs/common" +import { User } from "../entities/user.entity" +import { InjectTwilio, TwilioClient } from "nestjs-twilio" +import { ConfigService } from "@nestjs/config" + +@Injectable() +export class SmsMfaService { + public constructor( + @InjectTwilio() private readonly client: TwilioClient, + private readonly configService: ConfigService + ) {} + public async sendMfaCode(user: User, phoneNumber: string, mfaCode: string) { + return await this.client.messages.create({ + body: `Your Partners Portal account access token: ${mfaCode}`, + from: this.configService.get("TWILIO_PHONE_NUMBER"), + to: phoneNumber, + }) + } +} diff --git a/backend/core/src/auth/services/user-preferences.services.ts b/backend/core/src/auth/services/user-preferences.services.ts new file mode 100644 index 0000000000..480eecd778 --- /dev/null +++ b/backend/core/src/auth/services/user-preferences.services.ts @@ -0,0 +1,20 @@ +import { UserPreferences } from "../entities/user-preferences.entity" +import { UserPreferencesDto } from "../dto/user-preferences.dto" +import { InjectRepository } from "@nestjs/typeorm" +import { Repository } from "typeorm" +import { AuthContext } from "../types/auth-context" + +export class UserPreferencesService { + constructor( + @InjectRepository(UserPreferences) + private readonly repository: Repository + ) {} + + async update(dto: UserPreferencesDto, authContext: AuthContext) { + await this.repository.save({ + user: authContext.user, + ...dto, + }) + return dto + } +} diff --git a/backend/core/src/auth/services/user.service.spec.ts b/backend/core/src/auth/services/user.service.spec.ts new file mode 100644 index 0000000000..40494865a9 --- /dev/null +++ b/backend/core/src/auth/services/user.service.spec.ts @@ -0,0 +1,285 @@ +import { Test, TestingModule } from "@nestjs/testing" +import { HttpException } from "@nestjs/common" +import { UserService } from "./user.service" +import { getRepositoryToken } from "@nestjs/typeorm" +import { User } from "../entities/user.entity" +import { USER_ERRORS } from "../user-errors" +import { AuthService } from "./auth.service" +import { AuthzService } from "./authz.service" +import { PasswordService } from "./password.service" +import { JurisdictionResolverService } from "../../jurisdictions/services/jurisdiction-resolver.service" +import { ConfigService } from "@nestjs/config" +import { UserCreateDto } from "../dto/user-create.dto" +import { Application } from "../../applications/entities/application.entity" +import { EmailService } from "../../email/email.service" +import { SmsMfaService } from "./sms-mfa.service" +import { UserInviteDto } from "../dto/user-invite.dto" +import { JurisdictionsService } from "../../jurisdictions/services/jurisdictions.service" + +// Cypress brings in Chai types for the global expect, but we want to use jest +// expect here so we need to re-declare it. +// see: https://github.com/cypress-io/cypress/issues/1319#issuecomment-593500345 +declare const expect: jest.Expect + +describe("UserService", () => { + let service: UserService + const mockUserRepo = { findOne: jest.fn(), save: jest.fn() } + const mockApplicationRepo = { + createQueryBuilder: jest.fn(), + save: jest.fn(), + } + + beforeEach(async () => { + process.env.APP_SECRET = "SECRET" + const module: TestingModule = await Test.createTestingModule({ + providers: [ + UserService, + { + provide: getRepositoryToken(User), + useValue: mockUserRepo, + }, + { + provide: getRepositoryToken(Application), + useValue: mockApplicationRepo, + }, + { + provide: EmailService, + useValue: { forgotPassword: jest.fn(), invite: jest.fn() }, + }, + { + provide: AuthService, + useValue: { generateAccessToken: jest.fn().mockReturnValue("accessToken") }, + }, + { + provide: JurisdictionResolverService, + useValue: { + getJurisdiction: jest.fn(), + }, + }, + { + provide: JurisdictionsService, + useValue: { + findOne: jest.fn(), + }, + }, + AuthzService, + { provide: SmsMfaService, useValue: { sendMfaCode: jest.fn() } }, + PasswordService, + { + provide: ConfigService, + useValue: { get: jest.fn() }, + }, + ], + }).compile() + + service = await module.resolve(UserService) + }) + + afterEach(() => { + jest.resetAllMocks() + }) + + it("should be defined", () => { + expect(service).toBeDefined() + }) + + describe("createUser", () => { + it("should return EMAIL_IN_USE error if email is already in use", async () => { + const mockedUser = { id: "123", email: "abc@xyz.com" } + mockUserRepo.findOne.mockResolvedValueOnce(mockedUser) + const user: UserCreateDto = { + email: "abc@xyz.com", + emailConfirmation: "abc@xyz.com", + password: "qwerty", + passwordConfirmation: "qwerty", + firstName: "First", + lastName: "Last", + dob: new Date(), + } + await expect(service.createPublicUser(user, null, null)).rejects.toThrow( + new HttpException(USER_ERRORS.EMAIL_IN_USE.message, USER_ERRORS.EMAIL_IN_USE.status) + ) + }) + + it("should return ERROR_SAVING if new user fails to save", async () => { + const user: UserCreateDto = { + email: "new@email.com", + emailConfirmation: "new@email.com", + password: "qwerty", + passwordConfirmation: "qwerty", + firstName: "First", + lastName: "Last", + dob: new Date(), + } + mockUserRepo.findOne = jest.fn().mockResolvedValue(null) + mockUserRepo.save = jest.fn().mockRejectedValue(new Error(USER_ERRORS.ERROR_SAVING.message)) + await expect(service.createPublicUser(user, null, null)).rejects.toThrow( + new HttpException(USER_ERRORS.ERROR_SAVING.message, USER_ERRORS.ERROR_SAVING.status) + ) + + // Reset mockUserRepo.save + mockUserRepo.save = jest.fn() + }) + + it("should return EMAIL_IN_USE if attempting to resave existing public user", async () => { + const user: UserCreateDto = { + email: "new@email.com", + emailConfirmation: "new@email.com", + password: "qwerty", + passwordConfirmation: "qwerty", + firstName: "First", + lastName: "Last", + dob: new Date(), + } + mockUserRepo.findOne = jest.fn().mockResolvedValue({ ...user, confirmedAt: new Date() }) + await expect(service._createUser(user, null)).rejects.toThrow( + new HttpException(USER_ERRORS.EMAIL_IN_USE.message, USER_ERRORS.EMAIL_IN_USE.status) + ) + + // Reset mockUserRepo.save + mockUserRepo.save = jest.fn() + }) + + it("should return EMAIL_IN_USE if attempting to create public user from existing partner user", async () => { + const existingUser: User = { + id: "mock id", + email: "new@email.com", + firstName: "First", + lastName: "Last", + dob: new Date(), + confirmedAt: new Date(), + passwordHash: "", + passwordUpdatedAt: new Date(), + passwordValidForDays: 0, + resetToken: "", + createdAt: new Date(), + updatedAt: new Date(), + jurisdictions: [], + } + existingUser.roles = { user: existingUser } + + const user: UserCreateDto = { + email: "new@email.com", + emailConfirmation: "new@email.com", + password: "qwerty", + passwordConfirmation: "qwerty", + firstName: "First", + lastName: "Last", + dob: new Date(), + } + mockUserRepo.findOne = jest.fn().mockResolvedValue(existingUser) + await expect(service._createUser(user, null)).rejects.toThrow( + new HttpException(USER_ERRORS.EMAIL_IN_USE.message, USER_ERRORS.EMAIL_IN_USE.status) + ) + + // Reset mockUserRepo.save + mockUserRepo.save = jest.fn() + }) + + it("should save successfully if attempting to create partner user from public user", async () => { + const existingUser: User = { + id: "mock id", + email: "new@email.com", + firstName: "First", + lastName: "Last", + dob: new Date(), + confirmedAt: new Date(), + passwordHash: "", + passwordUpdatedAt: new Date(), + passwordValidForDays: 0, + resetToken: "", + createdAt: new Date(), + updatedAt: new Date(), + jurisdictions: [], + } + + const user: UserInviteDto = { + email: "new@email.com", + firstName: "First", + lastName: "Last", + dob: new Date(), + roles: { isPartner: true }, + jurisdictions: [], + } + + mockUserRepo.findOne = jest.fn().mockResolvedValue(existingUser) + mockUserRepo.save = jest.fn().mockResolvedValue(user) + const savedUser = await service.invitePartnersPortalUser(user, null) + expect(savedUser).toBe(user) + + // Reset mockUserRepo.save + mockUserRepo.save = jest.fn() + }) + + it("should return EMAIL_IN_USE if attempting to recreate existing partner user", async () => { + const existingUser: User = { + id: "mock id", + email: "new@email.com", + firstName: "First", + lastName: "Last", + dob: new Date(), + confirmedAt: new Date(), + passwordHash: "", + passwordUpdatedAt: new Date(), + passwordValidForDays: 0, + resetToken: "", + createdAt: new Date(), + updatedAt: new Date(), + jurisdictions: [], + } + existingUser.roles = { user: existingUser } + + const user: UserInviteDto = { + email: "new@email.com", + firstName: "First", + lastName: "Last", + dob: new Date(), + roles: { isPartner: true }, + jurisdictions: [], + } + + mockUserRepo.findOne = jest.fn().mockResolvedValue(existingUser) + await expect(service._createUser(user, null)).rejects.toThrow( + new HttpException(USER_ERRORS.EMAIL_IN_USE.message, USER_ERRORS.EMAIL_IN_USE.status) + ) + + // Reset mockUserRepo.save + mockUserRepo.save = jest.fn() + }) + }) + + describe("forgotPassword", () => { + it("should return 400 if email is not found", async () => { + mockUserRepo.findOne = jest.fn().mockResolvedValue(null) + await expect(service.forgotPassword({ email: "abc@xyz.com" })).rejects.toThrow( + new HttpException(USER_ERRORS.NOT_FOUND.message, USER_ERRORS.NOT_FOUND.status) + ) + }) + + it("should set resetToken", async () => { + const mockedUser = { id: "123", email: "abc@xyz.com" } + mockUserRepo.findOne = jest.fn().mockResolvedValue({ ...mockedUser, resetToken: null }) + const user = await service.forgotPassword({ email: "abc@xyz.com" }) + expect(user["resetToken"]).toBeDefined() + }) + }) + + describe("updatePassword", () => { + const updateDto = { password: "qwerty", passwordConfirmation: "qwerty", token: "abcefg" } + it("should return 400 if email is not found", async () => { + mockUserRepo.findOne = jest.fn().mockResolvedValue(null) + await expect(service.updatePassword(updateDto)).rejects.toThrow( + new HttpException(USER_ERRORS.TOKEN_MISSING.message, USER_ERRORS.TOKEN_MISSING.status) + ) + }) + + it("should set resetToken", async () => { + const mockedUser = { id: "123", email: "abc@xyz.com" } + mockUserRepo.findOne = jest.fn().mockResolvedValue(mockedUser) + // Sets resetToken + await service.forgotPassword({ email: "abc@xyz.com" }) + const accessToken = await service.updatePassword(updateDto) + expect(accessToken).toBeDefined() + }) + }) +}) diff --git a/backend/core/src/auth/services/user.service.ts b/backend/core/src/auth/services/user.service.ts new file mode 100644 index 0000000000..ad89bbec03 --- /dev/null +++ b/backend/core/src/auth/services/user.service.ts @@ -0,0 +1,587 @@ +import { + BadRequestException, + HttpException, + Injectable, + NotFoundException, + Scope, + UnauthorizedException, +} from "@nestjs/common" +import { InjectRepository } from "@nestjs/typeorm" +import { DeepPartial, FindConditions, Repository } from "typeorm" +import { paginate, Pagination, PaginationTypeEnum } from "nestjs-typeorm-paginate" +import { decode, encode } from "jwt-simple" +import dayjs from "dayjs" +import crypto from "crypto" +import { User } from "../entities/user.entity" +import { ConfirmDto } from "../dto/confirm.dto" +import { USER_ERRORS } from "../user-errors" +import { UpdatePasswordDto } from "../dto/update-password.dto" +import { AuthService } from "./auth.service" +import { AuthzService } from "./authz.service" +import { ForgotPasswordDto } from "../dto/forgot-password.dto" + +import { AuthContext } from "../types/auth-context" +import { PasswordService } from "./password.service" +import { JurisdictionResolverService } from "../../jurisdictions/services/jurisdiction-resolver.service" +import { EmailDto } from "../dto/email.dto" +import { UserCreateDto } from "../dto/user-create.dto" +import { UserUpdateDto } from "../dto/user-update.dto" +import { UserListQueryParams } from "../dto/user-list-query-params" +import { UserInviteDto } from "../dto/user-invite.dto" +import { ConfigService } from "@nestjs/config" +import { authzActions } from "../enum/authz-actions.enum" +import { userFilterTypeToFieldMap } from "../dto/user-filter-type-to-field-map" +import { Application } from "../../applications/entities/application.entity" +import { Listing } from "../../listings/entities/listing.entity" +import { UserRoles } from "../entities/user-roles.entity" +import { UserPreferences } from "../entities/user-preferences.entity" +import { Jurisdiction } from "../../jurisdictions/entities/jurisdiction.entity" +import { UserQueryFilter } from "../filters/user-query-filter" +import { assignDefined } from "../../shared/utils/assign-defined" +import { EmailService } from "../../email/email.service" +import { RequestMfaCodeDto } from "../dto/request-mfa-code.dto" +import { RequestMfaCodeResponseDto } from "../dto/request-mfa-code-response.dto" +import { MfaType } from "../types/mfa-type" +import { SmsMfaService } from "./sms-mfa.service" +import { GetMfaInfoDto } from "../dto/get-mfa-info.dto" +import { GetMfaInfoResponseDto } from "../dto/get-mfa-info-response.dto" +import { addFilters } from "../../shared/query-filter" +import { UserFilterParams } from "../dto/user-filter-params" + +import advancedFormat from "dayjs/plugin/advancedFormat" +import { JurisdictionsService } from "../../jurisdictions/services/jurisdictions.service" + +dayjs.extend(advancedFormat) + +@Injectable({ scope: Scope.REQUEST }) +export class UserService { + constructor( + @InjectRepository(User) private readonly userRepository: Repository, + @InjectRepository(Application) private readonly applicationsRepository: Repository, + private readonly emailService: EmailService, + private readonly configService: ConfigService, + private readonly authService: AuthService, + private readonly authzService: AuthzService, + private readonly passwordService: PasswordService, + private readonly jurisdictionResolverService: JurisdictionResolverService, + private readonly jurisdictionService: JurisdictionsService, + private readonly smsMfaService: SmsMfaService + ) {} + + public async findByEmail(email: string) { + return await this.userRepository.findOne({ + where: { email: email.toLowerCase() }, + relations: ["leasingAgentInListings"], + }) + } + + public async find(options: FindConditions) { + return await this.userRepository.findOne({ + where: options, + relations: ["leasingAgentInListings"], + }) + } + + public static isPasswordOutdated(user: User) { + return ( + new Date(user.passwordUpdatedAt.getTime() + user.passwordValidForDays * 24 * 60 * 60 * 1000) < + new Date() && + user.roles && + (user.roles.isAdmin || user.roles.isPartner) + ) + } + + public async findOneOrFail(options: FindConditions) { + const user = await this.find(options) + if (!user) { + throw new NotFoundException() + } + return user + } + + public async list( + params: UserListQueryParams, + authContext: AuthContext + ): Promise> { + const options = { + limit: params.limit === "all" ? undefined : params.limit, + page: params.page || 10, + PaginationType: PaginationTypeEnum.TAKE_AND_SKIP, + } + // https://www.npmjs.com/package/nestjs-typeorm-paginate + const distinctIDQB = this._getQb(false) + distinctIDQB.select("user.id") + distinctIDQB.groupBy("user.id") + distinctIDQB.orderBy("user.id") + const qb = this._getQb() + + if (params.filter) { + addFilters, typeof userFilterTypeToFieldMap>( + params.filter, + userFilterTypeToFieldMap, + distinctIDQB + ) + addFilters, typeof userFilterTypeToFieldMap>( + params.filter, + userFilterTypeToFieldMap, + qb + ) + } + const distinctIDResult = await paginate(distinctIDQB, options) + + qb.andWhere("user.id IN (:...distinctIDs)", { + distinctIDs: distinctIDResult.items.map((elem) => elem.id), + }) + const result = await qb.getMany() + /** + * admin are the only ones that can access all users + * so this will throw on the first user that isn't their own (non admin users can access themselves) + */ + await Promise.all( + result.map(async (user) => { + await this.authzService.canOrThrow(authContext.user, "user", authzActions.read, user) + }) + ) + + return { + ...distinctIDResult, + items: result, + } + } + + public async listAllUsers(): Promise { + return await this.userRepository.find() + } + + async update(dto: UserUpdateDto, authContext: AuthContext) { + const user = await this.find({ + id: dto.id, + }) + if (!user) { + throw new NotFoundException() + } + + let passwordHash + let passwordUpdatedAt + if (dto.password) { + if (!dto.currentPassword) { + // Validation is handled at DTO definition level + throw new BadRequestException() + } + if (!(await this.passwordService.isPasswordValid(user, dto.currentPassword))) { + throw new UnauthorizedException("invalidPassword") + } + + passwordHash = await this.passwordService.passwordToHash(dto.password) + passwordUpdatedAt = new Date() + delete dto.password + } + + /** + * only admin users can update roles + */ + if (!authContext.user?.roles?.isAdmin) { + delete dto.roles + } + + /** + * jurisdictions should be filtered based off of what the authContext user has + */ + if (authContext.user.jurisdictions) { + if (dto.jurisdictions) { + dto.jurisdictions = dto.jurisdictions.filter( + (jurisdiction) => + authContext.user.jurisdictions.findIndex((val) => val.id === jurisdiction.id) > -1 + ) + } + } else { + delete dto.jurisdictions + } + + if (dto.newEmail && dto.appUrl) { + user.confirmationToken = UserService.createConfirmationToken(user.id, dto.newEmail) + const confirmationUrl = UserService.getPublicConfirmationUrl(dto.appUrl, user) + await this.emailService.changeEmail(user, dto.appUrl, confirmationUrl, dto.newEmail) + } + + delete dto.newEmail + delete dto.appUrl + + assignDefined(user, { + ...dto, + passwordHash, + passwordUpdatedAt, + }) + + return await this.userRepository.save(user) + } + + public async confirm(dto: ConfirmDto) { + let token: Record = {} + try { + token = decode(dto.token, process.env.APP_SECRET) + } catch (e) { + throw new HttpException(USER_ERRORS.TOKEN_EXPIRED.message, USER_ERRORS.TOKEN_EXPIRED.status) + } + + const user = await this.find({ id: token.id }) + if (!user) { + console.error(`Trying to confirm non-existing user ${token.id}.`) + throw new HttpException(USER_ERRORS.NOT_FOUND.message, USER_ERRORS.NOT_FOUND.status) + } + + if (user.confirmationToken !== dto.token) { + console.error(`Confirmation token mismatch for user ${token.id}.`) + throw new HttpException(USER_ERRORS.TOKEN_MISSING.message, USER_ERRORS.TOKEN_MISSING.status) + } + + user.confirmedAt = new Date() + user.confirmationToken = null + + if (dto.password) { + user.passwordHash = await this.passwordService.passwordToHash(dto.password) + user.passwordUpdatedAt = new Date() + } + + try { + await this.userRepository.save({ + ...user, + ...(token.email && { email: token.email }), + }) + return this.authService.generateAccessToken(user) + } catch (err) { + throw new HttpException(USER_ERRORS.ERROR_SAVING.message, USER_ERRORS.ERROR_SAVING.status) + } + } + + public async resendPartnerConfirmation(dto: EmailDto) { + const user = await this.findByEmail(dto.email) + if (!user) { + throw new HttpException(USER_ERRORS.NOT_FOUND.message, USER_ERRORS.NOT_FOUND.status) + } + if (user.confirmedAt) { + // if the user is already confirmed, we do nothing + // this is so on the front end people can't cheat to find out who has an email in the system + return {} + } else { + user.confirmationToken = UserService.createConfirmationToken(user.id, user.email) + try { + await this.userRepository.save(user) + const confirmationUrl = UserService.getPartnersConfirmationUrl(dto.appUrl, user) + await this.emailService.invite(user, dto.appUrl, confirmationUrl) + return user + } catch (err) { + throw new HttpException(USER_ERRORS.ERROR_SAVING.message, USER_ERRORS.ERROR_SAVING.status) + } + } + } + + private async setHitConfirmationURl(user: User, token: string) { + if (!user) { + throw new HttpException(USER_ERRORS.NOT_FOUND.message, USER_ERRORS.NOT_FOUND.status) + } + + if (user.confirmationToken !== token) { + throw new HttpException(USER_ERRORS.TOKEN_MISSING.message, USER_ERRORS.TOKEN_MISSING.status) + } + user.hitConfirmationURL = new Date() + await this.userRepository.save({ + ...user, + }) + } + + public async isUserConfirmationTokenValid(dto: ConfirmDto) { + try { + const token = decode(dto.token, process.env.APP_SECRET) + const user = await this.find({ id: token.id }) + await this.setHitConfirmationURl(user, dto.token) + return true + } catch (e) { + console.error("isUserConfirmationTokenValid error = ", e) + try { + const user = await this.find({ confirmationToken: dto.token }) + await this.setHitConfirmationURl(user, dto.token) + } catch (e) { + console.error("isUserConfirmationTokenValid error = ", e) + } + return false + } + } + + private static createConfirmationToken(userId: string, email: string) { + const payload = { + id: userId, + email, + exp: Number.parseInt(dayjs().add(24, "hours").format("X")), + } + return encode(payload, process.env.APP_SECRET) + } + + public async resendPublicConfirmation(dto: EmailDto) { + const user = await this.findByEmail(dto.email) + if (!user) { + throw new HttpException(USER_ERRORS.NOT_FOUND.message, USER_ERRORS.NOT_FOUND.status) + } + if (user.confirmedAt) { + throw new HttpException( + USER_ERRORS.ACCOUNT_CONFIRMED.message, + USER_ERRORS.ACCOUNT_CONFIRMED.status + ) + } else { + user.confirmationToken = UserService.createConfirmationToken(user.id, user.email) + try { + await this.userRepository.save(user) + const confirmationUrl = UserService.getPublicConfirmationUrl(dto.appUrl, user) + await this.emailService.welcome(user, dto.appUrl, confirmationUrl) + return user + } catch (err) { + throw new HttpException(USER_ERRORS.ERROR_SAVING.message, USER_ERRORS.ERROR_SAVING.status) + } + } + } + + private static getPublicConfirmationUrl(appUrl: string, user: User) { + return `${appUrl}?token=${user.confirmationToken}` + } + + private static getPartnersConfirmationUrl(appUrl: string, user: User) { + return `${appUrl}/users/confirm?token=${user.confirmationToken}` + } + + public async connectUserWithExistingApplications(user: User) { + const applications = await this.applicationsRepository + .createQueryBuilder("applications") + .leftJoinAndSelect("applications.applicant", "applicant") + .where("applications.user IS NULL") + .andWhere("applicant.emailAddress = :email", { email: user.email }) + .getMany() + + for (const application of applications) { + application.user = user + } + + await this.applicationsRepository.save(applications) + } + + public async _createUser(dto: DeepPartial, authContext: AuthContext) { + if (dto.confirmedAt) { + await this.authzService.canOrThrow(authContext.user, "user", authzActions.confirm, { + ...dto, + }) + } + const existingUser = await this.findByEmail(dto.email) + + if (existingUser) { + if (!existingUser.roles && dto.roles) { + // existing user && public user && user will get roles -> trying to grant partner access to a public user + return await this.userRepository.save({ + ...existingUser, + roles: dto.roles, + leasingAgentInListings: dto.leasingAgentInListings, + confirmationToken: + existingUser.confirmationToken || + UserService.createConfirmationToken(existingUser.id, existingUser.email), + confirmedAt: null, + }) + } else { + // existing user && ((partner user -> trying to recreate user) || (public user -> trying to recreate a public user)) + throw new HttpException(USER_ERRORS.EMAIL_IN_USE.message, USER_ERRORS.EMAIL_IN_USE.status) + } + } + const newUser = await this.userRepository.save(dto) + newUser.confirmationToken = UserService.createConfirmationToken(newUser.id, newUser.email) + return await this.userRepository.save(newUser) + } + + public async createPublicUser( + dto: UserCreateDto, + authContext: AuthContext, + sendWelcomeEmail = false + ) { + const newUser = await this._createUser( + { + ...dto, + passwordHash: await this.passwordService.passwordToHash(dto.password), + jurisdictions: dto.jurisdictions + ? (dto.jurisdictions as Jurisdiction[]) + : [await this.jurisdictionResolverService.getJurisdiction()], + mfaEnabled: false, + preferences: dto.preferences as UserPreferences, + }, + authContext + ) + if (sendWelcomeEmail) { + const confirmationUrl = UserService.getPublicConfirmationUrl(dto.appUrl, newUser) + await this.emailService.welcome(newUser, dto.appUrl, confirmationUrl) + } + await this.connectUserWithExistingApplications(newUser) + return newUser + } + + public async forgotPassword(dto: ForgotPasswordDto) { + const user = await this.findByEmail(dto.email) + if (!user) { + throw new HttpException(USER_ERRORS.NOT_FOUND.message, USER_ERRORS.NOT_FOUND.status) + } + + // Token expires in 1 hour + const payload = { id: user.id, exp: Number.parseInt(dayjs().add(1, "hour").format("X")) } + user.resetToken = encode(payload, process.env.APP_SECRET) + await this.userRepository.save(user) + await this.emailService.forgotPassword(user, dto.appUrl) + return user + } + + public async updatePassword(dto: UpdatePasswordDto) { + const user = await this.find({ resetToken: dto.token }) + if (!user) { + throw new HttpException(USER_ERRORS.TOKEN_MISSING.message, USER_ERRORS.TOKEN_MISSING.status) + } + + const token = decode(user.resetToken, process.env.APP_SECRET) + if (token.id !== user.id) { + throw new HttpException(USER_ERRORS.TOKEN_MISSING.message, USER_ERRORS.TOKEN_MISSING.status) + } + + user.passwordHash = await this.passwordService.passwordToHash(dto.password) + user.passwordUpdatedAt = new Date() + user.resetToken = null + await this.userRepository.save(user) + return this.authService.generateAccessToken(user) + } + + private _getQb(withSelect = true) { + const qb = this.userRepository.createQueryBuilder("user") + if (withSelect) { + qb.leftJoinAndSelect("user.leasingAgentInListings", "listings") + qb.leftJoinAndSelect("user.roles", "user_roles") + } else { + qb.leftJoin("user.leasingAgentInListings", "listings") + qb.leftJoin("user.roles", "user_roles") + } + + return qb + } + + async invitePartnersPortalUser(dto: UserInviteDto, authContext: AuthContext) { + const password = crypto.randomBytes(8).toString("hex") + const user = await this._createUser( + { + ...dto, + passwordHash: await this.passwordService.passwordToHash(password), + leasingAgentInListings: dto.leasingAgentInListings as Listing[], + roles: dto.roles as UserRoles, + jurisdictions: dto.jurisdictions + ? (dto.jurisdictions as Jurisdiction[]) + : [await this.jurisdictionService.findOne({ where: { name: "Detroit" } })], + preferences: (dto.preferences as unknown) as UserPreferences, + mfaEnabled: false, + }, + authContext + ) + + await this.emailService.invite( + user, + this.configService.get("PARTNERS_PORTAL_URL"), + UserService.getPartnersConfirmationUrl(this.configService.get("PARTNERS_PORTAL_URL"), user) + ) + return user + } + + async delete(userId: string) { + const user = await this.userRepository.findOne({ id: userId }) + if (!user) { + throw new NotFoundException() + } + await this.userRepository.remove(user) + } + + generateMfaCode() { + let out = "" + const characters = "0123456789" + for (let i = 0; i < this.configService.get("MFA_CODE_LENGTH"); i++) { + out += characters.charAt(Math.floor(Math.random() * characters.length)) + } + return out + } + + private static hasUsedMfaInThePast(user: User): boolean { + return !!user.mfaCodeUpdatedAt + } + + async getMfaInfo(getMfaInfoDto: GetMfaInfoDto): Promise { + const user = await this.userRepository.findOne({ + where: { email: getMfaInfoDto.email.toLowerCase() }, + }) + if (!user) { + throw new UnauthorizedException() + } + + const validPassword = await this.passwordService.isPasswordValid(user, getMfaInfoDto.password) + if (!validPassword) { + throw new UnauthorizedException() + } + + return { + email: user.email, + phoneNumber: user.phoneNumber ?? undefined, + isMfaEnabled: user.mfaEnabled, + mfaUsedInThePast: UserService.hasUsedMfaInThePast(user), + } + } + + async requestMfaCode(requestMfaCodeDto: RequestMfaCodeDto): Promise { + let user = await this.userRepository.findOne({ + where: { email: requestMfaCodeDto.email.toLowerCase() }, + }) + + if (!user || !user.mfaEnabled) { + throw new UnauthorizedException() + } + + const validPassword = await this.passwordService.isPasswordValid( + user, + requestMfaCodeDto.password + ) + if (!validPassword) { + throw new UnauthorizedException() + } + + if (requestMfaCodeDto.mfaType === MfaType.sms) { + if (requestMfaCodeDto.phoneNumber) { + if (user.phoneNumberVerified) { + throw new UnauthorizedException( + "phone number can only be specified the first time using mfa" + ) + } + user.phoneNumber = requestMfaCodeDto.phoneNumber + } else if (!requestMfaCodeDto.phoneNumber && !user.phoneNumber) { + throw new HttpException( + { name: "phoneNumberMissing", message: "no valid phone number was found" }, + 400 + ) + } + } + const mfaCode = this.generateMfaCode() + user.mfaCode = mfaCode + user.mfaCodeUpdatedAt = new Date() + + user = await this.userRepository.manager.transaction(async (entityManager) => { + const transactionalRepository = entityManager.getRepository(User) + await transactionalRepository.save(user) + if (requestMfaCodeDto.mfaType === MfaType.email) { + await this.emailService.sendMfaCode(user, user.email, mfaCode) + } else if (requestMfaCodeDto.mfaType === MfaType.sms) { + await this.smsMfaService.sendMfaCode(user, user.phoneNumber, mfaCode) + } + return user + }) + + return requestMfaCodeDto.mfaType === MfaType.email + ? { email: user.email, phoneNumberVerified: user.phoneNumberVerified } + : { + phoneNumber: user.phoneNumber, + phoneNumberVerified: user.phoneNumberVerified, + } + } +} diff --git a/backend/core/src/auth/types/auth-context.ts b/backend/core/src/auth/types/auth-context.ts new file mode 100644 index 0000000000..0a61bf455e --- /dev/null +++ b/backend/core/src/auth/types/auth-context.ts @@ -0,0 +1,5 @@ +import { User } from "../entities/user.entity" + +export class AuthContext { + constructor(public readonly user?: User) {} +} diff --git a/backend/core/src/auth/types/mfa-type.ts b/backend/core/src/auth/types/mfa-type.ts new file mode 100644 index 0000000000..475671f710 --- /dev/null +++ b/backend/core/src/auth/types/mfa-type.ts @@ -0,0 +1,4 @@ +export enum MfaType { + sms = "sms", + email = "email", +} diff --git a/backend/core/src/auth/types/user-filter-keys.ts b/backend/core/src/auth/types/user-filter-keys.ts new file mode 100644 index 0000000000..2c1f954844 --- /dev/null +++ b/backend/core/src/auth/types/user-filter-keys.ts @@ -0,0 +1,4 @@ +export enum UserFilterKeys { + isPartner = "isPartner", + isPortalUser = "isPortalUser", +} diff --git a/backend/core/src/auth/user-errors.ts b/backend/core/src/auth/user-errors.ts new file mode 100644 index 0000000000..8277a072dd --- /dev/null +++ b/backend/core/src/auth/user-errors.ts @@ -0,0 +1,40 @@ +import { HttpStatus } from "@nestjs/common" +import { ApiProperty } from "@nestjs/swagger" +import { Expose } from "class-transformer" + +export enum UserErrorMessages { + accountConfirmed = "accountConfirmed", + accountNotConfirmed = "accountNotConfirmed", + errorSaving = "errorSaving", + emailNotFound = "emailNotFound", + tokenExpired = "tokenExpired", + tokenMissing = "tokenMissing", + emailInUse = "emailInUse", + passwordOutdated = "passwordOutdated", +} + +export const USER_ERRORS = { + ACCOUNT_CONFIRMED: { + message: UserErrorMessages.accountConfirmed, + status: HttpStatus.NOT_ACCEPTABLE, + }, + ACCOUNT_NOT_CONFIRMED: { + message: UserErrorMessages.accountNotConfirmed, + status: HttpStatus.UNAUTHORIZED, + }, + ERROR_SAVING: { message: UserErrorMessages.errorSaving, status: HttpStatus.BAD_REQUEST }, + NOT_FOUND: { message: UserErrorMessages.emailNotFound, status: HttpStatus.NOT_FOUND }, + TOKEN_EXPIRED: { message: UserErrorMessages.tokenExpired, status: HttpStatus.BAD_REQUEST }, + TOKEN_MISSING: { message: UserErrorMessages.tokenMissing, status: HttpStatus.BAD_REQUEST }, + EMAIL_IN_USE: { message: UserErrorMessages.emailInUse, status: HttpStatus.BAD_REQUEST }, + PASSWORD_OUTDATED: { + message: UserErrorMessages.passwordOutdated, + status: HttpStatus.UNAUTHORIZED, + }, +} + +export class UserErrorExtraModel { + @ApiProperty({ enum: UserErrorMessages }) + @Expose() + userErrorMessages: UserErrorMessages +} diff --git a/backend/core/src/cron/cron.module.ts b/backend/core/src/cron/cron.module.ts new file mode 100644 index 0000000000..449e2594f5 --- /dev/null +++ b/backend/core/src/cron/cron.module.ts @@ -0,0 +1,11 @@ +import { Module } from "@nestjs/common" +import { AuthModule } from "../auth/auth.module" +import { ListingsModule } from "../listings/listings.module" +import { EmailModule } from "../email/email.module" +import { CronService } from "./cron.service" + +@Module({ + imports: [ListingsModule, EmailModule, AuthModule], + providers: [CronService], +}) +export class CronModule {} diff --git a/backend/core/src/cron/cron.service.ts b/backend/core/src/cron/cron.service.ts new file mode 100644 index 0000000000..07e3ed8a72 --- /dev/null +++ b/backend/core/src/cron/cron.service.ts @@ -0,0 +1,48 @@ +import { Injectable } from "@nestjs/common" +import { Cron, CronExpression } from "@nestjs/schedule" +import { UserListQueryParams } from "../auth/dto/user-list-query-params" +import { AuthContext } from "../auth/types/auth-context" +import { Compare } from "../shared/dto/filter.dto" +import { EmailService } from "../email/email.service" +import { ListingsService } from "../listings/listings.service" +import { UserService } from "../auth/services/user.service" + +@Injectable() +export class CronService { + constructor( + private readonly emailService: EmailService, + private readonly listingsService: ListingsService, + private readonly userService: UserService + ) {} + + @Cron(CronExpression.EVERY_1ST_DAY_OF_MONTH_AT_NOON) + async handleCron() { + if (!process.env.SEND_NOTIFICATIONS_FOR_UPDATE_LISTINGS_REMINDER) { + return + } + + const userQueryParams: UserListQueryParams = { + filter: [ + { + $comparison: Compare["EQUALS"], + isPartner: true, + }, + ], + } + + const users = await this.userService.list(userQueryParams, new AuthContext()) + const allListings = await this.listingsService.list({}) + + // For each listing, check whether the listed leasing agents are in the list of partner users. + // If the leasing agent is a partner and has their email notifications turned on, send the reminder email. + for (const listing of allListings.items) { + const recipients = [] + for (const leasingAgent of listing.leasingAgents) { + if (users.items.includes(leasingAgent) && leasingAgent.preferences.sendEmailNotifications) { + recipients.push(leasingAgent.email) + } + } + // await this.emailService.updateListingReminder(listing, recipients) + } + } +} diff --git a/backend/core/src/custom.d.ts b/backend/core/src/custom.d.ts new file mode 100644 index 0000000000..d27f3e1e3c --- /dev/null +++ b/backend/core/src/custom.d.ts @@ -0,0 +1,9 @@ +// Augment the definition of the Express Request object to have a well-typed version of "our" User object. +// https://stackoverflow.com/questions/37377731/extend-express-request-object-using-typescript +declare global { + declare module "express-serve-static-core" { + export interface Request { + user?: import("./user/entities/user.entity").User + } + } +} diff --git a/backend/core/src/email/email.module.ts b/backend/core/src/email/email.module.ts new file mode 100644 index 0000000000..74b51b102e --- /dev/null +++ b/backend/core/src/email/email.module.ts @@ -0,0 +1,25 @@ +import { forwardRef, Module } from "@nestjs/common" +import { SendGridModule } from "@anchan828/nest-sendgrid" +import { EmailService } from "./email.service" +import { ConfigModule, ConfigService } from "@nestjs/config" +import { SharedModule } from "../shared/shared.module" +import { TranslationsModule } from "../translations/translations.module" +import { JurisdictionsModule } from "../jurisdictions/jurisdictions.module" + +@Module({ + imports: [ + SharedModule, + forwardRef(() => JurisdictionsModule), + TranslationsModule, + SendGridModule.forRootAsync({ + imports: [ConfigModule], + inject: [ConfigService], + useFactory: (configService: ConfigService) => ({ + apikey: configService.get("EMAIL_API_KEY"), + }), + }), + ], + providers: [EmailService], + exports: [EmailService], +}) +export class EmailModule {} diff --git a/backend/core/src/email/email.service.spec.ts b/backend/core/src/email/email.service.spec.ts new file mode 100644 index 0000000000..101f5cb232 --- /dev/null +++ b/backend/core/src/email/email.service.spec.ts @@ -0,0 +1,202 @@ +import { Test, TestingModule } from "@nestjs/testing" +import { SendGridModule, SendGridService } from "@anchan828/nest-sendgrid" +import { User } from "../auth/entities/user.entity" +import { EmailService } from "./email.service" +import { ConfigModule } from "@nestjs/config" +import { ArcherListing, Language } from "../../types" +import { getRepositoryToken, TypeOrmModule } from "@nestjs/typeorm" +import { TranslationsService } from "../translations/services/translations.service" +import { Translation } from "../translations/entities/translation.entity" +import { Repository } from "typeorm" +import { REQUEST } from "@nestjs/core" + +import dbOptions = require("../../ormconfig.test") +import { JurisdictionResolverService } from "../jurisdictions/services/jurisdiction-resolver.service" +import { JurisdictionsService } from "../jurisdictions/services/jurisdictions.service" +import { Jurisdiction } from "../jurisdictions/entities/jurisdiction.entity" +import { GeneratedListingTranslation } from "../translations/entities/generated-listing-translation.entity" +import { GoogleTranslateService } from "../translations/services/google-translate.service" + +declare const expect: jest.Expect +jest.setTimeout(30000) +const user = new User() +user.firstName = "Test" +user.lastName = "User" +user.email = "test@xample.com" + +const listing = Object.assign({}, ArcherListing) + +const application = { + applicant: { emailAddress: "test@xample.com", firstName: "Test", lastName: "User" }, + id: "abcdefg", + confirmationCode: "abc123", +} +let sendMock + +describe("EmailService", () => { + let service: EmailService + let module: TestingModule + let sendGridService: SendGridService + + beforeAll(async () => { + module = await Test.createTestingModule({ + imports: [ + TypeOrmModule.forRoot(dbOptions), + TypeOrmModule.forFeature([Translation, Jurisdiction, GeneratedListingTranslation]), + ConfigModule, + SendGridModule.forRoot({ + apikey: "SG.fake", + }), + ], + providers: [ + EmailService, + TranslationsService, + JurisdictionsService, + GoogleTranslateService, + JurisdictionResolverService, + { + provide: REQUEST, + useValue: { + get: () => { + return "Detroit" + }, + }, + }, + ], + }).compile() + + const jurisdictionService = await module.resolve(JurisdictionsService) + const jurisdiction = await jurisdictionService.findOne({ where: { name: "Detroit" } }) + + const translationsRepository = module.get>( + getRepositoryToken(Translation) + ) + await translationsRepository.createQueryBuilder().delete().execute() + const translationsService = await module.resolve(TranslationsService) + + await translationsService.create({ + jurisdiction: { + id: null, + }, + language: Language.en, + translations: { + footer: { + footer: "Generic footer", + thankYou: "Thank you!", + }, + }, + }) + + await translationsService.create({ + jurisdiction: { + id: jurisdiction.id, + }, + language: Language.en, + translations: { + confirmation: { + yourConfirmationNumber: "Here is your confirmation number:", + shouldBeChosen: + "Should your application be chosen, be prepared to fill out a more detailed application and provide required supporting documents.", + subject: "Your Application Confirmation", + thankYouForApplying: "Thanks for applying. We have received your application for", + whatToExpectNext: "What to expect next:", + whatToExpect: { + FCFS: + "Applicants will be contacted by the property agent on a first come first serve basis until vacancies are filled.", + lottery: + "The lottery will be held on %{lotteryDate}. Applicants will be contacted by the agent in lottery rank order until vacancies are filled.", + noLottery: + "Applicants will be contacted by the agent in waitlist order until vacancies are filled.", + }, + }, + footer: { + callToAction: "How are we doing? We'd like to get your ", + callToActionUrl: + "https://docs.google.com/forms/d/e/1FAIpQLScr7JuVwiNW8q-ifFUWTFSWqEyV5ndA08jAhJQSlQ4ETrnl9w/viewform", + feedback: "feedback", + footer: "Alameda County - Housing and Community Development (HCD) Department", + thankYou: "Thank you", + }, + forgotPassword: { + callToAction: + "If you did make this request, please click on the link below to reset your password:", + changePassword: "Change my password", + ignoreRequest: "If you didn't request this, please ignore this email.", + passwordInfo: + "Your password won't change until you access the link above and create a new one.", + resetRequest: + "A request to reset your Bloom Housing Portal website password for %{appUrl} has recently been made.", + subject: "Forgot your password?", + }, + leasingAgent: { + contactAgentToUpdateInfo: + "If you need to update information on your application, do not apply again. Contact the agent. See below for contact information for the Agent for this listing.", + officeHours: "Office Hours:", + }, + register: { + confirmMyAccount: "Confirm my account", + toConfirmAccountMessage: + "To complete your account creation, please click the link below:", + welcome: "Welcome", + welcomeMessage: + "Thank you for setting up your account on %{appUrl}. It will now be easier for you to start, save, and submit online applications for listings that appear on the site.", + }, + t: { + hello: "Hello", + }, + }, + }) + }) + + beforeEach(async () => { + jest.useFakeTimers() + sendGridService = module.get(SendGridService) + sendMock = jest.fn() + sendGridService.send = sendMock + service = await module.resolve(EmailService) + }) + + it("should be defined", async () => { + const service = await module.resolve(EmailService) + expect(service).toBeDefined() + }) + + describe("welcome", () => { + it("should generate html body, jurisdictional footer", async () => { + await service.welcome(user, "http://localhost:3000", "http://localhost:3000/?token=") + expect(sendMock).toHaveBeenCalled() + expect(sendMock.mock.calls[0][0].to).toEqual(user.email) + expect(sendMock.mock.calls[0][0].subject).toEqual("Welcome to Detroit Home Connect") + // Check if translation is working correctly + expect(sendMock.mock.calls[0][0].html).toContain( + "Alameda County - Housing and Community Development (HCD) Department" + ) + expect(sendMock.mock.calls[0][0].html).toContain("

Hello Test \n User,

") + }) + }) + + describe("confirmation", () => { + it("should generate html body", async () => { + const service = await module.resolve(EmailService) + // TODO Remove BaseEntity from inheritance from all entities + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + await service.confirmation(listing, application, "http://localhost:3000") + expect(sendMock).toHaveBeenCalled() + expect(sendMock.mock.calls[0][0].to).toEqual(user.email) + expect(sendMock.mock.calls[0][0].subject).toEqual("Your Application Confirmation") + expect(sendMock.mock.calls[0][0].html).toMatch( + /Applicants will be contacted by the agent in waitlist order until vacancies are filled/ + ) + expect(sendMock.mock.calls[0][0].html).toMatch( + /http:\/\/localhost:3000\/listing\/Uvbk5qurpB2WI9V6WnNdH/ + ) + // contains application id + expect(sendMock.mock.calls[0][0].html).toMatch(/abc123/) + }) + }) + + afterAll(async () => { + await module.close() + }) +}) diff --git a/backend/core/src/email/email.service.ts b/backend/core/src/email/email.service.ts new file mode 100644 index 0000000000..8722287cc3 --- /dev/null +++ b/backend/core/src/email/email.service.ts @@ -0,0 +1,273 @@ +import { Injectable, Logger, Scope } from "@nestjs/common" +import { SendGridService } from "@anchan828/nest-sendgrid" +import { ResponseError } from "@sendgrid/helpers/classes" +import merge from "lodash/merge" +import Handlebars from "handlebars" +import path from "path" +import Polyglot from "node-polyglot" +import fs from "fs" +import { ConfigService } from "@nestjs/config" +import { TranslationsService } from "../translations/services/translations.service" +import { JurisdictionResolverService } from "../jurisdictions/services/jurisdiction-resolver.service" +import { User } from "../auth/entities/user.entity" +import { Listing } from "../listings/entities/listing.entity" +import { Application } from "../applications/entities/application.entity" +import { ListingReviewOrder } from "../listings/types/listing-review-order-enum" +import { Jurisdiction } from "../jurisdictions/entities/jurisdiction.entity" +import { Language } from "../shared/types/language-enum" +import { JurisdictionsService } from "../jurisdictions/services/jurisdictions.service" + +@Injectable({ scope: Scope.REQUEST }) +export class EmailService { + polyglot: Polyglot + + constructor( + private readonly sendGrid: SendGridService, + private readonly configService: ConfigService, + private readonly translationService: TranslationsService, + private readonly jurisdictionResolverService: JurisdictionResolverService, + private readonly jurisdictionService: JurisdictionsService + ) { + this.polyglot = new Polyglot({ + phrases: {}, + }) + const polyglot = this.polyglot + Handlebars.registerHelper("t", function ( + phrase: string, + options?: number | Polyglot.InterpolationOptions + ) { + return polyglot.t(phrase, options) + }) + const parts = this.partials() + Handlebars.registerPartial(parts) + } + + public async welcome(user: User, appUrl: string, confirmationUrl: string) { + const jurisdiction = await this.getUserJurisdiction(user) + await this.loadTranslationsForUser(user) + if (this.configService.get("NODE_ENV") === "production") { + Logger.log( + `Preparing to send a welcome email to ${user.email} from ${jurisdiction.emailFromAddress}...` + ) + } + await this.send( + user.email, + jurisdiction.emailFromAddress, + "Welcome to Detroit Home Connect", + this.template("register-email")({ + user: user, + confirmationUrl: confirmationUrl, + appOptions: { appUrl: appUrl }, + }) + ) + } + + private async getUserJurisdiction(user?: User) { + let jurisdiction = await this.jurisdictionResolverService.getJurisdiction() + if (!jurisdiction && user?.jurisdictions) { + jurisdiction = await this.jurisdictionService.findOne({ + where: { id: user.jurisdictions[0].id }, + }) + } + return jurisdiction + } + + private async getListingJurisdiction(listing?: Listing) { + let jurisdiction = await this.jurisdictionResolverService.getJurisdiction() + if (!jurisdiction && listing?.jurisdiction) { + jurisdiction = listing.jurisdiction + } + + return jurisdiction + } + + private async loadTranslationsForUser(user: User) { + const language = user.language || Language.en + const jurisdiction = await this.getUserJurisdiction(user) + void (await this.loadTranslations(jurisdiction, language)) + } + + public async changeEmail(user: User, appUrl: string, confirmationUrl: string, newEmail: string) { + const jurisdiction = await this.getUserJurisdiction(user) + await this.loadTranslationsForUser(user) + await this.send( + newEmail, + jurisdiction.emailFromAddress, + "Email change request", + this.template("change-email")({ + user: user, + confirmationUrl: confirmationUrl, + appOptions: { appUrl: appUrl }, + }) + ) + } + + public async sendMfaCode(user: User, email: string, mfaCode: string) { + const jurisdiction = await this.getUserJurisdiction(user) + await this.loadTranslationsForUser(user) + await this.send( + email, + jurisdiction.emailFromAddress, + "Partners Portal account access token", + this.template("mfa-code")({ + user: user, + mfaCodeOptions: { mfaCode }, + }) + ) + } + + public async confirmation(listing: Listing, application: Application, appUrl: string) { + const jurisdiction = await this.getListingJurisdiction(listing) + void (await this.loadTranslations(jurisdiction, application.language || Language.en)) + let whatToExpectText + const listingUrl = `${appUrl}/listing/${listing.id}` + const compiledTemplate = this.template("confirmation") + + if (this.configService.get("NODE_ENV") == "production") { + Logger.log( + `Preparing to send a confirmation email to ${application.applicant.emailAddress} from ${jurisdiction.emailFromAddress}...` + ) + } + + if (listing.applicationDueDate) { + if (listing.reviewOrderType === ListingReviewOrder.lottery) { + whatToExpectText = this.polyglot.t("confirmation.whatToExpect.lottery", { + lotteryDate: listing.applicationDueDate, + }) + } else { + whatToExpectText = this.polyglot.t("confirmation.whatToExpect.noLottery", { + lotteryDate: listing.applicationDueDate, + }) + } + } else { + whatToExpectText = this.polyglot.t("confirmation.whatToExpect.FCFS") + } + const user = { + firstName: application.applicant.firstName, + middleName: application.applicant.middleName, + lastName: application.applicant.lastName, + } + await this.send( + application.applicant.emailAddress, + jurisdiction.emailFromAddress, + this.polyglot.t("confirmation.subject"), + compiledTemplate({ + listing: listing, + listingUrl: listingUrl, + application: application, + whatToExpectText: whatToExpectText, + user: user, + }) + ) + } + + public async forgotPassword(user: User, appUrl: string) { + const jurisdiction = await this.getUserJurisdiction(user) + void (await this.loadTranslations(jurisdiction, user.language)) + const compiledTemplate = this.template("forgot-password") + const resetUrl = `${appUrl}/reset-password?token=${user.resetToken}` + + if (this.configService.get("NODE_ENV") == "production") { + Logger.log( + `Preparing to send a forget password email to ${user.email} from ${jurisdiction.emailFromAddress}...` + ) + } + + await this.send( + user.email, + jurisdiction.emailFromAddress, + this.polyglot.t("forgotPassword.subject"), + compiledTemplate({ + resetUrl: resetUrl, + resetOptions: { appUrl: appUrl }, + user: user, + }) + ) + } + + private async loadTranslations(jurisdiction: Jurisdiction | null, language: Language) { + const jurisdictionalTranslations = await this.translationService.getTranslationByLanguageAndJurisdictionOrDefaultEn( + language, + jurisdiction ? jurisdiction.id : null + ) + const genericTranslations = await this.translationService.getTranslationByLanguageAndJurisdictionOrDefaultEn( + language, + null + ) + + // Deep merge + const translations = merge( + genericTranslations.translations, + jurisdictionalTranslations.translations + ) + + this.polyglot.replace(translations) + } + + private template(view: string) { + return Handlebars.compile( + fs.readFileSync( + path.join(path.resolve(__dirname, "..", "shared", "views"), `/${view}.hbs`), + "utf8" + ) + ) + } + + private partial(view: string) { + return fs.readFileSync( + path.join(path.resolve(__dirname, "..", "shared", "views"), `/${view}`), + "utf8" + ) + } + + private partials() { + const partials = {} + const dirName = path.resolve(__dirname, "..", "shared", "views/partials") + + fs.readdirSync(dirName).forEach((filename) => { + partials[filename.slice(0, -4)] = this.partial("partials/" + filename) + }) + + return partials + } + + private async send(to: string, from: string, subject: string, body: string, retry = 3) { + await this.sendGrid.send( + { + to: to, + from, + subject: subject, + html: body, + }, + false, + (error) => { + if (error instanceof ResponseError) { + const { response } = error + const { body: errBody } = response + console.error(`Error sending email to: ${to}! Error body: ${errBody}`) + if (retry > 0) { + void this.send(to, from, subject, body, retry - 1) + } + } + } + ) + } + + async invite(user: User, appUrl: string, confirmationUrl: string) { + void (await this.loadTranslations( + user.jurisdictions?.length === 1 ? user.jurisdictions[0] : null, + user.language || Language.en + )) + const jurisdiction = await this.getUserJurisdiction(user) + await this.send( + user.email, + jurisdiction.emailFromAddress, + this.polyglot.t("invite.hello"), + this.template("invite")({ + user: user, + confirmationUrl: confirmationUrl, + appOptions: { appUrl }, + }) + ) + } +} diff --git a/backend/core/src/jurisdictions/dto/jurisdiction-create.dto.ts b/backend/core/src/jurisdictions/dto/jurisdiction-create.dto.ts new file mode 100644 index 0000000000..70ef81bd89 --- /dev/null +++ b/backend/core/src/jurisdictions/dto/jurisdiction-create.dto.ts @@ -0,0 +1,8 @@ +import { OmitType } from "@nestjs/swagger" +import { JurisdictionDto } from "./jurisdiction.dto" + +export class JurisdictionCreateDto extends OmitType(JurisdictionDto, [ + "id", + "createdAt", + "updatedAt", +] as const) {} diff --git a/backend/core/src/jurisdictions/dto/jurisdiction-update.dto.ts b/backend/core/src/jurisdictions/dto/jurisdiction-update.dto.ts new file mode 100644 index 0000000000..33cabae2c3 --- /dev/null +++ b/backend/core/src/jurisdictions/dto/jurisdiction-update.dto.ts @@ -0,0 +1,28 @@ +import { OmitType } from "@nestjs/swagger" +import { Expose, Type } from "class-transformer" +import { IsDate, IsOptional, IsUUID } from "class-validator" +import { ValidationsGroupsEnum } from "../../shared/types/validations-groups-enum" +import { JurisdictionDto } from "./jurisdiction.dto" + +export class JurisdictionUpdateDto extends OmitType(JurisdictionDto, [ + "id", + "createdAt", + "updatedAt", +] as const) { + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsUUID(4, { groups: [ValidationsGroupsEnum.default] }) + id?: string + + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsDate({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => Date) + createdAt?: Date + + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsDate({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => Date) + updatedAt?: Date +} diff --git a/backend/core/src/jurisdictions/dto/jurisdiction.dto.ts b/backend/core/src/jurisdictions/dto/jurisdiction.dto.ts new file mode 100644 index 0000000000..302966b2b2 --- /dev/null +++ b/backend/core/src/jurisdictions/dto/jurisdiction.dto.ts @@ -0,0 +1,29 @@ +import { OmitType } from "@nestjs/swagger" +import { Expose, Type } from "class-transformer" +import { ArrayMaxSize, IsArray, IsString, ValidateNested } from "class-validator" +import { ValidationsGroupsEnum } from "../../shared/types/validations-groups-enum" +import { Jurisdiction } from "../entities/jurisdiction.entity" +import { IdDto } from "../../shared/dto/id.dto" +import { IdNameDto } from "../../shared/dto/idName.dto" + +export class JurisdictionDto extends OmitType(Jurisdiction, ["preferences", "programs"] as const) { + @Expose() + @IsArray({ groups: [ValidationsGroupsEnum.default] }) + @ArrayMaxSize(1024, { groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => IdDto) + programs: IdDto[] + + @Expose() + @IsArray({ groups: [ValidationsGroupsEnum.default] }) + @ArrayMaxSize(1024, { groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => IdDto) + preferences: IdDto[] +} + +export class JurisdictionSlimDto extends IdNameDto { + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + publicUrl: string +} diff --git a/backend/core/src/jurisdictions/entities/jurisdiction.entity.ts b/backend/core/src/jurisdictions/entities/jurisdiction.entity.ts new file mode 100644 index 0000000000..efbd79e9ea --- /dev/null +++ b/backend/core/src/jurisdictions/entities/jurisdiction.entity.ts @@ -0,0 +1,68 @@ +import { Column, Entity, JoinTable, ManyToMany } from "typeorm" +import { AbstractEntity } from "../../shared/entities/abstract.entity" +import { Program } from "../../program/entities/program.entity" +import { + IsString, + MaxLength, + IsOptional, + IsEnum, + ArrayMaxSize, + IsArray, + ValidateNested, +} from "class-validator" +import { ValidationsGroupsEnum } from "../../shared/types/validations-groups-enum" +import { Language } from "../../shared/types/language-enum" +import { Expose, Type } from "class-transformer" +import { Preference } from "../../preferences/entities/preference.entity" + +@Entity({ name: "jurisdictions" }) +export class Jurisdiction extends AbstractEntity { + @Column({ type: "text", unique: true }) + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @MaxLength(256, { groups: [ValidationsGroupsEnum.default] }) + name: string + + @Column({ nullable: true, type: "text" }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + notificationsSignUpURL?: string | null + + @ManyToMany(() => Program, (program) => program.jurisdictions, { cascade: true }) + @JoinTable() + @Expose() + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => Program) + programs: Program[] + + @Column({ type: "enum", enum: Language, array: true, default: [Language.en] }) + @Expose() + @IsArray({ groups: [ValidationsGroupsEnum.default] }) + @ArrayMaxSize(256, { groups: [ValidationsGroupsEnum.default] }) + @IsEnum(Language, { groups: [ValidationsGroupsEnum.default], each: true }) + languages: Language[] + + @ManyToMany(() => Preference, (preference) => preference.jurisdictions, { cascade: true }) + @JoinTable() + @Expose() + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => Preference) + preferences: Preference[] + + @Column({ nullable: true, type: "text" }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + partnerTerms?: string | null + + @Column({ type: "text", default: "" }) + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + publicUrl: string + + @Column({ nullable: true, type: "text" }) + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + emailFromAddress: string +} diff --git a/backend/core/src/jurisdictions/jurisdictions.controller.ts b/backend/core/src/jurisdictions/jurisdictions.controller.ts new file mode 100644 index 0000000000..99cff62c5d --- /dev/null +++ b/backend/core/src/jurisdictions/jurisdictions.controller.ts @@ -0,0 +1,76 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + Post, + Put, + UseGuards, + UsePipes, + ValidationPipe, +} from "@nestjs/common" +import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger" +import { OptionalAuthGuard } from "../auth/guards/optional-auth.guard" +import { AuthzGuard } from "../auth/guards/authz.guard" +import { ResourceType } from "../auth/decorators/resource-type.decorator" +import { mapTo } from "../shared/mapTo" +import { defaultValidationPipeOptions } from "../shared/default-validation-pipe-options" +import { JurisdictionsService } from "./services/jurisdictions.service" +import { JurisdictionDto } from "./dto/jurisdiction.dto" +import { JurisdictionCreateDto } from "./dto/jurisdiction-create.dto" +import { JurisdictionUpdateDto } from "./dto/jurisdiction-update.dto" + +@Controller("jurisdictions") +@ApiTags("jurisdictions") +@ApiBearerAuth() +@ResourceType("jurisdiction") +@UseGuards(OptionalAuthGuard, AuthzGuard) +@UsePipes(new ValidationPipe(defaultValidationPipeOptions)) +export class JurisdictionsController { + constructor(private readonly jurisdictionsService: JurisdictionsService) {} + + @Get() + @ApiOperation({ summary: "List jurisdictions", operationId: "list" }) + async list(): Promise { + return mapTo(JurisdictionDto, await this.jurisdictionsService.list()) + } + + @Post() + @ApiOperation({ summary: "Create jurisdiction", operationId: "create" }) + async create(@Body() jurisdiction: JurisdictionCreateDto): Promise { + return mapTo(JurisdictionDto, await this.jurisdictionsService.create(jurisdiction)) + } + + @Put(`:jurisdictionId`) + @ApiOperation({ summary: "Update jurisdiction", operationId: "update" }) + async update(@Body() jurisdiction: JurisdictionUpdateDto): Promise { + return mapTo(JurisdictionDto, await this.jurisdictionsService.update(jurisdiction)) + } + + @Get(`:jurisdictionId`) + @ApiOperation({ summary: "Get jurisdiction by id", operationId: "retrieve" }) + async retrieve(@Param("jurisdictionId") jurisdictionId: string): Promise { + return mapTo( + JurisdictionDto, + await this.jurisdictionsService.findOne({ where: { id: jurisdictionId } }) + ) + } + + @Get(`byName/:jurisdictionName`) + @ApiOperation({ summary: "Get jurisdiction by name", operationId: "retrieveByName" }) + async retrieveByName( + @Param("jurisdictionName") jurisdictionName: string + ): Promise { + return mapTo( + JurisdictionDto, + await this.jurisdictionsService.findOne({ where: { name: jurisdictionName } }) + ) + } + + @Delete(`:jurisdictionId`) + @ApiOperation({ summary: "Delete jurisdiction by id", operationId: "delete" }) + async delete(@Param("jurisdictionId") jurisdictionId: string): Promise { + return await this.jurisdictionsService.delete(jurisdictionId) + } +} diff --git a/backend/core/src/jurisdictions/jurisdictions.module.ts b/backend/core/src/jurisdictions/jurisdictions.module.ts new file mode 100644 index 0000000000..6a615aa085 --- /dev/null +++ b/backend/core/src/jurisdictions/jurisdictions.module.ts @@ -0,0 +1,15 @@ +import { forwardRef, Module } from "@nestjs/common" +import { TypeOrmModule } from "@nestjs/typeorm" +import { AuthModule } from "../auth/auth.module" +import { Jurisdiction } from "./entities/jurisdiction.entity" +import { JurisdictionsController } from "./jurisdictions.controller" +import { JurisdictionsService } from "./services/jurisdictions.service" +import { JurisdictionResolverService } from "./services/jurisdiction-resolver.service" + +@Module({ + imports: [TypeOrmModule.forFeature([Jurisdiction]), forwardRef(() => AuthModule)], + controllers: [JurisdictionsController], + providers: [JurisdictionsService, JurisdictionResolverService], + exports: [JurisdictionsService, JurisdictionResolverService], +}) +export class JurisdictionsModule {} diff --git a/backend/core/src/jurisdictions/services/jurisdiction-resolver.service.ts b/backend/core/src/jurisdictions/services/jurisdiction-resolver.service.ts new file mode 100644 index 0000000000..ab9654ee00 --- /dev/null +++ b/backend/core/src/jurisdictions/services/jurisdiction-resolver.service.ts @@ -0,0 +1,30 @@ +import { Inject, Injectable, NotFoundException, Scope } from "@nestjs/common" +import { REQUEST } from "@nestjs/core" +import { InjectRepository } from "@nestjs/typeorm" +import { Request as ExpressRequest } from "express" +import { Repository } from "typeorm" +import { Jurisdiction } from "../entities/jurisdiction.entity" + +@Injectable({ scope: Scope.REQUEST }) +export class JurisdictionResolverService { + constructor( + @Inject(REQUEST) private req: ExpressRequest, + @InjectRepository(Jurisdiction) + private readonly jurisdictionRepository: Repository + ) {} + + async getJurisdiction(): Promise { + const jurisdictionName = this.req.get("jurisdictionName") + if (jurisdictionName === "undefined") return undefined + + const jurisdiction = await this.jurisdictionRepository.findOne({ + where: { name: jurisdictionName }, + }) + + if (jurisdiction === undefined) { + throw new NotFoundException("The jurisdiction is not configured.") + } + + return jurisdiction + } +} diff --git a/backend/core/src/jurisdictions/services/jurisdictions.service.ts b/backend/core/src/jurisdictions/services/jurisdictions.service.ts new file mode 100644 index 0000000000..140874bd41 --- /dev/null +++ b/backend/core/src/jurisdictions/services/jurisdictions.service.ts @@ -0,0 +1,58 @@ +import { NotFoundException } from "@nestjs/common" +import { InjectRepository } from "@nestjs/typeorm" +import { FindOneOptions, Repository } from "typeorm" +import { Jurisdiction } from "../entities/jurisdiction.entity" +import { JurisdictionCreateDto } from "../dto/jurisdiction-create.dto" +import { JurisdictionUpdateDto } from "../dto/jurisdiction-update.dto" +import { assignDefined } from "../../shared/utils/assign-defined" + +export class JurisdictionsService { + constructor( + @InjectRepository(Jurisdiction) + private readonly repository: Repository + ) {} + joinOptions = { + alias: "jurisdiction", + leftJoinAndSelect: { + programs: "jurisdiction.programs", + preferences: "jurisdiction.preferences", + }, + } + + list(): Promise { + return this.repository.find({ + join: this.joinOptions, + }) + } + + async create(dto: JurisdictionCreateDto): Promise { + return await this.repository.save(dto) + } + + async findOne(findOneOptions: FindOneOptions): Promise { + const obj = await this.repository.findOne({ ...findOneOptions, join: this.joinOptions }) + if (!obj) { + throw new NotFoundException() + } + return obj + } + + async delete(objId: string) { + await this.repository.delete(objId) + } + + async update(dto: JurisdictionUpdateDto) { + const obj = await this.repository.findOne({ + where: { + id: dto.id, + }, + join: this.joinOptions, + }) + if (!obj) { + throw new NotFoundException() + } + assignDefined(obj, dto) + await this.repository.save(obj) + return obj + } +} diff --git a/backend/core/src/listings/dto/filter-type-to-field-map.ts b/backend/core/src/listings/dto/filter-type-to-field-map.ts new file mode 100644 index 0000000000..1a85ff2491 --- /dev/null +++ b/backend/core/src/listings/dto/filter-type-to-field-map.ts @@ -0,0 +1,62 @@ +// Using a record lets us enforce that all types are handled in addFilter +import { ListingFilterKeys } from "../../.." + +/** + * Fields for the Availability and AMI filters are determined based on the value + * of the filter or by checking multiple columns. Since we can't specify a single + * field the filters correspond to, we remove them from the filterTypeToFieldMap. + */ +type keysWithMappedField = Exclude< + keyof typeof ListingFilterKeys, + "minAmiPercentage" | "availability" +> + +export const filterTypeToFieldMap: Record = { + id: "listings.id", + status: "listings.status", + name: "listings.name", + isVerified: "listings.isVerified", + bedrooms: "summaryUnitType.num_bedrooms", + zipcode: "buildingAddress.zipCode", + leasingAgents: "leasingAgents.id", + program: "listingsProgramsProgram.title", + // This is the inverse of the explanation for maxRent below. + minRent: "unitGroups.monthly_rent_max", + /** + * The maxRent filter uses the monthly_rent_min field to avoid missing units + * in the unitGroups's rent range. For example, if there's a unitGroups with + * monthly_rent_min of $300 and monthly_rent_max of $800, we could have a + * real unit with rent $500, which would look like: + * + * $300 ---------------- $500 ------ $600 ----------- $800 + * ^ ^ ^ ^ + * ^ ^ ^ ^ + * | | | unitGroups.monthly_rent_max + * | | maxRent filter value + * | actual unit's rent + * unitGroups.monthly_rent_min + * + * If a user sets the maxRent filter to $600 we should show this potential unit. + * To make sure we show this potential unit in results, we want to search for + * listings with a monthly_rent_min that's <= $600. If we used the + * monthly_rent_max field, we'd miss it. + */ + maxRent: "unitGroups.monthly_rent_min", + elevator: "listing_features.elevator", + wheelchairRamp: "listing_features.wheelchairRamp", + serviceAnimalsAllowed: "listing_features.serviceAnimalsAllowed", + accessibleParking: "listing_features.accessibleParking", + parkingOnSite: "listing_features.parkingOnSite", + inUnitWasherDryer: "listing_features.inUnitWasherDryer", + laundryInBuilding: "listing_features.laundryInBuilding", + barrierFreeEntrance: "listing_features.barrierFreeEntrance", + rollInShower: "listing_features.rollInShower", + grabBars: "listing_features.grabBars", + heatingInUnit: "listing_features.heatingInUnit", + acInUnit: "listing_features.acInUnit", + neighborhood: "property.neighborhood", + jurisdiction: "jurisdiction.id", + favorited: "", + marketingType: "listings.marketingType", + region: "property.region", +} diff --git a/backend/core/src/listings/dto/listing-create.dto.ts b/backend/core/src/listings/dto/listing-create.dto.ts new file mode 100644 index 0000000000..983521457f --- /dev/null +++ b/backend/core/src/listings/dto/listing-create.dto.ts @@ -0,0 +1,235 @@ +import { OmitType } from "@nestjs/swagger" +import { Expose, Type } from "class-transformer" +import { + ArrayMaxSize, + IsDefined, + IsNumber, + IsOptional, + IsString, + ValidateNested, +} from "class-validator" +import { ValidationsGroupsEnum } from "../../shared/types/validations-groups-enum" +import { IdDto } from "../../shared/dto/id.dto" +import { AddressCreateDto } from "../../shared/dto/address.dto" +import { ListingEventCreateDto } from "./listing-event.dto" +import { AssetCreateDto } from "../../assets/dto/asset.dto" +import { UnitGroupCreateDto } from "../../units-summary/dto/unit-group.dto" +import { ListingDto } from "./listing.dto" +import { ApplicationMethodCreateDto } from "../../application-methods/dto/application-method.dto" +import { UnitCreateDto } from "../../units/dto/unit-create.dto" +import { ListingPreferenceUpdateDto } from "../../preferences/dto/listing-preference-update.dto" +import { ListingProgramUpdateDto } from "../../program/dto/listing-program-update.dto" +import { ListingImageUpdateDto } from "./listing-image-update.dto" + +export class ListingCreateDto extends OmitType(ListingDto, [ + "id", + "applicationPickUpAddress", + "applicationDropOffAddress", + "applicationMailingAddress", + "createdAt", + "updatedAt", + "applicationMethods", + "buildingSelectionCriteriaFile", + "events", + "images", + "leasingAgentAddress", + "leasingAgents", + "urlSlug", + "showWaitlist", + "units", + "accessibility", + "amenities", + "buildingAddress", + "buildingTotalUnits", + "developer", + "householdSizeMax", + "householdSizeMin", + "neighborhood", + "petPolicy", + "smokingPolicy", + "unitsAvailable", + "unitAmenities", + "servicesOffered", + "yearBuilt", + "unitSummaries", + "jurisdiction", + "reservedCommunityType", + "result", + "unitGroups", + "referralApplication", + "listingPreferences", + "listingPrograms", + "publishedAt", + "closedAt", +] as const) { + @Expose() + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default], each: true }) + @Type(() => ApplicationMethodCreateDto) + applicationMethods: ApplicationMethodCreateDto[] + + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => AddressCreateDto) + applicationPickUpAddress?: AddressCreateDto | null + + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => AddressCreateDto) + applicationDropOffAddress: AddressCreateDto | null + + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => AddressCreateDto) + applicationMailingAddress: AddressCreateDto | null + + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => AssetCreateDto) + buildingSelectionCriteriaFile?: AssetCreateDto | null + + @Expose() + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default], each: true }) + @Type(() => ListingEventCreateDto) + events: ListingEventCreateDto[] + + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default], each: true }) + @Type(() => ListingImageUpdateDto) + images?: ListingImageUpdateDto[] | null + + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => AddressCreateDto) + leasingAgentAddress?: AddressCreateDto | null + + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default], each: true }) + @Type(() => IdDto) + leasingAgents?: IdDto[] | null + + @Expose() + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default], each: true }) + @ArrayMaxSize(512, { groups: [ValidationsGroupsEnum.default] }) + @Type(() => UnitCreateDto) + units: UnitCreateDto[] + + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + accessibility?: string | null + + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + amenities?: string | null + + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => AddressCreateDto) + buildingAddress?: AddressCreateDto | null + + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsNumber({}, { groups: [ValidationsGroupsEnum.default] }) + buildingTotalUnits?: number | null + + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + developer?: string | null + + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsNumber({}, { groups: [ValidationsGroupsEnum.default] }) + householdSizeMax?: number | null + + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsNumber({}, { groups: [ValidationsGroupsEnum.default] }) + householdSizeMin?: number | null + + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + neighborhood?: string | null + + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + petPolicy?: string | null + + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + smokingPolicy?: string | null + + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsNumber({}, { groups: [ValidationsGroupsEnum.default] }) + unitsAvailable?: number | null + + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + unitAmenities?: string | null + + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + servicesOffered?: string | null + + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsNumber({}, { groups: [ValidationsGroupsEnum.default] }) + yearBuilt?: number | null + + @Expose() + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => IdDto) + jurisdiction: IdDto + + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => IdDto) + reservedCommunityType?: IdDto + + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => AssetCreateDto) + result?: AssetCreateDto | null + + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default], each: true }) + @Type(() => UnitGroupCreateDto) + unitGroups?: UnitGroupCreateDto[] + + @Expose() + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default], each: true }) + @Type(() => ListingPreferenceUpdateDto) + listingPreferences: ListingPreferenceUpdateDto[] + + @Expose() + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default], each: true }) + @Type(() => ListingProgramUpdateDto) + listingPrograms?: ListingProgramUpdateDto[] +} diff --git a/backend/core/src/listings/dto/listing-event.dto.ts b/backend/core/src/listings/dto/listing-event.dto.ts new file mode 100644 index 0000000000..91d2e347cd --- /dev/null +++ b/backend/core/src/listings/dto/listing-event.dto.ts @@ -0,0 +1,50 @@ +import { OmitType } from "@nestjs/swagger" +import { ListingEvent } from "../entities/listing-event.entity" +import { Expose, Type } from "class-transformer" +import { IsDate, IsOptional, IsUUID, ValidateNested } from "class-validator" +import { ValidationsGroupsEnum } from "../../shared/types/validations-groups-enum" +import { AssetCreateDto, AssetUpdateDto } from "../../assets/dto/asset.dto" + +export class ListingEventDto extends OmitType(ListingEvent, ["listing"]) {} +export class ListingEventCreateDto extends OmitType(ListingEventDto, [ + "id", + "createdAt", + "updatedAt", + "file", +] as const) { + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => AssetCreateDto) + file?: AssetCreateDto +} + +export class ListingEventUpdateDto extends OmitType(ListingEventDto, [ + "id", + "createdAt", + "updatedAt", + "file", +] as const) { + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsUUID(4, { groups: [ValidationsGroupsEnum.default] }) + id?: string + + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsDate({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => Date) + createdAt?: Date + + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsDate({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => Date) + updatedAt?: Date + + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => AssetUpdateDto) + file?: AssetUpdateDto +} diff --git a/backend/core/src/listings/dto/listing-features.dto.ts b/backend/core/src/listings/dto/listing-features.dto.ts new file mode 100644 index 0000000000..b3b4562363 --- /dev/null +++ b/backend/core/src/listings/dto/listing-features.dto.ts @@ -0,0 +1,9 @@ +import { OmitType } from "@nestjs/swagger" +import { ListingFeatures } from "../entities/listing-features.entity" + +export class ListingFeaturesDto extends OmitType(ListingFeatures, [ + "id", + "createdAt", + "updatedAt", + "listing", +] as const) {} diff --git a/backend/core/src/listings/dto/listing-filter-params.ts b/backend/core/src/listings/dto/listing-filter-params.ts new file mode 100644 index 0000000000..bb5d09fa46 --- /dev/null +++ b/backend/core/src/listings/dto/listing-filter-params.ts @@ -0,0 +1,303 @@ +// add other listing filter params here +import { BaseFilter } from "../../shared/dto/filter.dto" +import { Expose } from "class-transformer" +import { ApiProperty } from "@nestjs/swagger" +import { IsBooleanString, IsEnum, IsNumberString, IsOptional, IsString } from "class-validator" +import { ValidationsGroupsEnum } from "../../shared/types/validations-groups-enum" +import { AvailabilityFilterEnum, ListingFilterKeys } from "../types/listing-filter-keys-enum" +import { ListingStatus } from "../types/listing-status-enum" +import { ListingMarketingTypeEnum } from "../types/listing-marketing-type-enum" +import { Region } from "../../property/types/region-enum" + +// add other listing filter params here +export class ListingFilterParams extends BaseFilter { + @Expose() + @ApiProperty({ + type: String, + example: "FAB1A3C6-965E-4054-9A48-A282E92E9426", + required: false, + }) + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + [ListingFilterKeys.id]?: string; + + @Expose() + @ApiProperty({ + type: String, + example: "Coliseum", + required: false, + }) + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + [ListingFilterKeys.name]?: string; + + @Expose() + @ApiProperty({ + enum: Object.keys(ListingStatus), + example: "active", + required: false, + }) + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsEnum(ListingStatus, { groups: [ValidationsGroupsEnum.default] }) + [ListingFilterKeys.status]?: ListingStatus; + + @Expose() + @ApiProperty({ + type: String, + example: "2, 3", + required: false, + }) + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + [ListingFilterKeys.bedrooms]?: string; + + @Expose() + @ApiProperty({ + type: String, + example: "48211, 48212", + required: false, + }) + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + [ListingFilterKeys.zipcode]?: string; + + @Expose() + @ApiProperty({ + type: String, + example: "FAB1A3C6-965E-4054-9A48-A282E92E9426", + required: false, + }) + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + [ListingFilterKeys.leasingAgents]?: string; + + @Expose() + @ApiProperty({ + enum: Object.keys(AvailabilityFilterEnum), + example: "hasAvailability", + required: false, + }) + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsEnum(AvailabilityFilterEnum, { groups: [ValidationsGroupsEnum.default] }) + [ListingFilterKeys.availability]?: AvailabilityFilterEnum; + + @Expose() + @ApiProperty({ + type: String, + example: "senior62", + required: false, + }) + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + [ListingFilterKeys.program]?: string; + + @Expose() + @ApiProperty({ + type: Boolean, + example: "true", + required: false, + }) + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsBooleanString({ groups: [ValidationsGroupsEnum.default] }) + [ListingFilterKeys.isVerified]?: boolean; + + @Expose() + @ApiProperty({ + type: Number, + example: "300", + required: false, + }) + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsNumberString({}, { groups: [ValidationsGroupsEnum.default] }) + [ListingFilterKeys.minRent]?: number; + + @Expose() + @ApiProperty({ + type: Number, + example: "700", + required: false, + }) + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsNumberString({}, { groups: [ValidationsGroupsEnum.default] }) + [ListingFilterKeys.maxRent]?: number; + + @Expose() + @ApiProperty({ + type: Number, + example: "40", + required: false, + }) + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsNumberString({}, { groups: [ValidationsGroupsEnum.default] }) + [ListingFilterKeys.minAmiPercentage]?: number; + + @Expose() + @ApiProperty({ + type: Boolean, + example: "true", + required: false, + }) + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsBooleanString({ groups: [ValidationsGroupsEnum.default] }) + [ListingFilterKeys.elevator]?: boolean; + + @Expose() + @ApiProperty({ + type: Boolean, + example: "true", + required: false, + }) + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsBooleanString({ groups: [ValidationsGroupsEnum.default] }) + [ListingFilterKeys.wheelchairRamp]?: boolean; + + @Expose() + @ApiProperty({ + type: Boolean, + example: "true", + required: false, + }) + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsBooleanString({ groups: [ValidationsGroupsEnum.default] }) + [ListingFilterKeys.serviceAnimalsAllowed]?: boolean; + + @Expose() + @ApiProperty({ + type: Boolean, + example: "true", + required: false, + }) + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsBooleanString({ groups: [ValidationsGroupsEnum.default] }) + [ListingFilterKeys.accessibleParking]?: boolean; + + @Expose() + @ApiProperty({ + type: Boolean, + example: "true", + required: false, + }) + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsBooleanString({ groups: [ValidationsGroupsEnum.default] }) + [ListingFilterKeys.parkingOnSite]?: boolean; + + @Expose() + @ApiProperty({ + type: Boolean, + example: "true", + required: false, + }) + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsBooleanString({ groups: [ValidationsGroupsEnum.default] }) + [ListingFilterKeys.inUnitWasherDryer]?: boolean; + + @Expose() + @ApiProperty({ + type: Boolean, + example: "true", + required: false, + }) + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsBooleanString({ groups: [ValidationsGroupsEnum.default] }) + [ListingFilterKeys.laundryInBuilding]?: boolean; + + @Expose() + @ApiProperty({ + type: Boolean, + example: "true", + required: false, + }) + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsBooleanString({ groups: [ValidationsGroupsEnum.default] }) + [ListingFilterKeys.barrierFreeEntrance]?: boolean; + + @Expose() + @ApiProperty({ + type: Boolean, + example: "true", + required: false, + }) + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsBooleanString({ groups: [ValidationsGroupsEnum.default] }) + [ListingFilterKeys.rollInShower]?: boolean; + + @Expose() + @ApiProperty({ + type: Boolean, + example: "true", + required: false, + }) + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsBooleanString({ groups: [ValidationsGroupsEnum.default] }) + [ListingFilterKeys.grabBars]?: boolean; + + @Expose() + @ApiProperty({ + type: Boolean, + example: "true", + required: false, + }) + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsBooleanString({ groups: [ValidationsGroupsEnum.default] }) + [ListingFilterKeys.heatingInUnit]?: boolean; + + @Expose() + @ApiProperty({ + type: Boolean, + example: "true", + required: false, + }) + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsBooleanString({ groups: [ValidationsGroupsEnum.default] }) + [ListingFilterKeys.acInUnit]?: boolean; + + @Expose() + @ApiProperty({ + type: String, + example: "Forest Park, Elmwood Park", + required: false, + }) + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + [ListingFilterKeys.neighborhood]?: string; + + @Expose() + @ApiProperty({ + enum: Object.keys(Region), + example: "eastside", + required: false, + }) + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsEnum(Region, { groups: [ValidationsGroupsEnum.default] }) + [ListingFilterKeys.region]?: Region; + + @Expose() + @ApiProperty({ + type: String, + example: "bab6cb4f-7a5a-4ee5-b327-0c2508807780", + required: false, + }) + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + [ListingFilterKeys.jurisdiction]?: string; + + @Expose() + @ApiProperty({ + enum: Object.keys(ListingMarketingTypeEnum), + example: "marketing", + required: false, + }) + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsEnum(ListingMarketingTypeEnum, { groups: [ValidationsGroupsEnum.default] }) + [ListingFilterKeys.marketingType]?: ListingMarketingTypeEnum; + + @Expose() + @ApiProperty({ + type: String, + example: "bab6cb4f-7a5a-4ee5-b327-0c2508807780", + required: false, + }) + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + [ListingFilterKeys.favorited]?: string +} diff --git a/backend/core/src/listings/dto/listing-image-update.dto.ts b/backend/core/src/listings/dto/listing-image-update.dto.ts new file mode 100644 index 0000000000..cce469daa0 --- /dev/null +++ b/backend/core/src/listings/dto/listing-image-update.dto.ts @@ -0,0 +1,15 @@ +import { OmitType } from "@nestjs/swagger" +import { Expose, Type } from "class-transformer" +import { IsDefined, IsOptional, ValidateNested } from "class-validator" +import { ValidationsGroupsEnum } from "../../shared/types/validations-groups-enum" +import { ListingImageDto } from "./listing-image.dto" +import { AssetUpdateDto } from "../../assets/dto/asset.dto" + +export class ListingImageUpdateDto extends OmitType(ListingImageDto, ["image"] as const) { + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => AssetUpdateDto) + image: AssetUpdateDto +} diff --git a/backend/core/src/listings/dto/listing-image.dto.ts b/backend/core/src/listings/dto/listing-image.dto.ts new file mode 100644 index 0000000000..05b40027de --- /dev/null +++ b/backend/core/src/listings/dto/listing-image.dto.ts @@ -0,0 +1,15 @@ +import { OmitType } from "@nestjs/swagger" +import { Expose, Type } from "class-transformer" +import { IsDefined, IsOptional, ValidateNested } from "class-validator" +import { ValidationsGroupsEnum } from "../../shared/types/validations-groups-enum" +import { ListingImage } from "../entities/listing-image.entity" +import { AssetUpdateDto } from "../../assets/dto/asset.dto" + +export class ListingImageDto extends OmitType(ListingImage, ["listing", "image"] as const) { + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => AssetUpdateDto) + image: AssetUpdateDto +} diff --git a/backend/core/src/listings/dto/listing-published-create.dto.ts b/backend/core/src/listings/dto/listing-published-create.dto.ts new file mode 100644 index 0000000000..526b484478 --- /dev/null +++ b/backend/core/src/listings/dto/listing-published-create.dto.ts @@ -0,0 +1,128 @@ +import { AssetCreateDto } from "../../assets/dto/asset.dto" +import { ListingReviewOrder } from "../types/listing-review-order-enum" +import { AddressCreateDto } from "../../shared/dto/address.dto" +import { ListingCreateDto } from "./listing-create.dto" +import { Expose, Type } from "class-transformer" +import { + ArrayMaxSize, + ArrayMinSize, + IsDefined, + IsEmail, + IsEnum, + IsString, + MaxLength, + ValidateNested, + IsBoolean, + IsPhoneNumber, +} from "class-validator" +import { ValidationsGroupsEnum } from "../../shared/types/validations-groups-enum" +import { OmitType } from "@nestjs/swagger" +import { UnitCreateDto } from "../../units/dto/unit-create.dto" +import { EnforceLowerCase } from "../../shared/decorators/enforceLowerCase.decorator" +import { ListingImageUpdateDto } from "./listing-image-update.dto" + +export class ListingPublishedCreateDto extends OmitType(ListingCreateDto, [ + "assets", + "buildingAddress", + "depositMax", + "depositMin", + "developer", + "digitalApplication", + "images", + "isWaitlistOpen", + "leasingAgentEmail", + "leasingAgentName", + "leasingAgentPhone", + "name", + "paperApplication", + "referralOpportunity", + "rentalAssistance", + "reviewOrderType", + "units", +] as const) { + @Expose() + @ValidateNested({ groups: [ValidationsGroupsEnum.default], each: true }) + @Type(() => AssetCreateDto) + assets: AssetCreateDto[] + + @Expose() + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => AddressCreateDto) + buildingAddress: AddressCreateDto + + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @MaxLength(64, { groups: [ValidationsGroupsEnum.default] }) + depositMin: string + + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @MaxLength(64, { groups: [ValidationsGroupsEnum.default] }) + depositMax: string + + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @MaxLength(256, { groups: [ValidationsGroupsEnum.default] }) + developer: string + + @Expose() + @IsBoolean({ groups: [ValidationsGroupsEnum.default] }) + digitalApplication: boolean + + @Expose() + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default], each: true }) + @Type(() => ListingImageUpdateDto) + images: ListingImageUpdateDto[] + + @Expose() + @IsBoolean({ groups: [ValidationsGroupsEnum.default] }) + isWaitlistOpen: boolean + + @Expose() + @IsEmail({}, { groups: [ValidationsGroupsEnum.default] }) + @EnforceLowerCase() + leasingAgentEmail: string + + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @MaxLength(256, { groups: [ValidationsGroupsEnum.default] }) + leasingAgentName: string + + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @IsPhoneNumber("US", { groups: [ValidationsGroupsEnum.default] }) + @MaxLength(64, { groups: [ValidationsGroupsEnum.default] }) + leasingAgentPhone: string + + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @MaxLength(256, { groups: [ValidationsGroupsEnum.default] }) + name: string + + @Expose() + @IsBoolean({ groups: [ValidationsGroupsEnum.default] }) + paperApplication: boolean + + @Expose() + @IsBoolean({ groups: [ValidationsGroupsEnum.default] }) + referralOpportunity: boolean + + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @MaxLength(4096, { groups: [ValidationsGroupsEnum.default] }) + rentalAssistance: string + + @Expose() + @IsEnum(ListingReviewOrder, { groups: [ValidationsGroupsEnum.default] }) + reviewOrderType: ListingReviewOrder + + @Expose() + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default], each: true }) + @ArrayMinSize(1, { groups: [ValidationsGroupsEnum.default] }) + @ArrayMaxSize(256, { groups: [ValidationsGroupsEnum.default] }) + @Type(() => UnitCreateDto) + units: UnitCreateDto[] +} diff --git a/backend/core/src/listings/dto/listing-published-update.dto.ts b/backend/core/src/listings/dto/listing-published-update.dto.ts new file mode 100644 index 0000000000..e3ef6bf350 --- /dev/null +++ b/backend/core/src/listings/dto/listing-published-update.dto.ts @@ -0,0 +1,128 @@ +import { AddressUpdateDto } from "../../shared/dto/address.dto" +import { Expose, Type } from "class-transformer" +import { + ArrayMaxSize, + ArrayMinSize, + IsDefined, + IsEmail, + IsEnum, + IsString, + MaxLength, + ValidateNested, + IsBoolean, + IsPhoneNumber, +} from "class-validator" +import { ValidationsGroupsEnum } from "../../shared/types/validations-groups-enum" +import { ListingUpdateDto } from "./listing-update.dto" +import { ListingReviewOrder } from "../types/listing-review-order-enum" +import { OmitType } from "@nestjs/swagger" +import { AssetUpdateDto } from "../../assets/dto/asset.dto" +import { UnitUpdateDto } from "../../units/dto/unit-update.dto" +import { EnforceLowerCase } from "../../shared/decorators/enforceLowerCase.decorator" +import { ListingImageUpdateDto } from "./listing-image-update.dto" + +export class ListingPublishedUpdateDto extends OmitType(ListingUpdateDto, [ + "assets", + "buildingAddress", + "depositMax", + "depositMin", + "developer", + "digitalApplication", + "images", + "isWaitlistOpen", + "leasingAgentEmail", + "leasingAgentName", + "leasingAgentPhone", + "name", + "paperApplication", + "referralOpportunity", + "rentalAssistance", + "reviewOrderType", + "units", +] as const) { + @Expose() + @ValidateNested({ groups: [ValidationsGroupsEnum.default], each: true }) + @Type(() => AssetUpdateDto) + assets: AssetUpdateDto[] + + @Expose() + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => AddressUpdateDto) + buildingAddress: AddressUpdateDto + + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @MaxLength(64, { groups: [ValidationsGroupsEnum.default] }) + depositMin: string + + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @MaxLength(64, { groups: [ValidationsGroupsEnum.default] }) + depositMax: string + + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @MaxLength(256, { groups: [ValidationsGroupsEnum.default] }) + developer: string + + @Expose() + @IsBoolean({ groups: [ValidationsGroupsEnum.default] }) + digitalApplication: boolean + + @Expose() + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default], each: true }) + @Type(() => ListingImageUpdateDto) + images: ListingImageUpdateDto[] + + @Expose() + @IsBoolean({ groups: [ValidationsGroupsEnum.default] }) + isWaitlistOpen: boolean + + @Expose() + @IsEmail({}, { groups: [ValidationsGroupsEnum.default] }) + @EnforceLowerCase() + leasingAgentEmail: string + + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @MaxLength(256, { groups: [ValidationsGroupsEnum.default] }) + leasingAgentName: string + + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @IsPhoneNumber("US", { groups: [ValidationsGroupsEnum.default] }) + @MaxLength(64, { groups: [ValidationsGroupsEnum.default] }) + leasingAgentPhone: string + + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @MaxLength(256, { groups: [ValidationsGroupsEnum.default] }) + name: string + + @Expose() + @IsBoolean({ groups: [ValidationsGroupsEnum.default] }) + paperApplication: boolean + + @Expose() + @IsBoolean({ groups: [ValidationsGroupsEnum.default] }) + referralOpportunity: boolean + + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @MaxLength(4096, { groups: [ValidationsGroupsEnum.default] }) + rentalAssistance: string + + @Expose() + @IsEnum(ListingReviewOrder, { groups: [ValidationsGroupsEnum.default] }) + reviewOrderType: ListingReviewOrder + + @Expose() + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default], each: true }) + @ArrayMinSize(1, { groups: [ValidationsGroupsEnum.default] }) + @ArrayMaxSize(256, { groups: [ValidationsGroupsEnum.default] }) + @Type(() => UnitUpdateDto) + units: UnitUpdateDto[] +} diff --git a/backend/core/src/listings/dto/listing-update.dto.ts b/backend/core/src/listings/dto/listing-update.dto.ts new file mode 100644 index 0000000000..0b56b5d962 --- /dev/null +++ b/backend/core/src/listings/dto/listing-update.dto.ts @@ -0,0 +1,254 @@ +import { OmitType } from "@nestjs/swagger" +import { Expose, Type } from "class-transformer" +import { + ArrayMaxSize, + IsDate, + IsDefined, + IsNumber, + IsOptional, + IsString, + IsUUID, + ValidateNested, +} from "class-validator" +import { ValidationsGroupsEnum } from "../../shared/types/validations-groups-enum" +import { IdDto } from "../../shared/dto/id.dto" +import { AddressUpdateDto } from "../../shared/dto/address.dto" +import { ListingEventUpdateDto } from "./listing-event.dto" +import { AssetUpdateDto } from "../../assets/dto/asset.dto" +import { UnitGroupUpdateDto } from "../../units-summary/dto/unit-group.dto" +import { ListingDto } from "./listing.dto" +import { ApplicationMethodUpdateDto } from "../../application-methods/dto/application-method.dto" +import { UnitUpdateDto } from "../../units/dto/unit-update.dto" +import { ListingPreferenceUpdateDto } from "../../preferences/dto/listing-preference-update.dto" +import { ListingProgramUpdateDto } from "../../program/dto/listing-program-update.dto" +import { ListingImageUpdateDto } from "./listing-image-update.dto" + +export class ListingUpdateDto extends OmitType(ListingDto, [ + "id", + "createdAt", + "updatedAt", + "applicationMailingAddress", + "applicationDropOffAddress", + "applicationPickUpAddress", + "applicationMethods", + "buildingSelectionCriteriaFile", + "images", + "events", + "leasingAgentAddress", + "urlSlug", + "leasingAgents", + "showWaitlist", + "units", + "accessibility", + "amenities", + "buildingAddress", + "buildingTotalUnits", + "developer", + "householdSizeMax", + "householdSizeMin", + "neighborhood", + "petPolicy", + "smokingPolicy", + "unitsAvailable", + "unitAmenities", + "servicesOffered", + "yearBuilt", + "unitSummaries", + "jurisdiction", + "reservedCommunityType", + "result", + "unitGroups", + "referralApplication", + "listingPreferences", + "listingPrograms", + "publishedAt", + "closedAt", +] as const) { + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsUUID(4, { groups: [ValidationsGroupsEnum.default] }) + id?: string + + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsDate({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => Date) + createdAt?: Date + + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsDate({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => Date) + updatedAt?: Date + + @Expose() + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default], each: true }) + @Type(() => ApplicationMethodUpdateDto) + applicationMethods: ApplicationMethodUpdateDto[] + + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => AddressUpdateDto) + applicationPickUpAddress?: AddressUpdateDto | null + + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => AddressUpdateDto) + applicationDropOffAddress: AddressUpdateDto | null + + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => AddressUpdateDto) + applicationMailingAddress: AddressUpdateDto | null + + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => AssetUpdateDto) + buildingSelectionCriteriaFile?: AssetUpdateDto | null + + @Expose() + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default], each: true }) + @Type(() => ListingEventUpdateDto) + events: ListingEventUpdateDto[] + + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default], each: true }) + @Type(() => ListingImageUpdateDto) + images?: ListingImageUpdateDto[] | null + + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => AddressUpdateDto) + leasingAgentAddress?: AddressUpdateDto | null + + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default], each: true }) + @Type(() => IdDto) + leasingAgents?: IdDto[] | null + + @Expose() + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default], each: true }) + @ArrayMaxSize(256, { groups: [ValidationsGroupsEnum.default] }) + @Type(() => UnitUpdateDto) + units: UnitUpdateDto[] + + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + accessibility?: string | null + + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + amenities?: string | null + + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => AddressUpdateDto) + buildingAddress?: AddressUpdateDto | null + + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsNumber({}, { groups: [ValidationsGroupsEnum.default] }) + buildingTotalUnits?: number | null + + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + developer?: string | null + + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsNumber({}, { groups: [ValidationsGroupsEnum.default] }) + householdSizeMax?: number | null + + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsNumber({}, { groups: [ValidationsGroupsEnum.default] }) + householdSizeMin?: number | null + + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + neighborhood?: string | null + + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + petPolicy?: string | null + + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + smokingPolicy?: string | null + + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsNumber({}, { groups: [ValidationsGroupsEnum.default] }) + unitsAvailable?: number | null + + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + unitAmenities?: string | null + + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + servicesOffered?: string | null + + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsNumber({}, { groups: [ValidationsGroupsEnum.default] }) + yearBuilt?: number | null + + @Expose() + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => IdDto) + jurisdiction: IdDto + + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => IdDto) + reservedCommunityType?: IdDto + + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => AssetUpdateDto) + result?: AssetUpdateDto + + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default], each: true }) + @Type(() => UnitGroupUpdateDto) + unitGroups?: UnitGroupUpdateDto[] + + @Expose() + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default], each: true }) + @Type(() => ListingPreferenceUpdateDto) + listingPreferences: ListingPreferenceUpdateDto[] + + @Expose() + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default], each: true }) + @Type(() => ListingProgramUpdateDto) + listingPrograms?: ListingProgramUpdateDto[] +} diff --git a/backend/core/src/listings/dto/listing.dto.ts b/backend/core/src/listings/dto/listing.dto.ts new file mode 100644 index 0000000000..c594a4da0b --- /dev/null +++ b/backend/core/src/listings/dto/listing.dto.ts @@ -0,0 +1,355 @@ +import { Listing } from "../entities/listing.entity" +import { Expose, plainToClass, Transform, Type } from "class-transformer" +import { IsDefined, IsEnum, IsNumber, IsOptional, IsString, ValidateNested } from "class-validator" +import dayjs from "dayjs" +import { ApiProperty, OmitType } from "@nestjs/swagger" +import { AddressDto } from "../../shared/dto/address.dto" +import { ValidationsGroupsEnum } from "../../shared/types/validations-groups-enum" +import { ListingStatus } from "../types/listing-status-enum" +import { UnitDto } from "../../units/dto/unit.dto" +import { ReservedCommunityTypeDto } from "../../reserved-community-type/dto/reserved-community-type.dto" +import { AssetDto } from "../../assets/dto/asset.dto" +import { ListingEventDto } from "./listing-event.dto" +import { listingUrlSlug } from "../../shared/url-helper" +import { JurisdictionSlimDto } from "../../jurisdictions/dto/jurisdiction.dto" +import { UserBasicDto } from "../../auth/dto/user-basic.dto" +import { ApplicationMethodDto } from "../../application-methods/dto/application-method.dto" +import { UnitGroupDto } from "../../units-summary/dto/unit-group.dto" +import { ListingFeaturesDto } from "./listing-features.dto" +import { ListingPreferenceDto } from "../../preferences/dto/listing-preference.dto" +import { ListingProgramDto } from "../../program/dto/listing-program.dto" +import { Column } from "typeorm" +import { Region } from "../../property/types/region-enum" +import { ListingImageDto } from "./listing-image.dto" + +export class ListingDto extends OmitType(Listing, [ + "applicationPickUpAddress", + "applicationDropOffAddress", + "applicationMailingAddress", + "applications", + "applicationMethods", + "buildingSelectionCriteriaFile", + "events", + "images", + "jurisdiction", + "leasingAgents", + "leasingAgentAddress", + "listingPreferences", + "listingPrograms", + "property", + "reservedCommunityType", + "result", + "unitGroups", + "features", +] as const) { + @Expose() + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default], each: true }) + @Type(() => ApplicationMethodDto) + applicationMethods: ApplicationMethodDto[] + + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => AddressDto) + applicationPickUpAddress?: AddressDto | null + + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => AddressDto) + applicationDropOffAddress: AddressDto | null + + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => AddressDto) + applicationMailingAddress: AddressDto | null + + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => AssetDto) + buildingSelectionCriteriaFile?: AssetDto | null + + @Expose() + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default], each: true }) + @Type(() => ListingEventDto) + events: ListingEventDto[] + + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default], each: true }) + @Type(() => ListingImageDto) + images?: ListingImageDto[] | null + + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => AddressDto) + leasingAgentAddress?: AddressDto | null + + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default], each: true }) + @Type(() => UserBasicDto) + leasingAgents?: UserBasicDto[] | null + + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default], each: true }) + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default], each: true }) + @Type(() => ListingProgramDto) + listingPrograms?: ListingProgramDto[] + + @Expose() + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default], each: true }) + @Type(() => ListingPreferenceDto) + listingPreferences: ListingPreferenceDto[] + + @Expose() + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => JurisdictionSlimDto) + jurisdiction: JurisdictionSlimDto + + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => ReservedCommunityTypeDto) + reservedCommunityType?: ReservedCommunityTypeDto + + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => AssetDto) + result?: AssetDto | null + + @Expose() + @Transform( + (_value, listing) => { + if ( + dayjs(listing.applicationDueDate).isBefore(dayjs()) && + listing.status !== ListingStatus.pending + ) { + listing.status = ListingStatus.closed + } + + return listing.status + }, + { toClassOnly: true } + ) + status: ListingStatus + + @Expose() + @Type(() => UnitDto) + @Transform( + (value, obj: Listing) => { + return plainToClass(UnitDto, obj.property?.units) + }, + { toClassOnly: true } + ) + units: UnitDto[] + + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @Transform( + (value, obj: Listing) => { + return obj.property?.accessibility + }, + { toClassOnly: true } + ) + accessibility?: string | null + + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @Transform( + (value, obj: Listing) => { + return obj.property?.amenities + }, + { toClassOnly: true } + ) + amenities?: string | null + + @Expose() + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => AddressDto) + @Transform( + (value, obj: Listing) => { + return plainToClass(AddressDto, obj.property.buildingAddress) + }, + { toClassOnly: true } + ) + buildingAddress: AddressDto + + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsNumber({}, { groups: [ValidationsGroupsEnum.default] }) + @Transform( + (value, obj: Listing) => { + return obj.property?.buildingTotalUnits + }, + { toClassOnly: true } + ) + buildingTotalUnits?: number | null + + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @Transform( + (value, obj: Listing) => { + return obj.property?.developer + }, + { toClassOnly: true } + ) + developer?: string | null + + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsNumber({}, { groups: [ValidationsGroupsEnum.default] }) + @Transform( + (value, obj: Listing) => { + return obj.property?.householdSizeMax + }, + { toClassOnly: true } + ) + householdSizeMax?: number | null + + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsNumber({}, { groups: [ValidationsGroupsEnum.default] }) + @Transform( + (value, obj: Listing) => { + return obj.property?.householdSizeMin + }, + { toClassOnly: true } + ) + householdSizeMin?: number | null + + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @Transform( + (value, obj: Listing) => { + return obj.property?.neighborhood + }, + { toClassOnly: true } + ) + neighborhood?: string | null + + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @Transform( + (value, obj: Listing) => { + return obj.property?.petPolicy + }, + { toClassOnly: true } + ) + petPolicy?: string | null + + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @Transform( + (value, obj: Listing) => { + return obj.property?.smokingPolicy + }, + { toClassOnly: true } + ) + smokingPolicy?: string | null + + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsNumber({}, { groups: [ValidationsGroupsEnum.default] }) + @Transform( + (value, obj: Listing) => { + return obj.property?.unitsAvailable + }, + { toClassOnly: true } + ) + unitsAvailable?: number | null + + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @Transform( + (value, obj: Listing) => { + return obj.property?.unitAmenities + }, + { toClassOnly: true } + ) + unitAmenities?: string | null + + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @Transform( + (value, obj: Listing) => { + return obj.property?.servicesOffered + }, + { toClassOnly: true } + ) + servicesOffered?: string | null + + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsNumber({}, { groups: [ValidationsGroupsEnum.default] }) + @Transform( + (value, obj: Listing) => { + return obj.property?.yearBuilt + }, + { toClassOnly: true } + ) + yearBuilt?: number | null + + @Column({ type: "enum", enum: Region, nullable: true }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsEnum(Region, { groups: [ValidationsGroupsEnum.default] }) + @ApiProperty({ + enum: Region, + enumName: "Region", + }) + region?: Region | null + + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @Transform( + (value, obj: Listing) => { + return listingUrlSlug(obj) + }, + { toClassOnly: true } + ) + urlSlug: string + + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default], each: true }) + @Type(() => UnitGroupDto) + unitGroups?: UnitGroupDto[] + + // Keep countyCode so we don't have to update frontend apps yet + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @Transform( + (value, obj: Listing) => { + return obj.jurisdiction?.name + }, + { toClassOnly: true } + ) + countyCode?: string + + @Expose() + @Type(() => ListingFeaturesDto) + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + features?: ListingFeaturesDto +} diff --git a/backend/core/src/listings/dto/listings-query-params.ts b/backend/core/src/listings/dto/listings-query-params.ts new file mode 100644 index 0000000000..441c56b96c --- /dev/null +++ b/backend/core/src/listings/dto/listings-query-params.ts @@ -0,0 +1,55 @@ +import { PaginationAllowsAllQueryParams } from "../../shared/dto/pagination.dto" +import { Expose, Type } from "class-transformer" +import { ApiProperty, getSchemaPath } from "@nestjs/swagger" +import { ListingFilterParams } from "./listing-filter-params" +import { + ArrayMaxSize, + IsArray, + IsEnum, + IsOptional, + IsString, + ValidateNested, +} from "class-validator" +import { ValidationsGroupsEnum } from "../../shared/types/validations-groups-enum" +import { OrderByFieldsEnum } from "../types/listing-orderby-enum" + +export class ListingsQueryParams extends PaginationAllowsAllQueryParams { + @Expose() + @ApiProperty({ + name: "filter", + required: false, + type: [String], + items: { + $ref: getSchemaPath(ListingFilterParams), + }, + example: { $comparison: "=", status: "active", name: "Coliseum" }, + }) + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsArray({ groups: [ValidationsGroupsEnum.default] }) + @ArrayMaxSize(16, { groups: [ValidationsGroupsEnum.default] }) + @Type(() => ListingFilterParams) + @ValidateNested({ groups: [ValidationsGroupsEnum.default], each: true }) + filter?: ListingFilterParams[] + + @Expose() + @ApiProperty({ + name: "view", + required: false, + type: String, + }) + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + view?: string + + @Expose() + @ApiProperty({ + name: "orderBy", + required: false, + enum: OrderByFieldsEnum, + enumName: "OrderByFieldsEnum", + example: "updatedAt", + }) + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsEnum(OrderByFieldsEnum, { groups: [ValidationsGroupsEnum.default] }) + orderBy?: OrderByFieldsEnum +} diff --git a/backend/core/src/listings/dto/listings-retrieve-query-params.ts b/backend/core/src/listings/dto/listings-retrieve-query-params.ts new file mode 100644 index 0000000000..9d8f9bb870 --- /dev/null +++ b/backend/core/src/listings/dto/listings-retrieve-query-params.ts @@ -0,0 +1,16 @@ +import { Expose } from "class-transformer" +import { ApiProperty } from "@nestjs/swagger" +import { IsOptional, IsString } from "class-validator" +import { ValidationsGroupsEnum } from "../../shared/types/validations-groups-enum" + +export class ListingsRetrieveQueryParams { + @Expose() + @ApiProperty({ + name: "view", + required: false, + type: String, + }) + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + view?: string +} diff --git a/backend/core/src/listings/dto/paginated-listing.dto.ts b/backend/core/src/listings/dto/paginated-listing.dto.ts new file mode 100644 index 0000000000..61b2462595 --- /dev/null +++ b/backend/core/src/listings/dto/paginated-listing.dto.ts @@ -0,0 +1,4 @@ +import { PaginationFactory } from "../../shared/dto/pagination.dto" +import { ListingDto } from "./listing.dto" + +export class PaginatedListingDto extends PaginationFactory(ListingDto) {} diff --git a/backend/core/src/listings/entities/listing-event.entity.ts b/backend/core/src/listings/entities/listing-event.entity.ts new file mode 100644 index 0000000000..708a918f0b --- /dev/null +++ b/backend/core/src/listings/entities/listing-event.entity.ts @@ -0,0 +1,64 @@ +import { Expose, Type } from "class-transformer" +import { IsDate, IsDefined, IsEnum, IsOptional, IsString, ValidateNested } from "class-validator" +import { ValidationsGroupsEnum } from "../../shared/types/validations-groups-enum" +import { ListingEventType } from "../types/listing-event-type-enum" +import { ApiProperty } from "@nestjs/swagger" +import { AbstractEntity } from "../../shared/entities/abstract.entity" +import { Column, Entity, ManyToOne } from "typeorm" +import { Listing } from "./listing.entity" +import { Asset } from "../../assets/entities/asset.entity" + +@Entity({ name: "listing_events" }) +export class ListingEvent extends AbstractEntity { + @Column({ type: "enum", enum: ListingEventType }) + @Expose() + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @IsEnum(ListingEventType, { groups: [ValidationsGroupsEnum.default] }) + @ApiProperty({ enum: ListingEventType, enumName: "ListingEventType" }) + type: ListingEventType + + @Column({ type: "timestamptz", nullable: true }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsDate({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => Date) + startTime?: Date + + @Column({ type: "timestamptz", nullable: true }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsDate({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => Date) + endTime?: Date + + @Column({ type: "text", nullable: true }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + url?: string | null + + @Column({ type: "text", nullable: true }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + note?: string | null + + @Column({ type: "text", nullable: true }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + label?: string | null + + @ManyToOne(() => Listing, (listing) => listing.events) + @Expose() + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => ListingEvent) + listing: Listing + + @ManyToOne(() => Asset, { eager: true, cascade: true }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => Asset) + file?: Asset +} diff --git a/backend/core/src/listings/entities/listing-features.entity.ts b/backend/core/src/listings/entities/listing-features.entity.ts new file mode 100644 index 0000000000..83c72424c1 --- /dev/null +++ b/backend/core/src/listings/entities/listing-features.entity.ts @@ -0,0 +1,105 @@ +import { Expose, Type } from "class-transformer" +import { IsBoolean, IsOptional, ValidateNested } from "class-validator" +import { ValidationsGroupsEnum } from "../../shared/types/validations-groups-enum" +import { AbstractEntity } from "../../shared/entities/abstract.entity" +import { Column, Entity, OneToOne } from "typeorm" +import { Listing } from "./listing.entity" + +@Entity({ name: "listing_features" }) +export class ListingFeatures extends AbstractEntity { + @OneToOne(() => Listing, (listing) => listing.features) + @Expose() + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => Listing) + listing: Listing + + @Column({ type: "boolean", nullable: true }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsBoolean({ groups: [ValidationsGroupsEnum.default] }) + elevator?: boolean | null + + @Column({ type: "boolean", nullable: true }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsBoolean({ groups: [ValidationsGroupsEnum.default] }) + wheelchairRamp?: boolean | null + + @Column({ type: "boolean", nullable: true }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsBoolean({ groups: [ValidationsGroupsEnum.default] }) + serviceAnimalsAllowed?: boolean | null + + @Column({ type: "boolean", nullable: true }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsBoolean({ groups: [ValidationsGroupsEnum.default] }) + accessibleParking?: boolean | null + + @Column({ type: "boolean", nullable: true }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsBoolean({ groups: [ValidationsGroupsEnum.default] }) + parkingOnSite?: boolean | null + + @Column({ type: "boolean", nullable: true }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsBoolean({ groups: [ValidationsGroupsEnum.default] }) + inUnitWasherDryer?: boolean | null + + @Column({ type: "boolean", nullable: true }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsBoolean({ groups: [ValidationsGroupsEnum.default] }) + laundryInBuilding?: boolean | null + + @Column({ type: "boolean", nullable: true }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsBoolean({ groups: [ValidationsGroupsEnum.default] }) + barrierFreeEntrance?: boolean | null + + @Column({ type: "boolean", nullable: true }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsBoolean({ groups: [ValidationsGroupsEnum.default] }) + rollInShower?: boolean | null + + @Column({ type: "boolean", nullable: true }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsBoolean({ groups: [ValidationsGroupsEnum.default] }) + grabBars?: boolean | null + + @Column({ type: "boolean", nullable: true }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsBoolean({ groups: [ValidationsGroupsEnum.default] }) + heatingInUnit?: boolean | null + + @Column({ type: "boolean", nullable: true }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsBoolean({ groups: [ValidationsGroupsEnum.default] }) + acInUnit?: boolean | null + + @Column({ type: "boolean", nullable: true }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsBoolean({ groups: [ValidationsGroupsEnum.default] }) + hearing?: boolean | null + + @Column({ type: "boolean", nullable: true }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsBoolean({ groups: [ValidationsGroupsEnum.default] }) + visual?: boolean | null + + @Column({ type: "boolean", nullable: true }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsBoolean({ groups: [ValidationsGroupsEnum.default] }) + mobility?: boolean | null +} diff --git a/backend/core/src/listings/entities/listing-image.entity.ts b/backend/core/src/listings/entities/listing-image.entity.ts new file mode 100644 index 0000000000..1d137f3082 --- /dev/null +++ b/backend/core/src/listings/entities/listing-image.entity.ts @@ -0,0 +1,32 @@ +import { Column, Entity, Index, ManyToOne } from "typeorm" +import { Expose, Type } from "class-transformer" +import { IsNumber, IsOptional } from "class-validator" +import { ValidationsGroupsEnum } from "../../shared/types/validations-groups-enum" +import { Listing } from "./listing.entity" +import { Asset } from "../../assets/entities/asset.entity" + +@Entity({ name: "listing_images" }) +export class ListingImage { + @ManyToOne(() => Listing, (listing) => listing.images, { + primary: true, + orphanedRowAction: "delete", + }) + @Index() + @Type(() => Listing) + listing: Listing + + @ManyToOne(() => Asset, { + primary: true, + eager: true, + cascade: true, + }) + @Expose() + @Type(() => Asset) + image: Asset + + @Column({ type: "integer", nullable: true }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsNumber({}, { groups: [ValidationsGroupsEnum.default] }) + ordinal?: number | null +} diff --git a/backend/core/src/listings/entities/listing.entity.ts b/backend/core/src/listings/entities/listing.entity.ts new file mode 100644 index 0000000000..fa5d8ccb20 --- /dev/null +++ b/backend/core/src/listings/entities/listing.entity.ts @@ -0,0 +1,666 @@ +import { + BaseEntity, + Column, + CreateDateColumn, + Entity, + JoinColumn, + Index, + JoinTable, + ManyToMany, + ManyToOne, + OneToMany, + OneToOne, + PrimaryGeneratedColumn, + UpdateDateColumn, +} from "typeorm" +import { Application } from "../../applications/entities/application.entity" +import { User } from "../../auth/entities/user.entity" +import { Expose, Type } from "class-transformer" +import { + IsBoolean, + IsDate, + IsEmail, + IsEnum, + IsNumber, + IsOptional, + IsString, + IsUrl, + IsUUID, + MaxLength, + ValidateNested, +} from "class-validator" +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger" +import { Property } from "../../property/entities/property.entity" +import { ValidationsGroupsEnum } from "../../shared/types/validations-groups-enum" +import { ListingStatus } from "../types/listing-status-enum" +import { Jurisdiction } from "../../jurisdictions/entities/jurisdiction.entity" +import { ReservedCommunityType } from "../../reserved-community-type/entities/reserved-community-type.entity" +import { Asset } from "../../assets/entities/asset.entity" +import { AssetCreateDto } from "../../assets/dto/asset.dto" +import { ListingApplicationAddressType } from "../types/listing-application-address-type" +import { ListingEvent } from "./listing-event.entity" +import { Address } from "../../shared/entities/address.entity" +import { ApplicationMethod } from "../../application-methods/entities/application-method.entity" +import { UnitSummaries } from "../../units/types/unit-summaries" +import { UnitGroup } from "../../units-summary/entities/unit-group.entity" +import { ListingReviewOrder } from "../types/listing-review-order-enum" +import { ApplicationMethodDto } from "../../application-methods/dto/application-method.dto" +import { ApplicationMethodType } from "../../application-methods/types/application-method-type-enum" +import { ListingFeatures } from "./listing-features.entity" +import { ListingProgram } from "../../program/entities/listing-program.entity" +import { EnforceLowerCase } from "../../shared/decorators/enforceLowerCase.decorator" +import { ListingPreference } from "../../preferences/entities/listing-preference.entity" +import { ListingImage } from "./listing-image.entity" +import { ListingMarketingTypeEnum } from "../types/listing-marketing-type-enum" +import { ListingSeasonEnum } from "../types/listing-season-enum" + +@Entity({ name: "listings" }) +@Index(["jurisdiction"]) +class Listing extends BaseEntity { + @PrimaryGeneratedColumn("uuid") + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @IsUUID(4, { groups: [ValidationsGroupsEnum.default] }) + id: string + + @CreateDateColumn() + @Expose() + @IsDate({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => Date) + createdAt: Date + + @UpdateDateColumn() + @Expose() + @IsDate({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => Date) + updatedAt: Date + + // The HRD ID is a Detroit-specific ID. + @Column({ type: "text", nullable: true }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + hrdId?: string | null + + @Column({ type: "text", nullable: true }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + additionalApplicationSubmissionNotes?: string | null + + @OneToMany(() => ListingPreference, (listingPreference) => listingPreference.listing, { + cascade: true, + eager: true, + }) + @Expose() + @ValidateNested({ groups: [ValidationsGroupsEnum.default], each: true }) + @Type(() => ListingPreference) + listingPreferences: ListingPreference[] + + @OneToMany(() => ApplicationMethod, (am) => am.listing, { cascade: true, eager: true }) + @Expose() + @ValidateNested({ groups: [ValidationsGroupsEnum.default], each: true }) + @Type(() => ApplicationMethod) + applicationMethods: ApplicationMethod[] + + @Expose() + @ApiPropertyOptional() + get referralApplication(): ApplicationMethodDto | undefined { + return this.applicationMethods + ? this.applicationMethods.find((method) => method.type === ApplicationMethodType.Referral) + : undefined + } + + // booleans to make dealing with different application methods easier to parse + @Column({ type: "boolean", nullable: true }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsBoolean({ groups: [ValidationsGroupsEnum.default] }) + digitalApplication?: boolean + + @Column({ type: "boolean", nullable: true }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsBoolean({ groups: [ValidationsGroupsEnum.default] }) + commonDigitalApplication?: boolean + + @Column({ type: "boolean", nullable: true }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsBoolean({ groups: [ValidationsGroupsEnum.default] }) + paperApplication?: boolean + + @Column({ type: "boolean", nullable: true }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsBoolean({ groups: [ValidationsGroupsEnum.default] }) + referralOpportunity?: boolean + + // end application method booleans + + @Column("jsonb") + @Expose() + @ValidateNested({ groups: [ValidationsGroupsEnum.default], each: true }) + @Type(() => AssetCreateDto) + assets: AssetCreateDto[] + + @OneToMany(() => ListingEvent, (listingEvent) => listingEvent.listing, { + eager: true, + cascade: true, + }) + @Expose() + @ValidateNested({ groups: [ValidationsGroupsEnum.default], each: true }) + @Type(() => ListingEvent) + events: ListingEvent[] + + @ManyToOne(() => Property, { nullable: false, cascade: true }) + @Expose() + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + property: Property + + @OneToMany(() => Application, (application) => application.listing) + @Expose() + @ValidateNested({ groups: [ValidationsGroupsEnum.default], each: true }) + @Type(() => Application) + applications: Application[] + + @Column({ type: "timestamptz", nullable: true }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsDate({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => Date) + applicationDueDate?: Date | null + + @Column({ type: "timestamptz", nullable: true }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsDate({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => Date) + applicationOpenDate?: Date | null + + @Column({ type: "text", nullable: true }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + applicationFee?: string | null + + @Column({ type: "text", nullable: true }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + applicationOrganization?: string | null + + @ManyToOne(() => Address, { eager: true, nullable: true, cascade: true }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => Address) + applicationPickUpAddress?: Address | null + + @Column({ type: "text", nullable: true }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + applicationPickUpAddressOfficeHours?: string | null + + @Column({ type: "enum", enum: ListingApplicationAddressType, nullable: true }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsEnum(ListingApplicationAddressType, { groups: [ValidationsGroupsEnum.default] }) + @ApiProperty({ + enum: ListingApplicationAddressType, + enumName: "ListingApplicationAddressType", + }) + applicationPickUpAddressType?: ListingApplicationAddressType | null + + @ManyToOne(() => Address, { eager: true, nullable: true, cascade: true }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => Address) + applicationDropOffAddress?: Address | null + + @Column({ type: "text", nullable: true }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + applicationDropOffAddressOfficeHours?: string | null + + @Column({ type: "enum", enum: ListingApplicationAddressType, nullable: true }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsEnum(ListingApplicationAddressType, { groups: [ValidationsGroupsEnum.default] }) + @ApiProperty({ + enum: ListingApplicationAddressType, + enumName: "ListingApplicationAddressType", + }) + applicationDropOffAddressType?: ListingApplicationAddressType | null + + @ManyToOne(() => Address, { eager: true, nullable: true, cascade: true }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => Address) + applicationMailingAddress?: Address | null + + @Column({ type: "enum", enum: ListingApplicationAddressType, nullable: true }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsEnum(ListingApplicationAddressType, { groups: [ValidationsGroupsEnum.default] }) + @ApiProperty({ + enum: ListingApplicationAddressType, + enumName: "ListingApplicationAddressType", + }) + applicationMailingAddressType?: ListingApplicationAddressType | null + + @Column({ type: "text", nullable: true }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + buildingSelectionCriteria?: string | null + + @ManyToOne(() => Asset, { eager: true, nullable: true, cascade: true }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => Asset) + buildingSelectionCriteriaFile?: Asset | null + + @Column({ type: "text", nullable: true }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + costsNotIncluded?: string | null + + @Column({ type: "text", nullable: true }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + creditHistory?: string | null + + @Column({ type: "text", nullable: true }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + criminalBackground?: string | null + + @Column({ type: "text", nullable: true }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + depositMin?: string | null + + @Column({ type: "text", nullable: true }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + depositMax?: string | null + + @Column({ type: "text", nullable: true }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + depositHelperText?: string | null + + @Column({ type: "boolean", nullable: true }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsBoolean({ groups: [ValidationsGroupsEnum.default] }) + disableUnitsAccordion?: boolean | null + + @ManyToOne(() => Jurisdiction, { eager: true }) + @Expose() + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => Jurisdiction) + jurisdiction: Jurisdiction + + @ManyToOne(() => Address, { eager: true, nullable: true, cascade: true }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => Address) + leasingAgentAddress?: Address | null + + @Column({ type: "text", nullable: true }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsEmail({}, { groups: [ValidationsGroupsEnum.default] }) + @EnforceLowerCase() + leasingAgentEmail?: string | null + + @Column({ type: "text", nullable: true }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + leasingAgentName?: string | null + + @ManyToMany(() => User, (leasingAgent) => leasingAgent.leasingAgentInListings, { + nullable: true, + }) + @JoinTable() + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default], each: true }) + @Type(() => User) + leasingAgents?: User[] | null + + @Column({ type: "text", nullable: true }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + leasingAgentOfficeHours?: string | null + + @Column({ type: "text", nullable: true }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + leasingAgentPhone?: string | null + + @Column({ type: "text", nullable: true }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + leasingAgentTitle?: string | null + + @Column({ type: "text" }) + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + name: string + + @Column({ type: "timestamptz", nullable: true }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsDate({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => Date) + postmarkedApplicationsReceivedByDate?: Date | null + + @Column({ type: "text", nullable: true }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + programRules?: string | null + + @Column({ type: "text", nullable: true }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + rentalAssistance?: string | null + + @Column({ type: "text", nullable: true }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + rentalHistory?: string | null + + @Column({ type: "text", nullable: true }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + requiredDocuments?: string | null + + @Column({ type: "text", nullable: true }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + specialNotes?: string | null + + @Column({ type: "integer", nullable: true }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsNumber({}, { groups: [ValidationsGroupsEnum.default] }) + waitlistCurrentSize?: number | null + + @Column({ type: "integer", nullable: true }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsNumber({}, { groups: [ValidationsGroupsEnum.default] }) + waitlistMaxSize?: number | null + + @Column({ type: "text", nullable: true }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + whatToExpect?: string | null + + @Column({ + type: "enum", + enum: ListingStatus, + default: ListingStatus.pending, + }) + @Expose() + @IsEnum(ListingStatus, { groups: [ValidationsGroupsEnum.default] }) + @ApiProperty({ enum: ListingStatus, enumName: "ListingStatus" }) + status: ListingStatus + + @Column({ type: "enum", enum: ListingReviewOrder, nullable: true }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsEnum(ListingReviewOrder, { groups: [ValidationsGroupsEnum.default] }) + @ApiProperty({ + enum: ListingReviewOrder, + enumName: "ListingReviewOrder", + }) + reviewOrderType?: ListingReviewOrder | null + + @Expose() + applicationConfig?: Record + + @Column({ type: "boolean" }) + @Expose() + @IsBoolean({ groups: [ValidationsGroupsEnum.default] }) + displayWaitlistSize: boolean + + @Expose() + @ApiProperty() + get showWaitlist(): boolean { + return ( + this.waitlistMaxSize !== null && + this.waitlistCurrentSize !== null && + this.waitlistCurrentSize < this.waitlistMaxSize + ) + } + + @Column({ type: "text", nullable: true }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @MaxLength(4096, { groups: [ValidationsGroupsEnum.default] }) + reservedCommunityDescription?: string | null + + @ManyToOne(() => ReservedCommunityType, { eager: true, nullable: true }) + @Expose() + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => ReservedCommunityType) + reservedCommunityType?: ReservedCommunityType + + @Column({ type: "integer", nullable: true }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsNumber({}, { groups: [ValidationsGroupsEnum.default] }) + reservedCommunityMinAge?: number | null + + @OneToMany(() => ListingImage, (listingImage) => listingImage.listing, { + cascade: true, + eager: true, + }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default], each: true }) + @Type(() => ListingImage) + images?: ListingImage[] | null + + @ManyToOne(() => Asset, { eager: true, nullable: true, cascade: true }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => Asset) + result?: Asset | null + + @Column({ type: "text", nullable: true }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @MaxLength(4096, { groups: [ValidationsGroupsEnum.default] }) + resultLink?: string | null + + @Column({ type: "boolean", nullable: true }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsBoolean({ groups: [ValidationsGroupsEnum.default] }) + isWaitlistOpen?: boolean | null + + @Column({ type: "integer", nullable: true }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsNumber({}, { groups: [ValidationsGroupsEnum.default] }) + waitlistOpenSpots?: number | null + + @Column({ type: "text", nullable: true }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + ownerCompany?: string | null + + @Column({ type: "text", nullable: true }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + managementCompany?: string | null + + @Column({ type: "text", nullable: true }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @IsUrl({ require_protocol: true }, { groups: [ValidationsGroupsEnum.default] }) + managementWebsite?: string | null + + // In the absence of AMI percentage information at the unit level, amiPercentageMin and + // amiPercentageMax will store the AMI percentage range for the listing as a whole. + @Column({ type: "integer", nullable: true }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsNumber({}, { groups: [ValidationsGroupsEnum.default] }) + amiPercentageMin?: number | null + + @Column({ type: "integer", nullable: true }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsNumber({}, { groups: [ValidationsGroupsEnum.default] }) + amiPercentageMax?: number | null + + @Expose() + @ApiProperty({ type: UnitSummaries }) + unitSummaries: UnitSummaries | undefined + + @Column({ type: "boolean", nullable: true }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsBoolean({ groups: [ValidationsGroupsEnum.default] }) + customMapPin?: boolean | null + + @Column({ type: "text", nullable: true }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + phoneNumber?: string | null + + @Column({ type: "text", nullable: true }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + region?: string | null + + @OneToMany(() => UnitGroup, (summary) => summary.listing, { + nullable: true, + eager: true, + cascade: true, + }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default], each: true }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default], each: true }) + @Type(() => UnitGroup) + unitGroups: UnitGroup[] + + @OneToMany(() => ListingProgram, (listingProgram) => listingProgram.listing, { + cascade: true, + eager: true, + }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default], each: true }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default], each: true }) + @Type(() => ListingProgram) + listingPrograms?: ListingProgram[] + + @Column({ type: "timestamptz", nullable: true }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsDate({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => Date) + publishedAt?: Date | null + + @Column({ type: "timestamptz", nullable: true }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsDate({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => Date) + closedAt?: Date | null + + @OneToOne(() => ListingFeatures, { + nullable: true, + eager: true, + cascade: true, + }) + @JoinColumn() + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default], each: true }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default], each: true }) + @Type(() => ListingFeatures) + features?: ListingFeatures + + @Column({ type: "boolean", default: false, nullable: true }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsBoolean({ groups: [ValidationsGroupsEnum.default] }) + isVerified?: boolean + + @Column({ type: "integer", nullable: true }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsNumber({}, { groups: [ValidationsGroupsEnum.default] }) + temporaryListingId?: number | null + + @Column({ + type: "enum", + enum: ListingMarketingTypeEnum, + default: ListingMarketingTypeEnum.Marketing, + }) + @Expose() + @IsEnum(ListingMarketingTypeEnum, { groups: [ValidationsGroupsEnum.default] }) + @ApiProperty({ + enum: ListingMarketingTypeEnum, + enumName: "ListingMarketingTypeEnum", + }) + marketingType: ListingMarketingTypeEnum + + @Column({ type: "timestamptz", nullable: true }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsDate({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => Date) + marketingDate?: Date | null + + @Column({ + enum: ListingSeasonEnum, + nullable: true, + }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsEnum(ListingSeasonEnum, { groups: [ValidationsGroupsEnum.default] }) + @ApiProperty({ + enum: ListingSeasonEnum, + enumName: "ListingSeasonEnum", + }) + marketingSeason?: ListingSeasonEnum | null +} + +export { Listing as default, Listing } diff --git a/backend/core/src/listings/listings-notifications.ts b/backend/core/src/listings/listings-notifications.ts new file mode 100644 index 0000000000..d9dd2ae74e --- /dev/null +++ b/backend/core/src/listings/listings-notifications.ts @@ -0,0 +1,32 @@ +import { Process, Processor } from "@nestjs/bull" +import { Job } from "bull" +import { Listing } from "./entities/listing.entity" +import { SmsService } from "../sms/services/sms.service" +import { StatusDto } from "../shared/dto/status.dto" + +export enum ListingUpdateType { + CREATE, + MODIFY, +} + +export class ListingNotificationInfo { + listing: Listing + updateType: ListingUpdateType +} + +// This class defines the processor for the "listings-notifications" queue. It is responsible +// for sending email and SMS notifications when listings are created or updated. +@Processor("listings-notifications") +export class ListingsNotificationsConsumer { + constructor(private readonly smsService: SmsService) {} + @Process() + async sendListingNotifications(job: Job): Promise { + const listing: Listing = job.data.listing + const status: StatusDto = await this.smsService.sendNewListingNotification(listing) + + // TODO(https://github.com/CityOfDetroit/bloom/issues/698): call out to the + // emailService to send email notifications. + + return status + } +} diff --git a/backend/core/src/listings/listings.controller.spec.ts b/backend/core/src/listings/listings.controller.spec.ts new file mode 100644 index 0000000000..cef5f0a222 --- /dev/null +++ b/backend/core/src/listings/listings.controller.spec.ts @@ -0,0 +1,40 @@ +import { Test, TestingModule } from "@nestjs/testing" +import { ListingsController } from "./listings.controller" +import { PassportModule } from "@nestjs/passport" +import { ListingsService } from "./listings.service" +import { AuthzService } from "../auth/services/authz.service" +import { CacheModule } from "@nestjs/common" +import { ActivityLogService } from "../activity-log/services/activity-log.service" + +// Cypress brings in Chai types for the global expect, but we want to use jest +// expect here so we need to re-declare it. +// see: https://github.com/cypress-io/cypress/issues/1319#issuecomment-593500345 +declare const expect: jest.Expect + +describe("Listings Controller", () => { + let controller: ListingsController + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + imports: [ + PassportModule, + CacheModule.register({ + ttl: 60, + max: 2, + }), + ], + providers: [ + { provide: AuthzService, useValue: {} }, + { provide: ListingsService, useValue: {} }, + { provide: ActivityLogService, useValue: {} }, + ], + controllers: [ListingsController], + }).compile() + + controller = module.get(ListingsController) + }) + + it("should be defined", () => { + expect(controller).toBeDefined() + }) +}) diff --git a/backend/core/src/listings/listings.controller.ts b/backend/core/src/listings/listings.controller.ts new file mode 100644 index 0000000000..7c4613087f --- /dev/null +++ b/backend/core/src/listings/listings.controller.ts @@ -0,0 +1,101 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + Post, + Put, + Query, + UseGuards, + UseInterceptors, + UsePipes, + ValidationPipe, + ClassSerializerInterceptor, + Headers, +} from "@nestjs/common" +import { ListingsService } from "./listings.service" +import { ApiBearerAuth, ApiExtraModels, ApiOperation, ApiTags } from "@nestjs/swagger" +import { ListingDto } from "./dto/listing.dto" +import { ResourceType } from "../auth/decorators/resource-type.decorator" +import { OptionalAuthGuard } from "../auth/guards/optional-auth.guard" +import { AuthzGuard } from "../auth/guards/authz.guard" +import { mapTo } from "../shared/mapTo" +import { defaultValidationPipeOptions } from "../shared/default-validation-pipe-options" +import { Language } from "../shared/types/language-enum" +import { PaginatedListingDto } from "./dto/paginated-listing.dto" +import { ListingCreateDto } from "./dto/listing-create.dto" +import { ListingUpdateDto } from "./dto/listing-update.dto" +import { ListingFilterParams } from "./dto/listing-filter-params" +import { ListingsQueryParams } from "./dto/listings-query-params" +import { ListingsRetrieveQueryParams } from "./dto/listings-retrieve-query-params" +import { ListingCreateValidationPipe } from "./validation-pipes/listing-create-validation-pipe" +import { ListingUpdateValidationPipe } from "./validation-pipes/listing-update-validation-pipe" +import { ActivityLogInterceptor } from "../activity-log/interceptors/activity-log.interceptor" +import { ActivityLogMetadata } from "../activity-log/decorators/activity-log-metadata.decorator" + +@Controller("listings") +@ApiTags("listings") +@ApiBearerAuth() +@ResourceType("listing") +@ApiExtraModels(ListingFilterParams) +@UseGuards(OptionalAuthGuard, AuthzGuard) +@ActivityLogMetadata([{ targetPropertyName: "status", propertyPath: "status" }]) +@UseInterceptors(ActivityLogInterceptor) +export class ListingsController { + constructor(private readonly listingsService: ListingsService) {} + + // TODO: Limit requests to defined fields + @Get() + @ApiExtraModels(ListingFilterParams) + @ApiOperation({ summary: "List listings", operationId: "list" }) + @UseInterceptors(ClassSerializerInterceptor) + @UsePipes(new ValidationPipe(defaultValidationPipeOptions)) + public async getAll(@Query() queryParams: ListingsQueryParams): Promise { + return mapTo(PaginatedListingDto, await this.listingsService.list(queryParams)) + } + + @Post() + @ApiOperation({ summary: "Create listing", operationId: "create" }) + @UsePipes(new ListingCreateValidationPipe(defaultValidationPipeOptions)) + async create(@Body() listingDto: ListingCreateDto): Promise { + const listing = await this.listingsService.create(listingDto) + return mapTo(ListingDto, listing) + } + + @Get(`:id`) + @ApiOperation({ summary: "Get listing by id", operationId: "retrieve" }) + @UseInterceptors(ClassSerializerInterceptor) + @UsePipes(new ValidationPipe(defaultValidationPipeOptions)) + async retrieve( + @Headers("language") language: Language, + @Param("id") listingId: string, + @Query() queryParams: ListingsRetrieveQueryParams + ): Promise { + if (listingId === undefined || listingId === "undefined") { + return mapTo(ListingDto, {}) + } + return mapTo( + ListingDto, + await this.listingsService.findOne(listingId, language, queryParams.view) + ) + } + + @Put(`:id`) + @ApiOperation({ summary: "Update listing by id", operationId: "update" }) + @UsePipes(new ListingUpdateValidationPipe(defaultValidationPipeOptions)) + async update( + @Param("id") listingId: string, + @Body() listingUpdateDto: ListingUpdateDto + ): Promise { + const listing = await this.listingsService.update(listingUpdateDto) + return mapTo(ListingDto, listing) + } + + @Delete(`:id`) + @ApiOperation({ summary: "Delete listing by id", operationId: "delete" }) + @UsePipes(new ValidationPipe(defaultValidationPipeOptions)) + async delete(@Param("id") listingId: string) { + await this.listingsService.delete(listingId) + } +} diff --git a/backend/core/src/listings/listings.module.ts b/backend/core/src/listings/listings.module.ts new file mode 100644 index 0000000000..73870c54db --- /dev/null +++ b/backend/core/src/listings/listings.module.ts @@ -0,0 +1,43 @@ +import { Module } from "@nestjs/common" +import { TypeOrmModule } from "@nestjs/typeorm" +import { BullModule } from "@nestjs/bull" +import { ListingsService } from "./listings.service" +import { ListingsController } from "./listings.controller" +import { Listing } from "./entities/listing.entity" +import { ListingsNotificationsConsumer } from "./listings-notifications" +import { Unit } from "../units/entities/unit.entity" +import { Preference } from "../preferences/entities/preference.entity" +import { AuthModule } from "../auth/auth.module" +import { User } from "../auth/entities/user.entity" +import { Property } from "../property/entities/property.entity" +import { TranslationsModule } from "../translations/translations.module" +import { AmiChart } from "../ami-charts/entities/ami-chart.entity" +import { SmsModule } from "../sms/sms.module" +import { ListingFeatures } from "./entities/listing-features.entity" +import { ActivityLogModule } from "../activity-log/activity-log.module" +import { UnitGroup } from "../units-summary/entities/unit-group.entity" + +@Module({ + imports: [ + TypeOrmModule.forFeature([ + Listing, + Preference, + Unit, + User, + Property, + AmiChart, + ListingFeatures, + UnitGroup, + ]), + AuthModule, + TranslationsModule, + BullModule.registerQueue({ name: "listings-notifications" }), + SmsModule, + ActivityLogModule, + ], + providers: [ListingsService, ListingsNotificationsConsumer], + exports: [ListingsService], + controllers: [ListingsController], +}) +// We have to manually disconnect from redis on app close +export class ListingsModule {} diff --git a/backend/core/src/listings/listings.service.ts b/backend/core/src/listings/listings.service.ts new file mode 100644 index 0000000000..a3ae6a6fcc --- /dev/null +++ b/backend/core/src/listings/listings.service.ts @@ -0,0 +1,329 @@ +import { HttpException, HttpStatus, Injectable, NotFoundException } from "@nestjs/common" +import { InjectRepository } from "@nestjs/typeorm" +import { Pagination } from "nestjs-typeorm-paginate" +import { In, Repository, SelectQueryBuilder } from "typeorm" +import { plainToClass } from "class-transformer" +import { Listing } from "./entities/listing.entity" +import { PropertyCreateDto, PropertyUpdateDto } from "../property/dto/property.dto" +import { addFilters } from "../shared/query-filter" +import { getView } from "./views/view" +import { summarizeUnits } from "../shared/units-transformations" +import { Language } from "../../types" +import { AmiChart } from "../ami-charts/entities/ami-chart.entity" +import { OrderByFieldsEnum } from "./types/listing-orderby-enum" +import { ListingCreateDto } from "./dto/listing-create.dto" +import { ListingUpdateDto } from "./dto/listing-update.dto" +import { ListingFilterParams } from "./dto/listing-filter-params" +import { ListingsQueryParams } from "./dto/listings-query-params" +import { filterTypeToFieldMap } from "./dto/filter-type-to-field-map" +import { ListingStatus } from "./types/listing-status-enum" +import { TranslationsService } from "../translations/services/translations.service" +import { UnitGroup } from "../units-summary/entities/unit-group.entity" +import { ListingSeasonEnum } from "./types/listing-season-enum" + +@Injectable() +export class ListingsService { + constructor( + @InjectRepository(Listing) private readonly listingRepository: Repository, + @InjectRepository(AmiChart) private readonly amiChartsRepository: Repository, + @InjectRepository(UnitGroup) private readonly unitGroupRepository: Repository, + private readonly translationService: TranslationsService + ) {} + + private getFullyJoinedQueryBuilder() { + return getView(this.listingRepository.createQueryBuilder("listings"), "full").getViewQb() + } + + public async list(params: ListingsQueryParams): Promise> { + // Inner query to get the sorted listing ids of the listings to display + // TODO(avaleske): Only join the tables we need for the filters that are applied + let innerFilteredQuery = this.listingRepository + .createQueryBuilder("listings") + .select("listings.id", "listings_id") + .leftJoin("listings.property", "property") + .leftJoin("property.buildingAddress", "buildingAddress") + .leftJoin("listings.reservedCommunityType", "reservedCommunityType") + .leftJoin("listings.features", "listing_features") + .groupBy("listings.id") + + innerFilteredQuery = ListingsService.addOrderByToQb(innerFilteredQuery, params) + + if (params.filter) { + addFilters, typeof filterTypeToFieldMap>( + params.filter, + filterTypeToFieldMap, + innerFilteredQuery + ) + } + + // TODO(avaleske): Typescript doesn't realize that the `paginate` bool is a + // type guard, but it will in version 4.4. Once this codebase is upgraded to + // v4.4, remove the extra type assertions on `params.limit` below. + const paginate = params.limit !== "all" && params.limit > 0 && params.page > 0 + if (paginate) { + // Calculate the number of listings to skip (because they belong to lower page numbers). + const offset = (params.page - 1) * (params.limit as number) + // Add the limit and offset to the inner query, so we only do the full + // join on the listings we want to show. + innerFilteredQuery.offset(offset).limit(params.limit as number) + } + const view = getView(this.listingRepository.createQueryBuilder("listings"), params.view) + + let mainQuery = view + .getViewQb() + .andWhere("listings.id IN (" + innerFilteredQuery.getQuery() + ")") + // Set the inner WHERE params on the outer query, as noted in the TypeORM docs. + // (WHERE params are the values passed to andWhere() that TypeORM escapes + // and substitues for the `:paramName` placeholders in the WHERE clause.) + .setParameters(innerFilteredQuery.getParameters()) + + mainQuery = ListingsService.addOrderByToQb(mainQuery, params) + + let listings = await mainQuery.getMany() + + listings = await this.addUnitSummariesToListings(listings) + // Set pagination info + const itemsPerPage = paginate ? (params.limit as number) : listings.length + const totalItems = paginate ? await innerFilteredQuery.getCount() : listings.length + const paginationInfo = { + currentPage: paginate ? params.page : 1, + itemCount: listings.length, + itemsPerPage: itemsPerPage, + totalItems: totalItems, + totalPages: Math.ceil(totalItems / itemsPerPage), // will be 1 if no pagination + } + + // There is a bug in nestjs-typeorm-paginate's handling of complex, nested + // queries (https://github.com/nestjsx/nestjs-typeorm-paginate/issues/6) so + // we build the pagination metadata manually. Additional details are in + // https://github.com/CityOfDetroit/bloom/issues/56#issuecomment-865202733 + const paginatedListings: Pagination = { + items: listings, + meta: paginationInfo, + // nestjs-typeorm-paginate leaves these empty if no route is defined + // This matches what other paginated endpoints, such as the applications + // service, currently return. + links: { + first: "", + previous: "", + next: "", + last: "", + }, + } + return paginatedListings + } + + private async addUnitSummariesToListings(listings: Listing[]) { + const res = await this.unitGroupRepository.find({ + cache: true, + where: { + listing: { + id: In(listings.map((listing) => listing.id)), + }, + }, + }) + + const unitGroupMap = res.reduce( + ( + obj: Record>, + current: UnitGroup + ): Record> => { + if (obj[current.listingId] !== undefined) { + obj[current.listingId].push(current) + } else { + obj[current.listingId] = [current] + } + + return obj + }, + {} + ) + + // using map with {...listing, unitSummaries} throws a type error + listings.forEach((listing) => { + listing.unitSummaries = summarizeUnits(unitGroupMap[listing.id], []) + }) + + return listings + } + + async create(listingDto: ListingCreateDto): Promise { + const listing = this.listingRepository.create({ + ...listingDto, + publishedAt: listingDto.status === ListingStatus.active ? new Date() : null, + closedAt: listingDto.status === ListingStatus.closed ? new Date() : null, + property: plainToClass(PropertyCreateDto, listingDto), + }) + return await listing.save() + } + + async update(listingDto: ListingUpdateDto) { + const qb = this.getFullyJoinedQueryBuilder() + qb.where("listings.id = :id", { id: listingDto.id }) + const listing = await qb.getOne() + + if (!listing) { + throw new NotFoundException() + } + let availableUnits = 0 + listingDto.units.forEach((unit) => { + if (!unit.id) { + delete unit.id + } + if (unit.status === "available") { + availableUnits++ + } + }) + listingDto.unitGroups.forEach((summary) => { + if (!summary.id) { + delete summary.id + } + }) + listingDto.unitsAvailable = availableUnits + Object.assign(listing, { + ...plainToClass(Listing, listingDto, { excludeExtraneousValues: false }), + publishedAt: + listing.status !== ListingStatus.active && listingDto.status === ListingStatus.active + ? new Date() + : listing.publishedAt, + closedAt: + listing.status !== ListingStatus.closed && listingDto.status === ListingStatus.closed + ? new Date() + : listing.closedAt, + property: plainToClass( + PropertyUpdateDto, + { + // NOTE: Create a property out of fields encapsulated in listingDto + ...listingDto, + // NOTE: Since we use the entire listingDto to create a property object the listing ID + // would overwrite propertyId fetched from DB + id: listing.property.id, + }, + { excludeExtraneousValues: true } + ), + }) + + return await this.listingRepository.save(listing) + } + + async delete(listingId: string) { + const listing = await this.listingRepository.findOneOrFail({ + where: { id: listingId }, + }) + return await this.listingRepository.remove(listing) + } + + async findOne(listingId: string, lang: Language = Language.en, view = "full") { + const qb = getView(this.listingRepository.createQueryBuilder("listings"), view).getViewQb() + const result = await qb + .where("listings.id = :id", { id: listingId }) + .orderBy({ + "listingPreferences.ordinal": "ASC", + }) + .getOne() + if (!result) { + throw new NotFoundException() + } + + if (lang !== Language.en) { + await this.translationService.translateListing(result, lang) + } + + await this.addUnitSummaries(result) + return result + } + + private async addUnitSummaries(listing: Listing) { + if (Array.isArray(listing.unitGroups) && listing.unitGroups.length > 0) { + const amiChartIds = listing.unitGroups.reduce((acc: string[], curr: UnitGroup) => { + curr.amiLevels.forEach((level) => { + if (acc.includes(level.amiChartId) === false) { + acc.push(level.amiChartId) + } + }) + return acc + }, []) + const amiCharts = await this.amiChartsRepository.find({ + where: { id: In(amiChartIds) }, + }) + listing.unitSummaries = summarizeUnits(listing.unitGroups, amiCharts) + } + return listing + } + + /** + * + * @param user + * @param listing + * @param action + * + * authz gaurd should already be used at this point, + * so we know the user has general permissions to do this action. + * We also have to check what the previous status was. + * A partner can save a listing as any status as long as the previous status was active. Otherwise they can only save as pending + */ + private userCanUpdateOrThrow( + user, + listing: ListingUpdateDto, + previousListingStatus: ListingStatus + ): boolean { + const { isAdmin } = user.roles + let canUpdate = false + + if (isAdmin) { + canUpdate = true + } else if (previousListingStatus !== ListingStatus.pending) { + canUpdate = true + } else if (listing.status === ListingStatus.pending) { + canUpdate = true + } + + if (!canUpdate) { + throw new HttpException("Forbidden", HttpStatus.FORBIDDEN) + } + + return canUpdate + } + + private static addOrderByToQb(qb: SelectQueryBuilder, params: ListingsQueryParams) { + switch (params.orderBy) { + case OrderByFieldsEnum.mostRecentlyUpdated: + qb.orderBy({ "listings.updated_at": "DESC" }) + break + case OrderByFieldsEnum.mostRecentlyClosed: + qb.orderBy({ + "listings.closedAt": { order: "DESC", nulls: "NULLS LAST" }, + "listings.publishedAt": { order: "DESC", nulls: "NULLS LAST" }, + }) + break + case OrderByFieldsEnum.applicationDates: + qb.orderBy({ + "listings.applicationDueDate": "ASC", + }) + break + case OrderByFieldsEnum.comingSoon: + qb.orderBy("listings.marketingType", "DESC", "NULLS LAST") + qb.addOrderBy(`to_char(listings.marketingDate, 'YYYY')`, "ASC") + qb.addOrderBy( + `CASE listings.marketingSeason WHEN '${ListingSeasonEnum.Spring}' THEN 1 WHEN '${ListingSeasonEnum.Summer}' THEN 2 WHEN '${ListingSeasonEnum.Fall}' THEN 3 WHEN '${ListingSeasonEnum.Winter}' THEN 4 END`, + "ASC" + ) + qb.addOrderBy("listings.updatedAt", "DESC") + break + case undefined: + // Default to ordering by applicationDates (i.e. applicationDueDate + // and applicationOpenDate) if no orderBy param is specified. + qb.orderBy({ + "listings.name": "ASC", + }) + break + default: + throw new HttpException( + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + `OrderBy parameter (${params.orderBy}) not recognized or not yet implemented.`, + HttpStatus.NOT_IMPLEMENTED + ) + } + return qb + } +} diff --git a/backend/core/src/listings/tests/listings.service.spec.ts b/backend/core/src/listings/tests/listings.service.spec.ts new file mode 100644 index 0000000000..3d1b84338f --- /dev/null +++ b/backend/core/src/listings/tests/listings.service.spec.ts @@ -0,0 +1,525 @@ +import { Test, TestingModule } from "@nestjs/testing" +import { getRepositoryToken } from "@nestjs/typeorm" +import { HttpException, HttpStatus } from "@nestjs/common" +import { AvailabilityFilterEnum } from "../types/listing-filter-keys-enum" +import { ListingStatus } from "../types/listing-status-enum" +import { ListingCreateDto } from "../dto/listing-create.dto" +import { ListingUpdateDto } from "../dto/listing-update.dto" +import { ListingsService } from "../listings.service" +import { Listing } from "../entities/listing.entity" +import { TranslationsService } from "../../translations/services/translations.service" +import { AmiChart } from "../../ami-charts/entities/ami-chart.entity" +import { ListingsQueryParams } from "../dto/listings-query-params" +import { Compare } from "../../shared/dto/filter.dto" +import { ListingFilterParams } from "../dto/listing-filter-params" +import { OrderByFieldsEnum } from "../types/listing-orderby-enum" +import { ContextIdFactory } from "@nestjs/core" +import { UnitGroup } from "../../units-summary/entities/unit-group.entity" +import { ListingMarketingTypeEnum } from "../types/listing-marketing-type-enum" + +// Cypress brings in Chai types for the global expect, but we want to use jest +// expect here so we need to re-declare it. +// see: https://github.com/cypress-io/cypress/issues/1319#issuecomment-593500345 +declare const expect: jest.Expect + +let service: ListingsService +const mockListings = [ + { + id: "asdf1", + property: { id: "test-property1", units: [] }, + preferences: [], + status: "closed", + unitSummaries: { byUnitTypeAndRent: [] }, + }, + { + id: "asdf2", + property: { id: "test-property2", units: [] }, + preferences: [], + status: "closed", + unitSummaries: { byUnitTypeAndRent: [] }, + }, + { + id: "asdf3", + property: { id: "test-property3", units: [] }, + preferences: [], + status: "closed", + unitSummaries: { byUnitTypeAndRent: [] }, + }, + { + id: "asdf4", + property: { id: "test-property4", units: [] }, + preferences: [], + status: "closed", + unitSummaries: { byUnitTypeAndRent: [] }, + }, + { + id: "asdf5", + property: { id: "test-property5", units: [] }, + preferences: [], + status: "closed", + unitSummaries: { byUnitTypeAndRent: [] }, + }, + { + id: "asdf6", + property: { id: "test-property6", units: [] }, + preferences: [], + status: "closed", + unitSummaries: { byUnitTypeAndRent: [] }, + }, + { + id: "asdf7", + property: { id: "test-property7", units: [] }, + preferences: [], + status: "closed", + unitSummaries: { byUnitTypeAndRent: [] }, + }, +] +const mockFilteredListings = mockListings.slice(0, 2) +const mockInnerQueryBuilder = { + select: jest.fn().mockReturnThis(), + leftJoin: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + addOrderBy: jest.fn().mockReturnThis(), + groupBy: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + offset: jest.fn().mockReturnThis(), + limit: jest.fn().mockReturnThis(), + getParameters: jest.fn().mockReturnValue({ param1: "param1value" }), + getQuery: jest.fn().mockReturnValue("innerQuery"), + getCount: jest.fn().mockReturnValue(7), +} +const mockQueryBuilder = { + select: jest.fn().mockReturnThis(), + leftJoin: jest.fn().mockReturnThis(), + leftJoinAndSelect: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + setParameters: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + addOrderBy: jest.fn().mockReturnThis(), + getMany: jest.fn().mockReturnValue(mockListings), + getOne: jest.fn(), +} +const mockListingsRepo = { + createQueryBuilder: jest.fn().mockReturnValue(mockQueryBuilder), + count: jest.fn().mockReturnValue(100), + create: jest.fn(), + save: jest.fn(), +} +const mockListingsCreateDto: ListingCreateDto = { + applicationMethods: [], + applicationDropOffAddress: null, + applicationMailingAddress: null, + events: [], + units: [], + buildingAddress: null, + jurisdiction: null, + assets: [], + name: null, + status: null, + displayWaitlistSize: false, + hasId: null, + marketingType: ListingMarketingTypeEnum.Marketing, + listingPreferences: [], + save: jest.fn(), + remove: jest.fn(), + softRemove: jest.fn(), + recover: jest.fn(), + reload: jest.fn(), +} +const mockListingsUpdateDto: ListingUpdateDto = { + applicationMethods: [], + applicationDropOffAddress: null, + applicationMailingAddress: null, + events: [], + units: [], + unitGroups: [], + buildingAddress: null, + jurisdiction: null, + assets: [], + name: null, + status: ListingStatus.pending, + displayWaitlistSize: false, + hasId: null, + marketingType: ListingMarketingTypeEnum.Marketing, + listingPreferences: [], + save: jest.fn(), + remove: jest.fn(), + softRemove: jest.fn(), + recover: jest.fn(), + reload: jest.fn(), +} + +const mockListingsNotificationsQueue = { add: jest.fn() } + +describe("ListingsService", () => { + beforeEach(async () => { + process.env.APP_SECRET = "SECRET" + process.env.EMAIL_API_KEY = "SG.KEY" + const module: TestingModule = await Test.createTestingModule({ + providers: [ + ListingsService, + { + provide: getRepositoryToken(Listing), + useValue: mockListingsRepo, + }, + { + provide: getRepositoryToken(AmiChart), + useValue: jest.fn(), + }, + { + provide: getRepositoryToken(UnitGroup), + useValue: { + find: jest.fn(() => { + return [] + }), + }, + }, + { + provide: TranslationsService, + useValue: { translateListing: jest.fn() }, + }, + ], + }).compile() + + const contextId = ContextIdFactory.create() + jest.spyOn(ContextIdFactory, "getByRequest").mockImplementation(() => contextId) + + service = await module.resolve(ListingsService, contextId) + }) + + afterEach(() => { + jest.clearAllMocks() + }) + + it("should be defined", () => { + expect(service).toBeDefined() + }) + + describe("getListingsList", () => { + it("should not add a WHERE clause if no filters are applied", async () => { + mockListingsRepo.createQueryBuilder + .mockReturnValueOnce(mockInnerQueryBuilder) + .mockReturnValueOnce(mockQueryBuilder) + + const listings = await service.list({}) + + expect(listings.items).toEqual(mockListings) + expect(mockInnerQueryBuilder.andWhere).toHaveBeenCalledTimes(0) + }) + + it("should add a WHERE clause if the status filter is applied", async () => { + mockListingsRepo.createQueryBuilder + .mockReturnValueOnce(mockInnerQueryBuilder) + .mockReturnValueOnce(mockQueryBuilder) + const expectedStatus = ListingStatus.active + + const queryParams: ListingsQueryParams = { + filter: [ + { + $comparison: Compare["="], + status: expectedStatus, + }, + ], + } + + const listings = await service.list(queryParams) + + expect(listings.items).toEqual(mockListings) + expect(mockInnerQueryBuilder.andWhere).toHaveBeenCalledWith( + "(LOWER(CAST(listings.status as text)) = LOWER(:status_0))", + { + status_0: expectedStatus, + } + ) + }) + + it("should support filters with comma-separated arrays", async () => { + mockListingsRepo.createQueryBuilder + .mockReturnValueOnce(mockInnerQueryBuilder) + .mockReturnValueOnce(mockQueryBuilder) + const expectedNeighborhoodString = "Fox Creek, , Coliseum," // intentional extra and trailing commas for test + // lowercased, trimmed spaces, filtered empty + const expectedNeighborhoodArray = ["fox creek", "coliseum"] + + const queryParams: ListingsQueryParams = { + filter: [ + { + $comparison: Compare["IN"], + neighborhood: expectedNeighborhoodString, + }, + ], + } + + const listings = await service.list(queryParams) + + expect(listings.items).toEqual(mockListings) + expect(mockInnerQueryBuilder.andWhere).toHaveBeenCalledWith( + "(LOWER(CAST(property.neighborhood as text)) IN (:...neighborhood_0))", + { + neighborhood_0: expectedNeighborhoodArray, + } + ) + }) + + it("should support filtering on neighborhoods", async () => { + mockListingsRepo.createQueryBuilder + .mockReturnValueOnce(mockInnerQueryBuilder) + .mockReturnValueOnce(mockQueryBuilder) + const neighborhoodString = "neighborhood 1, , neighborhood 2," // intentional extra and trailing commas for test + // lowercased, trimmed spaces, filtered empty + const expectedNeighborhoodArray = ["neighborhood 1", "neighborhood 2"] + + const queryParams: ListingsQueryParams = { + filter: [ + { + $comparison: Compare["IN"], + neighborhood: neighborhoodString, + }, + ], + } + + const listings = await service.list(queryParams) + + expect(listings.items).toEqual(mockListings) + expect(mockInnerQueryBuilder.andWhere).toHaveBeenCalledWith( + "(LOWER(CAST(property.neighborhood as text)) IN (:...neighborhood_0))", + { + neighborhood_0: expectedNeighborhoodArray, + } + ) + }) + + it("should support filtering on features", async () => { + mockListingsRepo.createQueryBuilder + .mockReturnValueOnce(mockInnerQueryBuilder) + .mockReturnValueOnce(mockQueryBuilder) + + const queryParams: ListingsQueryParams = { + filter: [ + { + $comparison: Compare["IN"], + elevator: true, + }, + ], + } + + const listings = await service.list(queryParams) + + expect(listings.items).toEqual(mockListings) + expect(mockInnerQueryBuilder.andWhere).toHaveBeenCalledWith( + "(LOWER(CAST(listing_features.elevator as text)) IN (:...elevator_0))", + { + elevator_0: ["true"], + } + ) + }) + + it("should include listings with missing data if $include_nulls is true", async () => { + mockListingsRepo.createQueryBuilder + .mockReturnValueOnce(mockInnerQueryBuilder) + .mockReturnValueOnce(mockQueryBuilder) + const queryParams: ListingsQueryParams = { + filter: [ + { + $comparison: Compare["="], + name: "minRent", + $include_nulls: true, + }, + ], + } + + const listings = await service.list(queryParams) + + expect(listings.items).toEqual(mockListings) + expect(mockInnerQueryBuilder.andWhere).toHaveBeenCalledWith( + "(LOWER(CAST(listings.name as text)) = LOWER(:name_0) OR listings.name IS NULL)", + { + name_0: "minRent", + } + ) + }) + + it("should include listings with missing data if $include_nulls is true for custom filters", async () => { + mockListingsRepo.createQueryBuilder + .mockReturnValueOnce(mockInnerQueryBuilder) + .mockReturnValueOnce(mockQueryBuilder) + const queryParams: ListingsQueryParams = { + filter: [ + { + $comparison: Compare["NA"], + availability: AvailabilityFilterEnum.waitlist, + $include_nulls: true, + }, + ], + } + + const listings = await service.list(queryParams) + + expect(listings.items).toEqual(mockListings) + expect(mockInnerQueryBuilder.andWhere).toHaveBeenCalledWith( + "(listings.is_waitlist_open = :availability OR listings.is_waitlist_open is NULL)", + { + availability: true, + } + ) + }) + + it("should throw an exception if an unsupported filter is used", async () => { + mockListingsRepo.createQueryBuilder.mockReturnValueOnce(mockInnerQueryBuilder) + + const queryParams: ListingsQueryParams = { + filter: [ + { + $comparison: Compare["="], + otherField: "otherField", + // The querystring can contain unknown fields that aren't on the + // ListingFilterParams type, so we force it to the type for testing. + } as ListingFilterParams, + ], + } + + await expect(service.list(queryParams)).rejects.toThrow( + new HttpException("Filter Not Implemented", HttpStatus.NOT_IMPLEMENTED) + ) + }) + + //TODO(avaleske): A lot of these tests should be moved to a spec file specific to the filters code. + it("should throw an exception if an unsupported comparison is used", async () => { + mockListingsRepo.createQueryBuilder.mockReturnValueOnce(mockInnerQueryBuilder) + + const queryParams: ListingsQueryParams = { + filter: [ + { + // The value of the filter[$comparison] query param is not validated, + // and the type system trusts that whatever is provided is correct, + // so we force it to an invalid type for testing. + $comparison: "); DROP TABLE Students;" as Compare, + name: "test name", + } as ListingFilterParams, + ], + } + + await expect(service.list(queryParams)).rejects.toThrow( + new HttpException("Comparison Not Implemented", HttpStatus.NOT_IMPLEMENTED) + ) + }) + + it("should not call limit() and offset() if pagination params are not specified", async () => { + mockListingsRepo.createQueryBuilder + .mockReturnValueOnce(mockInnerQueryBuilder) + .mockReturnValueOnce(mockQueryBuilder) + + // Empty params (no pagination) -> no limit/offset + const params = {} + const listings = await service.list(params) + + expect(listings.items).toEqual(mockListings) + expect(mockInnerQueryBuilder.limit).toHaveBeenCalledTimes(0) + expect(mockInnerQueryBuilder.offset).toHaveBeenCalledTimes(0) + }) + + it("should not call limit() and offset() if incomplete pagination params are specified", async () => { + mockListingsRepo.createQueryBuilder + .mockReturnValueOnce(mockInnerQueryBuilder) + .mockReturnValueOnce(mockQueryBuilder) + + // Invalid pagination params (page specified, but not limit) -> no limit/offset + const params = { page: 3 } + const listings = await service.list(params) + + expect(listings.items).toEqual(mockListings) + expect(mockInnerQueryBuilder.limit).toHaveBeenCalledTimes(0) + expect(mockInnerQueryBuilder.offset).toHaveBeenCalledTimes(0) + expect(listings.meta).toEqual({ + currentPage: 1, + itemCount: mockListings.length, + itemsPerPage: mockListings.length, + totalItems: mockListings.length, + totalPages: 1, + }) + }) + + it("should not call limit() and offset() if invalid pagination params are specified", async () => { + mockListingsRepo.createQueryBuilder + .mockReturnValueOnce(mockInnerQueryBuilder) + .mockReturnValueOnce(mockQueryBuilder) + + // Invalid pagination params (page specified, but not limit) -> no limit/offset + const params = { page: ("hello" as unknown) as number } // force the type for testing + const listings = await service.list(params) + + expect(listings.items).toEqual(mockListings) + expect(mockInnerQueryBuilder.limit).toHaveBeenCalledTimes(0) + expect(mockInnerQueryBuilder.offset).toHaveBeenCalledTimes(0) + expect(listings.meta).toEqual({ + currentPage: 1, + itemCount: mockListings.length, + itemsPerPage: mockListings.length, + totalItems: mockListings.length, + totalPages: 1, + }) + }) + + it("should call limit() and offset() if pagination params are specified", async () => { + mockQueryBuilder.getMany.mockReturnValueOnce(mockFilteredListings) + mockListingsRepo.createQueryBuilder + .mockReturnValueOnce(mockInnerQueryBuilder) + .mockReturnValueOnce(mockQueryBuilder) + + // Valid pagination params -> offset and limit called appropriately + const params = { page: 3, limit: 2 } + const listings = await service.list(params) + + expect(listings.items).toEqual(mockFilteredListings) + expect(mockInnerQueryBuilder.limit).toHaveBeenCalledWith(2) + expect(mockInnerQueryBuilder.offset).toHaveBeenCalledWith(4) + expect(mockInnerQueryBuilder.getCount).toHaveBeenCalledTimes(1) + expect(listings.meta).toEqual({ + currentPage: 3, + itemCount: 2, + itemsPerPage: 2, + totalItems: mockListings.length, + totalPages: 4, + }) + }) + }) + + describe("ListingsService.list sorting", () => { + it("defaults to ordering by name when no orderBy param is set", async () => { + mockListingsRepo.createQueryBuilder + .mockReturnValueOnce(mockInnerQueryBuilder) + .mockReturnValueOnce(mockQueryBuilder) + + await service.list({}) + + const expectedOrderByArgument = { + "listings.name": "ASC", + } + + // The inner query must be ordered so that the ordering applies across all pages (if pagination is requested) + expect(mockInnerQueryBuilder.orderBy).toHaveBeenCalledTimes(1) + expect(mockInnerQueryBuilder.orderBy).toHaveBeenCalledWith(expectedOrderByArgument) + + // The full query must be ordered so that the ordering is applied within a page (if pagination is requested) + expect(mockQueryBuilder.orderBy).toHaveBeenCalledTimes(1) + expect(mockQueryBuilder.orderBy).toHaveBeenCalledWith(expectedOrderByArgument) + }) + + it("orders by the orderBy param (when set)", async () => { + mockListingsRepo.createQueryBuilder + .mockReturnValueOnce(mockInnerQueryBuilder) + .mockReturnValueOnce(mockQueryBuilder) + + await service.list({ orderBy: OrderByFieldsEnum.mostRecentlyUpdated }) + + const expectedOrderByArgument = { "listings.updated_at": "DESC" } + + expect(mockInnerQueryBuilder.orderBy).toHaveBeenCalledTimes(1) + expect(mockInnerQueryBuilder.orderBy).toHaveBeenCalledWith(expectedOrderByArgument) + + expect(mockQueryBuilder.orderBy).toHaveBeenCalledTimes(1) + expect(mockQueryBuilder.orderBy).toHaveBeenCalledWith(expectedOrderByArgument) + }) + }) +}) diff --git a/backend/core/src/listings/types/listing-application-address-type.ts b/backend/core/src/listings/types/listing-application-address-type.ts new file mode 100644 index 0000000000..72a140f4fc --- /dev/null +++ b/backend/core/src/listings/types/listing-application-address-type.ts @@ -0,0 +1,3 @@ +export enum ListingApplicationAddressType { + leasingAgent = "leasingAgent", +} diff --git a/backend/core/src/listings/types/listing-event-type-enum.ts b/backend/core/src/listings/types/listing-event-type-enum.ts new file mode 100644 index 0000000000..5993ff6277 --- /dev/null +++ b/backend/core/src/listings/types/listing-event-type-enum.ts @@ -0,0 +1,5 @@ +export enum ListingEventType { + openHouse = "openHouse", + publicLottery = "publicLottery", + lotteryResults = "lotteryResults", +} diff --git a/backend/core/src/listings/types/listing-filter-keys-enum.ts b/backend/core/src/listings/types/listing-filter-keys-enum.ts new file mode 100644 index 0000000000..03446f09e9 --- /dev/null +++ b/backend/core/src/listings/types/listing-filter-keys-enum.ts @@ -0,0 +1,38 @@ +// The names of supported filters on /listings +export enum ListingFilterKeys { + id = "id", + status = "status", + name = "name", + isVerified = "isVerified", + bedrooms = "bedrooms", + zipcode = "zipcode", + availability = "availability", + program = "program", + minRent = "minRent", + maxRent = "maxRent", + minAmiPercentage = "minAmiPercentage", + leasingAgents = "leasingAgents", + elevator = "elevator", + wheelchairRamp = "wheelchairRamp", + serviceAnimalsAllowed = "serviceAnimalsAllowed", + accessibleParking = "accessibleParking", + parkingOnSite = "parkingOnSite", + inUnitWasherDryer = "inUnitWasherDryer", + laundryInBuilding = "laundryInBuilding", + barrierFreeEntrance = "barrierFreeEntrance", + rollInShower = "rollInShower", + grabBars = "grabBars", + heatingInUnit = "heatingInUnit", + acInUnit = "acInUnit", + neighborhood = "neighborhood", + jurisdiction = "jurisdiction", + favorited = "favorited", + marketingType = "marketingType", + region = "region", +} + +export enum AvailabilityFilterEnum { + hasAvailability = "hasAvailability", + noAvailability = "noAvailability", + waitlist = "waitlist", +} diff --git a/backend/core/src/listings/types/listing-marketing-type-enum.ts b/backend/core/src/listings/types/listing-marketing-type-enum.ts new file mode 100644 index 0000000000..5b8864ebda --- /dev/null +++ b/backend/core/src/listings/types/listing-marketing-type-enum.ts @@ -0,0 +1,4 @@ +export enum ListingMarketingTypeEnum { + Marketing = "marketing", + ComingSoon = "comingSoon", +} diff --git a/backend/core/src/listings/types/listing-orderby-enum.ts b/backend/core/src/listings/types/listing-orderby-enum.ts new file mode 100644 index 0000000000..0c351532d8 --- /dev/null +++ b/backend/core/src/listings/types/listing-orderby-enum.ts @@ -0,0 +1,6 @@ +export enum OrderByFieldsEnum { + mostRecentlyUpdated = "mostRecentlyUpdated", + applicationDates = "applicationDates", + mostRecentlyClosed = "mostRecentlyClosed", + comingSoon = "comingSoon", +} diff --git a/backend/core/src/listings/types/listing-review-order-enum.ts b/backend/core/src/listings/types/listing-review-order-enum.ts new file mode 100644 index 0000000000..41c0b46279 --- /dev/null +++ b/backend/core/src/listings/types/listing-review-order-enum.ts @@ -0,0 +1,4 @@ +export enum ListingReviewOrder { + lottery = "lottery", + firstComeFirstServe = "firstComeFirstServe", +} diff --git a/backend/core/src/listings/types/listing-season-enum.ts b/backend/core/src/listings/types/listing-season-enum.ts new file mode 100644 index 0000000000..5224c6b114 --- /dev/null +++ b/backend/core/src/listings/types/listing-season-enum.ts @@ -0,0 +1,6 @@ +export enum ListingSeasonEnum { + Spring = "spring", + Summer = "summer", + Fall = "fall", + Winter = "winter", +} diff --git a/backend/core/src/listings/types/listing-status-enum.ts b/backend/core/src/listings/types/listing-status-enum.ts new file mode 100644 index 0000000000..69c2ef1633 --- /dev/null +++ b/backend/core/src/listings/types/listing-status-enum.ts @@ -0,0 +1,5 @@ +export enum ListingStatus { + active = "active", + pending = "pending", + closed = "closed", +} diff --git a/backend/core/src/listings/types/listings-response-status-enum.ts b/backend/core/src/listings/types/listings-response-status-enum.ts new file mode 100644 index 0000000000..512f474553 --- /dev/null +++ b/backend/core/src/listings/types/listings-response-status-enum.ts @@ -0,0 +1,3 @@ +export enum ListingsResponseStatus { + ok = "ok", +} diff --git a/backend/core/src/listings/validation-pipes/listing-create-validation-pipe.ts b/backend/core/src/listings/validation-pipes/listing-create-validation-pipe.ts new file mode 100644 index 0000000000..0da9d38499 --- /dev/null +++ b/backend/core/src/listings/validation-pipes/listing-create-validation-pipe.ts @@ -0,0 +1,22 @@ +import { ArgumentMetadata, ValidationPipe } from "@nestjs/common" +import { ListingStatus } from "../types/listing-status-enum" +import { ListingCreateDto } from "../dto/listing-create.dto" + +export class ListingCreateValidationPipe extends ValidationPipe { + statusToListingValidationModelMap: Record = { + [ListingStatus.closed]: ListingCreateDto, + [ListingStatus.pending]: ListingCreateDto, + [ListingStatus.active]: ListingCreateDto, + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + async transform(value: any, metadata: ArgumentMetadata): Promise { + if (metadata.type === "body") { + return await super.transform(value, { + ...metadata, + metatype: this.statusToListingValidationModelMap[value.status], + }) + } + return await super.transform(value, metadata) + } +} diff --git a/backend/core/src/listings/validation-pipes/listing-update-validation-pipe.ts b/backend/core/src/listings/validation-pipes/listing-update-validation-pipe.ts new file mode 100644 index 0000000000..58cddf2727 --- /dev/null +++ b/backend/core/src/listings/validation-pipes/listing-update-validation-pipe.ts @@ -0,0 +1,22 @@ +import { ArgumentMetadata, ValidationPipe } from "@nestjs/common" +import { ListingStatus } from "../types/listing-status-enum" +import { ListingUpdateDto } from "../dto/listing-update.dto" + +export class ListingUpdateValidationPipe extends ValidationPipe { + statusToListingValidationModelMap: Record = { + [ListingStatus.closed]: ListingUpdateDto, + [ListingStatus.pending]: ListingUpdateDto, + [ListingStatus.active]: ListingUpdateDto, + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + async transform(value: any, metadata: ArgumentMetadata): Promise { + if (metadata.type === "body") { + return await super.transform(value, { + ...metadata, + metatype: this.statusToListingValidationModelMap[value.status], + }) + } + return await super.transform(value, metadata) + } +} diff --git a/backend/core/src/listings/views/config.ts b/backend/core/src/listings/views/config.ts new file mode 100644 index 0000000000..90595f7334 --- /dev/null +++ b/backend/core/src/listings/views/config.ts @@ -0,0 +1,212 @@ +import { Views } from "./types" +import { getBaseAddressSelect } from "../../views/base.view" + +const views: Views = { + base: { + select: [ + "listings.id", + "listings.name", + "listings.applicationDueDate", + "listings.applicationOpenDate", + "listings.marketingType", + "listings.marketingDate", + "listings.marketingSeason", + "listings.reviewOrderType", + "listings.status", + "listings.assets", + "listings.isVerified", + "jurisdiction.id", + "jurisdiction.name", + "reservedCommunityType.id", + "reservedCommunityType.name", + "property.id", + ...getBaseAddressSelect(["buildingAddress"]), + "listingImages.ordinal", + "listingImagesImage.id", + "listingImagesImage.fileId", + "listingImagesImage.label", + "features.id", + "features.elevator", + "features.wheelchairRamp", + "features.serviceAnimalsAllowed", + "features.accessibleParking", + "features.parkingOnSite", + "features.inUnitWasherDryer", + "features.barrierFreeEntrance", + "features.rollInShower", + "features.grabBars", + "features.heatingInUnit", + "features.acInUnit", + "features.laundryInBuilding", + "listingPrograms.ordinal", + "listingsProgramsProgram.id", + "listingsProgramsProgram.title", + "summaryUnitType.numBedrooms", + ], + leftJoins: [ + { join: "listings.jurisdiction", alias: "jurisdiction" }, + { join: "listings.property", alias: "property" }, + { join: "property.buildingAddress", alias: "buildingAddress" }, + { join: "listings.reservedCommunityType", alias: "reservedCommunityType" }, + { join: "listings.images", alias: "listingImages" }, + { join: "listingImages.image", alias: "listingImagesImage" }, + { join: "listings.features", alias: "features" }, + { join: "listings.listingPrograms", alias: "listingPrograms" }, + { join: "listingPrograms.program", alias: "listingsProgramsProgram" }, + { join: "listings.unitGroups", alias: "unitGroups" }, + { join: "unitGroups.unitType", alias: "summaryUnitType" }, + ], + }, +} + +views.partnerList = { + select: [ + "listings.id", + "listings.name", + "listings.applicationDueDate", + "listings.status", + "listings.waitlistMaxSize", + "listings.waitlistCurrentSize", + "property.unitsAvailable", + ], + leftJoins: [{ join: "listings.property", alias: "property" }], +} + +views.detail = { + select: [ + ...views.base.select, + "listings.additionalApplicationSubmissionNotes", + "listings.applicationFee", + "listings.applicationOrganization", + "listings.applicationPickUpAddressOfficeHours", + "listings.applicationPickUpAddressType", + "listings.applicationDropOffAddressOfficeHours", + "listings.applicationDropOffAddressType", + "listings.applicationMailingAddressType", + "listings.buildingSelectionCriteria", + "listings.costsNotIncluded", + "listings.creditHistory", + "listings.criminalBackground", + "listings.depositMin", + "listings.depositMax", + "listings.disableUnitsAccordion", + "listings.jurisdiction", + "listings.leasingAgentEmail", + "listings.leasingAgentName", + "listings.leasingAgentOfficeHours", + "listings.leasingAgentPhone", + "listings.leasingAgentTitle", + "listings.postmarkedApplicationsReceivedByDate", + "listings.programRules", + "listings.rentalAssistance", + "listings.rentalHistory", + "listings.requiredDocuments", + "listings.specialNotes", + "listings.whatToExpect", + "listings.displayWaitlistSize", + "listings.reservedCommunityDescription", + "listings.reservedCommunityMinAge", + "listings.resultLink", + "listings.isWaitlistOpen", + "listings.waitlistOpenSpots", + "listings.customMapPin", + "listings.features", + "buildingSelectionCriteriaFile.id", + "buildingSelectionCriteriaFile.fileId", + "buildingSelectionCriteriaFile.label", + "applicationMethods.id", + "applicationMethods.label", + "applicationMethods.externalReference", + "applicationMethods.acceptsPostmarkedApplications", + "applicationMethods.phoneNumber", + "applicationMethods.type", + "paperApplications.id", + "paperApplications.language", + "paperApplicationFile.id", + "paperApplicationFile.fileId", + "paperApplicationFile.label", + "listingEvents.id", + "listingEvents.type", + "listingEvents.startTime", + "listingEvents.endTime", + "listingEvents.url", + "listingEvents.note", + "listingEvents.label", + "listingEventFile.id", + "listingEventFile.fileId", + "listingEventFile.label", + "result.id", + "result.fileId", + "result.label", + ...getBaseAddressSelect([ + "leasingAgentAddress", + "applicationPickUpAddress", + "applicationMailingAddress", + "applicationDropOffAddress", + ]), + "leasingAgents.firstName", + "leasingAgents.lastName", + "leasingAgents.email", + "listingPreferencesPreference.title", + "listingPreferencesPreference.subtitle", + "listingPreferencesPreference.description", + "listingPreferencesPreference.ordinal", + "listingPreferencesPreference.links", + "listingPreferencesPreference.formMetadata", + ], + leftJoins: [ + ...views.base.leftJoins, + { join: "listings.applicationMethods", alias: "applicationMethods" }, + { join: "applicationMethods.paperApplications", alias: "paperApplications" }, + { join: "paperApplications.file", alias: "paperApplicationFile" }, + { join: "listings.buildingSelectionCriteriaFile", alias: "buildingSelectionCriteriaFile" }, + { join: "listings.events", alias: "listingEvents" }, + { join: "listingEvents.file", alias: "listingEventFile" }, + { join: "listings.result", alias: "result" }, + { join: "listings.leasingAgentAddress", alias: "leasingAgentAddress" }, + { join: "listings.applicationPickUpAddress", alias: "applicationPickUpAddress" }, + { join: "listings.applicationMailingAddress", alias: "applicationMailingAddress" }, + { join: "listings.applicationDropOffAddress", alias: "applicationDropOffAddress" }, + { join: "listings.leasingAgents", alias: "leasingAgents" }, + ], +} + +views.full = { + leftJoinAndSelect: [ + ["listings.applicationMethods", "applicationMethods"], + ["applicationMethods.paperApplications", "paperApplications"], + ["paperApplications.file", "paperApplicationFile"], + ["listings.buildingSelectionCriteriaFile", "buildingSelectionCriteriaFile"], + ["listings.events", "listingEvents"], + ["listingEvents.file", "listingEventFile"], + ["listings.result", "result"], + ["listings.leasingAgentAddress", "leasingAgentAddress"], + ["listings.applicationPickUpAddress", "applicationPickUpAddress"], + ["listings.applicationMailingAddress", "applicationMailingAddress"], + ["listings.applicationDropOffAddress", "applicationDropOffAddress"], + ["listings.leasingAgents", "leasingAgents"], + ["listings.listingPreferences", "listingPreferences"], + ["listingPreferences.preference", "listingPreferencesPreference"], + ["listings.property", "property"], + ["property.buildingAddress", "buildingAddress"], + ["property.units", "units"], + ["units.amiChartOverride", "amiChartOverride"], + ["units.unitType", "unitTypeRef"], + ["units.unitRentType", "unitRentType"], + ["units.priorityType", "priorityType"], + ["units.amiChart", "amiChart"], + ["listings.jurisdiction", "jurisdiction"], + ["listings.reservedCommunityType", "reservedCommunityType"], + ["listings.unitGroups", "unitGroups"], + ["unitGroups.unitType", "summaryUnitType"], + ["unitGroups.priorityType", "summaryPriorityType"], + ["unitGroups.amiLevels", "unitGroupsAmiLevels"], + ["listings.features", "listing_features"], + ["listings.listingPrograms", "listingPrograms"], + ["listingPrograms.program", "listingProgramsProgram"], + ["listings.images", "listingImages"], + ["listingImages.image", "listingImagesImage"], + ], +} + +export { views } diff --git a/backend/core/src/listings/views/types.ts b/backend/core/src/listings/views/types.ts new file mode 100644 index 0000000000..ca7c33f559 --- /dev/null +++ b/backend/core/src/listings/views/types.ts @@ -0,0 +1,12 @@ +import { View } from "../../views/base.view" + +export enum ListingViewEnum { + base = "base", + detail = "detail", + full = "full", + partnerList = "partnerList", +} + +export type Views = { + [key in ListingViewEnum]?: View +} diff --git a/backend/core/src/listings/views/view.spec.ts b/backend/core/src/listings/views/view.spec.ts new file mode 100644 index 0000000000..3f70d5d173 --- /dev/null +++ b/backend/core/src/listings/views/view.spec.ts @@ -0,0 +1,116 @@ +import { BaseListingView, FullView, getView } from "./view" +import { views } from "./config" + +const mockQueryBuilder = { + select: jest.fn().mockReturnThis(), + leftJoin: jest.fn().mockReturnThis(), + leftJoinAndSelect: jest.fn().mockReturnThis(), +} + +const mockListingsRepo = { + createQueryBuilder: jest.fn().mockReturnValue(mockQueryBuilder), +} + +const mockUnitTypes = [ + { id: "unit-1", name: "oneBdrm" }, + { id: "unit-2", name: "twoBdrm" }, + { id: "unit-3", name: "threeBdrm" }, +] + +const mockListings = [ + { + id: "listing-1", + property: { + units: [ + { unitType: mockUnitTypes[0], minimumIncome: "0", rent: "100" }, + { unitType: mockUnitTypes[0], minimumIncome: "1", rent: "101" }, + { unitType: mockUnitTypes[1], minimumIncome: "0", rent: "100" }, + ], + }, + }, + { + id: "listing-2", + property: { + units: [ + { unitType: mockUnitTypes[0], minimumIncome: "0", rent: "100" }, + { unitType: mockUnitTypes[1], minimumIncome: "1", rent: "101" }, + { unitType: mockUnitTypes[2], minimumIncome: "2", rent: "102" }, + ], + }, + }, +] + +describe("listing views", () => { + afterEach(() => { + jest.clearAllMocks() + }) + + describe("BaseView", () => { + it("should create a new BaseView with qb view properties", () => { + const view = new BaseListingView(mockListingsRepo.createQueryBuilder()) + + expect(view.qb).toEqual(mockQueryBuilder) + expect(view.view).toEqual(views.base) + }) + + it("should call getView qb select and leftJoin", () => { + const view = new BaseListingView(mockListingsRepo.createQueryBuilder()) + + view.getViewQb() + + expect(mockQueryBuilder.select).toHaveBeenCalledTimes(1) + expect(mockQueryBuilder.leftJoin).toHaveBeenCalledTimes(11) + }) + + it("should map unitSummary to listings", () => { + const view = new BaseListingView(mockListingsRepo.createQueryBuilder()) + + const listings = view.mapUnitSummary(mockListings) + + listings.forEach((listing) => { + expect(listing).toHaveProperty("unitSummaries") + expect(listing.unitSummaries).toHaveProperty("byUnitTypeAndRent") + }) + }) + }) + + describe("FullView", () => { + it("should call getView qb leftJoinAndSelect", () => { + const view = new FullView(mockListingsRepo.createQueryBuilder()) + + view.getViewQb() + + expect(mockQueryBuilder.leftJoinAndSelect).toHaveBeenCalledTimes(33) + }) + }) + + describe("view function", () => { + it("should create a new BaseView with view param", () => { + const listingView = getView(mockListingsRepo.createQueryBuilder(), "base") + + expect(listingView.qb).toEqual(mockQueryBuilder) + expect(listingView.view).toEqual(views.base) + }) + + it("should create a new FullView without view param", () => { + const listingView = getView(mockListingsRepo.createQueryBuilder()) + + expect(listingView.qb).toEqual(mockQueryBuilder) + expect(listingView.view).toEqual(views.full) + }) + + it("should create a new FullView with view param", () => { + const listingView = getView(mockListingsRepo.createQueryBuilder(), "full") + + expect(listingView.qb).toEqual(mockQueryBuilder) + expect(listingView.view).toEqual(views.full) + }) + + it("should create a new DetailView with view param", () => { + const view = getView(mockListingsRepo.createQueryBuilder(), "detail") + + expect(view.qb).toEqual(mockQueryBuilder) + expect(view.view).toEqual(views.detail) + }) + }) +}) diff --git a/backend/core/src/listings/views/view.ts b/backend/core/src/listings/views/view.ts new file mode 100644 index 0000000000..ee8b162156 --- /dev/null +++ b/backend/core/src/listings/views/view.ts @@ -0,0 +1,65 @@ +import { SelectQueryBuilder } from "typeorm" +import { getUnitGroupSummary } from "../../shared/units-transformations" +import { Listing } from "../entities/listing.entity" +import { views } from "./config" +import { View, BaseView } from "../../views/base.view" + +export function getView(qb: SelectQueryBuilder, view?: string) { + switch (views[view]) { + case views.base: + return new BaseListingView(qb) + case views.detail: + return new DetailView(qb) + case views.full: + default: + return new FullView(qb) + } +} + +export class BaseListingView extends BaseView { + qb: SelectQueryBuilder+ view: View + constructor(qb: SelectQueryBuilder) { + super(qb) + this.view = views.base + } + + getViewQb(): SelectQueryBuilder { + this.qb.select(this.view.select) + + this.view.leftJoins.forEach((join) => { + this.qb.leftJoin(join.join, join.alias) + }) + + return this.qb + } + + mapUnitSummary(listings) { + return listings.map((listing) => ({ + ...listing, + unitSummaries: { + byUnitTypeAndRent: getUnitGroupSummary(listing.unitGroups), + }, + })) + } +} + +export class DetailView extends BaseListingView { + constructor(qb: SelectQueryBuilder) { + super(qb) + this.view = views.detail + } +} + +export class FullView extends BaseListingView { + constructor(qb: SelectQueryBuilder) { + super(qb) + this.view = views.full + } + + getViewQb(): SelectQueryBuilder { + this.view.leftJoinAndSelect.forEach((tuple) => this.qb.leftJoinAndSelect(...tuple)) + + return this.qb + } +} diff --git a/backend/core/src/main.ts b/backend/core/src/main.ts new file mode 100644 index 0000000000..95ec6411e7 --- /dev/null +++ b/backend/core/src/main.ts @@ -0,0 +1,45 @@ +import { NestFactory } from "@nestjs/core" +import { applicationSetup, AppModule } from "./app.module" +import { Logger } from "@nestjs/common" +import { DocumentBuilder, SwaggerModule } from "@nestjs/swagger" +import { getConnection } from "typeorm" +import { ConfigService } from "@nestjs/config" +import dbOptions = require("../ormconfig") + +let app +async function bootstrap() { + app = await NestFactory.create(AppModule.register(dbOptions), { + logger: process.env.NODE_ENV === "development" ? ["error", "warn", "log"] : ["error", "warn"], + }) + // Starts listening for shutdown hooks + app.enableShutdownHooks() + app = applicationSetup(app) + const conn = getConnection() + // showMigrations returns true if there are pending migrations + if (await conn.showMigrations()) { + if (process.env.NODE_ENV === "development") { + Logger.warn( + "Detected pending migrations. Please run 'yarn db:migration:run' or remove /dist directory " + + "(compiled files are retained and you could e.g. have migration .js files from other" + + "branches that TypeORM is incorrectly trying to find in migrations table in the DB)." + ) + } else { + Logger.error( + "Detected pending migrations. Please run 'yarn db:migration:run' before starting the app." + ) + process.exit(1) + } + } + const options = new DocumentBuilder() + .setTitle("Bloom API") + .setVersion("1.0") + .addBearerAuth() + .build() + const document = SwaggerModule.createDocument(app, options) + SwaggerModule.setup("docs", app, document) + const configService: ConfigService = app.get(ConfigService) + await app.listen(configService.get("PORT")) +} +void bootstrap() + +export default app diff --git a/backend/core/src/migration/1599673143230-initial.ts b/backend/core/src/migration/1599673143230-initial.ts new file mode 100644 index 0000000000..9b7838bc69 --- /dev/null +++ b/backend/core/src/migration/1599673143230-initial.ts @@ -0,0 +1,81 @@ +import { MigrationInterface, QueryRunner } from "typeorm" + +export class initial1599673143230 implements MigrationInterface { + name = "initial1599673143230" + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "units" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "created_at" TIMESTAMP NOT NULL DEFAULT now(), "updated_at" TIMESTAMP NOT NULL DEFAULT now(), "ami_percentage" text, "annual_income_min" text, "monthly_income_min" text, "floor" numeric, "annual_income_max" text, "max_occupancy" numeric, "min_occupancy" numeric, "monthly_rent" text, "num_bathrooms" numeric, "num_bedrooms" numeric, "number" text, "priority_type" text, "reserved_type" text, "sq_feet" numeric(8,2), "status" text, "unit_type" text, "ami_chart_id" numeric, "monthly_rent_as_percent_of_income" numeric(8,2), "listing_id" uuid, "bmr_program_chart" boolean, CONSTRAINT "PK_5a8f2f064919b587d93936cb223" PRIMARY KEY ("id"))` + ) + await queryRunner.query( + `CREATE TABLE "user_accounts" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "password_hash" character varying NOT NULL, "email" character varying NOT NULL, "first_name" character varying NOT NULL, "middle_name" character varying, "last_name" character varying NOT NULL, "dob" TIMESTAMP NOT NULL, "created_at" TIMESTAMP NOT NULL DEFAULT now(), "updated_at" TIMESTAMP NOT NULL DEFAULT now(), CONSTRAINT "PK_125e915cf23ad1cfb43815ce59b" PRIMARY KEY ("id"))` + ) + await queryRunner.query( + `CREATE TABLE "applications" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "created_at" TIMESTAMP NOT NULL DEFAULT now(), "updated_at" TIMESTAMP NOT NULL DEFAULT now(), "app_url" text NOT NULL, "application" jsonb, "user_id" uuid, "listing_id" uuid, CONSTRAINT "PK_938c0a27255637bde919591888f" PRIMARY KEY ("id"))` + ) + await queryRunner.query( + `CREATE TABLE "assets" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "created_at" TIMESTAMP NOT NULL DEFAULT now(), "updated_at" TIMESTAMP NOT NULL DEFAULT now(), "label" text NOT NULL, "file_id" text NOT NULL, "listing_id" uuid, CONSTRAINT "PK_da96729a8b113377cfb6a62439c" PRIMARY KEY ("id"))` + ) + await queryRunner.query( + `CREATE TABLE "preferences" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "created_at" TIMESTAMP NOT NULL DEFAULT now(), "updated_at" TIMESTAMP NOT NULL DEFAULT now(), "ordinal" text, "title" text, "subtitle" text, "description" text, "links" jsonb, "listing_id" uuid, CONSTRAINT "PK_17f8855e4145192bbabd91a51be" PRIMARY KEY ("id"))` + ) + await queryRunner.query(`CREATE TYPE "listings_status_enum" AS ENUM('active', 'pending')`) + await queryRunner.query( + `CREATE TABLE "listings" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "created_at" TIMESTAMP NOT NULL DEFAULT now(), "updated_at" TIMESTAMP NOT NULL DEFAULT now(), "accessibility" text, "amenities" text, "application_due_date" text, "application_open_date" text, "application_fee" text, "application_organization" text, "application_address" jsonb, "blank_paper_application_can_be_picked_up" boolean, "building_address" jsonb, "building_total_units" numeric, "building_selection_criteria" text, "costs_not_included" text, "credit_history" text, "criminal_background" text, "deposit_min" text, "deposit_max" text, "developer" text, "disable_units_accordion" boolean, "household_size_max" numeric, "household_size_min" numeric, "image_url" text, "leasing_agent_address" jsonb, "leasing_agent_email" text, "leasing_agent_name" text, "leasing_agent_office_hours" text, "leasing_agent_phone" text, "leasing_agent_title" text, "name" text, "neighborhood" text, "pet_policy" text, "postmarked_applications_received_by_date" text, "program_rules" text, "rental_history" text, "required_documents" text, "smoking_policy" text, "units_available" numeric, "unit_amenities" text, "waitlist_current_size" numeric, "waitlist_max_size" numeric, "what_to_expect" jsonb, "year_built" numeric, "status" "listings_status_enum" NOT NULL DEFAULT 'pending', CONSTRAINT "PK_520ecac6c99ec90bcf5a603cdcb" PRIMARY KEY ("id"))` + ) + await queryRunner.query( + `CREATE TYPE "application_methods_type_enum" AS ENUM('Internal', 'FileDownload', 'ExternalLink', 'PaperPickup', 'POBox', 'LeasingAgent')` + ) + await queryRunner.query( + `CREATE TABLE "application_methods" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "created_at" TIMESTAMP NOT NULL DEFAULT now(), "updated_at" TIMESTAMP NOT NULL DEFAULT now(), "type" "application_methods_type_enum" NOT NULL, "label" text, "external_reference" text, "accepts_postmarked_applications" boolean, "listing_id" uuid, CONSTRAINT "PK_c58506819ffaba3863a4edc5e9e" PRIMARY KEY ("id"))` + ) + await queryRunner.query( + `CREATE TABLE "revoked_tokens" ("token" character varying NOT NULL, "revoked_at" TIMESTAMP NOT NULL DEFAULT now(), CONSTRAINT "PK_f38f625b4823c8903e819bfedd1" PRIMARY KEY ("token"))` + ) + await queryRunner.query( + `ALTER TABLE "units" ADD CONSTRAINT "FK_9aebcde52d6e054e5ac5d26228c" FOREIGN KEY ("listing_id") REFERENCES "listings"("id") ON DELETE CASCADE ON UPDATE CASCADE` + ) + await queryRunner.query( + `ALTER TABLE "applications" ADD CONSTRAINT "FK_9e7594d5b474d9cbebba15c1ae7" FOREIGN KEY ("user_id") REFERENCES "user_accounts"("id") ON DELETE NO ACTION ON UPDATE NO ACTION` + ) + await queryRunner.query( + `ALTER TABLE "applications" ADD CONSTRAINT "FK_cc9d65c58d8deb0ef5353e9037d" FOREIGN KEY ("listing_id") REFERENCES "listings"("id") ON DELETE NO ACTION ON UPDATE NO ACTION` + ) + await queryRunner.query( + `ALTER TABLE "assets" ADD CONSTRAINT "FK_8cb54e950245d30651b903a4c61" FOREIGN KEY ("listing_id") REFERENCES "listings"("id") ON DELETE NO ACTION ON UPDATE NO ACTION` + ) + await queryRunner.query( + `ALTER TABLE "preferences" ADD CONSTRAINT "FK_91017f2182ec7b0dcd4abe68b5a" FOREIGN KEY ("listing_id") REFERENCES "listings"("id") ON DELETE CASCADE ON UPDATE CASCADE` + ) + await queryRunner.query( + `ALTER TABLE "application_methods" ADD CONSTRAINT "FK_3057650361c2aeab15dfee5c3cc" FOREIGN KEY ("listing_id") REFERENCES "listings"("id") ON DELETE NO ACTION ON UPDATE NO ACTION` + ) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "application_methods" DROP CONSTRAINT "FK_3057650361c2aeab15dfee5c3cc"` + ) + await queryRunner.query( + `ALTER TABLE "preferences" DROP CONSTRAINT "FK_91017f2182ec7b0dcd4abe68b5a"` + ) + await queryRunner.query(`ALTER TABLE "assets" DROP CONSTRAINT "FK_8cb54e950245d30651b903a4c61"`) + await queryRunner.query( + `ALTER TABLE "applications" DROP CONSTRAINT "FK_cc9d65c58d8deb0ef5353e9037d"` + ) + await queryRunner.query( + `ALTER TABLE "applications" DROP CONSTRAINT "FK_9e7594d5b474d9cbebba15c1ae7"` + ) + await queryRunner.query(`ALTER TABLE "units" DROP CONSTRAINT "FK_9aebcde52d6e054e5ac5d26228c"`) + await queryRunner.query(`DROP TABLE "revoked_tokens"`) + await queryRunner.query(`DROP TABLE "application_methods"`) + await queryRunner.query(`DROP TYPE "application_methods_type_enum"`) + await queryRunner.query(`DROP TABLE "listings"`) + await queryRunner.query(`DROP TYPE "listings_status_enum"`) + await queryRunner.query(`DROP TABLE "preferences"`) + await queryRunner.query(`DROP TABLE "assets"`) + await queryRunner.query(`DROP TABLE "applications"`) + await queryRunner.query(`DROP TABLE "user_accounts"`) + await queryRunner.query(`DROP TABLE "units"`) + } +} diff --git a/backend/core/src/migration/1600106585058-add-unique-email-constraint.ts b/backend/core/src/migration/1600106585058-add-unique-email-constraint.ts new file mode 100644 index 0000000000..e51f5e09ad --- /dev/null +++ b/backend/core/src/migration/1600106585058-add-unique-email-constraint.ts @@ -0,0 +1,13 @@ +import { MigrationInterface, QueryRunner } from "typeorm" + +export class addUniqueEmailConstraint1600106585058 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE UNIQUE INDEX user_accounts_email_unique_idx ON "user_accounts" (lower("email"))` + ) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX user_accounts_email_unique_idx`) + } +} diff --git a/backend/core/src/migration/1600106987673-add_admin_flag.ts b/backend/core/src/migration/1600106987673-add_admin_flag.ts new file mode 100644 index 0000000000..02a4de676d --- /dev/null +++ b/backend/core/src/migration/1600106987673-add_admin_flag.ts @@ -0,0 +1,15 @@ +import { MigrationInterface, QueryRunner } from "typeorm" + +export class addAdminFlag1600106987673 implements MigrationInterface { + name = "addAdminFlag1600106987673" + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "user_accounts" ADD "is_admin" boolean NOT NULL DEFAULT false` + ) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "user_accounts" DROP COLUMN "is_admin"`) + } +} diff --git a/backend/core/src/migration/1600773730888-AddRentalAssistanceToListing.ts b/backend/core/src/migration/1600773730888-AddRentalAssistanceToListing.ts new file mode 100644 index 0000000000..7f55eedd3e --- /dev/null +++ b/backend/core/src/migration/1600773730888-AddRentalAssistanceToListing.ts @@ -0,0 +1,11 @@ +import { MigrationInterface, QueryRunner } from "typeorm" + +export class AddRentalAssistanceToListing1600773730888 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "listings" ADD COLUMN "rental_assistance" text`) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "listings" DROP COLUMN "rental_assistance"`) + } +} diff --git a/backend/core/src/migration/1600891752631-change-ordinal-type.ts b/backend/core/src/migration/1600891752631-change-ordinal-type.ts new file mode 100644 index 0000000000..62e3d4b6d4 --- /dev/null +++ b/backend/core/src/migration/1600891752631-change-ordinal-type.ts @@ -0,0 +1,13 @@ +import { MigrationInterface, QueryRunner } from "typeorm" + +export class changeOrdinalType1600891752631 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "preferences" ALTER COLUMN "ordinal" TYPE numeric USING (LEFT(ordinal,1)::NUMERIC)` + ) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "preferences" ALTER COLUMN "ordinal" TYPE text`) + } +} diff --git a/backend/core/src/migration/1602262127031-add-listing-events.ts b/backend/core/src/migration/1602262127031-add-listing-events.ts new file mode 100644 index 0000000000..d8d4f2814b --- /dev/null +++ b/backend/core/src/migration/1602262127031-add-listing-events.ts @@ -0,0 +1,25 @@ +import { MigrationInterface, QueryRunner } from "typeorm" + +export class addListingEvents1602262127031 implements MigrationInterface { + name = "addListingEvents1602262127031" + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TYPE "listing_events_type_enum" AS ENUM('openHouse', 'publicLottery')` + ) + await queryRunner.query( + `CREATE TABLE "listing_events" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "created_at" TIMESTAMP NOT NULL DEFAULT now(), "updated_at" TIMESTAMP NOT NULL DEFAULT now(), "type" "listing_events_type_enum" NOT NULL, "start_time" TIMESTAMP NOT NULL, "end_time" TIMESTAMP NOT NULL, "url" text, "note" text, "listing_id" uuid, CONSTRAINT "PK_a9a209828028e14e2caf8def25c" PRIMARY KEY ("id"))` + ) + await queryRunner.query( + `ALTER TABLE "listing_events" ADD CONSTRAINT "FK_d0b9892bc613e4d9f8b5c25d03e" FOREIGN KEY ("listing_id") REFERENCES "listings"("id") ON DELETE CASCADE ON UPDATE CASCADE` + ) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "listing_events" DROP CONSTRAINT "FK_d0b9892bc613e4d9f8b5c25d03e"` + ) + await queryRunner.query(`DROP TABLE "listing_events"`) + await queryRunner.query(`DROP TYPE "listing_events_type_enum"`) + } +} diff --git a/backend/core/src/migration/1603364073148-fix-integer-types.ts b/backend/core/src/migration/1603364073148-fix-integer-types.ts new file mode 100644 index 0000000000..b21811bd25 --- /dev/null +++ b/backend/core/src/migration/1603364073148-fix-integer-types.ts @@ -0,0 +1,25 @@ +import { MigrationInterface, QueryRunner } from "typeorm" + +export class fixIntegerTypes1603364073148 implements MigrationInterface { + name = "fixIntegerTypes1603364073148" + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "units" ALTER COLUMN "floor" TYPE integer`) + await queryRunner.query(`ALTER TABLE "units" ALTER COLUMN "max_occupancy" TYPE integer`) + await queryRunner.query(`ALTER TABLE "units" ALTER COLUMN "min_occupancy" TYPE integer`) + await queryRunner.query(`ALTER TABLE "units" ALTER COLUMN "num_bathrooms" TYPE integer`) + await queryRunner.query(`ALTER TABLE "units" ALTER COLUMN "num_bedrooms" TYPE integer`) + await queryRunner.query(`ALTER TABLE "units" ALTER COLUMN "ami_chart_id" TYPE integer`) + await queryRunner.query(`ALTER TABLE "preferences" ALTER COLUMN "ordinal" TYPE integer`) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "units" ALTER COLUMN "floor" TYPE numeric`) + await queryRunner.query(`ALTER TABLE "units" ALTER COLUMN "max_occupancy" TYPE numeric`) + await queryRunner.query(`ALTER TABLE "units" ALTER COLUMN "min_occupancy" TYPE numeric`) + await queryRunner.query(`ALTER TABLE "units" ALTER COLUMN "num_bathrooms" TYPE numeric`) + await queryRunner.query(`ALTER TABLE "units" ALTER COLUMN "num_bedrooms" TYPE numeric`) + await queryRunner.query(`ALTER TABLE "units" ALTER COLUMN "ami_chart_id" TYPE numeric`) + await queryRunner.query(`ALTER TABLE "preferences" ALTER COLUMN "ordinal" TYPE numeric`) + } +} diff --git a/backend/core/src/migration/1603449837150-fix-integer-types-in-listing.ts b/backend/core/src/migration/1603449837150-fix-integer-types-in-listing.ts new file mode 100644 index 0000000000..51c345dfc7 --- /dev/null +++ b/backend/core/src/migration/1603449837150-fix-integer-types-in-listing.ts @@ -0,0 +1,33 @@ +import { MigrationInterface, QueryRunner } from "typeorm" + +export class fixIntegerTypesInListing1603449837150 implements MigrationInterface { + name = "fixIntegerTypesInListing1603449837150" + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "listings" ALTER COLUMN "building_total_units" TYPE integer` + ) + await queryRunner.query(`ALTER TABLE "listings" ALTER COLUMN "household_size_max" TYPE integer`) + await queryRunner.query(`ALTER TABLE "listings" ALTER COLUMN "household_size_min" TYPE integer`) + await queryRunner.query(`ALTER TABLE "listings" ALTER COLUMN "units_available" TYPE integer`) + await queryRunner.query( + `ALTER TABLE "listings" ALTER COLUMN "waitlist_current_size" TYPE integer` + ) + await queryRunner.query(`ALTER TABLE "listings" ALTER COLUMN "waitlist_max_size" TYPE integer`) + await queryRunner.query(`ALTER TABLE "listings" ALTER COLUMN "year_built" TYPE integer`) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "listings" ALTER COLUMN "year_built" TYPE numeric`) + await queryRunner.query(`ALTER TABLE "listings" ALTER COLUMN "waitlist_max_size" TYPE numeric`) + await queryRunner.query( + `ALTER TABLE "listings" ALTER COLUMN "waitlist_current_size" TYPE numeric` + ) + await queryRunner.query(`ALTER TABLE "listings" ALTER COLUMN "units_available" TYPE numeric`) + await queryRunner.query(`ALTER TABLE "listings" ALTER COLUMN "household_size_min" TYPE numeric`) + await queryRunner.query(`ALTER TABLE "listings" ALTER COLUMN "household_size_max" TYPE numeric`) + await queryRunner.query( + `ALTER TABLE "listings" ALTER COLUMN "building_total_units" TYPE numeric` + ) + } +} diff --git a/backend/core/src/migration/1604317983218-add-application-data-index.ts b/backend/core/src/migration/1604317983218-add-application-data-index.ts new file mode 100644 index 0000000000..576e998db6 --- /dev/null +++ b/backend/core/src/migration/1604317983218-add-application-data-index.ts @@ -0,0 +1,13 @@ +import { MigrationInterface, QueryRunner } from "typeorm" + +export class addApplicationDataIndex1604317983218 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE INDEX ON "applications" USING GIN ( to_tsvector('english', application) )` + ) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX applications_to_tsvector_idx`) + } +} diff --git a/backend/core/src/migration/1604656541138-add-missing-listing-properties.ts b/backend/core/src/migration/1604656541138-add-missing-listing-properties.ts new file mode 100644 index 0000000000..93d7e504c6 --- /dev/null +++ b/backend/core/src/migration/1604656541138-add-missing-listing-properties.ts @@ -0,0 +1,19 @@ +import { MigrationInterface, QueryRunner } from "typeorm" + +export class addMissingListingProperties1604656541138 implements MigrationInterface { + name = "addMissingListingProperties1604656541138" + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "listings" ADD "application_pick_up_address" jsonb`) + await queryRunner.query( + `ALTER TABLE "listings" ADD "application_pick_up_address_office_hours" text` + ) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "listings" DROP COLUMN "application_pick_up_address_office_hours"` + ) + await queryRunner.query(`ALTER TABLE "listings" DROP COLUMN "application_pick_up_address"`) + } +} diff --git a/backend/core/src/migration/1604669588916-add-properties-and-groups.ts b/backend/core/src/migration/1604669588916-add-properties-and-groups.ts new file mode 100644 index 0000000000..4d3ab4c07d --- /dev/null +++ b/backend/core/src/migration/1604669588916-add-properties-and-groups.ts @@ -0,0 +1,89 @@ +import { MigrationInterface, QueryRunner } from "typeorm" + +export class addPropertiesAndGroups1604669588916 implements MigrationInterface { + name = "addPropertiesAndGroups1604669588916" + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "units" DROP CONSTRAINT "FK_9aebcde52d6e054e5ac5d26228c"`) + await queryRunner.query(`ALTER TABLE "units" RENAME COLUMN "listing_id" TO "property_id"`) + await queryRunner.query( + `CREATE TABLE "property_group" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "created_at" TIMESTAMP NOT NULL DEFAULT now(), "updated_at" TIMESTAMP NOT NULL DEFAULT now(), "name" character varying NOT NULL, CONSTRAINT "PK_30c4d5d238ffc95e72d94837e54" PRIMARY KEY ("id"))` + ) + await queryRunner.query( + `CREATE TABLE "property" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "created_at" TIMESTAMP NOT NULL DEFAULT now(), "updated_at" TIMESTAMP NOT NULL DEFAULT now(), "accessibility" text, "amenities" text, "building_address" jsonb, "building_total_units" integer, "developer" text, "household_size_max" integer, "household_size_min" integer, "neighborhood" text, "pet_policy" text, "smoking_policy" text, "units_available" integer, "unit_amenities" text, "year_built" integer, CONSTRAINT "PK_d80743e6191258a5003d5843b4f" PRIMARY KEY ("id"))` + ) + await queryRunner.query( + `CREATE TABLE "property_group_properties_property" ("property_group_id" uuid NOT NULL, "property_id" uuid NOT NULL, CONSTRAINT "PK_7d88a8faf587c93493dd120dd83" PRIMARY KEY ("property_group_id", "property_id"))` + ) + await queryRunner.query( + `CREATE INDEX "IDX_84e6a1949911510df0eff691f0" ON "property_group_properties_property" ("property_group_id") ` + ) + await queryRunner.query( + `CREATE INDEX "IDX_c99e75ee805d56fea44bf2970f" ON "property_group_properties_property" ("property_id") ` + ) + await queryRunner.query(`ALTER TABLE "listings" DROP COLUMN "accessibility"`) + await queryRunner.query(`ALTER TABLE "listings" DROP COLUMN "amenities"`) + await queryRunner.query(`ALTER TABLE "listings" DROP COLUMN "building_address"`) + await queryRunner.query(`ALTER TABLE "listings" DROP COLUMN "building_total_units"`) + await queryRunner.query(`ALTER TABLE "listings" DROP COLUMN "developer"`) + await queryRunner.query(`ALTER TABLE "listings" DROP COLUMN "household_size_max"`) + await queryRunner.query(`ALTER TABLE "listings" DROP COLUMN "household_size_min"`) + await queryRunner.query(`ALTER TABLE "listings" DROP COLUMN "image_url"`) + await queryRunner.query(`ALTER TABLE "listings" DROP COLUMN "neighborhood"`) + await queryRunner.query(`ALTER TABLE "listings" DROP COLUMN "pet_policy"`) + await queryRunner.query(`ALTER TABLE "listings" DROP COLUMN "smoking_policy"`) + await queryRunner.query(`ALTER TABLE "listings" DROP COLUMN "units_available"`) + await queryRunner.query(`ALTER TABLE "listings" DROP COLUMN "unit_amenities"`) + await queryRunner.query(`ALTER TABLE "listings" DROP COLUMN "year_built"`) + await queryRunner.query(`ALTER TABLE "listings" ADD "property_id" uuid NOT NULL`) + await queryRunner.query( + `ALTER TABLE "units" ADD CONSTRAINT "FK_f221e6d7bfd686266003b982b5f" FOREIGN KEY ("property_id") REFERENCES "property"("id") ON DELETE CASCADE ON UPDATE CASCADE` + ) + await queryRunner.query( + `ALTER TABLE "listings" ADD CONSTRAINT "FK_9eef913a9013d6e3d09a92ec075" FOREIGN KEY ("property_id") REFERENCES "property"("id") ON DELETE NO ACTION ON UPDATE NO ACTION` + ) + await queryRunner.query( + `ALTER TABLE "property_group_properties_property" ADD CONSTRAINT "FK_84e6a1949911510df0eff691f0d" FOREIGN KEY ("property_group_id") REFERENCES "property_group"("id") ON DELETE CASCADE ON UPDATE NO ACTION` + ) + await queryRunner.query( + `ALTER TABLE "property_group_properties_property" ADD CONSTRAINT "FK_c99e75ee805d56fea44bf2970f2" FOREIGN KEY ("property_id") REFERENCES "property"("id") ON DELETE CASCADE ON UPDATE NO ACTION` + ) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "property_group_properties_property" DROP CONSTRAINT "FK_c99e75ee805d56fea44bf2970f2"` + ) + await queryRunner.query( + `ALTER TABLE "property_group_properties_property" DROP CONSTRAINT "FK_84e6a1949911510df0eff691f0d"` + ) + await queryRunner.query( + `ALTER TABLE "listings" DROP CONSTRAINT "FK_9eef913a9013d6e3d09a92ec075"` + ) + await queryRunner.query(`ALTER TABLE "units" DROP CONSTRAINT "FK_f221e6d7bfd686266003b982b5f"`) + await queryRunner.query(`ALTER TABLE "listings" DROP COLUMN "property_id"`) + await queryRunner.query(`ALTER TABLE "listings" ADD "year_built" integer`) + await queryRunner.query(`ALTER TABLE "listings" ADD "unit_amenities" text`) + await queryRunner.query(`ALTER TABLE "listings" ADD "units_available" integer`) + await queryRunner.query(`ALTER TABLE "listings" ADD "smoking_policy" text`) + await queryRunner.query(`ALTER TABLE "listings" ADD "pet_policy" text`) + await queryRunner.query(`ALTER TABLE "listings" ADD "neighborhood" text`) + await queryRunner.query(`ALTER TABLE "listings" ADD "image_url" text`) + await queryRunner.query(`ALTER TABLE "listings" ADD "household_size_min" integer`) + await queryRunner.query(`ALTER TABLE "listings" ADD "household_size_max" integer`) + await queryRunner.query(`ALTER TABLE "listings" ADD "developer" text`) + await queryRunner.query(`ALTER TABLE "listings" ADD "building_total_units" integer`) + await queryRunner.query(`ALTER TABLE "listings" ADD "building_address" jsonb`) + await queryRunner.query(`ALTER TABLE "listings" ADD "amenities" text`) + await queryRunner.query(`ALTER TABLE "listings" ADD "accessibility" text`) + await queryRunner.query(`DROP INDEX "IDX_c99e75ee805d56fea44bf2970f"`) + await queryRunner.query(`DROP INDEX "IDX_84e6a1949911510df0eff691f0"`) + await queryRunner.query(`DROP TABLE "property_group_properties_property"`) + await queryRunner.query(`DROP TABLE "property"`) + await queryRunner.query(`DROP TABLE "property_group"`) + await queryRunner.query(`ALTER TABLE "units" RENAME COLUMN "property_id" TO "listing_id"`) + await queryRunner.query( + `ALTER TABLE "units" ADD CONSTRAINT "FK_9aebcde52d6e054e5ac5d26228c" FOREIGN KEY ("listing_id") REFERENCES "listings"("id") ON DELETE CASCADE ON UPDATE CASCADE` + ) + } +} diff --git a/backend/core/src/migration/1604932002979-add-ami-chart.ts b/backend/core/src/migration/1604932002979-add-ami-chart.ts new file mode 100644 index 0000000000..7f22c38f05 --- /dev/null +++ b/backend/core/src/migration/1604932002979-add-ami-chart.ts @@ -0,0 +1,39 @@ +import { MigrationInterface, QueryRunner } from "typeorm" + +export class addAmiChart1604932002979 implements MigrationInterface { + name = "addAmiChart1604932002979" + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "ami_chart" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "created_at" TIMESTAMP NOT NULL DEFAULT now(), "updated_at" TIMESTAMP NOT NULL DEFAULT now(), "name" character varying NOT NULL, CONSTRAINT "PK_e079bbfad233fdc79072acb33b5" PRIMARY KEY ("id"))` + ) + await queryRunner.query( + `CREATE TABLE "ami_chart_item" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "created_at" TIMESTAMP NOT NULL DEFAULT now(), "updated_at" TIMESTAMP NOT NULL DEFAULT now(), "percent_of_ami" integer NOT NULL, "household_size" integer NOT NULL, "income" integer NOT NULL, "ami_chart_id" uuid, CONSTRAINT "PK_50c1f3d69f4675d775e08d7465e" PRIMARY KEY ("id"))` + ) + await queryRunner.query( + `ALTER TABLE "listings" DROP COLUMN "blank_paper_application_can_be_picked_up"` + ) + await queryRunner.query(`ALTER TABLE "property" ADD "ami_chart_id" uuid`) + await queryRunner.query( + `ALTER TABLE "property" ADD CONSTRAINT "FK_d639fcd25af4127bc979d5146a9" FOREIGN KEY ("ami_chart_id") REFERENCES "ami_chart"("id") ON DELETE NO ACTION ON UPDATE NO ACTION` + ) + await queryRunner.query( + `ALTER TABLE "ami_chart_item" ADD CONSTRAINT "FK_98d10c0d335d9e4aca6fb5335b3" FOREIGN KEY ("ami_chart_id") REFERENCES "ami_chart"("id") ON DELETE NO ACTION ON UPDATE NO ACTION` + ) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "ami_chart_item" DROP CONSTRAINT "FK_98d10c0d335d9e4aca6fb5335b3"` + ) + await queryRunner.query( + `ALTER TABLE "property" DROP CONSTRAINT "FK_d639fcd25af4127bc979d5146a9"` + ) + await queryRunner.query(`ALTER TABLE "property" DROP COLUMN "ami_chart_id"`) + await queryRunner.query( + `ALTER TABLE "listings" ADD "blank_paper_application_can_be_picked_up" boolean` + ) + await queryRunner.query(`DROP TABLE "ami_chart_item"`) + await queryRunner.query(`DROP TABLE "ami_chart"`) + } +} diff --git a/backend/core/src/migration/1605691160237-add-application-data.ts b/backend/core/src/migration/1605691160237-add-application-data.ts new file mode 100644 index 0000000000..fb2980d102 --- /dev/null +++ b/backend/core/src/migration/1605691160237-add-application-data.ts @@ -0,0 +1,197 @@ +import { MigrationInterface, QueryRunner } from "typeorm" + +export class addApplicationData1605691160237 implements MigrationInterface { + name = "addApplicationData1605691160237" + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "accessibility" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "created_at" TIMESTAMP NOT NULL DEFAULT now(), "updated_at" TIMESTAMP NOT NULL DEFAULT now(), "mobility" boolean, "vision" boolean, "hearing" boolean, CONSTRAINT "PK_9729339e162bc7ec98a8815758c" PRIMARY KEY ("id"))` + ) + await queryRunner.query( + `CREATE TABLE "address" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "created_at" TIMESTAMP NOT NULL DEFAULT now(), "updated_at" TIMESTAMP NOT NULL DEFAULT now(), "place_name" text, "city" character varying NOT NULL, "county" text, "state" character varying NOT NULL, "street" character varying NOT NULL, "street2" text, "zip_code" character varying NOT NULL, "latitude" numeric, "longitude" numeric, CONSTRAINT "PK_d92de1f82754668b5f5f5dd4fd5" PRIMARY KEY ("id"))` + ) + await queryRunner.query( + `CREATE TABLE "alternate_contact" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "created_at" TIMESTAMP NOT NULL DEFAULT now(), "updated_at" TIMESTAMP NOT NULL DEFAULT now(), "type" character varying NOT NULL, "other_type" character varying, "first_name" character varying NOT NULL, "last_name" character varying NOT NULL, "agency" character varying, "phone_number" character varying NOT NULL, "email_address" character varying NOT NULL, CONSTRAINT "PK_4b35560218b2062cccb339975e7" PRIMARY KEY ("id"))` + ) + await queryRunner.query( + `CREATE TABLE "applicant" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "created_at" TIMESTAMP NOT NULL DEFAULT now(), "updated_at" TIMESTAMP NOT NULL DEFAULT now(), "first_name" character varying NOT NULL, "middle_name" character varying NOT NULL, "last_name" character varying NOT NULL, "birth_month" character varying NOT NULL, "birth_day" character varying NOT NULL, "birth_year" character varying NOT NULL, "email_address" character varying NOT NULL, "no_email" boolean NOT NULL, "phone_number" character varying NOT NULL, "phone_number_type" character varying NOT NULL, "no_phone" boolean NOT NULL, "work_in_region" text, "work_address_id" uuid, "address_id" uuid, CONSTRAINT "REL_7d357035705ebbbe91b5034678" UNIQUE ("work_address_id"), CONSTRAINT "REL_8ba2b09030c3a2b857dda5f83f" UNIQUE ("address_id"), CONSTRAINT "PK_f4a6e907b8b17f293eb073fc5ea" PRIMARY KEY ("id"))` + ) + await queryRunner.query( + `CREATE TABLE "application_preferences" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "created_at" TIMESTAMP NOT NULL DEFAULT now(), "updated_at" TIMESTAMP NOT NULL DEFAULT now(), "live_in" boolean NOT NULL, "none" boolean NOT NULL, "work_in" boolean NOT NULL, CONSTRAINT "PK_97729a397c6bff3aaa3bde8be94" PRIMARY KEY ("id"))` + ) + await queryRunner.query( + `CREATE TABLE "demographics" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "created_at" TIMESTAMP NOT NULL DEFAULT now(), "updated_at" TIMESTAMP NOT NULL DEFAULT now(), "ethnicity" character varying NOT NULL, "gender" character varying NOT NULL, "sexual_orientation" character varying NOT NULL, "how_did_you_hear" text array NOT NULL, "race" character varying NOT NULL, CONSTRAINT "PK_17bf4db5727bd0ad0462c67eda9" PRIMARY KEY ("id"))` + ) + await queryRunner.query( + `CREATE TABLE "household_member" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "created_at" TIMESTAMP NOT NULL DEFAULT now(), "updated_at" TIMESTAMP NOT NULL DEFAULT now(), "order_id" integer NOT NULL, "first_name" character varying NOT NULL, "middle_name" character varying NOT NULL, "last_name" character varying NOT NULL, "birth_month" character varying NOT NULL, "birth_day" character varying NOT NULL, "birth_year" character varying NOT NULL, "email_address" character varying NOT NULL, "no_email" boolean, "phone_number" character varying NOT NULL, "phone_number_type" character varying NOT NULL, "no_phone" boolean, "same_address" boolean, "relationship" text, "work_in_region" boolean, "address_id" uuid, "work_address_id" uuid, "application_id" uuid, CONSTRAINT "REL_7b61da64f1b7a6bbb48eb5bbb4" UNIQUE ("address_id"), CONSTRAINT "REL_f390552cbb929761927c70b7a0" UNIQUE ("work_address_id"), CONSTRAINT "PK_84e1d1f2553646d38e7c8b72a10" PRIMARY KEY ("id"))` + ) + await queryRunner.query(`ALTER TABLE "applications" DROP COLUMN "application"`) + await queryRunner.query(`ALTER TABLE "applications" ADD "additional_phone" boolean NOT NULL`) + await queryRunner.query( + `ALTER TABLE "applications" ADD "additional_phone_number" character varying NOT NULL` + ) + await queryRunner.query( + `ALTER TABLE "applications" ADD "additional_phone_number_type" character varying NOT NULL` + ) + await queryRunner.query( + `ALTER TABLE "applications" ADD "contact_preferences" text array NOT NULL` + ) + await queryRunner.query(`ALTER TABLE "applications" ADD "household_size" integer NOT NULL`) + await queryRunner.query( + `ALTER TABLE "applications" ADD "housing_status" character varying NOT NULL` + ) + await queryRunner.query( + `ALTER TABLE "applications" ADD "send_mail_to_mailing_address" boolean NOT NULL` + ) + await queryRunner.query(`ALTER TABLE "applications" ADD "income_vouchers" boolean NOT NULL`) + await queryRunner.query(`ALTER TABLE "applications" ADD "income" character varying NOT NULL`) + await queryRunner.query( + `ALTER TABLE "applications" ADD "income_period" character varying NOT NULL` + ) + await queryRunner.query(`ALTER TABLE "applications" ADD "preferred_unit" text array NOT NULL`) + await queryRunner.query(`ALTER TABLE "applications" ADD "language" character varying NOT NULL`) + await queryRunner.query( + `ALTER TABLE "applications" ADD "submission_type" character varying NOT NULL` + ) + await queryRunner.query(`ALTER TABLE "applications" ADD "accepted_terms" boolean NOT NULL`) + await queryRunner.query(`ALTER TABLE "applications" ADD "applicant_id" uuid`) + await queryRunner.query( + `ALTER TABLE "applications" ADD CONSTRAINT "UQ_194d0fca275b8661a56e486cb64" UNIQUE ("applicant_id")` + ) + await queryRunner.query(`ALTER TABLE "applications" ADD "mailing_address_id" uuid`) + await queryRunner.query( + `ALTER TABLE "applications" ADD CONSTRAINT "UQ_b72ba26ebc88981f441b30fe3c5" UNIQUE ("mailing_address_id")` + ) + await queryRunner.query(`ALTER TABLE "applications" ADD "alternate_address_id" uuid`) + await queryRunner.query( + `ALTER TABLE "applications" ADD CONSTRAINT "UQ_7fc41f89f22ca59ffceab5da80e" UNIQUE ("alternate_address_id")` + ) + await queryRunner.query(`ALTER TABLE "applications" ADD "alternate_contact_id" uuid`) + await queryRunner.query( + `ALTER TABLE "applications" ADD CONSTRAINT "UQ_56abaa378952856aaccc64d7eb3" UNIQUE ("alternate_contact_id")` + ) + await queryRunner.query(`ALTER TABLE "applications" ADD "accessibility_id" uuid`) + await queryRunner.query( + `ALTER TABLE "applications" ADD CONSTRAINT "UQ_3a4c71bc34dce9f6c196f110935" UNIQUE ("accessibility_id")` + ) + await queryRunner.query(`ALTER TABLE "applications" ADD "demographics_id" uuid`) + await queryRunner.query( + `ALTER TABLE "applications" ADD CONSTRAINT "UQ_fed5da45b7b4dafd9f025a37dd1" UNIQUE ("demographics_id")` + ) + await queryRunner.query( + `ALTER TABLE "applicant" ADD CONSTRAINT "FK_7d357035705ebbbe91b50346781" FOREIGN KEY ("work_address_id") REFERENCES "address"("id") ON DELETE NO ACTION ON UPDATE NO ACTION` + ) + await queryRunner.query( + `ALTER TABLE "applicant" ADD CONSTRAINT "FK_8ba2b09030c3a2b857dda5f83fe" FOREIGN KEY ("address_id") REFERENCES "address"("id") ON DELETE NO ACTION ON UPDATE NO ACTION` + ) + await queryRunner.query( + `ALTER TABLE "household_member" ADD CONSTRAINT "FK_7b61da64f1b7a6bbb48eb5bbb43" FOREIGN KEY ("address_id") REFERENCES "address"("id") ON DELETE NO ACTION ON UPDATE NO ACTION` + ) + await queryRunner.query( + `ALTER TABLE "household_member" ADD CONSTRAINT "FK_f390552cbb929761927c70b7a0d" FOREIGN KEY ("work_address_id") REFERENCES "address"("id") ON DELETE NO ACTION ON UPDATE NO ACTION` + ) + await queryRunner.query( + `ALTER TABLE "household_member" ADD CONSTRAINT "FK_520996eeecf9f6fb9425dc7352c" FOREIGN KEY ("application_id") REFERENCES "applications"("id") ON DELETE NO ACTION ON UPDATE NO ACTION` + ) + await queryRunner.query( + `ALTER TABLE "applications" ADD CONSTRAINT "FK_194d0fca275b8661a56e486cb64" FOREIGN KEY ("applicant_id") REFERENCES "applicant"("id") ON DELETE NO ACTION ON UPDATE NO ACTION` + ) + await queryRunner.query( + `ALTER TABLE "applications" ADD CONSTRAINT "FK_b72ba26ebc88981f441b30fe3c5" FOREIGN KEY ("mailing_address_id") REFERENCES "address"("id") ON DELETE NO ACTION ON UPDATE NO ACTION` + ) + await queryRunner.query( + `ALTER TABLE "applications" ADD CONSTRAINT "FK_7fc41f89f22ca59ffceab5da80e" FOREIGN KEY ("alternate_address_id") REFERENCES "address"("id") ON DELETE NO ACTION ON UPDATE NO ACTION` + ) + await queryRunner.query( + `ALTER TABLE "applications" ADD CONSTRAINT "FK_56abaa378952856aaccc64d7eb3" FOREIGN KEY ("alternate_contact_id") REFERENCES "alternate_contact"("id") ON DELETE NO ACTION ON UPDATE NO ACTION` + ) + await queryRunner.query( + `ALTER TABLE "applications" ADD CONSTRAINT "FK_3a4c71bc34dce9f6c196f110935" FOREIGN KEY ("accessibility_id") REFERENCES "accessibility"("id") ON DELETE NO ACTION ON UPDATE NO ACTION` + ) + await queryRunner.query( + `ALTER TABLE "applications" ADD CONSTRAINT "FK_fed5da45b7b4dafd9f025a37dd1" FOREIGN KEY ("demographics_id") REFERENCES "demographics"("id") ON DELETE NO ACTION ON UPDATE NO ACTION` + ) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "applications" DROP CONSTRAINT "FK_fed5da45b7b4dafd9f025a37dd1"` + ) + await queryRunner.query( + `ALTER TABLE "applications" DROP CONSTRAINT "FK_3a4c71bc34dce9f6c196f110935"` + ) + await queryRunner.query( + `ALTER TABLE "applications" DROP CONSTRAINT "FK_56abaa378952856aaccc64d7eb3"` + ) + await queryRunner.query( + `ALTER TABLE "applications" DROP CONSTRAINT "FK_7fc41f89f22ca59ffceab5da80e"` + ) + await queryRunner.query( + `ALTER TABLE "applications" DROP CONSTRAINT "FK_b72ba26ebc88981f441b30fe3c5"` + ) + await queryRunner.query( + `ALTER TABLE "applications" DROP CONSTRAINT "FK_194d0fca275b8661a56e486cb64"` + ) + await queryRunner.query( + `ALTER TABLE "household_member" DROP CONSTRAINT "FK_520996eeecf9f6fb9425dc7352c"` + ) + await queryRunner.query( + `ALTER TABLE "household_member" DROP CONSTRAINT "FK_f390552cbb929761927c70b7a0d"` + ) + await queryRunner.query( + `ALTER TABLE "household_member" DROP CONSTRAINT "FK_7b61da64f1b7a6bbb48eb5bbb43"` + ) + await queryRunner.query( + `ALTER TABLE "applicant" DROP CONSTRAINT "FK_8ba2b09030c3a2b857dda5f83fe"` + ) + await queryRunner.query( + `ALTER TABLE "applicant" DROP CONSTRAINT "FK_7d357035705ebbbe91b50346781"` + ) + await queryRunner.query( + `ALTER TABLE "applications" DROP CONSTRAINT "UQ_fed5da45b7b4dafd9f025a37dd1"` + ) + await queryRunner.query(`ALTER TABLE "applications" DROP COLUMN "demographics_id"`) + await queryRunner.query( + `ALTER TABLE "applications" DROP CONSTRAINT "UQ_3a4c71bc34dce9f6c196f110935"` + ) + await queryRunner.query(`ALTER TABLE "applications" DROP COLUMN "accessibility_id"`) + await queryRunner.query( + `ALTER TABLE "applications" DROP CONSTRAINT "UQ_56abaa378952856aaccc64d7eb3"` + ) + await queryRunner.query(`ALTER TABLE "applications" DROP COLUMN "alternate_contact_id"`) + await queryRunner.query( + `ALTER TABLE "applications" DROP CONSTRAINT "UQ_7fc41f89f22ca59ffceab5da80e"` + ) + await queryRunner.query(`ALTER TABLE "applications" DROP COLUMN "alternate_address_id"`) + await queryRunner.query( + `ALTER TABLE "applications" DROP CONSTRAINT "UQ_b72ba26ebc88981f441b30fe3c5"` + ) + await queryRunner.query(`ALTER TABLE "applications" DROP COLUMN "mailing_address_id"`) + await queryRunner.query( + `ALTER TABLE "applications" DROP CONSTRAINT "UQ_194d0fca275b8661a56e486cb64"` + ) + await queryRunner.query(`ALTER TABLE "applications" DROP COLUMN "applicant_id"`) + await queryRunner.query(`ALTER TABLE "applications" DROP COLUMN "accepted_terms"`) + await queryRunner.query(`ALTER TABLE "applications" DROP COLUMN "submission_type"`) + await queryRunner.query(`ALTER TABLE "applications" DROP COLUMN "language"`) + await queryRunner.query(`ALTER TABLE "applications" DROP COLUMN "preferred_unit"`) + await queryRunner.query(`ALTER TABLE "applications" DROP COLUMN "income_period"`) + await queryRunner.query(`ALTER TABLE "applications" DROP COLUMN "income"`) + await queryRunner.query(`ALTER TABLE "applications" DROP COLUMN "income_vouchers"`) + await queryRunner.query(`ALTER TABLE "applications" DROP COLUMN "send_mail_to_mailing_address"`) + await queryRunner.query(`ALTER TABLE "applications" DROP COLUMN "housing_status"`) + await queryRunner.query(`ALTER TABLE "applications" DROP COLUMN "household_size"`) + await queryRunner.query(`ALTER TABLE "applications" DROP COLUMN "contact_preferences"`) + await queryRunner.query(`ALTER TABLE "applications" DROP COLUMN "additional_phone_number_type"`) + await queryRunner.query(`ALTER TABLE "applications" DROP COLUMN "additional_phone_number"`) + await queryRunner.query(`ALTER TABLE "applications" DROP COLUMN "additional_phone"`) + await queryRunner.query(`ALTER TABLE "applications" ADD "application" jsonb`) + await queryRunner.query(`DROP TABLE "household_member"`) + await queryRunner.query(`DROP TABLE "demographics"`) + await queryRunner.query(`DROP TABLE "application_preferences"`) + await queryRunner.query(`DROP TABLE "applicant"`) + await queryRunner.query(`DROP TABLE "alternate_contact"`) + await queryRunner.query(`DROP TABLE "address"`) + await queryRunner.query(`DROP TABLE "accessibility"`) + } +} diff --git a/backend/core/src/migration/1606144763359-update-listing-date-strings.ts b/backend/core/src/migration/1606144763359-update-listing-date-strings.ts new file mode 100644 index 0000000000..cf5522ffc3 --- /dev/null +++ b/backend/core/src/migration/1606144763359-update-listing-date-strings.ts @@ -0,0 +1,51 @@ +import { MigrationInterface, QueryRunner } from "typeorm" + +export class updateListingDateStrings1606144763359 implements MigrationInterface { + name = "updateListingDateStrings1606144763359" + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "units" DROP COLUMN "ami_chart_id"`) + await queryRunner.query(`ALTER TABLE "alternate_contact" ADD "mailing_address_id" uuid`) + await queryRunner.query( + `ALTER TABLE "alternate_contact" ADD CONSTRAINT "UQ_5eb038a51b9cd6872359a687b18" UNIQUE ("mailing_address_id")` + ) + await queryRunner.query(`ALTER TABLE "listings" DROP COLUMN "application_due_date"`) + await queryRunner.query( + `ALTER TABLE "listings" ADD "application_due_date" TIMESTAMP WITH TIME ZONE` + ) + await queryRunner.query(`ALTER TABLE "listings" DROP COLUMN "application_open_date"`) + await queryRunner.query( + `ALTER TABLE "listings" ADD "application_open_date" TIMESTAMP WITH TIME ZONE` + ) + await queryRunner.query( + `ALTER TABLE "listings" DROP COLUMN "postmarked_applications_received_by_date"` + ) + await queryRunner.query( + `ALTER TABLE "listings" ADD "postmarked_applications_received_by_date" TIMESTAMP WITH TIME ZONE` + ) + await queryRunner.query( + `ALTER TABLE "alternate_contact" ADD CONSTRAINT "FK_5eb038a51b9cd6872359a687b18" FOREIGN KEY ("mailing_address_id") REFERENCES "address"("id") ON DELETE NO ACTION ON UPDATE NO ACTION` + ) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "alternate_contact" DROP CONSTRAINT "FK_5eb038a51b9cd6872359a687b18"` + ) + await queryRunner.query( + `ALTER TABLE "listings" DROP COLUMN "postmarked_applications_received_by_date"` + ) + await queryRunner.query( + `ALTER TABLE "listings" ADD "postmarked_applications_received_by_date" text` + ) + await queryRunner.query(`ALTER TABLE "listings" DROP COLUMN "application_open_date"`) + await queryRunner.query(`ALTER TABLE "listings" ADD "application_open_date" text`) + await queryRunner.query(`ALTER TABLE "listings" DROP COLUMN "application_due_date"`) + await queryRunner.query(`ALTER TABLE "listings" ADD "application_due_date" text`) + await queryRunner.query( + `ALTER TABLE "alternate_contact" DROP CONSTRAINT "UQ_5eb038a51b9cd6872359a687b18"` + ) + await queryRunner.query(`ALTER TABLE "alternate_contact" DROP COLUMN "mailing_address_id"`) + await queryRunner.query(`ALTER TABLE "units" ADD "ami_chart_id" integer`) + } +} diff --git a/backend/core/src/migration/1606316990163-update-units-ami-charts-relation.ts b/backend/core/src/migration/1606316990163-update-units-ami-charts-relation.ts new file mode 100644 index 0000000000..be4a58ec42 --- /dev/null +++ b/backend/core/src/migration/1606316990163-update-units-ami-charts-relation.ts @@ -0,0 +1,25 @@ +import { MigrationInterface, QueryRunner } from "typeorm" + +export class updateUnitsAmiChartsRelation1606316990163 implements MigrationInterface { + name = "updateUnitsAmiChartsRelation1606316990163" + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "property" DROP CONSTRAINT "FK_d639fcd25af4127bc979d5146a9"` + ) + await queryRunner.query(`ALTER TABLE "property" DROP COLUMN "ami_chart_id"`) + await queryRunner.query(`ALTER TABLE "units" ADD "ami_chart_id" uuid`) + await queryRunner.query( + `ALTER TABLE "units" ADD CONSTRAINT "FK_35571c6bd2a1ff690201d1dff08" FOREIGN KEY ("ami_chart_id") REFERENCES "ami_chart"("id") ON DELETE NO ACTION ON UPDATE NO ACTION` + ) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "units" DROP CONSTRAINT "FK_35571c6bd2a1ff690201d1dff08"`) + await queryRunner.query(`ALTER TABLE "units" DROP COLUMN "ami_chart_id"`) + await queryRunner.query(`ALTER TABLE "property" ADD "ami_chart_id" uuid`) + await queryRunner.query( + `ALTER TABLE "property" ADD CONSTRAINT "FK_d639fcd25af4127bc979d5146a9" FOREIGN KEY ("ami_chart_id") REFERENCES "ami_chart"("id") ON DELETE NO ACTION ON UPDATE NO ACTION` + ) + } +} diff --git a/backend/core/src/migration/1606386989965-update-property-building-address.ts b/backend/core/src/migration/1606386989965-update-property-building-address.ts new file mode 100644 index 0000000000..422c6739ff --- /dev/null +++ b/backend/core/src/migration/1606386989965-update-property-building-address.ts @@ -0,0 +1,33 @@ +import { MigrationInterface, QueryRunner } from "typeorm" + +export class updatePropertyBuildingAddress1606386989965 implements MigrationInterface { + name = "updatePropertyBuildingAddress1606386989965" + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "property" RENAME COLUMN "building_address" TO "building_address_id"` + ) + await queryRunner.query(`ALTER TABLE "property" DROP COLUMN "building_address_id"`) + await queryRunner.query(`ALTER TABLE "property" ADD "building_address_id" uuid`) + await queryRunner.query( + `ALTER TABLE "property" ADD CONSTRAINT "UQ_f0f7062f34738e0b338163786fd" UNIQUE ("building_address_id")` + ) + await queryRunner.query( + `ALTER TABLE "property" ADD CONSTRAINT "FK_f0f7062f34738e0b338163786fd" FOREIGN KEY ("building_address_id") REFERENCES "address"("id") ON DELETE NO ACTION ON UPDATE NO ACTION` + ) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "property" DROP CONSTRAINT "FK_f0f7062f34738e0b338163786fd"` + ) + await queryRunner.query( + `ALTER TABLE "property" DROP CONSTRAINT "UQ_f0f7062f34738e0b338163786fd"` + ) + await queryRunner.query(`ALTER TABLE "property" DROP COLUMN "building_address_id"`) + await queryRunner.query(`ALTER TABLE "property" ADD "building_address_id" jsonb`) + await queryRunner.query( + `ALTER TABLE "property" RENAME COLUMN "building_address_id" TO "building_address"` + ) + } +} diff --git a/backend/core/src/migration/1606475798955-update-demographics-race.ts b/backend/core/src/migration/1606475798955-update-demographics-race.ts new file mode 100644 index 0000000000..6607582998 --- /dev/null +++ b/backend/core/src/migration/1606475798955-update-demographics-race.ts @@ -0,0 +1,15 @@ +import { MigrationInterface, QueryRunner } from "typeorm" + +export class updateDemographicsRace1606475798955 implements MigrationInterface { + name = "updateDemographicsRace1606475798955" + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "demographics" DROP COLUMN "race"`) + await queryRunner.query(`ALTER TABLE "demographics" ADD "race" text`) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "demographics" DROP COLUMN "race"`) + await queryRunner.query(`ALTER TABLE "demographics" ADD "race" character varying NOT NULL`) + } +} diff --git a/backend/core/src/migration/1606477233962-update-application-entity.ts b/backend/core/src/migration/1606477233962-update-application-entity.ts new file mode 100644 index 0000000000..b8fa523a5d --- /dev/null +++ b/backend/core/src/migration/1606477233962-update-application-entity.ts @@ -0,0 +1,27 @@ +import { MigrationInterface, QueryRunner } from "typeorm" + +export class updateApplicationEntity1606477233962 implements MigrationInterface { + name = "updateApplicationEntity1606477233962" + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "applications" ADD "status" character varying NOT NULL`) + await queryRunner.query(`ALTER TABLE "applications" ADD "preferences_id" uuid`) + await queryRunner.query( + `ALTER TABLE "applications" ADD CONSTRAINT "UQ_94732707236694795230ad64c78" UNIQUE ("preferences_id")` + ) + await queryRunner.query( + `ALTER TABLE "applications" ADD CONSTRAINT "FK_94732707236694795230ad64c78" FOREIGN KEY ("preferences_id") REFERENCES "application_preferences"("id") ON DELETE NO ACTION ON UPDATE NO ACTION` + ) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "applications" DROP CONSTRAINT "FK_94732707236694795230ad64c78"` + ) + await queryRunner.query( + `ALTER TABLE "applications" DROP CONSTRAINT "UQ_94732707236694795230ad64c78"` + ) + await queryRunner.query(`ALTER TABLE "applications" DROP COLUMN "preferences_id"`) + await queryRunner.query(`ALTER TABLE "applications" DROP COLUMN "status"`) + } +} diff --git a/backend/core/src/migration/1606488094709-update-applicant-email.ts b/backend/core/src/migration/1606488094709-update-applicant-email.ts new file mode 100644 index 0000000000..f4de2fca22 --- /dev/null +++ b/backend/core/src/migration/1606488094709-update-applicant-email.ts @@ -0,0 +1,13 @@ +import { MigrationInterface, QueryRunner } from "typeorm" + +export class updateApplicantEmail1606488094709 implements MigrationInterface { + name = "updateApplicantEmail1606488094709" + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "applicant" ALTER COLUMN "email_address" DROP NOT NULL`) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "applicant" ALTER COLUMN "email_address" SET NOT NULL`) + } +} diff --git a/backend/core/src/migration/1607179140908-fix-application-text-types.ts b/backend/core/src/migration/1607179140908-fix-application-text-types.ts new file mode 100644 index 0000000000..f579ff2f64 --- /dev/null +++ b/backend/core/src/migration/1607179140908-fix-application-text-types.ts @@ -0,0 +1,120 @@ +import { MigrationInterface, QueryRunner } from "typeorm" + +export class fixApplicationTextTypes1607179140908 implements MigrationInterface { + name = "fixApplicationTextTypes1607179140908" + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "address" ALTER COLUMN "city" TYPE text`) + await queryRunner.query(`ALTER TABLE "address" ALTER COLUMN "state" TYPE text`) + await queryRunner.query(`ALTER TABLE "address" ALTER COLUMN "street" TYPE text`) + await queryRunner.query(`ALTER TABLE "address" ALTER COLUMN "zip_code" TYPE text`) + await queryRunner.query(`ALTER TABLE "alternate_contact" ALTER COLUMN "type" TYPE text`) + await queryRunner.query(`ALTER TABLE "alternate_contact" ALTER COLUMN "other_type" TYPE text`) + await queryRunner.query(`ALTER TABLE "alternate_contact" ALTER COLUMN "first_name" TYPE text`) + await queryRunner.query(`ALTER TABLE "alternate_contact" ALTER COLUMN "last_name" TYPE text`) + await queryRunner.query(`ALTER TABLE "alternate_contact" ALTER COLUMN "agency" TYPE text`) + await queryRunner.query(`ALTER TABLE "alternate_contact" ALTER COLUMN "phone_number" TYPE text`) + await queryRunner.query( + `ALTER TABLE "alternate_contact" ALTER COLUMN "email_address" TYPE text` + ) + await queryRunner.query(`ALTER TABLE "applicant" ALTER COLUMN "first_name" TYPE text`) + await queryRunner.query(`ALTER TABLE "applicant" ALTER COLUMN "middle_name" TYPE text`) + await queryRunner.query(`ALTER TABLE "applicant" ALTER COLUMN "last_name" TYPE text`) + await queryRunner.query(`ALTER TABLE "applicant" ALTER COLUMN "birth_month" TYPE text`) + await queryRunner.query(`ALTER TABLE "applicant" ALTER COLUMN "birth_day" TYPE text`) + await queryRunner.query(`ALTER TABLE "applicant" ALTER COLUMN "birth_year" TYPE text`) + await queryRunner.query(`ALTER TABLE "applicant" ALTER COLUMN "email_address" TYPE text`) + await queryRunner.query(`ALTER TABLE "applicant" ALTER COLUMN "no_email" DROP NOT NULL`) + await queryRunner.query(`ALTER TABLE "applicant" ALTER COLUMN "phone_number" TYPE text`) + await queryRunner.query(`ALTER TABLE "applicant" ALTER COLUMN "phone_number_type" TYPE text`) + await queryRunner.query(`ALTER TABLE "applicant" ALTER COLUMN "no_phone" DROP NOT NULL`) + await queryRunner.query(`ALTER TABLE "demographics" ALTER COLUMN "ethnicity" TYPE text`) + await queryRunner.query(`ALTER TABLE "demographics" ALTER COLUMN "gender" TYPE text`) + await queryRunner.query( + `ALTER TABLE "demographics" ALTER COLUMN "sexual_orientation" TYPE text` + ) + await queryRunner.query(`ALTER TABLE "household_member" ALTER COLUMN "order_id" DROP NOT NULL`) + await queryRunner.query(`ALTER TABLE "household_member" ALTER COLUMN "first_name" TYPE text`) + await queryRunner.query(`ALTER TABLE "household_member" ALTER COLUMN "middle_name" TYPE text`) + await queryRunner.query(`ALTER TABLE "household_member" ALTER COLUMN "last_name" TYPE text`) + await queryRunner.query(`ALTER TABLE "household_member" ALTER COLUMN "birth_month" TYPE text`) + await queryRunner.query(`ALTER TABLE "household_member" ALTER COLUMN "birth_day" TYPE text`) + await queryRunner.query(`ALTER TABLE "household_member" ALTER COLUMN "birth_year" TYPE text`) + await queryRunner.query(`ALTER TABLE "household_member" ALTER COLUMN "email_address" TYPE text`) + await queryRunner.query(`ALTER TABLE "household_member" ALTER COLUMN "phone_number" TYPE text`) + await queryRunner.query( + `ALTER TABLE "household_member" ALTER COLUMN "phone_number_type" TYPE text` + ) + await queryRunner.query( + `ALTER TABLE "applications" ALTER COLUMN "additional_phone_number" TYPE text` + ) + await queryRunner.query( + `ALTER TABLE "applications" ALTER COLUMN "additional_phone_number_type" TYPE text` + ) + await queryRunner.query(`ALTER TABLE "applications" ALTER COLUMN "housing_status" TYPE text`) + await queryRunner.query(`ALTER TABLE "applications" ALTER COLUMN "income" TYPE text`) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "applications" ALTER COLUMN "income" TYPE varchar`) + await queryRunner.query(`ALTER TABLE "applications" ALTER COLUMN "housing_status" TYPE varchar`) + await queryRunner.query( + `ALTER TABLE "applications" ALTER COLUMN "additional_phone_number_type" TYPE varchar` + ) + await queryRunner.query( + `ALTER TABLE "applications" ALTER COLUMN "additional_phone_number" TYPE varchar` + ) + await queryRunner.query( + `ALTER TABLE "household_member" ALTER COLUMN "phone_number_type" TYPE varchar` + ) + await queryRunner.query( + `ALTER TABLE "household_member" ALTER COLUMN "phone_number" TYPE varchar` + ) + await queryRunner.query( + `ALTER TABLE "household_member" ALTER COLUMN "email_address" TYPE varchar` + ) + await queryRunner.query(`ALTER TABLE "household_member" ALTER COLUMN "birth_year" TYPE varchar`) + await queryRunner.query(`ALTER TABLE "household_member" ALTER COLUMN "birth_day" TYPE varchar`) + await queryRunner.query( + `ALTER TABLE "household_member" ALTER COLUMN "birth_month" TYPE varchar` + ) + await queryRunner.query(`ALTER TABLE "household_member" ALTER COLUMN "last_name" TYPE varchar`) + await queryRunner.query( + `ALTER TABLE "household_member" ALTER COLUMN "middle_name" TYPE varchar` + ) + await queryRunner.query(`ALTER TABLE "household_member" ALTER COLUMN "first_name" TYPE varchar`) + await queryRunner.query( + `ALTER TABLE "demographics" ALTER COLUMN "sexual_orientation" TYPE varchar` + ) + await queryRunner.query(`ALTER TABLE "demographics" ALTER COLUMN "gender" TYPE varchar`) + await queryRunner.query(`ALTER TABLE "demographics" ALTER COLUMN "ethnicity" TYPE varchar`) + await queryRunner.query(`ALTER TABLE "applicant" ALTER COLUMN "phone_number_type" TYPE varchar`) + await queryRunner.query(`ALTER TABLE "applicant" ALTER COLUMN "phone_number" TYPE varchar`) + await queryRunner.query(`ALTER TABLE "applicant" ALTER COLUMN "email_address" TYPE varchar`) + await queryRunner.query(`ALTER TABLE "applicant" ALTER COLUMN "birth_year" TYPE varchar`) + await queryRunner.query(`ALTER TABLE "applicant" ALTER COLUMN "birth_day" TYPE varchar`) + await queryRunner.query(`ALTER TABLE "applicant" ALTER COLUMN "birth_month" TYPE varchar`) + await queryRunner.query(`ALTER TABLE "applicant" ALTER COLUMN "last_name" TYPE varchar`) + await queryRunner.query(`ALTER TABLE "applicant" ALTER COLUMN "middle_name" TYPE varchar`) + await queryRunner.query(`ALTER TABLE "applicant" ALTER COLUMN "first_name" TYPE varchar`) + await queryRunner.query( + `ALTER TABLE "alternate_contact" ALTER COLUMN "email_address" TYPE varchar` + ) + await queryRunner.query( + `ALTER TABLE "alternate_contact" ALTER COLUMN "phone_number" TYPE varchar` + ) + await queryRunner.query(`ALTER TABLE "alternate_contact" ALTER COLUMN "agency" TYPE varchar`) + await queryRunner.query(`ALTER TABLE "alternate_contact" ALTER COLUMN "last_name" TYPE varchar`) + await queryRunner.query( + `ALTER TABLE "alternate_contact" ALTER COLUMN "first_name" TYPE varchar` + ) + await queryRunner.query( + `ALTER TABLE "alternate_contact" ALTER COLUMN "other_type" TYPE varchar` + ) + await queryRunner.query(`ALTER TABLE "alternate_contact" ALTER COLUMN "type" TYPE varchar`) + await queryRunner.query(`ALTER TABLE "address" ALTER COLUMN "zip_code" TYPE varchar`) + await queryRunner.query(`ALTER TABLE "address" ALTER COLUMN "street" TYPE varchar`) + await queryRunner.query(`ALTER TABLE "address" ALTER COLUMN "state" TYPE varchar`) + await queryRunner.query(`ALTER TABLE "address" ALTER COLUMN "city" TYPE varchar`) + } +} diff --git a/backend/core/src/migration/1607180515097-make-application-properties-nullable-for-partners.ts b/backend/core/src/migration/1607180515097-make-application-properties-nullable-for-partners.ts new file mode 100644 index 0000000000..72e0d6d967 --- /dev/null +++ b/backend/core/src/migration/1607180515097-make-application-properties-nullable-for-partners.ts @@ -0,0 +1,162 @@ +import { MigrationInterface, QueryRunner } from "typeorm" + +export class makeApplicationPropertiesNullableForPartners1607180515097 + implements MigrationInterface { + name = "makeApplicationPropertiesNullableForPartners1607180515097" + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "address" ALTER COLUMN "city" DROP NOT NULL`) + await queryRunner.query(`ALTER TABLE "address" ALTER COLUMN "state" DROP NOT NULL`) + await queryRunner.query(`ALTER TABLE "address" ALTER COLUMN "street" DROP NOT NULL`) + await queryRunner.query(`ALTER TABLE "address" ALTER COLUMN "zip_code" DROP NOT NULL`) + await queryRunner.query(`ALTER TABLE "alternate_contact" ALTER COLUMN "type" DROP NOT NULL`) + await queryRunner.query( + `ALTER TABLE "alternate_contact" ALTER COLUMN "first_name" DROP NOT NULL` + ) + await queryRunner.query( + `ALTER TABLE "alternate_contact" ALTER COLUMN "last_name" DROP NOT NULL` + ) + await queryRunner.query( + `ALTER TABLE "alternate_contact" ALTER COLUMN "phone_number" DROP NOT NULL` + ) + await queryRunner.query( + `ALTER TABLE "alternate_contact" ALTER COLUMN "email_address" DROP NOT NULL` + ) + await queryRunner.query(`ALTER TABLE "applicant" ALTER COLUMN "first_name" DROP NOT NULL`) + await queryRunner.query(`ALTER TABLE "applicant" ALTER COLUMN "middle_name" DROP NOT NULL`) + await queryRunner.query(`ALTER TABLE "applicant" ALTER COLUMN "last_name" DROP NOT NULL`) + await queryRunner.query(`ALTER TABLE "applicant" ALTER COLUMN "birth_month" DROP NOT NULL`) + await queryRunner.query(`ALTER TABLE "applicant" ALTER COLUMN "birth_day" DROP NOT NULL`) + await queryRunner.query(`ALTER TABLE "applicant" ALTER COLUMN "birth_year" DROP NOT NULL`) + await queryRunner.query(`ALTER TABLE "applicant" ALTER COLUMN "phone_number" DROP NOT NULL`) + await queryRunner.query( + `ALTER TABLE "applicant" ALTER COLUMN "phone_number_type" DROP NOT NULL` + ) + await queryRunner.query(`ALTER TABLE "demographics" ALTER COLUMN "ethnicity" DROP NOT NULL`) + await queryRunner.query(`ALTER TABLE "demographics" ALTER COLUMN "gender" DROP NOT NULL`) + await queryRunner.query( + `ALTER TABLE "demographics" ALTER COLUMN "sexual_orientation" DROP NOT NULL` + ) + await queryRunner.query( + `ALTER TABLE "household_member" ALTER COLUMN "first_name" DROP NOT NULL` + ) + await queryRunner.query( + `ALTER TABLE "household_member" ALTER COLUMN "middle_name" DROP NOT NULL` + ) + await queryRunner.query(`ALTER TABLE "household_member" ALTER COLUMN "last_name" DROP NOT NULL`) + await queryRunner.query( + `ALTER TABLE "household_member" ALTER COLUMN "birth_month" DROP NOT NULL` + ) + await queryRunner.query(`ALTER TABLE "household_member" ALTER COLUMN "birth_day" DROP NOT NULL`) + await queryRunner.query( + `ALTER TABLE "household_member" ALTER COLUMN "birth_year" DROP NOT NULL` + ) + await queryRunner.query( + `ALTER TABLE "household_member" ALTER COLUMN "email_address" DROP NOT NULL` + ) + await queryRunner.query( + `ALTER TABLE "household_member" ALTER COLUMN "phone_number" DROP NOT NULL` + ) + await queryRunner.query( + `ALTER TABLE "household_member" ALTER COLUMN "phone_number_type" DROP NOT NULL` + ) + await queryRunner.query(`ALTER TABLE "applications" ALTER COLUMN "app_url" DROP NOT NULL`) + await queryRunner.query( + `ALTER TABLE "applications" ALTER COLUMN "additional_phone" DROP NOT NULL` + ) + await queryRunner.query( + `ALTER TABLE "applications" ALTER COLUMN "additional_phone_number" DROP NOT NULL` + ) + await queryRunner.query( + `ALTER TABLE "applications" ALTER COLUMN "additional_phone_number_type" DROP NOT NULL` + ) + await queryRunner.query( + `ALTER TABLE "applications" ALTER COLUMN "household_size" DROP NOT NULL` + ) + await queryRunner.query( + `ALTER TABLE "applications" ALTER COLUMN "housing_status" DROP NOT NULL` + ) + await queryRunner.query( + `ALTER TABLE "applications" ALTER COLUMN "send_mail_to_mailing_address" DROP NOT NULL` + ) + await queryRunner.query( + `ALTER TABLE "applications" ALTER COLUMN "income_vouchers" DROP NOT NULL` + ) + await queryRunner.query(`ALTER TABLE "applications" ALTER COLUMN "income" DROP NOT NULL`) + await queryRunner.query(`ALTER TABLE "applications" ALTER COLUMN "income_period" DROP NOT NULL`) + await queryRunner.query( + `ALTER TABLE "applications" ALTER COLUMN "accepted_terms" DROP NOT NULL` + ) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "applications" ALTER COLUMN "accepted_terms" SET NOT NULL`) + await queryRunner.query(`ALTER TABLE "applications" ALTER COLUMN "income_period" SET NOT NULL`) + await queryRunner.query(`ALTER TABLE "applications" ALTER COLUMN "income" SET NOT NULL`) + await queryRunner.query( + `ALTER TABLE "applications" ALTER COLUMN "income_vouchers" SET NOT NULL` + ) + await queryRunner.query( + `ALTER TABLE "applications" ALTER COLUMN "send_mail_to_mailing_address" SET NOT NULL` + ) + await queryRunner.query(`ALTER TABLE "applications" ALTER COLUMN "housing_status" SET NOT NULL`) + await queryRunner.query(`ALTER TABLE "applications" ALTER COLUMN "household_size" SET NOT NULL`) + await queryRunner.query( + `ALTER TABLE "applications" ALTER COLUMN "additional_phone_number_type" SET NOT NULL` + ) + await queryRunner.query( + `ALTER TABLE "applications" ALTER COLUMN "additional_phone_number" SET NOT NULL` + ) + await queryRunner.query( + `ALTER TABLE "applications" ALTER COLUMN "additional_phone" SET NOT NULL` + ) + await queryRunner.query(`ALTER TABLE "applications" ALTER COLUMN "app_url" SET NOT NULL`) + await queryRunner.query( + `ALTER TABLE "household_member" ALTER COLUMN "phone_number_type" SET NOT NULL` + ) + await queryRunner.query( + `ALTER TABLE "household_member" ALTER COLUMN "phone_number" SET NOT NULL` + ) + await queryRunner.query( + `ALTER TABLE "household_member" ALTER COLUMN "email_address" SET NOT NULL` + ) + await queryRunner.query(`ALTER TABLE "household_member" ALTER COLUMN "birth_year" SET NOT NULL`) + await queryRunner.query(`ALTER TABLE "household_member" ALTER COLUMN "birth_day" SET NOT NULL`) + await queryRunner.query( + `ALTER TABLE "household_member" ALTER COLUMN "birth_month" SET NOT NULL` + ) + await queryRunner.query(`ALTER TABLE "household_member" ALTER COLUMN "last_name" SET NOT NULL`) + await queryRunner.query( + `ALTER TABLE "household_member" ALTER COLUMN "middle_name" SET NOT NULL` + ) + await queryRunner.query(`ALTER TABLE "household_member" ALTER COLUMN "first_name" SET NOT NULL`) + await queryRunner.query( + `ALTER TABLE "demographics" ALTER COLUMN "sexual_orientation" SET NOT NULL` + ) + await queryRunner.query(`ALTER TABLE "demographics" ALTER COLUMN "gender" SET NOT NULL`) + await queryRunner.query(`ALTER TABLE "demographics" ALTER COLUMN "ethnicity" SET NOT NULL`) + await queryRunner.query(`ALTER TABLE "applicant" ALTER COLUMN "phone_number_type" SET NOT NULL`) + await queryRunner.query(`ALTER TABLE "applicant" ALTER COLUMN "phone_number" SET NOT NULL`) + await queryRunner.query(`ALTER TABLE "applicant" ALTER COLUMN "birth_year" SET NOT NULL`) + await queryRunner.query(`ALTER TABLE "applicant" ALTER COLUMN "birth_day" SET NOT NULL`) + await queryRunner.query(`ALTER TABLE "applicant" ALTER COLUMN "birth_month" SET NOT NULL`) + await queryRunner.query(`ALTER TABLE "applicant" ALTER COLUMN "last_name" SET NOT NULL`) + await queryRunner.query(`ALTER TABLE "applicant" ALTER COLUMN "middle_name" SET NOT NULL`) + await queryRunner.query(`ALTER TABLE "applicant" ALTER COLUMN "first_name" SET NOT NULL`) + await queryRunner.query( + `ALTER TABLE "alternate_contact" ALTER COLUMN "email_address" SET NOT NULL` + ) + await queryRunner.query( + `ALTER TABLE "alternate_contact" ALTER COLUMN "phone_number" SET NOT NULL` + ) + await queryRunner.query(`ALTER TABLE "alternate_contact" ALTER COLUMN "last_name" SET NOT NULL`) + await queryRunner.query( + `ALTER TABLE "alternate_contact" ALTER COLUMN "first_name" SET NOT NULL` + ) + await queryRunner.query(`ALTER TABLE "alternate_contact" ALTER COLUMN "type" SET NOT NULL`) + await queryRunner.query(`ALTER TABLE "address" ALTER COLUMN "zip_code" SET NOT NULL`) + await queryRunner.query(`ALTER TABLE "address" ALTER COLUMN "street" SET NOT NULL`) + await queryRunner.query(`ALTER TABLE "address" ALTER COLUMN "state" SET NOT NULL`) + await queryRunner.query(`ALTER TABLE "address" ALTER COLUMN "city" SET NOT NULL`) + } +} diff --git a/backend/core/src/migration/1608200172832-add-listing-submission-date.ts b/backend/core/src/migration/1608200172832-add-listing-submission-date.ts new file mode 100644 index 0000000000..56227ea32c --- /dev/null +++ b/backend/core/src/migration/1608200172832-add-listing-submission-date.ts @@ -0,0 +1,15 @@ +import { MigrationInterface, QueryRunner } from "typeorm" + +export class addListingSubmissionDate1608200172832 implements MigrationInterface { + name = "addListingSubmissionDate1608200172832" + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "applications" ADD "submission_date" TIMESTAMP WITH TIME ZONE` + ) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "applications" DROP COLUMN "submission_date"`) + } +} diff --git a/backend/core/src/migration/1609755983577-make-application-language-nullable.ts b/backend/core/src/migration/1609755983577-make-application-language-nullable.ts new file mode 100644 index 0000000000..84d112ee0f --- /dev/null +++ b/backend/core/src/migration/1609755983577-make-application-language-nullable.ts @@ -0,0 +1,13 @@ +import { MigrationInterface, QueryRunner } from "typeorm" + +export class makeApplicationLanguageNullable1609755983577 implements MigrationInterface { + name = "makeApplicationLanguageNullable1609755983577" + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "applications" ALTER COLUMN "language" DROP NOT NULL`) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "applications" ALTER COLUMN "language" SET NOT NULL`) + } +} diff --git a/backend/core/src/migration/1610361232572-add-deleted-at-to-application.ts b/backend/core/src/migration/1610361232572-add-deleted-at-to-application.ts new file mode 100644 index 0000000000..c21982a040 --- /dev/null +++ b/backend/core/src/migration/1610361232572-add-deleted-at-to-application.ts @@ -0,0 +1,13 @@ +import { MigrationInterface, QueryRunner } from "typeorm" + +export class addDeletedAtToApplication1610361232572 implements MigrationInterface { + name = "addDeletedAtToApplication1610361232572" + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "applications" ADD "deleted_at" TIMESTAMP`) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "applications" DROP COLUMN "deleted_at"`) + } +} diff --git a/backend/core/src/migration/1610401966407-add-listing-display-waitlist-size-column.ts b/backend/core/src/migration/1610401966407-add-listing-display-waitlist-size-column.ts new file mode 100644 index 0000000000..6971cea3b1 --- /dev/null +++ b/backend/core/src/migration/1610401966407-add-listing-display-waitlist-size-column.ts @@ -0,0 +1,15 @@ +import { MigrationInterface, QueryRunner } from "typeorm" + +export class addListingDisplayWaitlistSizeColumn1610401966407 implements MigrationInterface { + name = "addListingDisplayWaitlistSizeColumn1610401966407" + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "listings" ADD "display_waitlist_size" boolean NOT NULL DEFAULT false` + ) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "listings" DROP COLUMN "display_waitlist_size"`) + } +} diff --git a/backend/core/src/migration/1610577576914-add-forget-password-fields.ts b/backend/core/src/migration/1610577576914-add-forget-password-fields.ts new file mode 100644 index 0000000000..d7299d9bf8 --- /dev/null +++ b/backend/core/src/migration/1610577576914-add-forget-password-fields.ts @@ -0,0 +1,13 @@ +import { MigrationInterface, QueryRunner } from "typeorm" + +export class addForgetPasswordFields1610577576914 implements MigrationInterface { + name = "addForgetPasswordFields1610577576914" + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "user_accounts" ADD "reset_token" character varying`) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "user_accounts" DROP COLUMN "reset_token"`) + } +} diff --git a/backend/core/src/migration/1610715331956-add-listing-leasing-agent.ts b/backend/core/src/migration/1610715331956-add-listing-leasing-agent.ts new file mode 100644 index 0000000000..f47e0ec0b7 --- /dev/null +++ b/backend/core/src/migration/1610715331956-add-listing-leasing-agent.ts @@ -0,0 +1,49 @@ +import { MigrationInterface, QueryRunner } from "typeorm" + +export class addListingLeasingAgent1610715331956 implements MigrationInterface { + name = "addListingLeasingAgent1610715331956" + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "listings_leasing_agents_user_accounts" ("listings_id" uuid NOT NULL, "user_accounts_id" uuid NOT NULL, CONSTRAINT "PK_6c10161c8ebb6e0291145688c56" PRIMARY KEY ("listings_id", "user_accounts_id"))` + ) + await queryRunner.query( + `CREATE INDEX "IDX_f7b22af2c421e823f60c5f7d28" ON "listings_leasing_agents_user_accounts" ("listings_id") ` + ) + await queryRunner.query( + `CREATE INDEX "IDX_de53131bc8a08f824a5d3dd51e" ON "listings_leasing_agents_user_accounts" ("user_accounts_id") ` + ) + await queryRunner.query(`ALTER TABLE "user_accounts" ADD "address_id" uuid`) + await queryRunner.query( + `ALTER TABLE "user_accounts" ADD CONSTRAINT "UQ_a72c6ee9575828fce562bd20a63" UNIQUE ("address_id")` + ) + await queryRunner.query( + `ALTER TABLE "user_accounts" ADD CONSTRAINT "FK_a72c6ee9575828fce562bd20a63" FOREIGN KEY ("address_id") REFERENCES "address"("id") ON DELETE NO ACTION ON UPDATE NO ACTION` + ) + await queryRunner.query( + `ALTER TABLE "listings_leasing_agents_user_accounts" ADD CONSTRAINT "FK_f7b22af2c421e823f60c5f7d28b" FOREIGN KEY ("listings_id") REFERENCES "listings"("id") ON DELETE CASCADE ON UPDATE NO ACTION` + ) + await queryRunner.query( + `ALTER TABLE "listings_leasing_agents_user_accounts" ADD CONSTRAINT "FK_de53131bc8a08f824a5d3dd51e3" FOREIGN KEY ("user_accounts_id") REFERENCES "user_accounts"("id") ON DELETE CASCADE ON UPDATE NO ACTION` + ) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "listings_leasing_agents_user_accounts" DROP CONSTRAINT "FK_de53131bc8a08f824a5d3dd51e3"` + ) + await queryRunner.query( + `ALTER TABLE "listings_leasing_agents_user_accounts" DROP CONSTRAINT "FK_f7b22af2c421e823f60c5f7d28b"` + ) + await queryRunner.query( + `ALTER TABLE "user_accounts" DROP CONSTRAINT "FK_a72c6ee9575828fce562bd20a63"` + ) + await queryRunner.query( + `ALTER TABLE "user_accounts" DROP CONSTRAINT "UQ_a72c6ee9575828fce562bd20a63"` + ) + await queryRunner.query(`ALTER TABLE "user_accounts" DROP COLUMN "address_id"`) + await queryRunner.query(`DROP INDEX "IDX_de53131bc8a08f824a5d3dd51e"`) + await queryRunner.query(`DROP INDEX "IDX_f7b22af2c421e823f60c5f7d28"`) + await queryRunner.query(`DROP TABLE "listings_leasing_agents_user_accounts"`) + } +} diff --git a/backend/core/src/migration/1610715660175-remove-address-from-user.ts b/backend/core/src/migration/1610715660175-remove-address-from-user.ts new file mode 100644 index 0000000000..c77ae15df8 --- /dev/null +++ b/backend/core/src/migration/1610715660175-remove-address-from-user.ts @@ -0,0 +1,25 @@ +import { MigrationInterface, QueryRunner } from "typeorm" + +export class removeAddressFromUser1610715660175 implements MigrationInterface { + name = "removeAddressFromUser1610715660175" + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "user_accounts" DROP CONSTRAINT "FK_a72c6ee9575828fce562bd20a63"` + ) + await queryRunner.query( + `ALTER TABLE "user_accounts" DROP CONSTRAINT "UQ_a72c6ee9575828fce562bd20a63"` + ) + await queryRunner.query(`ALTER TABLE "user_accounts" DROP COLUMN "address_id"`) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "user_accounts" ADD "address_id" uuid`) + await queryRunner.query( + `ALTER TABLE "user_accounts" ADD CONSTRAINT "UQ_a72c6ee9575828fce562bd20a63" UNIQUE ("address_id")` + ) + await queryRunner.query( + `ALTER TABLE "user_accounts" ADD CONSTRAINT "FK_a72c6ee9575828fce562bd20a63" FOREIGN KEY ("address_id") REFERENCES "address"("id") ON DELETE NO ACTION ON UPDATE NO ACTION` + ) + } +} diff --git a/backend/core/src/migration/1611163327131-fix-yes-no-answers-db-column-type.ts b/backend/core/src/migration/1611163327131-fix-yes-no-answers-db-column-type.ts new file mode 100644 index 0000000000..4bf6ac3ab2 --- /dev/null +++ b/backend/core/src/migration/1611163327131-fix-yes-no-answers-db-column-type.ts @@ -0,0 +1,19 @@ +import { MigrationInterface, QueryRunner } from "typeorm" + +export class fixYesNoAnswersDbColumnType1611163327131 implements MigrationInterface { + name = "fixYesNoAnswersDbColumnType1611163327131" + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "household_member" DROP COLUMN "same_address"`) + await queryRunner.query(`ALTER TABLE "household_member" ADD "same_address" text`) + await queryRunner.query(`ALTER TABLE "household_member" DROP COLUMN "work_in_region"`) + await queryRunner.query(`ALTER TABLE "household_member" ADD "work_in_region" text`) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "household_member" DROP COLUMN "work_in_region"`) + await queryRunner.query(`ALTER TABLE "household_member" ADD "work_in_region" boolean`) + await queryRunner.query(`ALTER TABLE "household_member" DROP COLUMN "same_address"`) + await queryRunner.query(`ALTER TABLE "household_member" ADD "same_address" boolean`) + } +} diff --git a/backend/core/src/migration/1611170324796-redefine-application-preferences.ts b/backend/core/src/migration/1611170324796-redefine-application-preferences.ts new file mode 100644 index 0000000000..82dfdaa6e1 --- /dev/null +++ b/backend/core/src/migration/1611170324796-redefine-application-preferences.ts @@ -0,0 +1,41 @@ +import { MigrationInterface, QueryRunner } from "typeorm" + +export class redefineApplicationPreferences1611170324796 implements MigrationInterface { + name = "redefineApplicationPreferences1611170324796" + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "applications" DROP CONSTRAINT "FK_94732707236694795230ad64c78"` + ) + await queryRunner.query( + `CREATE TABLE "application_preference" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "created_at" TIMESTAMP NOT NULL DEFAULT now(), "updated_at" TIMESTAMP NOT NULL DEFAULT now(), "data" jsonb, "application_id" uuid, "preference_id" uuid, CONSTRAINT "PK_e24d88ff86742179cf93434fcae" PRIMARY KEY ("id"))` + ) + await queryRunner.query( + `ALTER TABLE "applications" DROP CONSTRAINT "UQ_94732707236694795230ad64c78"` + ) + await queryRunner.query(`ALTER TABLE "applications" DROP COLUMN "preferences_id"`) + await queryRunner.query( + `ALTER TABLE "application_preference" ADD CONSTRAINT "FK_3a650c5299b1063f57bf6a2422e" FOREIGN KEY ("application_id") REFERENCES "applications"("id") ON DELETE NO ACTION ON UPDATE NO ACTION` + ) + await queryRunner.query( + `ALTER TABLE "application_preference" ADD CONSTRAINT "FK_fb3200f0f8c9469aee290c37158" FOREIGN KEY ("preference_id") REFERENCES "preferences"("id") ON DELETE NO ACTION ON UPDATE NO ACTION` + ) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "application_preference" DROP CONSTRAINT "FK_fb3200f0f8c9469aee290c37158"` + ) + await queryRunner.query( + `ALTER TABLE "application_preference" DROP CONSTRAINT "FK_3a650c5299b1063f57bf6a2422e"` + ) + await queryRunner.query(`ALTER TABLE "applications" ADD "preferences_id" uuid`) + await queryRunner.query( + `ALTER TABLE "applications" ADD CONSTRAINT "UQ_94732707236694795230ad64c78" UNIQUE ("preferences_id")` + ) + await queryRunner.query(`DROP TABLE "application_preference"`) + await queryRunner.query( + `ALTER TABLE "applications" ADD CONSTRAINT "FK_94732707236694795230ad64c78" FOREIGN KEY ("preferences_id") REFERENCES "application_preferences"("id") ON DELETE NO ACTION ON UPDATE NO ACTION` + ) + } +} diff --git a/backend/core/src/migration/1611265543779-add-form-metadata-to-preference.ts b/backend/core/src/migration/1611265543779-add-form-metadata-to-preference.ts new file mode 100644 index 0000000000..769c3c223e --- /dev/null +++ b/backend/core/src/migration/1611265543779-add-form-metadata-to-preference.ts @@ -0,0 +1,19 @@ +import { MigrationInterface, QueryRunner } from "typeorm" + +export class addFormMetadataToPreference1611265543779 implements MigrationInterface { + name = "addFormMetadataToPreference1611265543779" + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "preferences" ADD "form_metadata" jsonb`) + await queryRunner.query(`ALTER TABLE "application_preference" ALTER COLUMN "data" SET NOT NULL`) + await queryRunner.query(`COMMENT ON COLUMN "application_preference"."data" IS NULL`) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`COMMENT ON COLUMN "application_preference"."data" IS NULL`) + await queryRunner.query( + `ALTER TABLE "application_preference" ALTER COLUMN "data" DROP NOT NULL` + ) + await queryRunner.query(`ALTER TABLE "preferences" DROP COLUMN "form_metadata"`) + } +} diff --git a/backend/core/src/migration/1612262618223-add-csv-formatting-type-to-listing.ts b/backend/core/src/migration/1612262618223-add-csv-formatting-type-to-listing.ts new file mode 100644 index 0000000000..ca369ae751 --- /dev/null +++ b/backend/core/src/migration/1612262618223-add-csv-formatting-type-to-listing.ts @@ -0,0 +1,15 @@ +import { MigrationInterface, QueryRunner } from "typeorm" + +export class addCsvFormattingTypeToListing1612262618223 implements MigrationInterface { + name = "addCsvFormattingTypeToListing1612262618223" + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "listings" ADD "csv_formatting_type" character varying NOT NULL DEFAULT 'basic'` + ) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "listings" DROP COLUMN "csv_formatting_type"`) + } +} diff --git a/backend/core/src/migration/1612345865476-temporarily-remove-application-preference-table.ts b/backend/core/src/migration/1612345865476-temporarily-remove-application-preference-table.ts new file mode 100644 index 0000000000..7f9bfb3c80 --- /dev/null +++ b/backend/core/src/migration/1612345865476-temporarily-remove-application-preference-table.ts @@ -0,0 +1,16 @@ +import { MigrationInterface, QueryRunner } from "typeorm" + +export class temporarilyRemoveApplicationPreferenceTable1612345865476 + implements MigrationInterface { + name = "temporarilyRemoveApplicationPreferenceTable1612345865476" + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "applications" ADD "preferences" jsonb NOT NULL default '[]'::jsonb` + ) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "applications" DROP COLUMN "preferences"`) + } +} diff --git a/backend/core/src/migration/1612777228056-make-ami-chart-items-a-jsonb-column.ts b/backend/core/src/migration/1612777228056-make-ami-chart-items-a-jsonb-column.ts new file mode 100644 index 0000000000..837c9534ed --- /dev/null +++ b/backend/core/src/migration/1612777228056-make-ami-chart-items-a-jsonb-column.ts @@ -0,0 +1,15 @@ +import { MigrationInterface, QueryRunner } from "typeorm" + +export class makeAmiChartItemsAJsonbColumn1612777228056 implements MigrationInterface { + name = "makeAmiChartItemsAJsonbColumn1612777228056" + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "ami_chart" ADD "items" jsonb NOT NULL default '[]'::jsonb` + ) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "ami_chart" DROP COLUMN "items"`) + } +} diff --git a/backend/core/src/migration/1612778538403-make-listing-assets-a-jsonb-column.ts b/backend/core/src/migration/1612778538403-make-listing-assets-a-jsonb-column.ts new file mode 100644 index 0000000000..3aeb1f7c23 --- /dev/null +++ b/backend/core/src/migration/1612778538403-make-listing-assets-a-jsonb-column.ts @@ -0,0 +1,15 @@ +import { MigrationInterface, QueryRunner } from "typeorm" + +export class makeListingAssetsAJsonbColumn1612778538403 implements MigrationInterface { + name = "makeListingAssetsAJsonbColumn1612778538403" + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "listings" ADD "assets" jsonb NOT NULL default '[]'::jsonb` + ) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "listings" DROP COLUMN "assets"`) + } +} diff --git a/backend/core/src/migration/1612778951266-make-listing-application-methods-a-jsonb-column.ts b/backend/core/src/migration/1612778951266-make-listing-application-methods-a-jsonb-column.ts new file mode 100644 index 0000000000..89350212d8 --- /dev/null +++ b/backend/core/src/migration/1612778951266-make-listing-application-methods-a-jsonb-column.ts @@ -0,0 +1,15 @@ +import { MigrationInterface, QueryRunner } from "typeorm" + +export class makeListingApplicationMethodsAJsonbColumn1612778951266 implements MigrationInterface { + name = "makeListingApplicationMethodsAJsonbColumn1612778951266" + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "listings" ADD "application_methods" jsonb NOT NULL default '[]'::jsonb` + ) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "listings" DROP COLUMN "application_methods"`) + } +} diff --git a/backend/core/src/migration/1612779568745-make-listing-events-a-jsonb-column.ts b/backend/core/src/migration/1612779568745-make-listing-events-a-jsonb-column.ts new file mode 100644 index 0000000000..25fb977b86 --- /dev/null +++ b/backend/core/src/migration/1612779568745-make-listing-events-a-jsonb-column.ts @@ -0,0 +1,15 @@ +import { MigrationInterface, QueryRunner } from "typeorm" + +export class makeListingEventsAJsonbColumn1612779568745 implements MigrationInterface { + name = "makeListingEventsAJsonbColumn1612779568745" + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "listings" ADD "events" jsonb NOT NULL default '[]'::jsonb` + ) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "listings" DROP COLUMN "events"`) + } +} diff --git a/backend/core/src/migration/1614083153257-align-migration-with-latest-changes.ts b/backend/core/src/migration/1614083153257-align-migration-with-latest-changes.ts new file mode 100644 index 0000000000..89b7801bba --- /dev/null +++ b/backend/core/src/migration/1614083153257-align-migration-with-latest-changes.ts @@ -0,0 +1,37 @@ +import { MigrationInterface, QueryRunner } from "typeorm" + +export class alignMigrationWithLatestChanges1614083153257 implements MigrationInterface { + name = "alignMigrationWithLatestChanges1614083153257" + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`COMMENT ON COLUMN "applications"."preferences" IS NULL`) + await queryRunner.query(`ALTER TABLE "applications" ALTER COLUMN "preferences" DROP DEFAULT`) + await queryRunner.query(`COMMENT ON COLUMN "listings"."application_methods" IS NULL`) + await queryRunner.query( + `ALTER TABLE "listings" ALTER COLUMN "application_methods" DROP DEFAULT` + ) + await queryRunner.query(`COMMENT ON COLUMN "listings"."assets" IS NULL`) + await queryRunner.query(`ALTER TABLE "listings" ALTER COLUMN "assets" DROP DEFAULT`) + await queryRunner.query(`COMMENT ON COLUMN "listings"."events" IS NULL`) + await queryRunner.query(`ALTER TABLE "listings" ALTER COLUMN "events" DROP DEFAULT`) + await queryRunner.query(`COMMENT ON COLUMN "ami_chart"."items" IS NULL`) + await queryRunner.query(`ALTER TABLE "ami_chart" ALTER COLUMN "items" DROP DEFAULT`) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "ami_chart" ALTER COLUMN "items" SET DEFAULT '[]'`) + await queryRunner.query(`COMMENT ON COLUMN "ami_chart"."items" IS NULL`) + await queryRunner.query(`ALTER TABLE "listings" ALTER COLUMN "events" SET DEFAULT '[]'`) + await queryRunner.query(`COMMENT ON COLUMN "listings"."events" IS NULL`) + await queryRunner.query(`ALTER TABLE "listings" ALTER COLUMN "assets" SET DEFAULT '[]'`) + await queryRunner.query(`COMMENT ON COLUMN "listings"."assets" IS NULL`) + await queryRunner.query( + `ALTER TABLE "listings" ALTER COLUMN "application_methods" SET DEFAULT '[]'` + ) + await queryRunner.query(`COMMENT ON COLUMN "listings"."application_methods" IS NULL`) + await queryRunner.query( + `ALTER TABLE "applications" ALTER COLUMN "preferences" SET DEFAULT '[]'` + ) + await queryRunner.query(`COMMENT ON COLUMN "applications"."preferences" IS NULL`) + } +} diff --git a/backend/core/src/migration/1616427736224-add-user-confirmation-fields.ts b/backend/core/src/migration/1616427736224-add-user-confirmation-fields.ts new file mode 100644 index 0000000000..66a8fe8355 --- /dev/null +++ b/backend/core/src/migration/1616427736224-add-user-confirmation-fields.ts @@ -0,0 +1,19 @@ +import { MigrationInterface, QueryRunner } from "typeorm" + +export class addUserConfirmationFields1616427736224 implements MigrationInterface { + name = "addUserConfirmationFields1616427736224" + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "user_accounts" ADD "confirmation_token" character varying` + ) + await queryRunner.query( + `ALTER TABLE "user_accounts" ADD "confirmed_at" TIMESTAMP WITH TIME ZONE` + ) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "user_accounts" DROP COLUMN "confirmed_at"`) + await queryRunner.query(`ALTER TABLE "user_accounts" DROP COLUMN "confirmation_token"`) + } +} diff --git a/backend/core/src/migration/1617095866285-add-afs-related-migrations.ts b/backend/core/src/migration/1617095866285-add-afs-related-migrations.ts new file mode 100644 index 0000000000..b6fe3e1bf3 --- /dev/null +++ b/backend/core/src/migration/1617095866285-add-afs-related-migrations.ts @@ -0,0 +1,51 @@ +import { MigrationInterface, QueryRunner } from "typeorm" + +export class addAfsRelatedMigrations1617095866285 implements MigrationInterface { + name = "addAfsRelatedMigrations1617095866285" + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "application_flagged_set" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "created_at" TIMESTAMP NOT NULL DEFAULT now(), "updated_at" TIMESTAMP NOT NULL DEFAULT now(), "rule" character varying NOT NULL, "resolved" boolean NOT NULL, "resolved_time" TIMESTAMP WITH TIME ZONE, "status" character varying NOT NULL DEFAULT 'flagged', "resolving_user_id_id" uuid, "listing_id" uuid, CONSTRAINT "PK_81969e689800a802b75ffd883cc" PRIMARY KEY ("id"))` + ) + await queryRunner.query( + `CREATE TABLE "application_flagged_set_applications_applications" ("application_flagged_set_id" uuid NOT NULL, "applications_id" uuid NOT NULL, CONSTRAINT "PK_ceffc85d4559c5de81c20081c5e" PRIMARY KEY ("application_flagged_set_id", "applications_id"))` + ) + await queryRunner.query( + `CREATE INDEX "IDX_93f583f2d43fb21c5d7ceac57e" ON "application_flagged_set_applications_applications" ("application_flagged_set_id") ` + ) + await queryRunner.query( + `CREATE INDEX "IDX_bbae218ba0eff977157fad5ea3" ON "application_flagged_set_applications_applications" ("applications_id") ` + ) + await queryRunner.query( + `ALTER TABLE "application_flagged_set" ADD CONSTRAINT "FK_23c3b0688a74c8c2c59e1016bf0" FOREIGN KEY ("resolving_user_id_id") REFERENCES "user_accounts"("id") ON DELETE NO ACTION ON UPDATE NO ACTION` + ) + await queryRunner.query( + `ALTER TABLE "application_flagged_set" ADD CONSTRAINT "FK_f2ace84eebd770f1387b47e5e45" FOREIGN KEY ("listing_id") REFERENCES "listings"("id") ON DELETE NO ACTION ON UPDATE NO ACTION` + ) + await queryRunner.query( + `ALTER TABLE "application_flagged_set_applications_applications" ADD CONSTRAINT "FK_93f583f2d43fb21c5d7ceac57e7" FOREIGN KEY ("application_flagged_set_id") REFERENCES "application_flagged_set"("id") ON DELETE CASCADE ON UPDATE NO ACTION` + ) + await queryRunner.query( + `ALTER TABLE "application_flagged_set_applications_applications" ADD CONSTRAINT "FK_bbae218ba0eff977157fad5ea31" FOREIGN KEY ("applications_id") REFERENCES "applications"("id") ON DELETE CASCADE ON UPDATE NO ACTION` + ) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "application_flagged_set_applications_applications" DROP CONSTRAINT "FK_bbae218ba0eff977157fad5ea31"` + ) + await queryRunner.query( + `ALTER TABLE "application_flagged_set_applications_applications" DROP CONSTRAINT "FK_93f583f2d43fb21c5d7ceac57e7"` + ) + await queryRunner.query( + `ALTER TABLE "application_flagged_set" DROP CONSTRAINT "FK_f2ace84eebd770f1387b47e5e45"` + ) + await queryRunner.query( + `ALTER TABLE "application_flagged_set" DROP CONSTRAINT "FK_23c3b0688a74c8c2c59e1016bf0"` + ) + await queryRunner.query(`DROP INDEX "IDX_bbae218ba0eff977157fad5ea3"`) + await queryRunner.query(`DROP INDEX "IDX_93f583f2d43fb21c5d7ceac57e"`) + await queryRunner.query(`DROP TABLE "application_flagged_set_applications_applications"`) + await queryRunner.query(`DROP TABLE "application_flagged_set"`) + } +} diff --git a/backend/core/src/migration/1617096743504-add-afs-related-migrations.ts b/backend/core/src/migration/1617096743504-add-afs-related-migrations.ts new file mode 100644 index 0000000000..416cba9f66 --- /dev/null +++ b/backend/core/src/migration/1617096743504-add-afs-related-migrations.ts @@ -0,0 +1,31 @@ +import { MigrationInterface, QueryRunner } from "typeorm" + +export class addAfsRelatedMigrations1617096743504 implements MigrationInterface { + name = "addAfsRelatedMigrations1617096743504" + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "application_flagged_set" DROP CONSTRAINT "FK_f2ace84eebd770f1387b47e5e45"` + ) + await queryRunner.query( + `ALTER TABLE "application_flagged_set" ALTER COLUMN "listing_id" SET NOT NULL` + ) + await queryRunner.query(`COMMENT ON COLUMN "application_flagged_set"."listing_id" IS NULL`) + await queryRunner.query( + `ALTER TABLE "application_flagged_set" ADD CONSTRAINT "FK_f2ace84eebd770f1387b47e5e45" FOREIGN KEY ("listing_id") REFERENCES "listings"("id") ON DELETE NO ACTION ON UPDATE NO ACTION` + ) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "application_flagged_set" DROP CONSTRAINT "FK_f2ace84eebd770f1387b47e5e45"` + ) + await queryRunner.query(`COMMENT ON COLUMN "application_flagged_set"."listing_id" IS NULL`) + await queryRunner.query( + `ALTER TABLE "application_flagged_set" ALTER COLUMN "listing_id" DROP NOT NULL` + ) + await queryRunner.query( + `ALTER TABLE "application_flagged_set" ADD CONSTRAINT "FK_f2ace84eebd770f1387b47e5e45" FOREIGN KEY ("listing_id") REFERENCES "listings"("id") ON DELETE NO ACTION ON UPDATE NO ACTION` + ) + } +} diff --git a/backend/core/src/migration/1617270725517-add-application-marked-as-duplicate.ts b/backend/core/src/migration/1617270725517-add-application-marked-as-duplicate.ts new file mode 100644 index 0000000000..b96556af0c --- /dev/null +++ b/backend/core/src/migration/1617270725517-add-application-marked-as-duplicate.ts @@ -0,0 +1,15 @@ +import { MigrationInterface, QueryRunner } from "typeorm" + +export class addApplicationMarkedAsDuplicate1617270725517 implements MigrationInterface { + name = "addApplicationMarkedAsDuplicate1617270725517" + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "applications" ADD "marked_as_duplicate" boolean NOT NULL DEFAULT false` + ) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "applications" DROP COLUMN "marked_as_duplicate"`) + } +} diff --git a/backend/core/src/migration/1617271816594-remove-application-unused-resolved-status.ts b/backend/core/src/migration/1617271816594-remove-application-unused-resolved-status.ts new file mode 100644 index 0000000000..9db613e3cb --- /dev/null +++ b/backend/core/src/migration/1617271816594-remove-application-unused-resolved-status.ts @@ -0,0 +1,31 @@ +import { MigrationInterface, QueryRunner } from "typeorm" + +export class removeApplicationUnusedResolvedStatus1617271816594 implements MigrationInterface { + name = "removeApplicationUnusedResolvedStatus1617271816594" + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "application_flagged_set" DROP CONSTRAINT "FK_23c3b0688a74c8c2c59e1016bf0"` + ) + await queryRunner.query(`ALTER TABLE "application_flagged_set" DROP COLUMN "resolved"`) + await queryRunner.query( + `ALTER TABLE "application_flagged_set" DROP COLUMN "resolving_user_id_id"` + ) + await queryRunner.query(`ALTER TABLE "application_flagged_set" ADD "resolving_user_id" uuid`) + await queryRunner.query( + `ALTER TABLE "application_flagged_set" ADD CONSTRAINT "FK_3aed12c210529ed798beee9d09e" FOREIGN KEY ("resolving_user_id") REFERENCES "user_accounts"("id") ON DELETE NO ACTION ON UPDATE NO ACTION` + ) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "application_flagged_set" DROP CONSTRAINT "FK_3aed12c210529ed798beee9d09e"` + ) + await queryRunner.query(`ALTER TABLE "application_flagged_set" DROP COLUMN "resolving_user_id"`) + await queryRunner.query(`ALTER TABLE "application_flagged_set" ADD "resolving_user_id_id" uuid`) + await queryRunner.query(`ALTER TABLE "application_flagged_set" ADD "resolved" boolean NOT NULL`) + await queryRunner.query( + `ALTER TABLE "application_flagged_set" ADD CONSTRAINT "FK_23c3b0688a74c8c2c59e1016bf0" FOREIGN KEY ("resolving_user_id_id") REFERENCES "user_accounts"("id") ON DELETE NO ACTION ON UPDATE NO ACTION` + ) + } +} diff --git a/backend/core/src/migration/1617314453753-add-missing-constraints.ts b/backend/core/src/migration/1617314453753-add-missing-constraints.ts new file mode 100644 index 0000000000..474b064e36 --- /dev/null +++ b/backend/core/src/migration/1617314453753-add-missing-constraints.ts @@ -0,0 +1,19 @@ +import { MigrationInterface, QueryRunner } from "typeorm" + +export class addMissingConstraints1617314453753 implements MigrationInterface { + name = "addMissingConstraints1617314453753" + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`COMMENT ON COLUMN "user_accounts"."email" IS NULL`) + await queryRunner.query( + `ALTER TABLE "user_accounts" ADD CONSTRAINT "UQ_df3802ec9c31dd9491e3589378d" UNIQUE ("email")` + ) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "user_accounts" DROP CONSTRAINT "UQ_df3802ec9c31dd9491e3589378d"` + ) + await queryRunner.query(`COMMENT ON COLUMN "user_accounts"."email" IS NULL`) + } +} diff --git a/backend/core/src/migration/1618916649051-add-translations-table.ts b/backend/core/src/migration/1618916649051-add-translations-table.ts new file mode 100644 index 0000000000..471be616a6 --- /dev/null +++ b/backend/core/src/migration/1618916649051-add-translations-table.ts @@ -0,0 +1,19 @@ +import { MigrationInterface, QueryRunner } from "typeorm" + +export class addTranslationsTable1618916649051 implements MigrationInterface { + name = "addTranslationsTable1618916649051" + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "translations" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "created_at" TIMESTAMP NOT NULL DEFAULT now(), "updated_at" TIMESTAMP NOT NULL DEFAULT now(), "county_code" character varying NOT NULL, "language" character varying NOT NULL, "key" text NOT NULL, "text" text NOT NULL, CONSTRAINT "PK_7aef875e43ab80d34a0cdd39c70" PRIMARY KEY ("id"))` + ) + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_4c17bc3d8eefdb8702bb24a2c5" ON "translations" ("county_code", "language", "key") ` + ) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX "IDX_4c17bc3d8eefdb8702bb24a2c5"`) + await queryRunner.query(`DROP TABLE "translations"`) + } +} diff --git a/backend/core/src/migration/1619094998058-alter-translations-table.ts b/backend/core/src/migration/1619094998058-alter-translations-table.ts new file mode 100644 index 0000000000..36bdbd4c13 --- /dev/null +++ b/backend/core/src/migration/1619094998058-alter-translations-table.ts @@ -0,0 +1,25 @@ +import { MigrationInterface, QueryRunner } from "typeorm" + +export class alterTranslationsTable1619094998058 implements MigrationInterface { + name = "alterTranslationsTable1619094998058" + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX "IDX_4c17bc3d8eefdb8702bb24a2c5"`) + await queryRunner.query(`ALTER TABLE "translations" DROP COLUMN "key"`) + await queryRunner.query(`ALTER TABLE "translations" DROP COLUMN "text"`) + await queryRunner.query(`ALTER TABLE "translations" ADD "translations" jsonb NOT NULL`) + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_ada354174d7f8a8f3d56c39bba" ON "translations" ("county_code", "language") ` + ) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX "IDX_ada354174d7f8a8f3d56c39bba"`) + await queryRunner.query(`ALTER TABLE "translations" DROP COLUMN "translations"`) + await queryRunner.query(`ALTER TABLE "translations" ADD "text" text NOT NULL`) + await queryRunner.query(`ALTER TABLE "translations" ADD "key" text NOT NULL`) + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_4c17bc3d8eefdb8702bb24a2c5" ON "translations" ("county_code", "language", "key") ` + ) + } +} diff --git a/backend/core/src/migration/1619453621997-add-user-language.ts b/backend/core/src/migration/1619453621997-add-user-language.ts new file mode 100644 index 0000000000..d8287f65b7 --- /dev/null +++ b/backend/core/src/migration/1619453621997-add-user-language.ts @@ -0,0 +1,13 @@ +import { MigrationInterface, QueryRunner } from "typeorm" + +export class addUserLanguage1619453621997 implements MigrationInterface { + name = "addUserLanguage1619453621997" + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "user_accounts" ADD "language" character varying`) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "user_accounts" DROP COLUMN "language"`) + } +} diff --git a/backend/core/src/migration/1619558199145-add-missing-listing-fields.ts b/backend/core/src/migration/1619558199145-add-missing-listing-fields.ts new file mode 100644 index 0000000000..c794bb8597 --- /dev/null +++ b/backend/core/src/migration/1619558199145-add-missing-listing-fields.ts @@ -0,0 +1,15 @@ +import { MigrationInterface, QueryRunner } from "typeorm" + +export class addMissingListingFields1619558199145 implements MigrationInterface { + name = "addMissingListingFields1619558199145" + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "listings" ADD "special_notes" text`) + await queryRunner.query(`ALTER TABLE "property" ADD "services_offered" text`) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "property" DROP COLUMN "services_offered"`) + await queryRunner.query(`ALTER TABLE "listings" DROP COLUMN "special_notes"`) + } +} diff --git a/backend/core/src/migration/1620653590005-add-county-code-to-listing-and-user.ts b/backend/core/src/migration/1620653590005-add-county-code-to-listing-and-user.ts new file mode 100644 index 0000000000..d19a5c8a97 --- /dev/null +++ b/backend/core/src/migration/1620653590005-add-county-code-to-listing-and-user.ts @@ -0,0 +1,26 @@ +import { MigrationInterface, QueryRunner } from "typeorm" +import { CountyCode } from "../shared/types/county-code" + +export class addCountyCodeToListingAndUser1620653590005 implements MigrationInterface { + name = "addCountyCodeToListingAndUser1620653590005" + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "listings" ADD "county_code" character varying NOT NULL DEFAULT 'Alameda'` + ) + const mappings = { + Alameda: CountyCode.alameda, + "San Mateo": CountyCode.san_mateo, + "San Jose": CountyCode.san_jose, + } + for (const [dbBuildingAddressCountyValue, countyCode] of Object.entries(mappings)) { + await queryRunner.query( + `UPDATE listings SET county_code = '${countyCode}' FROM property, address WHERE listings.property_id = property.id AND property.building_address_id = address.id AND address.county = '${dbBuildingAddressCountyValue}'` + ) + } + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "listings" DROP COLUMN "county_code"`) + } +} diff --git a/backend/core/src/migration/1620660845209-seed-translation-entries.ts b/backend/core/src/migration/1620660845209-seed-translation-entries.ts new file mode 100644 index 0000000000..09410cb302 --- /dev/null +++ b/backend/core/src/migration/1620660845209-seed-translation-entries.ts @@ -0,0 +1,66 @@ +import { MigrationInterface, QueryRunner } from "typeorm" +import { CountyCode } from "../shared/types/county-code" +import { Language } from "../shared/types/language-enum" + +export class seedTranslationEntries1620660845209 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + const defaultTranslation = { + confirmation: { + yourConfirmationNumber: "Here is your confirmation number:", + shouldBeChosen: + "Should your application be chosen, be prepared to fill out a more detailed application and provide required supporting documents.", + subject: "Your Application Confirmation", + thankYouForApplying: "Thanks for applying. We have received your application for", + whatToExpectNext: "What to expect next:", + whatToExpect: { + FCFS: + "Applicants will be contacted by the property agent on a first come first serve basis until vacancies are filled.", + lottery: + "Applicants will be contacted by the agent in lottery rank order until vacancies are filled.", + noLottery: + "Applicants will be contacted by the agent in waitlist order until vacancies are filled.", + }, + }, + footer: { + callToAction: "How are we doing? We'd like to get your ", + callToActionUrl: + "https://docs.google.com/forms/d/e/1FAIpQLScr7JuVwiNW8q-ifFUWTFSWqEyV5ndA08jAhJQSlQ4ETrnl9w/viewform", + feedback: "feedback", + footer: "Alameda County - Housing and Community Development (HCD) Department", + thankYou: "Thank you", + }, + forgotPassword: { + callToAction: + "If you did make this request, please click on the link below to reset your password:", + changePassword: "Change my password", + ignoreRequest: "If you didn't request this, please ignore this email.", + passwordInfo: + "Your password won't change until you access the link above and create a new one.", + resetRequest: + "A request to reset your Bloom Housing Portal website password for %{appUrl} has recently been made.", + subject: "Forgot your password?", + }, + leasingAgent: { + contactAgentToUpdateInfo: + "If you need to update information on your application, do not apply again. Contact the agent. See below for contact information for the Agent for this listing.", + officeHours: "Office Hours:", + }, + register: { + confirmMyAccount: "Confirm my account", + toConfirmAccountMessage: "To complete your account creation, please click the link below:", + welcome: "Welcome", + welcomeMessage: + "Thank you for setting up your account on %{appUrl}. It will now be easier for you to start, save, and submit online applications for listings that appear on the site.", + }, + t: { + hello: "Hello", + }, + } + await queryRunner.query( + `INSERT into "translations" (county_code, language, translations) VALUES ($1, $2, $3)`, + [CountyCode.alameda, Language.en, defaultTranslation] + ) + } + + public async down(queryRunner: QueryRunner): Promise {} +} diff --git a/backend/core/src/migration/1621363665191-preferencePage.ts b/backend/core/src/migration/1621363665191-preferencePage.ts new file mode 100644 index 0000000000..7f86d57f40 --- /dev/null +++ b/backend/core/src/migration/1621363665191-preferencePage.ts @@ -0,0 +1,13 @@ +import { MigrationInterface, QueryRunner } from "typeorm" + +export class preferencePage1621363665191 implements MigrationInterface { + name = "preferencePage1621363665191" + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "preferences" ADD "page" integer NOT NULL DEFAULT 1`) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "preferences" DROP COLUMN "page"`) + } +} diff --git a/backend/core/src/migration/1622194142757-update-extra-data-in-application-preferences.ts b/backend/core/src/migration/1622194142757-update-extra-data-in-application-preferences.ts new file mode 100644 index 0000000000..b6d3e6f845 --- /dev/null +++ b/backend/core/src/migration/1622194142757-update-extra-data-in-application-preferences.ts @@ -0,0 +1,32 @@ +import { MigrationInterface, QueryRunner } from "typeorm" +import { ApplicationPreference } from "../applications/entities/application-preferences.entity" + +export class updateExtraDataInApplicationPreferences1622194142757 implements MigrationInterface { + name = "updateExtraDataInApplicationPreferences1622194142757" + + public async up(queryRunner: QueryRunner): Promise { + const result: Array<{ + id: string + preferences: Array + }> = await queryRunner.query("SELECT id, preferences from applications") + // NOTE: Find every option in preferences where extraData is + // either undefined or null and replace it with an empty array + for (const item of result) { + for (const preference of item.preferences) { + if ("options" in preference) { + for (const option of preference.options) { + if (option.extraData === undefined || option.extraData === null) { + option.extraData = [] + } + } + } + } + await queryRunner.query("UPDATE applications SET preferences = ($1) WHERE id = ($2)", [ + JSON.stringify(item.preferences), + item.id, + ]) + } + } + + public async down(queryRunner: QueryRunner): Promise {} +} diff --git a/backend/core/src/migration/1623247120126-add-assets-table.ts b/backend/core/src/migration/1623247120126-add-assets-table.ts new file mode 100644 index 0000000000..498ab97f51 --- /dev/null +++ b/backend/core/src/migration/1623247120126-add-assets-table.ts @@ -0,0 +1,21 @@ +import { MigrationInterface, QueryRunner } from "typeorm" + +export class addAssetsTable1623247120126 implements MigrationInterface { + name = "addAssetsTable1623247120126" + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "assets" DROP CONSTRAINT "FK_8cb54e950245d30651b903a4c61"`) + await queryRunner.query(`DROP INDEX "IDX_ada354174d7f8a8f3d56c39bba"`) + await queryRunner.query(`ALTER TABLE "assets" DROP COLUMN "listing_id"`) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "assets" ADD "listing_id" uuid`) + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_ada354174d7f8a8f3d56c39bba" ON "translations" ("county_code", "language") ` + ) + await queryRunner.query( + `ALTER TABLE "assets" ADD CONSTRAINT "FK_8cb54e950245d30651b903a4c61" FOREIGN KEY ("listing_id") REFERENCES "listings"("id") ON DELETE NO ACTION ON UPDATE NO ACTION` + ) + } +} diff --git a/backend/core/src/migration/1623279600284-listing-status-enum-closed.ts b/backend/core/src/migration/1623279600284-listing-status-enum-closed.ts new file mode 100644 index 0000000000..7943f5f540 --- /dev/null +++ b/backend/core/src/migration/1623279600284-listing-status-enum-closed.ts @@ -0,0 +1,33 @@ +import { MigrationInterface, QueryRunner } from "typeorm" + +export class listingStatusEnumClosed1623279600284 implements MigrationInterface { + name = "listingStatusEnumClosed1623279600284" + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TYPE "public"."listings_status_enum" RENAME TO "listings_status_enum_old"` + ) + await queryRunner.query( + `CREATE TYPE "listings_status_enum" AS ENUM('active', 'pending', 'closed')` + ) + await queryRunner.query(`ALTER TABLE "listings" ALTER COLUMN "status" DROP DEFAULT`) + await queryRunner.query( + `ALTER TABLE "listings" ALTER COLUMN "status" TYPE "listings_status_enum" USING "status"::"text"::"listings_status_enum"` + ) + await queryRunner.query(`ALTER TABLE "listings" ALTER COLUMN "status" SET DEFAULT 'pending'`) + await queryRunner.query(`DROP TYPE "listings_status_enum_old"`) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE TYPE "listings_status_enum_old" AS ENUM('active', 'pending')`) + await queryRunner.query(`ALTER TABLE "listings" ALTER COLUMN "status" DROP DEFAULT`) + await queryRunner.query( + `ALTER TABLE "listings" ALTER COLUMN "status" TYPE "listings_status_enum_old" USING "status"::"text"::"listings_status_enum_old"` + ) + await queryRunner.query(`ALTER TABLE "listings" ALTER COLUMN "status" SET DEFAULT 'pending'`) + await queryRunner.query(`DROP TYPE "listings_status_enum"`) + await queryRunner.query( + `ALTER TYPE "listings_status_enum_old" RENAME TO "listings_status_enum"` + ) + } +} diff --git a/backend/core/src/migration/1624272587523-add-jurisdictions-table.ts b/backend/core/src/migration/1624272587523-add-jurisdictions-table.ts new file mode 100644 index 0000000000..8d6df832b9 --- /dev/null +++ b/backend/core/src/migration/1624272587523-add-jurisdictions-table.ts @@ -0,0 +1,50 @@ +import { MigrationInterface, QueryRunner } from "typeorm" +import { CountyCode } from "../shared/types/county-code" + +export class addJurisdictionsTable1624272587523 implements MigrationInterface { + name = "addJurisdictionsTable1624272587523" + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "jurisdictions" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "created_at" TIMESTAMP NOT NULL DEFAULT now(), "updated_at" TIMESTAMP NOT NULL DEFAULT now(), "name" text NOT NULL, CONSTRAINT "PK_7cc0bed21c9e2b32866c1109ec5" PRIMARY KEY ("id"))` + ) + await queryRunner.query(`ALTER TABLE "listings" ADD "jurisdiction_id" uuid`) + await queryRunner.query(`ALTER TABLE "preferences" ALTER COLUMN "page" DROP NOT NULL`) + await queryRunner.query(`COMMENT ON COLUMN "preferences"."page" IS NULL`) + await queryRunner.query(`ALTER TABLE "preferences" ALTER COLUMN "page" DROP DEFAULT`) + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_8317da96d5a775889e2631cc25" ON "translations" ("county_code", "language") ` + ) + await queryRunner.query( + `ALTER TABLE "listings" ADD CONSTRAINT "FK_ba0026e02ecfe91791aed1a4818" FOREIGN KEY ("jurisdiction_id") REFERENCES "jurisdictions"("id") ON DELETE NO ACTION ON UPDATE NO ACTION` + ) + for (const jurisdictionName of [CountyCode.detroit, CountyCode.alameda]) { + const jurisdiction = await queryRunner.query( + `INSERT INTO "jurisdictions" (name) VALUES ($1)`, + [jurisdictionName] + ) + const listingsMatchingJurisdiction = await queryRunner.query( + `SELECT id from listings where county_code = ($1)`, + [jurisdictionName] + ) + for (const listing of listingsMatchingJurisdiction) { + await queryRunner.query(`UPDATE listings SET jurisdiction_id = ($1) WHERE id = ($2)`, [ + jurisdiction.id, + listing.id, + ]) + } + } + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "listings" DROP CONSTRAINT "FK_ba0026e02ecfe91791aed1a4818"` + ) + await queryRunner.query(`DROP INDEX "IDX_8317da96d5a775889e2631cc25"`) + await queryRunner.query(`ALTER TABLE "preferences" ALTER COLUMN "page" SET DEFAULT '1'`) + await queryRunner.query(`COMMENT ON COLUMN "preferences"."page" IS NULL`) + await queryRunner.query(`ALTER TABLE "preferences" ALTER COLUMN "page" SET NOT NULL`) + await queryRunner.query(`ALTER TABLE "listings" DROP COLUMN "jurisdiction_id"`) + await queryRunner.query(`DROP TABLE "jurisdictions"`) + } +} diff --git a/backend/core/src/migration/1624359766509-add-reserved-community-types-table.ts b/backend/core/src/migration/1624359766509-add-reserved-community-types-table.ts new file mode 100644 index 0000000000..b68a3075b2 --- /dev/null +++ b/backend/core/src/migration/1624359766509-add-reserved-community-types-table.ts @@ -0,0 +1,25 @@ +import { MigrationInterface, QueryRunner } from "typeorm" + +export class addReservedCommunityTypesTable1624359766509 implements MigrationInterface { + name = "addReservedCommunityTypesTable1624359766509" + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "reserved_community_types" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "created_at" TIMESTAMP NOT NULL DEFAULT now(), "updated_at" TIMESTAMP NOT NULL DEFAULT now(), "name" text NOT NULL, "description" text, CONSTRAINT "PK_af3937276e7bb53c30159d6ca0b" PRIMARY KEY ("id"))` + ) + await queryRunner.query(`ALTER TABLE "listings" ADD "reserved_community_min_age" integer`) + await queryRunner.query(`ALTER TABLE "listings" ADD "reserved_community_type_id" uuid`) + await queryRunner.query( + `ALTER TABLE "listings" ADD CONSTRAINT "FK_1f6fac73d27c81b656cc6100267" FOREIGN KEY ("reserved_community_type_id") REFERENCES "reserved_community_types"("id") ON DELETE NO ACTION ON UPDATE NO ACTION` + ) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "listings" DROP CONSTRAINT "FK_1f6fac73d27c81b656cc6100267"` + ) + await queryRunner.query(`ALTER TABLE "listings" DROP COLUMN "reserved_community_type_id"`) + await queryRunner.query(`ALTER TABLE "listings" DROP COLUMN "reserved_community_min_age"`) + await queryRunner.query(`DROP TABLE "reserved_community_types"`) + } +} diff --git a/backend/core/src/migration/1624542123483-seed-reserved-community-type.ts b/backend/core/src/migration/1624542123483-seed-reserved-community-type.ts new file mode 100644 index 0000000000..664394d3e7 --- /dev/null +++ b/backend/core/src/migration/1624542123483-seed-reserved-community-type.ts @@ -0,0 +1,18 @@ +import { MigrationInterface, QueryRunner } from "typeorm" + +export class seedReservedCommunityType1624542123483 implements MigrationInterface { + reservedCommunityTypes = ["specialNeeds", "senior55", "senior62"] + public async up(queryRunner: QueryRunner): Promise { + for (const reservedCommunityType of this.reservedCommunityTypes) { + await queryRunner.query(`INSERT INTO reserved_community_types (name) VALUES ($1)`, [ + reservedCommunityType, + ]) + } + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DELETE FROM reserved_community_types WHERE name = ANY($1)`, [ + this.reservedCommunityTypes, + ]) + } +} diff --git a/backend/core/src/migration/1624624546037-add-image-relation-to-listing.ts b/backend/core/src/migration/1624624546037-add-image-relation-to-listing.ts new file mode 100644 index 0000000000..8166803936 --- /dev/null +++ b/backend/core/src/migration/1624624546037-add-image-relation-to-listing.ts @@ -0,0 +1,19 @@ +import { MigrationInterface, QueryRunner } from "typeorm" + +export class addImageRelationToListing1624624546037 implements MigrationInterface { + name = "addImageRelationToListing1624624546037" + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "listings" ADD "image_id" uuid`) + await queryRunner.query( + `ALTER TABLE "listings" ADD CONSTRAINT "FK_ecc271b96bd18df0efe47b85186" FOREIGN KEY ("image_id") REFERENCES "assets"("id") ON DELETE NO ACTION ON UPDATE NO ACTION` + ) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "listings" DROP CONSTRAINT "FK_ecc271b96bd18df0efe47b85186"` + ) + await queryRunner.query(`ALTER TABLE "listings" DROP COLUMN "image_id"`) + } +} diff --git a/backend/core/src/migration/1624871726219-add-result-to-listing.ts b/backend/core/src/migration/1624871726219-add-result-to-listing.ts new file mode 100644 index 0000000000..6eb26bfda1 --- /dev/null +++ b/backend/core/src/migration/1624871726219-add-result-to-listing.ts @@ -0,0 +1,19 @@ +import { MigrationInterface, QueryRunner } from "typeorm" + +export class addResultToListing1624871726219 implements MigrationInterface { + name = "addResultToListing1624871726219" + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "listings" ADD "result_id" uuid`) + await queryRunner.query( + `ALTER TABLE "listings" ADD CONSTRAINT "FK_3f7b2aedbfccd6297923943e311" FOREIGN KEY ("result_id") REFERENCES "assets"("id") ON DELETE NO ACTION ON UPDATE NO ACTION` + ) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "listings" DROP CONSTRAINT "FK_3f7b2aedbfccd6297923943e311"` + ) + await queryRunner.query(`ALTER TABLE "listings" DROP COLUMN "result_id"`) + } +} diff --git a/backend/core/src/migration/1624873640959-add-result-link-and-waitlist-fields-to-listing.ts b/backend/core/src/migration/1624873640959-add-result-link-and-waitlist-fields-to-listing.ts new file mode 100644 index 0000000000..2d04b7b725 --- /dev/null +++ b/backend/core/src/migration/1624873640959-add-result-link-and-waitlist-fields-to-listing.ts @@ -0,0 +1,25 @@ +import { MigrationInterface, QueryRunner } from "typeorm" + +export class addResultLinkAndWaitlistFieldsToListing1624873640959 implements MigrationInterface { + name = "addResultLinkAndWaitlistFieldsToListing1624873640959" + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "listings" ADD "result_link" text`) + await queryRunner.query(`ALTER TABLE "listings" ADD "is_waitlist_open" boolean`) + await queryRunner.query(`ALTER TABLE "listings" ADD "waitlist_open_spots" integer`) + await queryRunner.query(`COMMENT ON COLUMN "listings"."display_waitlist_size" IS NULL`) + await queryRunner.query( + `ALTER TABLE "listings" ALTER COLUMN "display_waitlist_size" DROP DEFAULT` + ) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "listings" ALTER COLUMN "display_waitlist_size" SET DEFAULT false` + ) + await queryRunner.query(`COMMENT ON COLUMN "listings"."display_waitlist_size" IS NULL`) + await queryRunner.query(`ALTER TABLE "listings" DROP COLUMN "waitlist_open_spots"`) + await queryRunner.query(`ALTER TABLE "listings" DROP COLUMN "is_waitlist_open"`) + await queryRunner.query(`ALTER TABLE "listings" DROP COLUMN "result_link"`) + } +} diff --git a/backend/core/src/migration/1624959910201-add-pick-up-and-drop-off-columns-to-listing.ts b/backend/core/src/migration/1624959910201-add-pick-up-and-drop-off-columns-to-listing.ts new file mode 100644 index 0000000000..fed284baab --- /dev/null +++ b/backend/core/src/migration/1624959910201-add-pick-up-and-drop-off-columns-to-listing.ts @@ -0,0 +1,30 @@ +import { MigrationInterface, QueryRunner } from "typeorm" + +export class addPickUpAndDropOffColumnsToListing1624959910201 implements MigrationInterface { + name = "addPickUpAndDropOffColumnsToListing1624959910201" + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TYPE "listings_application_pick_up_address_type_enum" AS ENUM('leasingAgent', 'mailingAddress')` + ) + await queryRunner.query( + `ALTER TABLE "listings" ADD "application_pick_up_address_type" "listings_application_pick_up_address_type_enum"` + ) + + await queryRunner.query( + `CREATE TYPE "listings_application_drop_off_address_type_enum" AS ENUM('leasingAgent', 'mailingAddress')` + ) + await queryRunner.query( + `ALTER TABLE "listings" ADD "application_drop_off_address_type" "listings_application_drop_off_address_type_enum"` + ) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "listings" DROP COLUMN "application_drop_off_address_type"` + ) + await queryRunner.query(`DROP TYPE "listings_application_drop_off_address_type_enum"`) + await queryRunner.query(`ALTER TABLE "listings" DROP COLUMN "application_pick_up_address_type"`) + await queryRunner.query(`DROP TYPE "listings_application_pick_up_address_type_enum"`) + } +} diff --git a/backend/core/src/migration/1624985582782-application-drop-off-and-mailing-addresses.ts b/backend/core/src/migration/1624985582782-application-drop-off-and-mailing-addresses.ts new file mode 100644 index 0000000000..0269e47a40 --- /dev/null +++ b/backend/core/src/migration/1624985582782-application-drop-off-and-mailing-addresses.ts @@ -0,0 +1,23 @@ +import { MigrationInterface, QueryRunner } from "typeorm" + +export class applicationDropOffAndMailingAddresses1624985582782 implements MigrationInterface { + name = "applicationDropOffAndMailingAddresses1624985582782" + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "listings" ADD "application_mailing_address" jsonb`) + await queryRunner.query( + `ALTER TABLE "listings" ADD "application_drop_off_address_office_hours" text` + ) + await queryRunner.query(`ALTER TABLE "listings" ADD "application_drop_off_address" jsonb`) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "listings" DROP COLUMN "application_mailing_address"`) + await queryRunner.query( + `ALTER TABLE "listings" DROP COLUMN "application_drop_off_address_office_hours" text` + ) + await queryRunner.query( + `ALTER TABLE "listings" DROP COLUMN "application_drop_off_address" jsonb` + ) + } +} diff --git a/backend/core/src/migration/1625041988613-add-additional-notes-column-to-listing.ts b/backend/core/src/migration/1625041988613-add-additional-notes-column-to-listing.ts new file mode 100644 index 0000000000..21df72c2f7 --- /dev/null +++ b/backend/core/src/migration/1625041988613-add-additional-notes-column-to-listing.ts @@ -0,0 +1,17 @@ +import { MigrationInterface, QueryRunner } from "typeorm" + +export class addAdditionalNotesColumnToListing1625041988613 implements MigrationInterface { + name = "addAdditionalNotesColumnToListing1625041988613" + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "listings" ADD "additional_application_submission_notes" text` + ) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "listings" DROP COLUMN "additional_application_submission_notes"` + ) + } +} diff --git a/backend/core/src/migration/1625094786618-application-due-time.ts b/backend/core/src/migration/1625094786618-application-due-time.ts new file mode 100644 index 0000000000..7d794b1721 --- /dev/null +++ b/backend/core/src/migration/1625094786618-application-due-time.ts @@ -0,0 +1,14 @@ +import { MigrationInterface, QueryRunner } from "typeorm" + +export class applicationDueTime1625094786618 implements MigrationInterface { + name = "applicationDueTime1625094786618" + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "listings" ADD "application_due_time" TIMESTAMP WITH TIME ZONE` + ) + } + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "listings" DROP COLUMN "application_due_time"`) + } +} diff --git a/backend/core/src/migration/1625746856273-migrate-empty-strings-to-nulls-in-app.ts b/backend/core/src/migration/1625746856273-migrate-empty-strings-to-nulls-in-app.ts new file mode 100644 index 0000000000..2bd85ca5ad --- /dev/null +++ b/backend/core/src/migration/1625746856273-migrate-empty-strings-to-nulls-in-app.ts @@ -0,0 +1,13 @@ +import { MigrationInterface, QueryRunner } from "typeorm" + +export class migrateEmptyStringsToNullsInApp1625746856273 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`UPDATE applicant SET last_name = NULL where last_name = ''`) + await queryRunner.query(`UPDATE applicant SET first_name = NULL where first_name = ''`) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`UPDATE applicant SET first_name = '' where first_name = NULL`) + await queryRunner.query(`UPDATE applicant SET last_name = '' where last_name = NULL`) + } +} diff --git a/backend/core/src/migration/1625825502154-refactor-unit-types-rent-types-and-priority-types.ts b/backend/core/src/migration/1625825502154-refactor-unit-types-rent-types-and-priority-types.ts new file mode 100644 index 0000000000..9753a5b278 --- /dev/null +++ b/backend/core/src/migration/1625825502154-refactor-unit-types-rent-types-and-priority-types.ts @@ -0,0 +1,116 @@ +import { MigrationInterface, QueryRunner } from "typeorm" + +export class refactorUnitTypesRentTypesAndPriorityTypes1625825502154 implements MigrationInterface { + name = "refactorUnitTypesRentTypesAndPriorityTypes1625825502154" + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "unit_types" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "created_at" TIMESTAMP NOT NULL DEFAULT now(), "updated_at" TIMESTAMP NOT NULL DEFAULT now(), "name" text NOT NULL, CONSTRAINT "PK_105c42fcf447c1da21fd20bcb85" PRIMARY KEY ("id"))` + ) + await queryRunner.query(`ALTER TABLE "units" ADD "unit_type_id" uuid`) + await queryRunner.query( + `ALTER TABLE "units" ADD CONSTRAINT "FK_1e193f5ffdda908517e47d4e021" FOREIGN KEY ("unit_type_id") REFERENCES "unit_types"("id") ON DELETE NO ACTION ON UPDATE NO ACTION` + ) + + const unitTypeSeeds = ["studio", "oneBdrm", "twoBdrm", "threeBdrm", "fourBdrm"] + const existingUnitTypes = (await queryRunner.query(`SELECT DISTINCT unit_type FROM units`)).map( + (result) => result.unit_type + ) + const unitTypesSet = new Set() + for (const unitType of [...unitTypeSeeds, ...existingUnitTypes]) { + unitTypesSet.add(unitType) + } + for (const unitType of unitTypesSet.keys()) { + const [ + newUnitType, + ] = await queryRunner.query(`INSERT INTO "unit_types" (name) VALUES ($1) RETURNING id`, [ + unitType, + ]) + const unitsToBeUpdated = await queryRunner.query( + `SELECT id FROM units where unit_type = ($1)`, + [unitType] + ) + for (const unit of unitsToBeUpdated) { + await queryRunner.query(`UPDATE units SET unit_type_id = ($1) WHERE id = ($2)`, [ + newUnitType.id, + unit.id, + ]) + } + } + await queryRunner.query(`ALTER TABLE "units" DROP COLUMN "unit_type"`) + + await queryRunner.query( + `CREATE TABLE "unit_accessibility_priority_types" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "created_at" TIMESTAMP NOT NULL DEFAULT now(), "updated_at" TIMESTAMP NOT NULL DEFAULT now(), "name" text NOT NULL, CONSTRAINT "PK_2cf31d2ceea36e6a6b970608565" PRIMARY KEY ("id"))` + ) + await queryRunner.query(`ALTER TABLE "units" ADD "priority_type_id" uuid`) + await queryRunner.query( + `ALTER TABLE "units" ADD CONSTRAINT "FK_6981f323d01ba8d55190480078d" FOREIGN KEY ("priority_type_id") REFERENCES "unit_accessibility_priority_types"("id") ON DELETE NO ACTION ON UPDATE NO ACTION` + ) + + const accessibilityPriorityTypeSeeds = [ + "Mobility", + "Hearing", + "Visual", + "Hearing and Visual", + "Mobility and Hearing", + "Mobility and Visual", + "Mobility, Hearing and Visual", + ] + const existingAccessibilityPriorityTypes = ( + await queryRunner.query(`SELECT DISTINCT priority_type FROM units`) + ) + .map((result) => result.priority_type) + .filter((result) => result !== null) + const accessibilityPriorityTypeSet = new Set() + for (const accessibilityPriorityType of [ + ...accessibilityPriorityTypeSeeds, + ...existingAccessibilityPriorityTypes, + ]) { + accessibilityPriorityTypeSet.add(accessibilityPriorityType) + } + for (const accessibilityPriorityType of accessibilityPriorityTypeSet.keys()) { + const [ + newAccessibilityPriorityType, + ] = await queryRunner.query( + `INSERT INTO "unit_accessibility_priority_types" (name) VALUES ($1) RETURNING id`, + [accessibilityPriorityType] + ) + const unitsToBeUpdated = await queryRunner.query( + `SELECT id FROM units where priority_type = ($1)`, + [accessibilityPriorityType] + ) + for (const unit of unitsToBeUpdated) { + await queryRunner.query(`UPDATE units SET priority_type_id = ($1) WHERE id = ($2)`, [ + newAccessibilityPriorityType.id, + unit.id, + ]) + } + } + await queryRunner.query(`ALTER TABLE "units" DROP COLUMN "priority_type"`) + + await queryRunner.query( + `CREATE TABLE "unit_rent_types" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "created_at" TIMESTAMP NOT NULL DEFAULT now(), "updated_at" TIMESTAMP NOT NULL DEFAULT now(), "name" text NOT NULL, CONSTRAINT "PK_fb6b318fdee0a5b30521f63c516" PRIMARY KEY ("id"))` + ) + await queryRunner.query(`ALTER TABLE "units" ADD "unit_rent_type_id" uuid`) + await queryRunner.query( + `ALTER TABLE "units" ADD CONSTRAINT "FK_ff9559bf9a1daecef4a89bad4a9" FOREIGN KEY ("unit_rent_type_id") REFERENCES "unit_rent_types"("id") ON DELETE NO ACTION ON UPDATE NO ACTION` + ) + for (const unitRentType of ["fixed", "percentageOfIncome"]) { + await queryRunner.query(`INSERT INTO "unit_rent_types" (name) VALUES ($1)`, [unitRentType]) + } + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "units" DROP CONSTRAINT "FK_6981f323d01ba8d55190480078d"`) + await queryRunner.query(`ALTER TABLE "units" DROP CONSTRAINT "FK_ff9559bf9a1daecef4a89bad4a9"`) + await queryRunner.query(`ALTER TABLE "units" DROP CONSTRAINT "FK_1e193f5ffdda908517e47d4e021"`) + await queryRunner.query(`ALTER TABLE "units" DROP COLUMN "priority_type_id"`) + await queryRunner.query(`ALTER TABLE "units" DROP COLUMN "unit_rent_type_id"`) + await queryRunner.query(`ALTER TABLE "units" DROP COLUMN "unit_type_id"`) + await queryRunner.query(`ALTER TABLE "units" ADD "unit_type" text`) + await queryRunner.query(`ALTER TABLE "units" ADD "priority_type" text`) + await queryRunner.query(`DROP TABLE "unit_accessibility_priority_types"`) + await queryRunner.query(`DROP TABLE "unit_rent_types"`) + await queryRunner.query(`DROP TABLE "unit_types"`) + } +} diff --git a/backend/core/src/migration/1626207809474-unit-status-enum.ts b/backend/core/src/migration/1626207809474-unit-status-enum.ts new file mode 100644 index 0000000000..5bbc25fc49 --- /dev/null +++ b/backend/core/src/migration/1626207809474-unit-status-enum.ts @@ -0,0 +1,19 @@ +import { MigrationInterface, QueryRunner } from "typeorm" + +export class unitStatusEnum1626207809474 implements MigrationInterface { + name = "unitStatusEnum1626207809474" + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TYPE "units_status_enum" AS ENUM('unknown', 'available', 'occupied', 'unavailable')` + ) + await queryRunner.query( + `ALTER TABLE "units" ALTER COLUMN "status" TYPE "units_status_enum" using "status"::"units_status_enum"` + ) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "units" ALTER COLUMN "status" TYPE text`) + await queryRunner.query(`DROP TYPE "units_status_enum"`) + } +} diff --git a/backend/core/src/migration/1626258763008-convert-listing-addresses-jsonbs-to-tables.ts b/backend/core/src/migration/1626258763008-convert-listing-addresses-jsonbs-to-tables.ts new file mode 100644 index 0000000000..37e73eef5f --- /dev/null +++ b/backend/core/src/migration/1626258763008-convert-listing-addresses-jsonbs-to-tables.ts @@ -0,0 +1,103 @@ +import { MigrationInterface, QueryRunner } from "typeorm" +import { Address } from "../shared/entities/address.entity" + +export class convertListingAddressesJsonbsToTables1626258763008 implements MigrationInterface { + name = "convertListingAddressesJsonbsToTables1626258763008" + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "listings" ADD "application_address_id" uuid`) + await queryRunner.query(`ALTER TABLE "listings" ADD "application_pick_up_address_id" uuid`) + await queryRunner.query(`ALTER TABLE "listings" ADD "application_drop_off_address_id" uuid`) + await queryRunner.query(`ALTER TABLE "listings" ADD "application_mailing_address_id" uuid`) + await queryRunner.query(`ALTER TABLE "listings" ADD "leasing_agent_address_id" uuid`) + + const listings = await queryRunner.query( + `SELECT id, application_address, leasing_agent_address, application_pick_up_address, application_mailing_address, application_drop_off_address FROM listings` + ) + for (const listing of listings) { + const addressKeys = [ + "application_address", + "leasing_agent_address", + "application_pick_up_address", + "application_mailing_address", + "application_drop_off_address", + ] + for (const addressKey of addressKeys) { + if (listing[addressKey]) { + const addr = listing[addressKey] as Address + const [ + addrId, + ] = await queryRunner.query( + `INSERT INTO "address" (place_name, city, county, state, street, street2, zip_code, latitude, longitude) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING id`, + [ + addr.placeName, + addr.city, + addr.county, + addr.state, + addr.street, + addr.street2, + addr.zipCode, + addr.latitude, + addr.longitude, + ] + ) + await queryRunner.query(`UPDATE listings SET ${addressKey}_id = ($1) WHERE id = ($2)`, [ + addrId.id, + listing.id, + ]) + } + } + } + + await queryRunner.query(`ALTER TABLE "listings" DROP COLUMN "application_address"`) + await queryRunner.query(`ALTER TABLE "listings" DROP COLUMN "leasing_agent_address"`) + await queryRunner.query(`ALTER TABLE "listings" DROP COLUMN "application_pick_up_address"`) + await queryRunner.query(`ALTER TABLE "listings" DROP COLUMN "application_mailing_address"`) + await queryRunner.query(`ALTER TABLE "listings" DROP COLUMN "application_drop_off_address"`) + await queryRunner.query(`ALTER TABLE "listings" ALTER COLUMN "name" SET NOT NULL`) + await queryRunner.query( + `ALTER TABLE "listings" ADD CONSTRAINT "FK_42385e47be1780d1491f0c8c1c3" FOREIGN KEY ("application_address_id") REFERENCES "address"("id") ON DELETE NO ACTION ON UPDATE NO ACTION` + ) + await queryRunner.query( + `ALTER TABLE "listings" ADD CONSTRAINT "FK_d54596fd877e83a3126d3953f36" FOREIGN KEY ("application_pick_up_address_id") REFERENCES "address"("id") ON DELETE NO ACTION ON UPDATE NO ACTION` + ) + await queryRunner.query( + `ALTER TABLE "listings" ADD CONSTRAINT "FK_17e861d96c1bde13c1f4c344cb6" FOREIGN KEY ("application_drop_off_address_id") REFERENCES "address"("id") ON DELETE NO ACTION ON UPDATE NO ACTION` + ) + await queryRunner.query( + `ALTER TABLE "listings" ADD CONSTRAINT "FK_7cedb0a800e3c0af7ede27ab1ec" FOREIGN KEY ("application_mailing_address_id") REFERENCES "address"("id") ON DELETE NO ACTION ON UPDATE NO ACTION` + ) + await queryRunner.query( + `ALTER TABLE "listings" ADD CONSTRAINT "FK_8a93cc462d190d3f1a04fa69156" FOREIGN KEY ("leasing_agent_address_id") REFERENCES "address"("id") ON DELETE NO ACTION ON UPDATE NO ACTION` + ) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "listings" DROP CONSTRAINT "FK_8a93cc462d190d3f1a04fa69156"` + ) + await queryRunner.query( + `ALTER TABLE "listings" DROP CONSTRAINT "FK_7cedb0a800e3c0af7ede27ab1ec"` + ) + await queryRunner.query( + `ALTER TABLE "listings" DROP CONSTRAINT "FK_17e861d96c1bde13c1f4c344cb6"` + ) + await queryRunner.query( + `ALTER TABLE "listings" DROP CONSTRAINT "FK_d54596fd877e83a3126d3953f36"` + ) + await queryRunner.query( + `ALTER TABLE "listings" DROP CONSTRAINT "FK_42385e47be1780d1491f0c8c1c3"` + ) + await queryRunner.query(`ALTER TABLE "listings" ALTER COLUMN "name" DROP NOT NULL`) + await queryRunner.query(`ALTER TABLE "listings" DROP COLUMN "leasing_agent_address_id"`) + await queryRunner.query(`ALTER TABLE "listings" DROP COLUMN "application_mailing_address_id"`) + await queryRunner.query(`ALTER TABLE "listings" DROP COLUMN "application_drop_off_address_id"`) + await queryRunner.query(`ALTER TABLE "listings" DROP COLUMN "application_pick_up_address_id"`) + await queryRunner.query(`ALTER TABLE "listings" DROP COLUMN "application_address_id"`) + await queryRunner.query(`ALTER TABLE "listings" ADD "application_drop_off_address" jsonb`) + await queryRunner.query(`ALTER TABLE "listings" ADD "application_mailing_address" jsonb`) + await queryRunner.query(`ALTER TABLE "listings" ADD "application_pick_up_address" jsonb`) + await queryRunner.query(`ALTER TABLE "listings" ADD "leasing_agent_address" jsonb`) + await queryRunner.query(`ALTER TABLE "listings" ADD "application_address" jsonb`) + } +} diff --git a/backend/core/src/migration/1626268250228-recreate-foreign-key-constraints.ts b/backend/core/src/migration/1626268250228-recreate-foreign-key-constraints.ts new file mode 100644 index 0000000000..b282363e8e --- /dev/null +++ b/backend/core/src/migration/1626268250228-recreate-foreign-key-constraints.ts @@ -0,0 +1,85 @@ +import { MigrationInterface, QueryRunner } from "typeorm" + +export class recreateForeignKeyConstraints1626268250228 implements MigrationInterface { + name = "recreateForeignKeyConstraints1626268250228" + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "application_flagged_set_applications_applications" DROP CONSTRAINT "FK_bbae218ba0eff977157fad5ea31"` + ) + await queryRunner.query( + `ALTER TABLE "application_flagged_set_applications_applications" DROP CONSTRAINT "FK_93f583f2d43fb21c5d7ceac57e7"` + ) + await queryRunner.query( + `ALTER TABLE "listings_leasing_agents_user_accounts" DROP CONSTRAINT "FK_de53131bc8a08f824a5d3dd51e3"` + ) + await queryRunner.query( + `ALTER TABLE "listings_leasing_agents_user_accounts" DROP CONSTRAINT "FK_f7b22af2c421e823f60c5f7d28b"` + ) + await queryRunner.query( + `ALTER TABLE "property_group_properties_property" DROP CONSTRAINT "FK_84e6a1949911510df0eff691f0d"` + ) + await queryRunner.query( + `ALTER TABLE "property_group_properties_property" DROP CONSTRAINT "FK_c99e75ee805d56fea44bf2970f2"` + ) + await queryRunner.query(`ALTER TABLE "listings" ALTER COLUMN "name" SET NOT NULL`) + await queryRunner.query( + `ALTER TABLE "application_flagged_set_applications_applications" ADD CONSTRAINT "FK_93f583f2d43fb21c5d7ceac57e7" FOREIGN KEY ("application_flagged_set_id") REFERENCES "application_flagged_set"("id") ON DELETE CASCADE ON UPDATE CASCADE` + ) + await queryRunner.query( + `ALTER TABLE "application_flagged_set_applications_applications" ADD CONSTRAINT "FK_bbae218ba0eff977157fad5ea31" FOREIGN KEY ("applications_id") REFERENCES "applications"("id") ON DELETE CASCADE ON UPDATE CASCADE` + ) + await queryRunner.query( + `ALTER TABLE "listings_leasing_agents_user_accounts" ADD CONSTRAINT "FK_f7b22af2c421e823f60c5f7d28b" FOREIGN KEY ("listings_id") REFERENCES "listings"("id") ON DELETE CASCADE ON UPDATE CASCADE` + ) + await queryRunner.query( + `ALTER TABLE "listings_leasing_agents_user_accounts" ADD CONSTRAINT "FK_de53131bc8a08f824a5d3dd51e3" FOREIGN KEY ("user_accounts_id") REFERENCES "user_accounts"("id") ON DELETE NO ACTION ON UPDATE NO ACTION` + ) + await queryRunner.query( + `ALTER TABLE "property_group_properties_property" ADD CONSTRAINT "FK_84e6a1949911510df0eff691f0d" FOREIGN KEY ("property_group_id") REFERENCES "property_group"("id") ON DELETE CASCADE ON UPDATE CASCADE` + ) + await queryRunner.query( + `ALTER TABLE "property_group_properties_property" ADD CONSTRAINT "FK_c99e75ee805d56fea44bf2970f2" FOREIGN KEY ("property_id") REFERENCES "property"("id") ON DELETE CASCADE ON UPDATE CASCADE` + ) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "property_group_properties_property" DROP CONSTRAINT "FK_c99e75ee805d56fea44bf2970f2"` + ) + await queryRunner.query( + `ALTER TABLE "property_group_properties_property" DROP CONSTRAINT "FK_84e6a1949911510df0eff691f0d"` + ) + await queryRunner.query( + `ALTER TABLE "listings_leasing_agents_user_accounts" DROP CONSTRAINT "FK_de53131bc8a08f824a5d3dd51e3"` + ) + await queryRunner.query( + `ALTER TABLE "listings_leasing_agents_user_accounts" DROP CONSTRAINT "FK_f7b22af2c421e823f60c5f7d28b"` + ) + await queryRunner.query( + `ALTER TABLE "application_flagged_set_applications_applications" DROP CONSTRAINT "FK_bbae218ba0eff977157fad5ea31"` + ) + await queryRunner.query( + `ALTER TABLE "application_flagged_set_applications_applications" DROP CONSTRAINT "FK_93f583f2d43fb21c5d7ceac57e7"` + ) + await queryRunner.query(`ALTER TABLE "listings" ALTER COLUMN "name" DROP NOT NULL`) + await queryRunner.query( + `ALTER TABLE "property_group_properties_property" ADD CONSTRAINT "FK_c99e75ee805d56fea44bf2970f2" FOREIGN KEY ("property_id") REFERENCES "property"("id") ON DELETE CASCADE ON UPDATE NO ACTION` + ) + await queryRunner.query( + `ALTER TABLE "property_group_properties_property" ADD CONSTRAINT "FK_84e6a1949911510df0eff691f0d" FOREIGN KEY ("property_group_id") REFERENCES "property_group"("id") ON DELETE CASCADE ON UPDATE NO ACTION` + ) + await queryRunner.query( + `ALTER TABLE "listings_leasing_agents_user_accounts" ADD CONSTRAINT "FK_f7b22af2c421e823f60c5f7d28b" FOREIGN KEY ("listings_id") REFERENCES "listings"("id") ON DELETE CASCADE ON UPDATE NO ACTION` + ) + await queryRunner.query( + `ALTER TABLE "listings_leasing_agents_user_accounts" ADD CONSTRAINT "FK_de53131bc8a08f824a5d3dd51e3" FOREIGN KEY ("user_accounts_id") REFERENCES "user_accounts"("id") ON DELETE CASCADE ON UPDATE NO ACTION` + ) + await queryRunner.query( + `ALTER TABLE "application_flagged_set_applications_applications" ADD CONSTRAINT "FK_93f583f2d43fb21c5d7ceac57e7" FOREIGN KEY ("application_flagged_set_id") REFERENCES "application_flagged_set"("id") ON DELETE CASCADE ON UPDATE NO ACTION` + ) + await queryRunner.query( + `ALTER TABLE "application_flagged_set_applications_applications" ADD CONSTRAINT "FK_bbae218ba0eff977157fad5ea31" FOREIGN KEY ("applications_id") REFERENCES "applications"("id") ON DELETE CASCADE ON UPDATE NO ACTION` + ) + } +} diff --git a/backend/core/src/migration/1626271269291-convert-listing-events-jsonb-to-table.ts b/backend/core/src/migration/1626271269291-convert-listing-events-jsonb-to-table.ts new file mode 100644 index 0000000000..8fad57db0e --- /dev/null +++ b/backend/core/src/migration/1626271269291-convert-listing-events-jsonb-to-table.ts @@ -0,0 +1,82 @@ +import { MigrationInterface, QueryRunner } from "typeorm" +import { ListingEvent } from "../listings/entities/listing-event.entity" + +export class convertListingEventsJsonbToTable1626271269291 implements MigrationInterface { + name = "convertListingEventsJsonbToTable1626271269291" + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "listing_events" DROP CONSTRAINT "FK_d0b9892bc613e4d9f8b5c25d03e"` + ) + await queryRunner.query(`ALTER TABLE "listing_events" ADD "label" text`) + await queryRunner.query( + `ALTER TYPE "listing_events_type_enum" RENAME TO "listing_events_type_enum_old"` + ) + await queryRunner.query( + `CREATE TYPE "listing_events_type_enum" AS ENUM('openHouse', 'publicLottery', 'lotteryResults')` + ) + await queryRunner.query( + `ALTER TABLE "listing_events" ALTER COLUMN "type" TYPE "listing_events_type_enum" USING "type"::"text"::"listing_events_type_enum"` + ) + await queryRunner.query(`DROP TYPE "listing_events_type_enum_old"`) + await queryRunner.query(`ALTER TABLE "listing_events" DROP COLUMN "start_time"`) + await queryRunner.query( + `ALTER TABLE "listing_events" ADD "start_time" TIMESTAMP WITH TIME ZONE` + ) + await queryRunner.query(`ALTER TABLE "listing_events" DROP COLUMN "end_time"`) + await queryRunner.query(`ALTER TABLE "listing_events" ADD "end_time" TIMESTAMP WITH TIME ZONE`) + await queryRunner.query(`ALTER TABLE "listings" ALTER COLUMN "name" SET NOT NULL`) + await queryRunner.query( + `ALTER TABLE "listing_events" ADD CONSTRAINT "FK_d0b9892bc613e4d9f8b5c25d03e" FOREIGN KEY ("listing_id") REFERENCES "listings"("id") ON DELETE NO ACTION ON UPDATE NO ACTION` + ) + + const listings = await queryRunner.query(`SELECT id, events FROM listings`) + for (const listing of listings) { + if (listing.events && listing.events.length) { + const events = listing.events as Array + for (const event of events) { + await queryRunner.query( + `INSERT INTO "listing_events" (type, url, note, listing_id, label, start_time, end_time) VALUES ($1, $2, $3, $4, $5, $6, $7)`, + [ + event.type, + event.url, + event.note, + listing.id, + event.label, + event.startTime, + event.endTime, + ] + ) + } + } + } + + await queryRunner.query(`ALTER TABLE "listings" DROP COLUMN "events"`) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "listing_events" DROP CONSTRAINT "FK_d0b9892bc613e4d9f8b5c25d03e"` + ) + await queryRunner.query(`ALTER TABLE "listings" ALTER COLUMN "name" DROP NOT NULL`) + await queryRunner.query(`ALTER TABLE "listing_events" DROP COLUMN "end_time"`) + await queryRunner.query(`ALTER TABLE "listing_events" ADD "end_time" TIMESTAMP NOT NULL`) + await queryRunner.query(`ALTER TABLE "listing_events" DROP COLUMN "start_time"`) + await queryRunner.query(`ALTER TABLE "listing_events" ADD "start_time" TIMESTAMP NOT NULL`) + await queryRunner.query( + `CREATE TYPE "listing_events_type_enum_old" AS ENUM('openHouse', 'publicLottery')` + ) + await queryRunner.query( + `ALTER TABLE "listing_events" ALTER COLUMN "type" TYPE "listing_events_type_enum_old" USING "type"::"text"::"listing_events_type_enum_old"` + ) + await queryRunner.query(`DROP TYPE "listing_events_type_enum"`) + await queryRunner.query( + `ALTER TYPE "listing_events_type_enum_old" RENAME TO "listing_events_type_enum"` + ) + await queryRunner.query(`ALTER TABLE "listing_events" DROP COLUMN "label"`) + await queryRunner.query(`ALTER TABLE "listings" ADD "events" jsonb NOT NULL`) + await queryRunner.query( + `ALTER TABLE "listing_events" ADD CONSTRAINT "FK_d0b9892bc613e4d9f8b5c25d03e" FOREIGN KEY ("listing_id") REFERENCES "listings"("id") ON DELETE CASCADE ON UPDATE CASCADE` + ) + } +} diff --git a/backend/core/src/migration/1626352944366-add-listing-event-relation-to-assets.ts b/backend/core/src/migration/1626352944366-add-listing-event-relation-to-assets.ts new file mode 100644 index 0000000000..344f8c733e --- /dev/null +++ b/backend/core/src/migration/1626352944366-add-listing-event-relation-to-assets.ts @@ -0,0 +1,19 @@ +import { MigrationInterface, QueryRunner } from "typeorm" + +export class addListingEventRelationToAssets1626352944366 implements MigrationInterface { + name = "addListingEventRelationToAssets1626352944366" + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "listing_events" ADD "file_id" uuid`) + await queryRunner.query( + `ALTER TABLE "listing_events" ADD CONSTRAINT "FK_4fd176b179ce281bedb1b7b9f2b" FOREIGN KEY ("file_id") REFERENCES "assets"("id") ON DELETE NO ACTION ON UPDATE NO ACTION` + ) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "listing_events" DROP CONSTRAINT "FK_4fd176b179ce281bedb1b7b9f2b"` + ) + await queryRunner.query(`ALTER TABLE "listing_events" DROP COLUMN "file_id"`) + } +} diff --git a/backend/core/src/migration/1626703818188-change-applications-methods-from-jsonb-to-separate-table.ts b/backend/core/src/migration/1626703818188-change-applications-methods-from-jsonb-to-separate-table.ts new file mode 100644 index 0000000000..10ab56d466 --- /dev/null +++ b/backend/core/src/migration/1626703818188-change-applications-methods-from-jsonb-to-separate-table.ts @@ -0,0 +1,57 @@ +import { MigrationInterface, QueryRunner } from "typeorm" + +export class changeApplicationsMethodsFromJsonbToSeparateTable1626703818188 + implements MigrationInterface { + name = "changeApplicationsMethodsFromJsonbToSeparateTable1626703818188" + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "application_methods" ADD "file_id" uuid`) + await queryRunner.query( + `ALTER TYPE "application_methods_type_enum" RENAME TO "application_methods_type_enum_old"` + ) + await queryRunner.query( + `CREATE TYPE "application_methods_type_enum" AS ENUM('Internal', 'FileDownload', 'ExternalLink', 'PaperPickup', 'POBox', 'LeasingAgent')` + ) + await queryRunner.query( + `ALTER TABLE "application_methods" ALTER COLUMN "type" TYPE "application_methods_type_enum" USING "type"::"text"::"application_methods_type_enum"` + ) + await queryRunner.query(`DROP TYPE "application_methods_type_enum_old"`) + await queryRunner.query( + `ALTER TABLE "application_methods" ADD CONSTRAINT "FK_b629c3b2549f33a911bcc84b65b" FOREIGN KEY ("file_id") REFERENCES "assets"("id") ON DELETE NO ACTION ON UPDATE NO ACTION` + ) + const listings = await queryRunner.query(`SELECT id, application_methods FROM listings`) + for (const listing of listings) { + for (const applicationMethod of listing.application_methods) { + await queryRunner.query( + `INSERT INTO application_methods (type, label, external_reference, accepts_postmarked_applications, listing_id) VALUES ($1, $2, $3, $4, $5)`, + [ + applicationMethod.type, + applicationMethod.label, + applicationMethod.externalReference, + applicationMethod.acceptsPostmarkedApplications, + listing.id, + ] + ) + } + } + await queryRunner.query(`ALTER TABLE "listings" DROP COLUMN "application_methods"`) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "application_methods" DROP CONSTRAINT "FK_b629c3b2549f33a911bcc84b65b"` + ) + await queryRunner.query( + `CREATE TYPE "application_methods_type_enum_old" AS ENUM('Internal', 'FileDownload', 'ExternalLink', 'PaperPickup', 'POBox', 'LeasingAgent')` + ) + await queryRunner.query( + `ALTER TABLE "application_methods" ALTER COLUMN "type" TYPE "application_methods_type_enum_old" USING "type"::"text"::"application_methods_type_enum_old"` + ) + await queryRunner.query(`DROP TYPE "application_methods_type_enum"`) + await queryRunner.query( + `ALTER TYPE "application_methods_type_enum_old" RENAME TO "application_methods_type_enum"` + ) + await queryRunner.query(`ALTER TABLE "application_methods" DROP COLUMN "file_id"`) + await queryRunner.query(`ALTER TABLE "listings" ADD "application_methods" jsonb NOT NULL`) + } +} diff --git a/backend/core/src/migration/1626785750395-add-paper-applications.ts b/backend/core/src/migration/1626785750395-add-paper-applications.ts new file mode 100644 index 0000000000..f20da65ae8 --- /dev/null +++ b/backend/core/src/migration/1626785750395-add-paper-applications.ts @@ -0,0 +1,45 @@ +import { MigrationInterface, QueryRunner } from "typeorm" + +export class addPaperApplications1626785750395 implements MigrationInterface { + name = "addPaperApplications1626785750395" + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "application_methods" DROP CONSTRAINT "FK_b629c3b2549f33a911bcc84b65b"` + ) + await queryRunner.query( + `ALTER TABLE "application_methods" RENAME COLUMN "file_id" TO "phone_number"` + ) + await queryRunner.query( + `CREATE TABLE "paper_applications" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "created_at" TIMESTAMP NOT NULL DEFAULT now(), "updated_at" TIMESTAMP NOT NULL DEFAULT now(), "language" character varying NOT NULL, "file_id" uuid, "application_method_id" uuid, CONSTRAINT "PK_1bc5b0234d874ec03f500621d43" PRIMARY KEY ("id"))` + ) + await queryRunner.query(`ALTER TABLE "application_methods" DROP COLUMN "phone_number"`) + await queryRunner.query(`ALTER TABLE "application_methods" ADD "phone_number" text`) + await queryRunner.query(`ALTER TABLE "listings" ALTER COLUMN "name" SET NOT NULL`) + await queryRunner.query( + `ALTER TABLE "paper_applications" ADD CONSTRAINT "FK_493291d04c708dda2ffe5b521e7" FOREIGN KEY ("file_id") REFERENCES "assets"("id") ON DELETE NO ACTION ON UPDATE NO ACTION` + ) + await queryRunner.query( + `ALTER TABLE "paper_applications" ADD CONSTRAINT "FK_bd67da96ae3e2c0e37394ba1dd3" FOREIGN KEY ("application_method_id") REFERENCES "application_methods"("id") ON DELETE NO ACTION ON UPDATE NO ACTION` + ) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "paper_applications" DROP CONSTRAINT "FK_bd67da96ae3e2c0e37394ba1dd3"` + ) + await queryRunner.query( + `ALTER TABLE "paper_applications" DROP CONSTRAINT "FK_493291d04c708dda2ffe5b521e7"` + ) + await queryRunner.query(`ALTER TABLE "listings" ALTER COLUMN "name" DROP NOT NULL`) + await queryRunner.query(`ALTER TABLE "application_methods" DROP COLUMN "phone_number"`) + await queryRunner.query(`ALTER TABLE "application_methods" ADD "phone_number" uuid`) + await queryRunner.query(`DROP TABLE "paper_applications"`) + await queryRunner.query( + `ALTER TABLE "application_methods" RENAME COLUMN "phone_number" TO "file_id"` + ) + await queryRunner.query( + `ALTER TABLE "application_methods" ADD CONSTRAINT "FK_b629c3b2549f33a911bcc84b65b" FOREIGN KEY ("file_id") REFERENCES "assets"("id") ON DELETE NO ACTION ON UPDATE NO ACTION` + ) + } +} diff --git a/backend/core/src/migration/1626804878532-reserved-community-description.ts b/backend/core/src/migration/1626804878532-reserved-community-description.ts new file mode 100644 index 0000000000..e57186f926 --- /dev/null +++ b/backend/core/src/migration/1626804878532-reserved-community-description.ts @@ -0,0 +1,13 @@ +import { MigrationInterface, QueryRunner } from "typeorm" + +export class reservedCommunityDescription1626804878532 implements MigrationInterface { + name = "reservedCommunityDescription1626804878532" + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "listings" ADD "reserved_community_description" text`) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "listings" DROP COLUMN "reserved_community_description"`) + } +} diff --git a/backend/core/src/migration/1626897520264-remove-reserved-type-from-units.ts b/backend/core/src/migration/1626897520264-remove-reserved-type-from-units.ts new file mode 100644 index 0000000000..a013005464 --- /dev/null +++ b/backend/core/src/migration/1626897520264-remove-reserved-type-from-units.ts @@ -0,0 +1,13 @@ +import { MigrationInterface, QueryRunner } from "typeorm" + +export class removeReservedTypeFromUnits1626897520264 implements MigrationInterface { + name = "removeReservedTypeFromUnits1626897520264" + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "units" DROP COLUMN "reserved_type"`) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "units" ADD "reserved_type" text`) + } +} diff --git a/backend/core/src/migration/1627511769797-add custom map pin field.ts b/backend/core/src/migration/1627511769797-add custom map pin field.ts new file mode 100644 index 0000000000..4922f031b7 --- /dev/null +++ b/backend/core/src/migration/1627511769797-add custom map pin field.ts @@ -0,0 +1,13 @@ +import { MigrationInterface, QueryRunner } from "typeorm" + +export class addCustomMapPinField1627511769797 implements MigrationInterface { + name = "addCustomMapPinField1627511769797" + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "listings" ADD "custom_map_pin" boolean`) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "listings" DROP COLUMN "custom_map_pin"`) + } +} diff --git a/backend/core/src/migration/1628017712683-addListingAndPropertyFields.ts b/backend/core/src/migration/1628017712683-addListingAndPropertyFields.ts new file mode 100644 index 0000000000..37997c0c56 --- /dev/null +++ b/backend/core/src/migration/1628017712683-addListingAndPropertyFields.ts @@ -0,0 +1,27 @@ +import { MigrationInterface, QueryRunner } from "typeorm" + +export class addListingAndPropertyFields1628017712683 implements MigrationInterface { + name = "addListingAndPropertyFields1628017712683" + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "property" ADD "region" text`) + await queryRunner.query(`ALTER TABLE "property" ADD "phone_number" text`) + await queryRunner.query(`ALTER TABLE "listings" ADD "hrd_id" text`) + await queryRunner.query(`ALTER TABLE "listings" ADD "owner_company" text`) + await queryRunner.query(`ALTER TABLE "listings" ADD "management_company" text`) + await queryRunner.query(`ALTER TABLE "listings" ADD "management_website" text`) + await queryRunner.query(`ALTER TABLE "listings" ADD "ami_percentage_min" integer`) + await queryRunner.query(`ALTER TABLE "listings" ADD "ami_percentage_max" integer`) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "listings" DROP COLUMN "ami_percentage_max"`) + await queryRunner.query(`ALTER TABLE "listings" DROP COLUMN "ami_percentage_min"`) + await queryRunner.query(`ALTER TABLE "listings" DROP COLUMN "management_website"`) + await queryRunner.query(`ALTER TABLE "listings" DROP COLUMN "management_company"`) + await queryRunner.query(`ALTER TABLE "listings" DROP COLUMN "owner_company"`) + await queryRunner.query(`ALTER TABLE "listings" DROP COLUMN "hrd_id"`) + await queryRunner.query(`ALTER TABLE "property" DROP COLUMN "phone_number"`) + await queryRunner.query(`ALTER TABLE "property" DROP COLUMN "region"`) + } +} diff --git a/backend/core/src/migration/1628022449780-units-summary.ts b/backend/core/src/migration/1628022449780-units-summary.ts new file mode 100644 index 0000000000..b129e41e9c --- /dev/null +++ b/backend/core/src/migration/1628022449780-units-summary.ts @@ -0,0 +1,33 @@ +import { MigrationInterface, QueryRunner } from "typeorm" + +export class unitsSummary1628022449780 implements MigrationInterface { + name = "unitsSummary1628022449780" + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "units_summary" ("monthly_rent" character varying NOT NULL, "monthly_rent_as_percent_of_income" numeric(8,2), "ami_percentage" text, "minimum_income_min" text, "minimum_income_max" text, "max_occupancy" integer, "min_occupancy" integer, "floor_min" integer, "floor_max" integer, "sq_feet_min" numeric(8,2), "sq_feet_max" numeric(8,2), "total_count" integer, "total_available" integer, "unit_type_id" uuid NOT NULL, "property_id" uuid NOT NULL, "priority_type_id" uuid, CONSTRAINT "PK_dd5b004243c1536a412e425a9ec" PRIMARY KEY ("monthly_rent", "unit_type_id", "property_id"))` + ) + await queryRunner.query( + `ALTER TABLE "units_summary" ADD CONSTRAINT "FK_0eae6ec11a6109496d80d9a88f9" FOREIGN KEY ("unit_type_id") REFERENCES "unit_types"("id") ON DELETE NO ACTION ON UPDATE NO ACTION` + ) + await queryRunner.query( + `ALTER TABLE "units_summary" ADD CONSTRAINT "FK_a2b6519fc3d102d4611a0e2b879" FOREIGN KEY ("property_id") REFERENCES "property"("id") ON DELETE NO ACTION ON UPDATE NO ACTION` + ) + await queryRunner.query( + `ALTER TABLE "units_summary" ADD CONSTRAINT "FK_4791099ef82551aa9819a71d8f5" FOREIGN KEY ("priority_type_id") REFERENCES "unit_accessibility_priority_types"("id") ON DELETE NO ACTION ON UPDATE NO ACTION` + ) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "units_summary" DROP CONSTRAINT "FK_4791099ef82551aa9819a71d8f5"` + ) + await queryRunner.query( + `ALTER TABLE "units_summary" DROP CONSTRAINT "FK_a2b6519fc3d102d4611a0e2b879"` + ) + await queryRunner.query( + `ALTER TABLE "units_summary" DROP CONSTRAINT "FK_0eae6ec11a6109496d80d9a88f9"` + ) + await queryRunner.query(`DROP TABLE "units_summary"`) + } +} diff --git a/backend/core/src/migration/1628543278484-partner-role.ts b/backend/core/src/migration/1628543278484-partner-role.ts new file mode 100644 index 0000000000..51d4c3f0df --- /dev/null +++ b/backend/core/src/migration/1628543278484-partner-role.ts @@ -0,0 +1,42 @@ +import { MigrationInterface, QueryRunner } from "typeorm" + +export class partnerRole1628543278484 implements MigrationInterface { + name = "partnerRole1628543278484" + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "user_roles" ("is_admin" boolean NOT NULL DEFAULT false, "is_partner" boolean NOT NULL DEFAULT false, "user_id" uuid NOT NULL, CONSTRAINT "REL_87b8888186ca9769c960e92687" UNIQUE ("user_id"), CONSTRAINT "PK_87b8888186ca9769c960e926870" PRIMARY KEY ("user_id"))` + ) + // Add all partners to the table. + await queryRunner.query( + `INSERT INTO "user_roles" ("user_id") SELECT DISTINCT "user_accounts_id" FROM "listings_leasing_agents_user_accounts"` + ) + // Add any admins to the table. + await queryRunner.query( + `INSERT INTO "user_roles" ("user_id", "is_admin") SELECT "id", "is_admin" FROM "user_accounts" WHERE "user_accounts"."is_admin" = TRUE` + ) + // Give everyone partner permissions. + await queryRunner.query(`UPDATE "user_roles" SET "is_partner" = TRUE`) + await queryRunner.query(` + ALTER TABLE "user_roles" DROP CONSTRAINT IF EXISTS "UQ_87b8888186ca9769c960e926870"`) + await queryRunner.query(` + ALTER TABLE "user_roles" DROP CONSTRAINT IF EXISTS "FK_87b8888186ca9769c960e926870"`) + await queryRunner.query( + `ALTER TABLE "user_roles" ADD CONSTRAINT "FK_87b8888186ca9769c960e926870" FOREIGN KEY ("user_id") REFERENCES "user_accounts"("id") ON DELETE NO ACTION ON UPDATE NO ACTION` + ) + await queryRunner.query(`ALTER TABLE "user_accounts" DROP COLUMN "is_admin"`) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "user_roles" DROP CONSTRAINT "FK_87b8888186ca9769c960e926870"` + ) + await queryRunner.query( + `ALTER TABLE "user_accounts" ADD "is_admin" boolean NOT NULL DEFAULT false` + ) + await queryRunner.query( + `UPDATE "user_accounts" SET "is_admin" = "user_roles"."is_admin" FROM "user_roles" WHERE "user_roles"."user_id" = "user_accounts"."id"` + ) + await queryRunner.query(`DROP TABLE "user_roles"`) + } +} diff --git a/backend/core/src/migration/1628631437422-AddNumBedroomsFieldToUnitType.ts b/backend/core/src/migration/1628631437422-AddNumBedroomsFieldToUnitType.ts new file mode 100644 index 0000000000..1bdbf6d666 --- /dev/null +++ b/backend/core/src/migration/1628631437422-AddNumBedroomsFieldToUnitType.ts @@ -0,0 +1,23 @@ +import { MigrationInterface, QueryRunner } from "typeorm" + +export class AddNumBedroomsFieldToUnitType1628631437422 implements MigrationInterface { + name = "AddNumBedroomsFieldToUnitType1628631437422" + + public async up(queryRunner: QueryRunner): Promise { + queryRunner.query(`ALTER TABLE "unit_types" ADD "num_bedrooms" integer`) + queryRunner.query(`UPDATE "unit_types" SET "num_bedrooms" = CASE + WHEN "name" = 'studio' THEN 0 + WHEN "name" = 'SRO' THEN 0 + WHEN "name" = 'oneBdrm' THEN 1 + WHEN "name" = 'twoBdrm' THEN 2 + WHEN "name" = 'threeBdrm' THEN 3 + WHEN "name" = 'fourBdrm' THEN 4 + WHEN "name" = 'fiveBdrm' THEN 5 +END`) + queryRunner.query(`ALTER TABLE "unit_types" ALTER COLUMN "num_bedrooms" SET NOT NULL`) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "unit_types" DROP COLUMN "num_bedrooms"`) + } +} diff --git a/backend/core/src/migration/1629220482499-change-what-to-expect-to-string.ts b/backend/core/src/migration/1629220482499-change-what-to-expect-to-string.ts new file mode 100644 index 0000000000..92ce964ac3 --- /dev/null +++ b/backend/core/src/migration/1629220482499-change-what-to-expect-to-string.ts @@ -0,0 +1,15 @@ +import { MigrationInterface, QueryRunner } from "typeorm" + +export class changeWhatToExpectToString1629220482499 implements MigrationInterface { + name = "changeWhatToExpectToString1629220482499" + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "listings" DROP COLUMN "what_to_expect"`) + await queryRunner.query(`ALTER TABLE "listings" ADD "what_to_expect" text`) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "listings" DROP COLUMN "what_to_expect"`) + await queryRunner.query(`ALTER TABLE "listings" ADD "what_to_expect" jsonb`) + } +} diff --git a/backend/core/src/migration/1629225078065-make-review-order-not-computed.ts b/backend/core/src/migration/1629225078065-make-review-order-not-computed.ts new file mode 100644 index 0000000000..15fb377c4e --- /dev/null +++ b/backend/core/src/migration/1629225078065-make-review-order-not-computed.ts @@ -0,0 +1,38 @@ +import { MigrationInterface, QueryRunner } from "typeorm" + +export class makeReviewOrderNotComputed1629225078065 implements MigrationInterface { + name = "makeReviewOrderNotComputed1629225078065" + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TYPE "listings_review_order_type_enum" AS ENUM('lottery', 'firstComeFirstServe')` + ) + await queryRunner.query( + `ALTER TABLE "listings" ADD "review_order_type" "listings_review_order_type_enum"` + ) + + const listingsIDsWithoutReviewOrderType = await queryRunner.query( + "SELECT id FROM listings WHERE review_order_type IS NULL" + ) + + for (const listing of listingsIDsWithoutReviewOrderType) { + const listingEventTypes = await queryRunner.query( + `SELECT type FROM listing_events WHERE listing_id = '${listing.id}'` + ) + if (listingEventTypes.some((eventType) => eventType.type === "publicLottery")) { + await queryRunner.query( + `UPDATE listings SET review_order_type = 'lottery' where id = '${listing.id}'` + ) + } else { + await queryRunner.query( + `UPDATE listings SET review_order_type = 'firstComeFirstServe' where id = '${listing.id}'` + ) + } + } + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "listings" DROP COLUMN "review_order_type"`) + await queryRunner.query(`DROP TYPE "listings_review_order_type_enum"`) + } +} diff --git a/backend/core/src/migration/1629225262377-update-units-summary-structure.ts b/backend/core/src/migration/1629225262377-update-units-summary-structure.ts new file mode 100644 index 0000000000..f40116bd01 --- /dev/null +++ b/backend/core/src/migration/1629225262377-update-units-summary-structure.ts @@ -0,0 +1,41 @@ +import { MigrationInterface, QueryRunner } from "typeorm" + +export class updateUnitsSummaryStructure1629225262377 implements MigrationInterface { + name = "updateUnitsSummaryStructure1629225262377" + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "units_summary" DROP CONSTRAINT "FK_a2b6519fc3d102d4611a0e2b879"` + ) + await queryRunner.query( + `ALTER TABLE "units_summary" DROP CONSTRAINT "PK_dd5b004243c1536a412e425a9ec"` + ) + await queryRunner.query(`ALTER TABLE "units_summary" DROP COLUMN "property_id"`) + await queryRunner.query( + `ALTER TABLE "units_summary" ADD "id" uuid NOT NULL DEFAULT uuid_generate_v4()` + ) + await queryRunner.query(`ALTER TABLE "units_summary" ADD "listing_id" uuid`) + await queryRunner.query(`ALTER TABLE "units_summary" ALTER COLUMN "unit_type_id" DROP NOT NULL`) + await queryRunner.query(`ALTER TABLE "units_summary" ALTER COLUMN "monthly_rent" DROP NOT NULL`) + await queryRunner.query( + `ALTER TABLE "units_summary" ADD CONSTRAINT "PK_8d8c4940fab2a9d1b2e7ddd9e49" PRIMARY KEY ("id")` + ) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "units_summary" DROP CONSTRAINT "PK_8d8c4940fab2a9d1b2e7ddd9e49"` + ) + await queryRunner.query(`ALTER TABLE "units_summary" ALTER COLUMN "monthly_rent" SET NOT NULL`) + await queryRunner.query(`ALTER TABLE "units_summary" ALTER COLUMN "unit_type_id" SET NOT NULL`) + await queryRunner.query(`ALTER TABLE "units_summary" DROP COLUMN "listing_id"`) + await queryRunner.query(`ALTER TABLE "units_summary" DROP COLUMN "id"`) + await queryRunner.query(`ALTER TABLE "units_summary" ADD "property_id" uuid NOT NULL`) + await queryRunner.query( + `ALTER TABLE "units_summary" ADD CONSTRAINT "PK_dd5b004243c1536a412e425a9ec" PRIMARY KEY ("monthly_rent", "unit_type_id", "property_id")` + ) + await queryRunner.query( + `ALTER TABLE "units_summary" ADD CONSTRAINT "FK_a2b6519fc3d102d4611a0e2b879" FOREIGN KEY ("property_id") REFERENCES "property"("id") ON DELETE NO ACTION ON UPDATE NO ACTION` + ) + } +} diff --git a/backend/core/src/migration/1629306778673-migratePhoneNumberAndRegionToListing.ts b/backend/core/src/migration/1629306778673-migratePhoneNumberAndRegionToListing.ts new file mode 100644 index 0000000000..59dcd8e6b2 --- /dev/null +++ b/backend/core/src/migration/1629306778673-migratePhoneNumberAndRegionToListing.ts @@ -0,0 +1,19 @@ +import { MigrationInterface, QueryRunner } from "typeorm" + +export class migratePhoneNumberAndRegionToListing1629306778673 implements MigrationInterface { + name = "migratePhoneNumberAndRegionToListing1629306778673" + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "property" DROP COLUMN "region"`) + await queryRunner.query(`ALTER TABLE "property" DROP COLUMN "phone_number"`) + await queryRunner.query(`ALTER TABLE "listings" ADD "phone_number" text`) + await queryRunner.query(`ALTER TABLE "listings" ADD "region" text`) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "listings" DROP COLUMN "region"`) + await queryRunner.query(`ALTER TABLE "listings" DROP COLUMN "phone_number"`) + await queryRunner.query(`ALTER TABLE "property" ADD "phone_number" text`) + await queryRunner.query(`ALTER TABLE "property" ADD "region" text`) + } +} diff --git a/backend/core/src/migration/1629321955672-userRolesAndUnitStatus.ts b/backend/core/src/migration/1629321955672-userRolesAndUnitStatus.ts new file mode 100644 index 0000000000..5d1d378c37 --- /dev/null +++ b/backend/core/src/migration/1629321955672-userRolesAndUnitStatus.ts @@ -0,0 +1,43 @@ +import { MigrationInterface, QueryRunner } from "typeorm" + +export class userRolesAndUnitStatus1629321955672 implements MigrationInterface { + name = "userRolesAndUnitStatus1629321955672" + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "units" ALTER COLUMN "status" SET NOT NULL`) + await queryRunner.query(`ALTER TABLE "units" ALTER COLUMN "status" SET DEFAULT 'unknown'`) + await queryRunner.query(`ALTER TABLE "units_summary" DROP COLUMN "monthly_rent"`) + await queryRunner.query(`ALTER TABLE "units_summary" ADD "monthly_rent" integer`) + await queryRunner.query( + `ALTER TABLE "user_roles" DROP CONSTRAINT "FK_87b8888186ca9769c960e926870"` + ) + await queryRunner.query( + `ALTER TABLE "user_roles" ADD CONSTRAINT "UQ_87b8888186ca9769c960e926870" UNIQUE ("user_id")` + ) + await queryRunner.query( + `ALTER TABLE "units_summary" ADD CONSTRAINT "FK_4edda29192dbc0c6a18e15437a0" FOREIGN KEY ("listing_id") REFERENCES "listings"("id") ON DELETE NO ACTION ON UPDATE NO ACTION` + ) + await queryRunner.query( + `ALTER TABLE "user_roles" ADD CONSTRAINT "FK_87b8888186ca9769c960e926870" FOREIGN KEY ("user_id") REFERENCES "user_accounts"("id") ON DELETE NO ACTION ON UPDATE NO ACTION` + ) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "user_roles" DROP CONSTRAINT "FK_87b8888186ca9769c960e926870"` + ) + await queryRunner.query( + `ALTER TABLE "units_summary" DROP CONSTRAINT "FK_4edda29192dbc0c6a18e15437a0"` + ) + await queryRunner.query( + `ALTER TABLE "user_roles" DROP CONSTRAINT "UQ_87b8888186ca9769c960e926870"` + ) + await queryRunner.query( + `ALTER TABLE "user_roles" ADD CONSTRAINT "FK_87b8888186ca9769c960e926870" FOREIGN KEY ("user_id") REFERENCES "user_accounts"("id") ON DELETE NO ACTION ON UPDATE NO ACTION` + ) + await queryRunner.query(`ALTER TABLE "units_summary" DROP COLUMN "monthly_rent"`) + await queryRunner.query(`ALTER TABLE "units_summary" ADD "monthly_rent" character varying`) + await queryRunner.query(`ALTER TABLE "units" ALTER COLUMN "status" DROP DEFAULT`) + await queryRunner.query(`ALTER TABLE "units" ALTER COLUMN "status" DROP NOT NULL`) + } +} diff --git a/backend/core/src/migration/1629403650558-adds-application-method-referral-type.ts b/backend/core/src/migration/1629403650558-adds-application-method-referral-type.ts new file mode 100644 index 0000000000..98dd7b1b41 --- /dev/null +++ b/backend/core/src/migration/1629403650558-adds-application-method-referral-type.ts @@ -0,0 +1,64 @@ +import { MigrationInterface, QueryRunner } from "typeorm" + +export class addsApplicationMethodReferralType1629403650558 implements MigrationInterface { + name = "addsApplicationMethodReferralType1629403650558" + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TYPE "application_methods_type_enum" ADD VALUE 'Referral'`) + + const languageMap = { + English: "en", + Spanish: "es", + Vietnamese: "vi", + Chinese: "zh", + } + + /** + * moves applicable application methods to paper applications + * I checked the live DB and the data was reliably consistent + */ + const paperApplications = await queryRunner.query( + `SELECT id, type, label, external_reference, listing_id FROM application_methods WHERE type = 'FileDownload'` + ) + + // create application index by listing_id + const appIndex = paperApplications.reduce((obj, app) => { + const paperApp = { + language: languageMap[app.label], + file_id: app.external_reference, + } + if (obj[app.listing_id] === undefined) { + obj[app.listing_id] = [paperApp] + } else { + obj[app.listing_id].push(paperApp) + } + return obj + }, {}) + + // loop over the index, remove the application methods, add a new one and add corresponding paper applications + for (const app in appIndex) { + await queryRunner.query(`DELETE FROM application_methods WHERE listing_id = '${app}'`) + // insert one application method per group + const newMethod = await queryRunner.query( + `INSERT INTO application_methods (type, label, listing_id) VALUES ('FileDownload', 'Paper Application', '${app}') RETURNING id` + ) + + // insert paper applications + for (const paper of appIndex[app]) { + // insert new asset, since there's so few we can manually upload to Cloudinary if necessary + const asset = await queryRunner.query( + `INSERT INTO assets (label, file_id) VALUES ('${paper.language} Application', '${paper.file_id}') RETURNING id` + ) + + await queryRunner.query( + `INSERT INTO paper_applications (language, file_id, application_method_id) VALUES ('${paper.language}', '${asset[0].id}', '${newMethod[0].id}')` + ) + } + } + } + + public async down(queryRunner: QueryRunner): Promise { + // there is no equivalent statement to delete an enum value + // see: https://stackoverflow.com/a/25812436 + } +} diff --git a/backend/core/src/migration/1629902677445-add-unit-ami-chart-overrides.ts b/backend/core/src/migration/1629902677445-add-unit-ami-chart-overrides.ts new file mode 100644 index 0000000000..f20a179498 --- /dev/null +++ b/backend/core/src/migration/1629902677445-add-unit-ami-chart-overrides.ts @@ -0,0 +1,25 @@ +import { MigrationInterface, QueryRunner } from "typeorm" + +export class addUnitAmiChartOverrides1629902677445 implements MigrationInterface { + name = "addUnitAmiChartOverrides1629902677445" + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "unit_ami_chart_overrides" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "created_at" TIMESTAMP NOT NULL DEFAULT now(), "updated_at" TIMESTAMP NOT NULL DEFAULT now(), "items" jsonb NOT NULL, CONSTRAINT "PK_839676df1bd1ac12ff09b9d920d" PRIMARY KEY ("id"))` + ) + await queryRunner.query(`ALTER TABLE "units" ADD "ami_chart_override_id" uuid`) + await queryRunner.query( + `ALTER TABLE "units" ADD CONSTRAINT "UQ_4ca3d4c823e6bd5149ecaad363a" UNIQUE ("ami_chart_override_id")` + ) + await queryRunner.query( + `ALTER TABLE "units" ADD CONSTRAINT "FK_4ca3d4c823e6bd5149ecaad363a" FOREIGN KEY ("ami_chart_override_id") REFERENCES "unit_ami_chart_overrides"("id") ON DELETE NO ACTION ON UPDATE NO ACTION` + ) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "units" DROP CONSTRAINT "FK_4ca3d4c823e6bd5149ecaad363a"`) + await queryRunner.query(`ALTER TABLE "units" DROP CONSTRAINT "UQ_4ca3d4c823e6bd5149ecaad363a"`) + await queryRunner.query(`ALTER TABLE "units" DROP COLUMN "ami_chart_override_id"`) + await queryRunner.query(`DROP TABLE "unit_ami_chart_overrides"`) + } +} diff --git a/backend/core/src/migration/1630105131436-add-jurisdiction-notification-setting.ts b/backend/core/src/migration/1630105131436-add-jurisdiction-notification-setting.ts new file mode 100644 index 0000000000..9d34727d23 --- /dev/null +++ b/backend/core/src/migration/1630105131436-add-jurisdiction-notification-setting.ts @@ -0,0 +1,19 @@ +import { MigrationInterface, QueryRunner } from "typeorm" +import { CountyCode } from "../shared/types/county-code" + +export class addJurisdictionNotificationSetting1630105131436 implements MigrationInterface { + name = "addJurisdictionNotificationSetting1630105131436" + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "jurisdictions" ADD "notifications_sign_up_url" text`) + + await queryRunner.query( + `UPDATE "jurisdictions" SET notifications_sign_up_url = 'https://public.govdelivery.com/accounts/CAALAME/signup/29386' WHERE name = ($1)`, + [CountyCode.alameda] + ) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "jurisdictions" DROP COLUMN "notifications_sign_up_url"`) + } +} diff --git a/backend/core/src/migration/1630250097191-adds-jurisdiction-relations.ts b/backend/core/src/migration/1630250097191-adds-jurisdiction-relations.ts new file mode 100644 index 0000000000..6639a9601e --- /dev/null +++ b/backend/core/src/migration/1630250097191-adds-jurisdiction-relations.ts @@ -0,0 +1,111 @@ +import { MigrationInterface, QueryRunner } from "typeorm" + +export class addsJurisdictionRelations1630250097191 implements MigrationInterface { + name = "addsJurisdictionRelations1630250097191" + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX "IDX_8317da96d5a775889e2631cc25"`) + // get translation county codes before rename + const translations = await queryRunner.query(`SELECT id, county_code FROM translations`) + await queryRunner.query( + `ALTER TABLE "translations" RENAME COLUMN "county_code" TO "jurisdiction_id"` + ) + await queryRunner.query( + `CREATE TABLE "user_accounts_jurisdictions_jurisdictions" ("user_accounts_id" uuid NOT NULL, "jurisdictions_id" uuid NOT NULL, CONSTRAINT "PK_66ae1ae446619b775cafb03ce4a" PRIMARY KEY ("user_accounts_id", "jurisdictions_id"))` + ) + await queryRunner.query( + `CREATE INDEX "IDX_e51e812700e143101aeaabbccc" ON "user_accounts_jurisdictions_jurisdictions" ("user_accounts_id") ` + ) + await queryRunner.query( + `CREATE INDEX "IDX_fe359f4430f9e0e7b278e03f0f" ON "user_accounts_jurisdictions_jurisdictions" ("jurisdictions_id") ` + ) + // assign jurisdiciton ID from county code + await queryRunner.query(` + UPDATE listings + SET jurisdiction_id = j.id + FROM listings AS l + INNER JOIN jurisdictions AS j + ON l.county_code = j.name + `) + // drops county code from listings + await queryRunner.query(`ALTER TABLE "listings" DROP COLUMN "county_code"`) + await queryRunner.query( + `ALTER TABLE "jurisdictions" ADD CONSTRAINT "UQ_60b3294568b273d896687dea59f" UNIQUE ("name")` + ) + await queryRunner.query(`ALTER TABLE "translations" DROP COLUMN "jurisdiction_id"`) + await queryRunner.query(`ALTER TABLE "translations" ADD "jurisdiction_id" uuid`) + await queryRunner.query( + `ALTER TABLE "translations" ADD CONSTRAINT "UQ_181f8168d13457f0fd00b08b359" UNIQUE ("jurisdiction_id")` + ) + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_4655e7b2c26deb4b8156ea8100" ON "translations" ("jurisdiction_id", "language") ` + ) + await queryRunner.query( + `ALTER TABLE "translations" ADD CONSTRAINT "FK_181f8168d13457f0fd00b08b359" FOREIGN KEY ("jurisdiction_id") REFERENCES "jurisdictions"("id") ON DELETE NO ACTION ON UPDATE NO ACTION` + ) + await queryRunner.query( + `ALTER TABLE "user_accounts_jurisdictions_jurisdictions" ADD CONSTRAINT "FK_e51e812700e143101aeaabbccc6" FOREIGN KEY ("user_accounts_id") REFERENCES "user_accounts"("id") ON DELETE CASCADE ON UPDATE CASCADE` + ) + await queryRunner.query( + `ALTER TABLE "user_accounts_jurisdictions_jurisdictions" ADD CONSTRAINT "FK_fe359f4430f9e0e7b278e03f0f3" FOREIGN KEY ("jurisdictions_id") REFERENCES "jurisdictions"("id") ON DELETE CASCADE ON UPDATE CASCADE` + ) + // get first jurisdiction_id + const [{ id }] = await queryRunner.query( + `SELECT id FROM jurisdictions WHERE name = 'Detroit' LIMIT 1` + ) + // insert into user_accounts_jurisdictions_jurisdictions + // TODO: This works for Alameda, but if you have Alameda as a Jurisdiction and want to assign another, you'll want to change it, for example with Detroit, if Detroit isn't the only Jurisdiction in your DB. + await queryRunner.query( + `INSERT INTO user_accounts_jurisdictions_jurisdictions ("user_accounts_id", "jurisdictions_id") + SELECT id, '${id}' FROM user_accounts` + ) + + // update translations - set jurisdiction id from old county code + for (const translation of translations) { + await queryRunner.query(` + UPDATE translations + SET jurisdiction_id = j.id + FROM translations AS t + INNER JOIN jurisdictions AS j + ON '${translation.county_code}' = j.name + WHERE t.id = '${translation.id}' + + `) + } + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "user_accounts_jurisdictions_jurisdictions" DROP CONSTRAINT "FK_fe359f4430f9e0e7b278e03f0f3"` + ) + await queryRunner.query( + `ALTER TABLE "user_accounts_jurisdictions_jurisdictions" DROP CONSTRAINT "FK_e51e812700e143101aeaabbccc6"` + ) + await queryRunner.query( + `ALTER TABLE "translations" DROP CONSTRAINT "FK_181f8168d13457f0fd00b08b359"` + ) + await queryRunner.query(`DROP INDEX "IDX_4655e7b2c26deb4b8156ea8100"`) + await queryRunner.query( + `ALTER TABLE "translations" DROP CONSTRAINT "UQ_181f8168d13457f0fd00b08b359"` + ) + await queryRunner.query(`ALTER TABLE "translations" DROP COLUMN "jurisdiction_id"`) + await queryRunner.query( + `ALTER TABLE "translations" ADD "jurisdiction_id" character varying NOT NULL` + ) + await queryRunner.query( + `ALTER TABLE "jurisdictions" DROP CONSTRAINT "UQ_60b3294568b273d896687dea59f"` + ) + await queryRunner.query( + `ALTER TABLE "listings" ADD "county_code" character varying NOT NULL DEFAULT 'Alameda'` + ) + await queryRunner.query(`DROP INDEX "IDX_fe359f4430f9e0e7b278e03f0f"`) + await queryRunner.query(`DROP INDEX "IDX_e51e812700e143101aeaabbccc"`) + await queryRunner.query(`DROP TABLE "user_accounts_jurisdictions_jurisdictions"`) + await queryRunner.query( + `ALTER TABLE "translations" RENAME COLUMN "jurisdiction_id" TO "county_code"` + ) + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_8317da96d5a775889e2631cc25" ON "translations" ("county_code", "language") ` + ) + } +} diff --git a/backend/core/src/migration/1630388600246-convert-preferred-unit-to-unit-types.ts b/backend/core/src/migration/1630388600246-convert-preferred-unit-to-unit-types.ts new file mode 100644 index 0000000000..3869cb5be3 --- /dev/null +++ b/backend/core/src/migration/1630388600246-convert-preferred-unit-to-unit-types.ts @@ -0,0 +1,48 @@ +import { MigrationInterface, QueryRunner } from "typeorm" + +export class convertPreferredUnitToUnitTypes1630388600246 implements MigrationInterface { + name = "convertPreferredUnitToUnitTypes1630388600246" + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "applications_preferred_unit_unit_types" ("applications_id" uuid NOT NULL, "unit_types_id" uuid NOT NULL, CONSTRAINT "PK_63f7ac5b0db34696dd8c5098b87" PRIMARY KEY ("applications_id", "unit_types_id"))` + ) + await queryRunner.query( + `CREATE INDEX "IDX_8249d47edacc30250c18c53915" ON "applications_preferred_unit_unit_types" ("applications_id") ` + ) + await queryRunner.query( + `CREATE INDEX "IDX_5838635fbe9294cac64d1a0b60" ON "applications_preferred_unit_unit_types" ("unit_types_id") ` + ) + // get applications + const applications = await queryRunner.query(`SELECT id, preferred_unit FROM applications`) + // insert into applications_preferred_unit_unit_types + for (const application of applications) { + if (!applications?.preferred_unit) continue + for (const unit of applications.preferred_unit) { + await queryRunner.query( + `INSERT INTO applications_preferred_unit_unit_types (applications_id, unit_types_id) SELECT '${application.id}', id FROM unit_types WHERE name = '${unit}'` + ) + } + } + await queryRunner.query(`ALTER TABLE "applications" DROP COLUMN "preferred_unit"`) + await queryRunner.query( + `ALTER TABLE "applications_preferred_unit_unit_types" ADD CONSTRAINT "FK_8249d47edacc30250c18c53915a" FOREIGN KEY ("applications_id") REFERENCES "applications"("id") ON DELETE CASCADE ON UPDATE CASCADE` + ) + await queryRunner.query( + `ALTER TABLE "applications_preferred_unit_unit_types" ADD CONSTRAINT "FK_5838635fbe9294cac64d1a0b605" FOREIGN KEY ("unit_types_id") REFERENCES "unit_types"("id") ON DELETE CASCADE ON UPDATE CASCADE` + ) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "applications_preferred_unit_unit_types" DROP CONSTRAINT "FK_5838635fbe9294cac64d1a0b605"` + ) + await queryRunner.query( + `ALTER TABLE "applications_preferred_unit_unit_types" DROP CONSTRAINT "FK_8249d47edacc30250c18c53915a"` + ) + await queryRunner.query(`ALTER TABLE "applications" ADD "preferred_unit" text array NOT NULL`) + await queryRunner.query(`DROP INDEX "IDX_5838635fbe9294cac64d1a0b60"`) + await queryRunner.query(`DROP INDEX "IDX_8249d47edacc30250c18c53915"`) + await queryRunner.query(`DROP TABLE "applications_preferred_unit_unit_types"`) + } +} diff --git a/backend/core/src/migration/1630699221778-change-ami-percentage-to-integer.ts b/backend/core/src/migration/1630699221778-change-ami-percentage-to-integer.ts new file mode 100644 index 0000000000..70b5debea8 --- /dev/null +++ b/backend/core/src/migration/1630699221778-change-ami-percentage-to-integer.ts @@ -0,0 +1,17 @@ +import { MigrationInterface, QueryRunner } from "typeorm" + +export class changeAmiPercentageToInteger1630699221778 implements MigrationInterface { + name = "changeAmiPercentageToInteger1630699221778" + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "units_summary" ALTER COLUMN "ami_percentage" TYPE integer USING ami_percentage::integer` + ) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "units_summary" ALTER COLUMN "ami_percentage" TYPE text USING ami_percentage::text` + ) + } +} diff --git a/backend/core/src/migration/1630777068604-add-application-method-booleans.ts b/backend/core/src/migration/1630777068604-add-application-method-booleans.ts new file mode 100644 index 0000000000..e2eb600605 --- /dev/null +++ b/backend/core/src/migration/1630777068604-add-application-method-booleans.ts @@ -0,0 +1,68 @@ +import { MigrationInterface, QueryRunner } from "typeorm" + +export class addApplicationMethodBooleans1630777068604 implements MigrationInterface { + name = "addApplicationMethodBooleans1630777068604" + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "listings" ADD "digital_application" boolean NOT NULL DEFAULT false` + ) + await queryRunner.query( + `ALTER TABLE "listings" ADD "common_digital_application" boolean NOT NULL DEFAULT true` + ) + await queryRunner.query( + `ALTER TABLE "listings" ADD "paper_application" boolean NOT NULL DEFAULT false` + ) + await queryRunner.query( + `ALTER TABLE "listings" ADD "referral_opportunity" boolean NOT NULL DEFAULT false` + ) + + // set new booleans according a listings application methods + // cannot operate with enum value Referral, since it's added in same transaction + const applicationMethods = await queryRunner.query( + `SELECT type, listing_id FROM application_methods WHERE type IN ('Internal', 'ExternalLink', 'FileDownload')` + ) + + for (const method of applicationMethods) { + let field: string + let value: boolean + + switch (method.type) { + case "FileDownload": + field = "paper_application" + value = true + break + case "Internal": + field = "digital_application" + value = true + break + case "ExternalLink": + field = "common_digital_application" + value = false + // also set digital application to true + await queryRunner.query( + `UPDATE listings SET digital_application = true WHERE id = '${method.listing_id}'` + ) + break + /* case "Referral": + field = "referral_opportunity" + value = true + break */ + default: + } + + if (field && value !== undefined) { + await queryRunner.query( + `UPDATE listings SET ${field} = ${value} WHERE id = '${method.listing_id}'` + ) + } + } + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "listings" DROP COLUMN "referral_opportunity"`) + await queryRunner.query(`ALTER TABLE "listings" DROP COLUMN "paper_application"`) + await queryRunner.query(`ALTER TABLE "listings" DROP COLUMN "common_digital_application"`) + await queryRunner.query(`ALTER TABLE "listings" DROP COLUMN "digital_application"`) + } +} diff --git a/backend/core/src/migration/1631040446229-splitRentMinMax.ts b/backend/core/src/migration/1631040446229-splitRentMinMax.ts new file mode 100644 index 0000000000..a3c8e0f292 --- /dev/null +++ b/backend/core/src/migration/1631040446229-splitRentMinMax.ts @@ -0,0 +1,17 @@ +import { MigrationInterface, QueryRunner } from "typeorm" + +export class splitRentMinMax1631040446229 implements MigrationInterface { + name = "splitRentMinMax1631040446229" + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "units_summary" DROP COLUMN "monthly_rent"`) + await queryRunner.query(`ALTER TABLE "units_summary" ADD "monthly_rent_min" integer`) + await queryRunner.query(`ALTER TABLE "units_summary" ADD "monthly_rent_max" integer`) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "units_summary" DROP COLUMN "monthly_rent_max"`) + await queryRunner.query(`ALTER TABLE "units_summary" DROP COLUMN "monthly_rent_min"`) + await queryRunner.query(`ALTER TABLE "units_summary" ADD "monthly_rent" integer`) + } +} diff --git a/backend/core/src/migration/1631110073158-add-null-jurisdiction-translation.ts b/backend/core/src/migration/1631110073158-add-null-jurisdiction-translation.ts new file mode 100644 index 0000000000..b80fea4ef9 --- /dev/null +++ b/backend/core/src/migration/1631110073158-add-null-jurisdiction-translation.ts @@ -0,0 +1,24 @@ +import { MigrationInterface, QueryRunner } from "typeorm" + +export class addNullJurisdictionTranslation1631110073158 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + const [{ language, translations }] = await queryRunner.query( + `SELECT language, translations from translations WHERE language = 'en' LIMIT 1` + ) + + translations["invite"] = { + hello: "Welcome to the Partners Portal", + inviteMessage: + "Welcome to the Partners Portal on %{appUrl}. You will now be able to manage listings and applications that you are a part of from one centralized location.", + toCompleteAccountCreation: "To complete your account creation, please click the link below:", + confirmMyAccount: "Confirm my account", + } + + await queryRunner.query(`INSERT INTO translations (language, translations) VALUES ($1, $2)`, [ + language, + translations, + ]) + } + + public async down(queryRunner: QueryRunner): Promise {} +} diff --git a/backend/core/src/migration/1631734948743-partially-senior-reserved-community-type.ts b/backend/core/src/migration/1631734948743-partially-senior-reserved-community-type.ts new file mode 100644 index 0000000000..885c304c90 --- /dev/null +++ b/backend/core/src/migration/1631734948743-partially-senior-reserved-community-type.ts @@ -0,0 +1,13 @@ +import { MigrationInterface, QueryRunner } from "typeorm" + +export class partiallySeniorReservedCommunityType1631734948743 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `INSERT INTO reserved_community_types (name) VALUES ('partiallySenior')` + ) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DELETE FROM reserved_community_types WHERE name = 'partiallySenior'`) + } +} diff --git a/backend/core/src/migration/1632218920979-add-confirmation-code-to-application.ts b/backend/core/src/migration/1632218920979-add-confirmation-code-to-application.ts new file mode 100644 index 0000000000..760e6c7421 --- /dev/null +++ b/backend/core/src/migration/1632218920979-add-confirmation-code-to-application.ts @@ -0,0 +1,31 @@ +import { MigrationInterface, QueryRunner } from "typeorm" +import crypto from "crypto" + +export class addConfirmationCodeToApplication1632218920979 implements MigrationInterface { + name = "addConfirmationCodeToApplication1632218920979" + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "applications" ADD "confirmation_code" text`) + const applications = await queryRunner.query(`SELECT id, confirmation_code FROM applications`) + for (const application of applications) { + const randomConfirmationCode = crypto.randomBytes(4).toString("hex").toUpperCase() + await queryRunner.query(`UPDATE applications SET confirmation_code = $1 WHERE id = $2`, [ + randomConfirmationCode, + application.id, + ]) + } + await queryRunner.query( + `ALTER TABLE "applications" ALTER COLUMN "confirmation_code" SET NOT NULL` + ) + await queryRunner.query( + `ALTER TABLE "applications" ADD CONSTRAINT "UQ_556c258a4439f1b7f53de2ed74f" UNIQUE ("listing_id", "confirmation_code")` + ) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "applications" DROP CONSTRAINT "UQ_556c258a4439f1b7f53de2ed74f"` + ) + await queryRunner.query(`ALTER TABLE "applications" DROP COLUMN "confirmation_code"`) + } +} diff --git a/backend/core/src/migration/1632263398753-adds-building-criteria-file.ts b/backend/core/src/migration/1632263398753-adds-building-criteria-file.ts new file mode 100644 index 0000000000..77efab295a --- /dev/null +++ b/backend/core/src/migration/1632263398753-adds-building-criteria-file.ts @@ -0,0 +1,21 @@ +import { MigrationInterface, QueryRunner } from "typeorm" + +export class addsBuildingCriteriaFile1632263398753 implements MigrationInterface { + name = "addsBuildingCriteriaFile1632263398753" + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "listings" ADD "building_selection_criteria_file_id" uuid`) + await queryRunner.query( + `ALTER TABLE "listings" ADD CONSTRAINT "FK_2634b9bcb29ec36a629d9e379f0" FOREIGN KEY ("building_selection_criteria_file_id") REFERENCES "assets"("id") ON DELETE NO ACTION ON UPDATE NO ACTION` + ) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "listings" DROP CONSTRAINT "FK_2634b9bcb29ec36a629d9e379f0"` + ) + await queryRunner.query( + `ALTER TABLE "listings" DROP COLUMN "building_selection_criteria_file_id"` + ) + } +} diff --git a/backend/core/src/migration/1632395374858-make-dob-nullable-in-user-model.ts b/backend/core/src/migration/1632395374858-make-dob-nullable-in-user-model.ts new file mode 100644 index 0000000000..d706d0fb22 --- /dev/null +++ b/backend/core/src/migration/1632395374858-make-dob-nullable-in-user-model.ts @@ -0,0 +1,13 @@ +import { MigrationInterface, QueryRunner } from "typeorm" + +export class makeDobNullableInUserModel1632395374858 implements MigrationInterface { + name = "makeDobNullableInUserModel1632395374858" + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "user_accounts" ALTER COLUMN "dob" DROP NOT NULL`) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "user_accounts" ALTER COLUMN "dob" SET NOT NULL`) + } +} diff --git a/backend/core/src/migration/1632428425207-update-ala-signup.ts b/backend/core/src/migration/1632428425207-update-ala-signup.ts new file mode 100644 index 0000000000..a57262b3bd --- /dev/null +++ b/backend/core/src/migration/1632428425207-update-ala-signup.ts @@ -0,0 +1,20 @@ +import { MigrationInterface, QueryRunner } from "typeorm" +import { CountyCode } from "../shared/types/county-code" + +export class updateAlaSignup1632428425207 implements MigrationInterface { + name = "updateAlaSignup1632428425207" + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `UPDATE "jurisdictions" SET notifications_sign_up_url = 'https://public.govdelivery.com/accounts/CAALAME/signup/29652' WHERE name = ($1)`, + [CountyCode.alameda] + ) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `UPDATE "jurisdictions" SET notifications_sign_up_url = 'https://public.govdelivery.com/accounts/CAALAME/signup/29386' WHERE name = ($1)`, + [CountyCode.alameda] + ) + } +} diff --git a/backend/core/src/migration/1632431578257-set-application-method-booleans.ts b/backend/core/src/migration/1632431578257-set-application-method-booleans.ts new file mode 100644 index 0000000000..ab07a50331 --- /dev/null +++ b/backend/core/src/migration/1632431578257-set-application-method-booleans.ts @@ -0,0 +1,55 @@ +import { MigrationInterface, QueryRunner } from "typeorm" + +export class setApplicationMethodBooleans1632431578257 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + // get application methods + const methods = await queryRunner.query(` + SELECT type, listing_id + FROM application_methods + `) + + for (const method of methods) { + if ((method.type = "Internal")) { + await queryRunner.query( + ` + UPDATE listings + SET digital_application = $1, + common_digital_application = $2 + WHERE id = $3 + `, + [true, true, method.listing_id] + ) + } else if ((method.type = "ExternalLink")) { + await queryRunner.query( + ` + UPDATE listings + SET digital_application = $1, + common_digital_application = $2 + WHERE id = $3 + `, + [true, false, method.listing_id] + ) + } else if ((method.type = "PaperPickup")) { + await queryRunner.query( + ` + UPDATE listings + SET paper_application = $1 + WHERE id = $2 + `, + [true, method.listing_id] + ) + } else if ((method.type = "Referral")) { + await queryRunner.query( + ` + UPDATE listings + SET referral_opportunity = $1 + WHERE id = $2 + `, + [true, method.listing_id] + ) + } + } + } + + public async down(queryRunner: QueryRunner): Promise {} +} diff --git a/backend/core/src/migration/1632850106492-add-jurisdiction-relation-to-reserved-community-type.ts b/backend/core/src/migration/1632850106492-add-jurisdiction-relation-to-reserved-community-type.ts new file mode 100644 index 0000000000..954636bbac --- /dev/null +++ b/backend/core/src/migration/1632850106492-add-jurisdiction-relation-to-reserved-community-type.ts @@ -0,0 +1,29 @@ +import { MigrationInterface, QueryRunner } from "typeorm" + +export class addJurisdictionRelationToReservedCommunityType1632850106492 + implements MigrationInterface { + name = "addJurisdictionRelationToReservedCommunityType1632850106492" + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "reserved_community_types" ADD "jurisdiction_id" uuid`) + const [{ id: jurisdictionId }] = await queryRunner.query( + `SELECT id FROM jurisdictions WHERE name = 'Alameda' LIMIT 1` + ) + await queryRunner.query(`UPDATE reserved_community_types SET jurisdiction_id = $1`, [ + jurisdictionId, + ]) + await queryRunner.query( + `ALTER TABLE "reserved_community_types" ALTER COLUMN "jurisdiction_id" SET NOT NULL` + ) + await queryRunner.query( + `ALTER TABLE "reserved_community_types" ADD CONSTRAINT "FK_8b43c85a0dd0c39ca795c369edc" FOREIGN KEY ("jurisdiction_id") REFERENCES "jurisdictions"("id") ON DELETE NO ACTION ON UPDATE NO ACTION` + ) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "reserved_community_types" DROP CONSTRAINT "FK_8b43c85a0dd0c39ca795c369edc"` + ) + await queryRunner.query(`ALTER TABLE "reserved_community_types" DROP COLUMN "jurisdiction_id"`) + } +} diff --git a/backend/core/src/migration/1632987393556-add-jurisdiction-relation-to-ami-charts.ts b/backend/core/src/migration/1632987393556-add-jurisdiction-relation-to-ami-charts.ts new file mode 100644 index 0000000000..99e8a8adb9 --- /dev/null +++ b/backend/core/src/migration/1632987393556-add-jurisdiction-relation-to-ami-charts.ts @@ -0,0 +1,24 @@ +import { MigrationInterface, QueryRunner } from "typeorm" + +export class addJurisdictionRelationToAmiCharts1632987393556 implements MigrationInterface { + name = "addJurisdictionRelationToAmiCharts1632987393556" + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "ami_chart" ADD "jurisdiction_id" uuid`) + const [{ id: jurisdictionId }] = await queryRunner.query( + `SELECT id FROM jurisdictions WHERE name = 'Detroit' LIMIT 1` + ) + await queryRunner.query(`UPDATE ami_chart SET jurisdiction_id = $1`, [jurisdictionId]) + await queryRunner.query(`ALTER TABLE "ami_chart" ALTER COLUMN "jurisdiction_id" SET NOT NULL`) + await queryRunner.query( + `ALTER TABLE "ami_chart" ADD CONSTRAINT "FK_5566b52b2e7c0056e3b81c171f1" FOREIGN KEY ("jurisdiction_id") REFERENCES "jurisdictions"("id") ON DELETE NO ACTION ON UPDATE NO ACTION` + ) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "ami_chart" DROP CONSTRAINT "FK_5566b52b2e7c0056e3b81c171f1"` + ) + await queryRunner.query(`ALTER TABLE "ami_chart" DROP COLUMN "jurisdiction_id"`) + } +} diff --git a/backend/core/src/migration/1633359409242-add-listing-preferences-intermediate-relation.ts b/backend/core/src/migration/1633359409242-add-listing-preferences-intermediate-relation.ts new file mode 100644 index 0000000000..3831f70199 --- /dev/null +++ b/backend/core/src/migration/1633359409242-add-listing-preferences-intermediate-relation.ts @@ -0,0 +1,61 @@ +import { MigrationInterface, QueryRunner } from "typeorm" + +export class addListingPreferencesIntermediateRelation1633359409242 implements MigrationInterface { + name = "addListingPreferencesIntermediateRelation1633359409242" + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "listing_preferences" ("ordinal" integer, "page" integer, "listing_id" uuid NOT NULL, "preference_id" uuid NOT NULL, CONSTRAINT "PK_3a99e1cc861df8e2b81ab885839" PRIMARY KEY ("listing_id", "preference_id"))` + ) + await queryRunner.query( + `ALTER TABLE "listing_preferences" ADD CONSTRAINT "FK_b7fad48d744befbd6532d8a04a0" FOREIGN KEY ("listing_id") REFERENCES "listings"("id") ON DELETE NO ACTION ON UPDATE NO ACTION` + ) + await queryRunner.query( + `ALTER TABLE "listing_preferences" ADD CONSTRAINT "FK_797708bfa7897f574b8eb73cdcb" FOREIGN KEY ("preference_id") REFERENCES "preferences"("id") ON DELETE NO ACTION ON UPDATE NO ACTION` + ) + + const uniquePreferences: [ + { id: string; title: string; ordinal: number } + ] = await queryRunner.query(`SELECT DISTINCT id, title, ordinal, page FROM preferences`) + + const uniquePreferencesMap = uniquePreferences.reduce((acc, val) => { + acc[val.title] = val + return acc + }, {}) + + const listings: [{ listing_id: string; preference_title: string }] = await queryRunner.query( + `SELECT listings.id as listing_id, preferences.title as preference_title FROM listings INNER JOIN preferences preferences ON preferences.listing_id = listings.id` + ) + for (const listing of listings) { + const uniquePreference = uniquePreferencesMap[listing.preference_title] + await queryRunner.query( + `INSERT INTO listing_preferences (listing_id, preference_id, ordinal, page) VALUES ($1, $2, $3, $4)`, + [listing.listing_id, uniquePreference.id, uniquePreference.ordinal, uniquePreference.page] + ) + } + + await queryRunner.query(`DELETE FROM preferences where NOT (id = ANY($1::uuid[]))`, [ + uniquePreferences.map((pref) => pref.id), + ]) + await queryRunner.query(`ALTER TABLE "preferences" DROP COLUMN "ordinal"`) + await queryRunner.query( + `ALTER TABLE "preferences" DROP CONSTRAINT "FK_91017f2182ec7b0dcd4abe68b5a"` + ) + await queryRunner.query(`ALTER TABLE "preferences" DROP COLUMN "listing_id"`) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "preferences" ADD "listing_id" uuid`) + await queryRunner.query( + `ALTER TABLE "preferences" ADD CONSTRAINT "FK_91017f2182ec7b0dcd4abe68b5a" FOREIGN KEY ("listing_id") REFERENCES "listings"("id") ON DELETE CASCADE ON UPDATE CASCADE` + ) + await queryRunner.query(`ALTER TABLE "preferences" ADD "ordinal" integer`) + await queryRunner.query( + `ALTER TABLE "listing_preferences" DROP CONSTRAINT "FK_797708bfa7897f574b8eb73cdcb"` + ) + await queryRunner.query( + `ALTER TABLE "listing_preferences" DROP CONSTRAINT "FK_b7fad48d744befbd6532d8a04a0"` + ) + await queryRunner.query(`DROP TABLE "listing_preferences"`) + } +} diff --git a/backend/core/src/migration/1633557587028-user-email-lower-case.ts b/backend/core/src/migration/1633557587028-user-email-lower-case.ts new file mode 100644 index 0000000000..186fa86ef9 --- /dev/null +++ b/backend/core/src/migration/1633557587028-user-email-lower-case.ts @@ -0,0 +1,25 @@ +import { MigrationInterface, QueryRunner } from "typeorm" + +export class userEmailLowerCase1633557587028 implements MigrationInterface { + name = "userEmailLowerCase1633557587028" + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + UPDATE "user_accounts" SET email = lower(email); + UPDATE household_member SET email_address = lower(email_address); + UPDATE listings SET leasing_agent_email = lower(leasing_agent_email); + UPDATE applicant SET email_address = lower(email_address); + UPDATE alternate_contact SET email_address = lower(email_address); + `) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + UPDATE "user_accounts" SET email = lower(email); + UPDATE household_member SET email_address = lower(email_address); + UPDATE listings SET leasing_agent_email = lower(leasing_agent_email); + UPDATE applicant SET email_address = lower(email_address); + UPDATE alternate_contact SET email_address = lower(email_address); + `) + } +} diff --git a/backend/core/src/migration/1633621446887-add-preference-jurisidiction-many-to-many-relation.ts b/backend/core/src/migration/1633621446887-add-preference-jurisidiction-many-to-many-relation.ts new file mode 100644 index 0000000000..dc8c3d1aba --- /dev/null +++ b/backend/core/src/migration/1633621446887-add-preference-jurisidiction-many-to-many-relation.ts @@ -0,0 +1,49 @@ +import { MigrationInterface, QueryRunner } from "typeorm" +import { query } from "express" +import { CountyCode } from "../shared/types/county-code" + +export class addPreferenceJurisidictionManyToManyRelation1633621446887 + implements MigrationInterface { + name = "addPreferenceJurisidictionManyToManyRelation1633621446887" + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "jurisdictions_preferences_preferences" ("jurisdictions_id" uuid NOT NULL, "preferences_id" uuid NOT NULL, CONSTRAINT "PK_e5e8a8e6f1d02a2e228444aef76" PRIMARY KEY ("jurisdictions_id", "preferences_id"))` + ) + await queryRunner.query( + `CREATE INDEX "IDX_46e20b8b62dbdabfd76955e95b" ON "jurisdictions_preferences_preferences" ("jurisdictions_id") ` + ) + await queryRunner.query( + `CREATE INDEX "IDX_7a0eef07c822800c4e9b9d4361" ON "jurisdictions_preferences_preferences" ("preferences_id") ` + ) + await queryRunner.query( + `ALTER TABLE "jurisdictions_preferences_preferences" ADD CONSTRAINT "FK_46e20b8b62dbdabfd76955e95b1" FOREIGN KEY ("jurisdictions_id") REFERENCES "jurisdictions"("id") ON DELETE CASCADE ON UPDATE CASCADE` + ) + await queryRunner.query( + `ALTER TABLE "jurisdictions_preferences_preferences" ADD CONSTRAINT "FK_7a0eef07c822800c4e9b9d43619" FOREIGN KEY ("preferences_id") REFERENCES "preferences"("id") ON DELETE NO ACTION ON UPDATE NO ACTION` + ) + // Assign all existing preferences to Alameda jurisdiction + const [{ id: alamedaJurisdictionId }] = await queryRunner.query( + `SELECT id FROM jurisdictions where name = '${CountyCode.alameda}'` + ) + const preferences: [{ id: string }] = await queryRunner.query(`SELECT id FROM preferences`) + for (const preference of preferences) { + await queryRunner.query( + `INSERT INTO jurisdictions_preferences_preferences (jurisdictions_id, preferences_id) VALUES ($1, $2)`, + [alamedaJurisdictionId, preference.id] + ) + } + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "jurisdictions_preferences_preferences" DROP CONSTRAINT "FK_7a0eef07c822800c4e9b9d43619"` + ) + await queryRunner.query( + `ALTER TABLE "jurisdictions_preferences_preferences" DROP CONSTRAINT "FK_46e20b8b62dbdabfd76955e95b1"` + ) + await queryRunner.query(`DROP INDEX "IDX_7a0eef07c822800c4e9b9d4361"`) + await queryRunner.query(`DROP INDEX "IDX_46e20b8b62dbdabfd76955e95b"`) + await queryRunner.query(`DROP TABLE "jurisdictions_preferences_preferences"`) + } +} diff --git a/backend/core/src/migration/1633948803537-add-jurisdictional-program-entity.ts b/backend/core/src/migration/1633948803537-add-jurisdictional-program-entity.ts new file mode 100644 index 0000000000..c248476131 --- /dev/null +++ b/backend/core/src/migration/1633948803537-add-jurisdictional-program-entity.ts @@ -0,0 +1,55 @@ +import { MigrationInterface, QueryRunner } from "typeorm" + +export class addJurisdictionalProgramEntity1633948803537 implements MigrationInterface { + name = "addJurisdictionalProgramEntity1633948803537" + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "listing_programs" ("ordinal" integer, "page" integer, "listing_id" uuid NOT NULL, "program_id" uuid NOT NULL, CONSTRAINT "PK_84171c3ea1066baeed32822b139" PRIMARY KEY ("listing_id", "program_id"))` + ) + await queryRunner.query( + `CREATE TABLE "programs" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "created_at" TIMESTAMP NOT NULL DEFAULT now(), "updated_at" TIMESTAMP NOT NULL DEFAULT now(), "question" text, "description" text, "subtitle" text, "subdescription" text, CONSTRAINT "PK_d43c664bcaafc0e8a06dfd34e05" PRIMARY KEY ("id"))` + ) + await queryRunner.query( + `CREATE TABLE "jurisdictions_programs_programs" ("jurisdictions_id" uuid NOT NULL, "programs_id" uuid NOT NULL, CONSTRAINT "PK_5e2009964fd0aab1366091610d3" PRIMARY KEY ("jurisdictions_id", "programs_id"))` + ) + await queryRunner.query( + `CREATE INDEX "IDX_1ec5e2b056309e1248fb43bb08" ON "jurisdictions_programs_programs" ("jurisdictions_id") ` + ) + await queryRunner.query( + `CREATE INDEX "IDX_cc8517c9311a8e8a4bbabac30f" ON "jurisdictions_programs_programs" ("programs_id") ` + ) + await queryRunner.query( + `ALTER TABLE "listing_programs" ADD CONSTRAINT "FK_89b3daa7bbc2dbd95f2760958c2" FOREIGN KEY ("listing_id") REFERENCES "listings"("id") ON DELETE NO ACTION ON UPDATE NO ACTION` + ) + await queryRunner.query( + `ALTER TABLE "listing_programs" ADD CONSTRAINT "FK_0fc46ddd2b9468b011d567740b5" FOREIGN KEY ("program_id") REFERENCES "programs"("id") ON DELETE NO ACTION ON UPDATE NO ACTION` + ) + await queryRunner.query( + `ALTER TABLE "jurisdictions_programs_programs" ADD CONSTRAINT "FK_1ec5e2b056309e1248fb43bb08b" FOREIGN KEY ("jurisdictions_id") REFERENCES "jurisdictions"("id") ON DELETE CASCADE ON UPDATE CASCADE` + ) + await queryRunner.query( + `ALTER TABLE "jurisdictions_programs_programs" ADD CONSTRAINT "FK_cc8517c9311a8e8a4bbabac30f3" FOREIGN KEY ("programs_id") REFERENCES "programs"("id") ON DELETE NO ACTION ON UPDATE NO ACTION` + ) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "jurisdictions_programs_programs" DROP CONSTRAINT "FK_cc8517c9311a8e8a4bbabac30f3"` + ) + await queryRunner.query( + `ALTER TABLE "jurisdictions_programs_programs" DROP CONSTRAINT "FK_1ec5e2b056309e1248fb43bb08b"` + ) + await queryRunner.query( + `ALTER TABLE "listing_programs" DROP CONSTRAINT "FK_0fc46ddd2b9468b011d567740b5"` + ) + await queryRunner.query( + `ALTER TABLE "listing_programs" DROP CONSTRAINT "FK_89b3daa7bbc2dbd95f2760958c2"` + ) + await queryRunner.query(`DROP INDEX "IDX_cc8517c9311a8e8a4bbabac30f"`) + await queryRunner.query(`DROP INDEX "IDX_1ec5e2b056309e1248fb43bb08"`) + await queryRunner.query(`DROP TABLE "jurisdictions_programs_programs"`) + await queryRunner.query(`DROP TABLE "programs"`) + await queryRunner.query(`DROP TABLE "listing_programs"`) + } +} diff --git a/backend/core/src/migration/1634210584036-add-cascade-to-user-roles-user-relation.ts b/backend/core/src/migration/1634210584036-add-cascade-to-user-roles-user-relation.ts new file mode 100644 index 0000000000..6c2b0b99c8 --- /dev/null +++ b/backend/core/src/migration/1634210584036-add-cascade-to-user-roles-user-relation.ts @@ -0,0 +1,21 @@ +import { MigrationInterface, QueryRunner } from "typeorm" + +export class addCascadeToUserRolesUserRelation1634210584036 implements MigrationInterface { + name = "addCascadeToUserRolesUserRelation1634210584036" + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE "user_roles" DROP CONSTRAINT IF EXISTS "FK_87b8888186ca9769c960e926870"`) + await queryRunner.query( + `ALTER TABLE "user_roles" ADD CONSTRAINT "FK_87b8888186ca9769c960e926870" FOREIGN KEY ("user_id") REFERENCES "user_accounts"("id") ON DELETE CASCADE ON UPDATE CASCADE` + ) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE "user_roles" DROP CONSTRAINT IF EXISTS "FK_87b8888186ca9769c960e926870"`) + await queryRunner.query( + `ALTER TABLE "user_roles" ADD CONSTRAINT "FK_87b8888186ca9769c960e926870" FOREIGN KEY ("user_id") REFERENCES "user_accounts"("id") ON DELETE NO ACTION ON UPDATE NO ACTION` + ) + } +} diff --git a/backend/core/src/migration/1634213955270-add-language-to-jurisdiction.ts b/backend/core/src/migration/1634213955270-add-language-to-jurisdiction.ts new file mode 100644 index 0000000000..dbca1fb6d5 --- /dev/null +++ b/backend/core/src/migration/1634213955270-add-language-to-jurisdiction.ts @@ -0,0 +1,19 @@ +import { MigrationInterface, QueryRunner } from "typeorm" + +export class addLanguageToJurisdiction1634213955270 implements MigrationInterface { + name = "addLanguageToJurisdiction1634213955270" + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TYPE "jurisdictions_languages_enum" AS ENUM('en', 'es', 'vi', 'zh')` + ) + await queryRunner.query( + `ALTER TABLE "jurisdictions" ADD "languages" "jurisdictions_languages_enum" array NOT NULL DEFAULT '{en}'` + ) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "jurisdictions" DROP COLUMN "languages"`) + await queryRunner.query(`DROP TYPE "jurisdictions_languages_enum"`) + } +} diff --git a/backend/core/src/migration/1634268265134-add-indexes-to-applications-and-householdmembers.ts b/backend/core/src/migration/1634268265134-add-indexes-to-applications-and-householdmembers.ts new file mode 100644 index 0000000000..9693be4c2d --- /dev/null +++ b/backend/core/src/migration/1634268265134-add-indexes-to-applications-and-householdmembers.ts @@ -0,0 +1,28 @@ +import { MigrationInterface, QueryRunner } from "typeorm" + +export class addIndexesToApplicationsAndHouseholdmembers1634268265134 + implements MigrationInterface { + name = "addIndexesToApplicationsAndHouseholdmembers1634268265134" + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "listings" DROP COLUMN "csv_formatting_type"`) + await queryRunner.query( + `CREATE INDEX "IDX_520996eeecf9f6fb9425dc7352" ON "household_member" ("application_id") ` + ) + await queryRunner.query( + `CREATE INDEX "IDX_cc9d65c58d8deb0ef5353e9037" ON "applications" ("listing_id") ` + ) + await queryRunner.query( + `CREATE INDEX "IDX_f2ace84eebd770f1387b47e5e4" ON "application_flagged_set" ("listing_id") ` + ) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX "IDX_f2ace84eebd770f1387b47e5e4"`) + await queryRunner.query(`DROP INDEX "IDX_cc9d65c58d8deb0ef5353e9037"`) + await queryRunner.query(`DROP INDEX "IDX_520996eeecf9f6fb9425dc7352"`) + await queryRunner.query( + `ALTER TABLE "listings" ADD "csv_formatting_type" character varying NOT NULL DEFAULT 'basic'` + ) + } +} diff --git a/backend/core/src/migration/1634316081536-remove-app-address.ts b/backend/core/src/migration/1634316081536-remove-app-address.ts new file mode 100644 index 0000000000..8327e637e6 --- /dev/null +++ b/backend/core/src/migration/1634316081536-remove-app-address.ts @@ -0,0 +1,19 @@ +import { MigrationInterface, QueryRunner } from "typeorm" + +export class removeAppAddress1634316081536 implements MigrationInterface { + name = "removeAppAddress1634316081536" + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "listings" DROP CONSTRAINT "FK_42385e47be1780d1491f0c8c1c3"` + ) + await queryRunner.query(`ALTER TABLE "listings" DROP COLUMN "application_address_id"`) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "listings" ADD "application_address_id" uuid`) + await queryRunner.query( + `ALTER TABLE "listings" ADD CONSTRAINT "FK_42385e47be1780d1491f0c8c1c3" FOREIGN KEY ("application_address_id") REFERENCES "address"("id") ON DELETE NO ACTION ON UPDATE NO ACTION` + ) + } +} diff --git a/backend/core/src/migration/1634547352243-add-program-jsonb-to-application.ts b/backend/core/src/migration/1634547352243-add-program-jsonb-to-application.ts new file mode 100644 index 0000000000..cd885b64b8 --- /dev/null +++ b/backend/core/src/migration/1634547352243-add-program-jsonb-to-application.ts @@ -0,0 +1,15 @@ +import { MigrationInterface, QueryRunner } from "typeorm" + +export class addProgramJsonbToApplication1634547352243 implements MigrationInterface { + name = "addProgramJsonbToApplication1634547352243" + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "applications" ADD "programs" jsonb`) + await queryRunner.query(`ALTER TABLE "programs" ADD "form_metadata" jsonb`) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "programs" DROP COLUMN "form_metadata"`) + await queryRunner.query(`ALTER TABLE "applications" DROP COLUMN "programs"`) + } +} diff --git a/backend/core/src/migration/1634647281728-align-program-entity-model-with-preference.ts b/backend/core/src/migration/1634647281728-align-program-entity-model-with-preference.ts new file mode 100644 index 0000000000..e58030e801 --- /dev/null +++ b/backend/core/src/migration/1634647281728-align-program-entity-model-with-preference.ts @@ -0,0 +1,17 @@ +import { MigrationInterface, QueryRunner } from "typeorm" + +export class alignProgramEntityModelWithPreference1634647281728 implements MigrationInterface { + name = "alignProgramEntityModelWithPreference1634647281728" + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "programs" DROP COLUMN "question"`) + await queryRunner.query(`ALTER TABLE "programs" DROP COLUMN "subdescription"`) + await queryRunner.query(`ALTER TABLE "programs" ADD "title" text`) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "programs" DROP COLUMN "title"`) + await queryRunner.query(`ALTER TABLE "programs" ADD "subdescription" text`) + await queryRunner.query(`ALTER TABLE "programs" ADD "question" text`) + } +} diff --git a/backend/core/src/migration/1634664593091-adds-jurisdiction-index-to-listings.ts b/backend/core/src/migration/1634664593091-adds-jurisdiction-index-to-listings.ts new file mode 100644 index 0000000000..84294835d1 --- /dev/null +++ b/backend/core/src/migration/1634664593091-adds-jurisdiction-index-to-listings.ts @@ -0,0 +1,15 @@ +import { MigrationInterface, QueryRunner } from "typeorm" + +export class addsJurisdictionIndexToListings1634664593091 implements MigrationInterface { + name = "addsJurisdictionIndexToListings1634664593091" + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE INDEX "IDX_ba0026e02ecfe91791aed1a481" ON "listings" ("jurisdiction_id") ` + ) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX "IDX_ba0026e02ecfe91791aed1a481"`) + } +} diff --git a/backend/core/src/migration/1634814157491-add-on-delete-action-to-application-user-relation.ts b/backend/core/src/migration/1634814157491-add-on-delete-action-to-application-user-relation.ts new file mode 100644 index 0000000000..0e954cd001 --- /dev/null +++ b/backend/core/src/migration/1634814157491-add-on-delete-action-to-application-user-relation.ts @@ -0,0 +1,23 @@ +import { MigrationInterface, QueryRunner } from "typeorm" + +export class addOnDeleteActionToApplicationUserRelation1634814157491 implements MigrationInterface { + name = "addOnDeleteActionToApplicationUserRelation1634814157491" + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "applications" DROP CONSTRAINT "FK_9e7594d5b474d9cbebba15c1ae7"` + ) + await queryRunner.query( + `ALTER TABLE "applications" ADD CONSTRAINT "FK_9e7594d5b474d9cbebba15c1ae7" FOREIGN KEY ("user_id") REFERENCES "user_accounts"("id") ON DELETE SET NULL ON UPDATE NO ACTION` + ) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "applications" DROP CONSTRAINT "FK_9e7594d5b474d9cbebba15c1ae7"` + ) + await queryRunner.query( + `ALTER TABLE "applications" ADD CONSTRAINT "FK_9e7594d5b474d9cbebba15c1ae7" FOREIGN KEY ("user_id") REFERENCES "user_accounts"("id") ON DELETE NO ACTION ON UPDATE NO ACTION` + ) + } +} diff --git a/backend/core/src/migration/1634830165326-remove-page-from-listing-program-entity.ts b/backend/core/src/migration/1634830165326-remove-page-from-listing-program-entity.ts new file mode 100644 index 0000000000..347b835ed6 --- /dev/null +++ b/backend/core/src/migration/1634830165326-remove-page-from-listing-program-entity.ts @@ -0,0 +1,13 @@ +import { MigrationInterface, QueryRunner } from "typeorm" + +export class removePageFromListingProgramEntity1634830165326 implements MigrationInterface { + name = "removePageFromListingProgramEntity1634830165326" + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "listing_programs" DROP COLUMN "page"`) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "listing_programs" ADD "page" integer`) + } +} diff --git a/backend/core/src/migration/1634848388161-add-phone-number.ts b/backend/core/src/migration/1634848388161-add-phone-number.ts new file mode 100644 index 0000000000..34e26f2d23 --- /dev/null +++ b/backend/core/src/migration/1634848388161-add-phone-number.ts @@ -0,0 +1,13 @@ +import { MigrationInterface, QueryRunner } from "typeorm" + +export class addPhoneNumber1634848388161 implements MigrationInterface { + name = "addPhoneNumber1634848388161" + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "user_accounts" ADD "phone_number" character varying`) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "user_accounts" DROP COLUMN "phone_number"`) + } +} diff --git a/backend/core/src/migration/1634912142711-remove-preferences-page.ts b/backend/core/src/migration/1634912142711-remove-preferences-page.ts new file mode 100644 index 0000000000..03d2703426 --- /dev/null +++ b/backend/core/src/migration/1634912142711-remove-preferences-page.ts @@ -0,0 +1,15 @@ +import { MigrationInterface, QueryRunner } from "typeorm" + +export class removePreferencesPage1634912142711 implements MigrationInterface { + name = "removePreferencesPage1634912142711" + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "listing_preferences" DROP COLUMN "page"`) + await queryRunner.query(`ALTER TABLE "preferences" DROP COLUMN "page"`) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "preferences" ADD "page" integer`) + await queryRunner.query(`ALTER TABLE "listing_preferences" ADD "page" integer`) + } +} diff --git a/backend/core/src/migration/1635126814120-remove-app-defaults.ts b/backend/core/src/migration/1635126814120-remove-app-defaults.ts new file mode 100644 index 0000000000..8cad054545 --- /dev/null +++ b/backend/core/src/migration/1635126814120-remove-app-defaults.ts @@ -0,0 +1,53 @@ +import { MigrationInterface, QueryRunner } from "typeorm" + +export class removeAppDefaults1635126814120 implements MigrationInterface { + name = "removeAppDefaults1635126814120" + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "listings" ALTER COLUMN "digital_application" DROP NOT NULL` + ) + await queryRunner.query( + `ALTER TABLE "listings" ALTER COLUMN "digital_application" DROP DEFAULT` + ) + await queryRunner.query( + `ALTER TABLE "listings" ALTER COLUMN "common_digital_application" DROP NOT NULL` + ) + await queryRunner.query( + `ALTER TABLE "listings" ALTER COLUMN "common_digital_application" DROP DEFAULT` + ) + await queryRunner.query(`ALTER TABLE "listings" ALTER COLUMN "paper_application" DROP NOT NULL`) + await queryRunner.query(`ALTER TABLE "listings" ALTER COLUMN "paper_application" DROP DEFAULT`) + await queryRunner.query( + `ALTER TABLE "listings" ALTER COLUMN "referral_opportunity" DROP NOT NULL` + ) + await queryRunner.query( + `ALTER TABLE "listings" ALTER COLUMN "referral_opportunity" DROP DEFAULT` + ) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "listings" ALTER COLUMN "referral_opportunity" SET DEFAULT false` + ) + await queryRunner.query( + `ALTER TABLE "listings" ALTER COLUMN "referral_opportunity" SET NOT NULL` + ) + await queryRunner.query( + `ALTER TABLE "listings" ALTER COLUMN "paper_application" SET DEFAULT false` + ) + await queryRunner.query(`ALTER TABLE "listings" ALTER COLUMN "paper_application" SET NOT NULL`) + await queryRunner.query( + `ALTER TABLE "listings" ALTER COLUMN "common_digital_application" SET DEFAULT true` + ) + await queryRunner.query( + `ALTER TABLE "listings" ALTER COLUMN "common_digital_application" SET NOT NULL` + ) + await queryRunner.query( + `ALTER TABLE "listings" ALTER COLUMN "digital_application" SET DEFAULT false` + ) + await queryRunner.query( + `ALTER TABLE "listings" ALTER COLUMN "digital_application" SET NOT NULL` + ) + } +} diff --git a/backend/core/src/migration/1635216780193-new-household-common-app-questions.ts b/backend/core/src/migration/1635216780193-new-household-common-app-questions.ts new file mode 100644 index 0000000000..7fd4837661 --- /dev/null +++ b/backend/core/src/migration/1635216780193-new-household-common-app-questions.ts @@ -0,0 +1,15 @@ +import { MigrationInterface, QueryRunner } from "typeorm" + +export class newHouseholdCommonAppQuestions1635216780193 implements MigrationInterface { + name = "newHouseholdCommonAppQuestions1635216780193" + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "applications" ADD "household_expecting_changes" boolean`) + await queryRunner.query(`ALTER TABLE "applications" ADD "household_student" boolean`) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "applications" DROP COLUMN "household_student"`) + await queryRunner.query(`ALTER TABLE "applications" DROP COLUMN "household_expecting_changes"`) + } +} diff --git a/backend/core/src/migration/1635535204972-allow-multiple-race-checkboxes.ts b/backend/core/src/migration/1635535204972-allow-multiple-race-checkboxes.ts new file mode 100644 index 0000000000..6ba5e81914 --- /dev/null +++ b/backend/core/src/migration/1635535204972-allow-multiple-race-checkboxes.ts @@ -0,0 +1,33 @@ +import { MigrationInterface, QueryRunner } from "typeorm" + +export class allowMultipleRaceCheckboxes1635535204972 implements MigrationInterface { + name = "allowMultipleRaceCheckboxes1635535204972" + + public async up(queryRunner: QueryRunner): Promise { + const existingRaceFields = await queryRunner.query(`SELECT id, race FROM demographics`) + + await queryRunner.query(`ALTER TABLE "demographics" DROP COLUMN "race"`) + await queryRunner.query(`ALTER TABLE "demographics" ADD "race" text array`) + + for (const demographic in existingRaceFields) { + await queryRunner.query(`UPDATE demographics SET race = ($1) WHERE id = ($2)`, [ + [existingRaceFields[demographic]["race"]], + existingRaceFields[demographic]["id"], + ]) + } + } + + public async down(queryRunner: QueryRunner): Promise { + const existingRaceFields = await queryRunner.query(`SELECT id, race FROM demographics`) + + await queryRunner.query(`ALTER TABLE "demographics" DROP COLUMN "race"`) + await queryRunner.query(`ALTER TABLE "demographics" ADD "race" text`) + + for (const demographic in existingRaceFields) { + await queryRunner.query(`UPDATE demographics race = ($1) WHERE id = ($2)`, [ + existingRaceFields[demographic]["race"][0], + existingRaceFields[demographic]["id"], + ]) + } + } +} diff --git a/backend/core/src/migration/1635546032998-add-jurisdictional-email-signatures.ts b/backend/core/src/migration/1635546032998-add-jurisdictional-email-signatures.ts new file mode 100644 index 0000000000..5a6bfbcf75 --- /dev/null +++ b/backend/core/src/migration/1635546032998-add-jurisdictional-email-signatures.ts @@ -0,0 +1,53 @@ +import { MigrationInterface, QueryRunner } from "typeorm" +import { Language } from "../shared/types/language-enum" + +export class addJurisdictionalEmailSignatures1635546032998 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + const detroitTranslation = { + footer: { + footer: "City of Detroit - Housing Connect", + }, + } + const [{ id: detroitJurisdiction }] = await queryRunner.query( + `SELECT id FROM jurisdictions WHERE name = 'Detroit' LIMIT 1` + ) + + const existingDetroitTranslations = await queryRunner.query( + `SELECT translations FROM translations WHERE jurisdiction_id = ($1)`, + [detroitJurisdiction] + ) + + const existingGeneralTranslations = await queryRunner.query( + `SELECT translations FROM translations WHERE jurisdiction_id is NULL` + ) + + let genericTranslation = {} + + if (existingDetroitTranslations?.length) { + genericTranslation = { + ...existingDetroitTranslations["0"]["translations"], + } + } + + genericTranslation = { + ...genericTranslation, + ...existingGeneralTranslations["0"]["translations"], + footer: { + footer: "", + thankYou: "Thanks!", + }, + } + + await queryRunner.query( + `UPDATE "translations" SET translations = ($1) where jurisdiction_id = ($2) and language = ($3)`, + [detroitTranslation, detroitJurisdiction, Language.en] + ) + + await queryRunner.query( + `UPDATE "translations" SET translations = ($1) where jurisdiction_id is NULL and language = ($2)`, + [genericTranslation, Language.en] + ) + } + + public async down(queryRunner: QueryRunner): Promise {} +} diff --git a/backend/core/src/migration/1635889621244-seed-detroit-translation-entries.ts b/backend/core/src/migration/1635889621244-seed-detroit-translation-entries.ts new file mode 100644 index 0000000000..bb679932f1 --- /dev/null +++ b/backend/core/src/migration/1635889621244-seed-detroit-translation-entries.ts @@ -0,0 +1,68 @@ +import { MigrationInterface, QueryRunner } from "typeorm" +import { Language } from "../shared/types/language-enum" + +export class seedDetroitTranslationEntries1635889621244 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + const defaultTranslation = { + confirmation: { + yourConfirmationNumber: "Here is your confirmation number:", + shouldBeChosen: + "Should your application be chosen, be prepared to fill out a more detailed application and provide required supporting documents.", + subject: "Your Application Confirmation", + thankYouForApplying: "Thanks for applying. We have received your application for", + whatToExpectNext: "What to expect next:", + whatToExpect: { + FCFS: + "Applicants will be contacted by the property agent on a first come first serve basis until vacancies are filled.", + lottery: + "The lottery will be held on %{lotteryDate}. Applicants will be contacted by the agent in lottery rank order until vacancies are filled.", + noLottery: + "Applicants will be contacted by the agent in waitlist order until vacancies are filled.", + }, + }, + footer: { + callToAction: "How are we doing? We'd like to get your ", + callToActionUrl: "FEEDBACK URL UNIMPLEMENTED", + feedback: "feedback", + footer: "Detroit Home Connect", + thankYou: "Thank you", + }, + forgotPassword: { + callToAction: + "If you did make this request, please click on the link below to reset your password:", + changePassword: "Change my password", + ignoreRequest: "If you didn't request this, please ignore this email.", + passwordInfo: + "Your password won't change until you access the link above and create a new one.", + resetRequest: + "A request to reset your Bloom Housing Portal website password for %{appUrl} has recently been made.", + subject: "Forgot your password?", + }, + leasingAgent: { + contactAgentToUpdateInfo: + "If you need to update information on your application, do not apply again. Contact the agent. See below for contact information for the Agent for this listing.", + officeHours: "Office Hours:", + }, + register: { + confirmMyAccount: "Confirm my account", + toConfirmAccountMessage: "To complete your account creation, please click the link below:", + welcome: "Welcome", + welcomeMessage: + "Thank you for setting up your account on %{appUrl}. It will now be easier for you to start, save, and submit online applications for listings that appear on the site.", + }, + t: { + hello: "Hello", + }, + } + + const [{ id: detroit_jurisdiction_id }] = await queryRunner.query( + `SELECT id FROM jurisdictions where name='Detroit'` + ) + await queryRunner.query( + `INSERT into "translations" (jurisdiction_id, language, translations) VALUES ($1, $2, $3)`, + [detroit_jurisdiction_id, Language.en, defaultTranslation] + ) + } + + public async down(queryRunner: QueryRunner): Promise {} +} diff --git a/backend/core/src/migration/1635946663862-add-change-email-translations.ts b/backend/core/src/migration/1635946663862-add-change-email-translations.ts new file mode 100644 index 0000000000..63a650b763 --- /dev/null +++ b/backend/core/src/migration/1635946663862-add-change-email-translations.ts @@ -0,0 +1,21 @@ +import { MigrationInterface, QueryRunner } from "typeorm" + +export class addChangeEmailTranslations1635946663862 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + const changeEmail = { + message: "An email address change has been requested for your account.", + onChangeEmailMessage: + "To confirm the change to your email address, please click the link below:", + changeMyEmail: "Confirm email change", + } + const translations = await queryRunner.query(`SELECT * from translations`) + for (const t of translations) { + await queryRunner.query(`UPDATE translations SET translations = ($1) WHERE id = ($2)`, [ + { ...t.translations, changeEmail }, + t.id, + ]) + } + } + + public async down(queryRunner: QueryRunner): Promise {} +} diff --git a/backend/core/src/migration/1636490631999-add-user-preferences.ts b/backend/core/src/migration/1636490631999-add-user-preferences.ts new file mode 100644 index 0000000000..c543c9acd9 --- /dev/null +++ b/backend/core/src/migration/1636490631999-add-user-preferences.ts @@ -0,0 +1,21 @@ +import { MigrationInterface, QueryRunner } from "typeorm" + +export class addUserPreferences1636490631999 implements MigrationInterface { + name = "addUserPreferences1636490631999" + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "user_preferences" ("send_email_notifications" boolean NOT NULL DEFAULT false, "send_sms_notifications" boolean NOT NULL DEFAULT false, "user_id" uuid NOT NULL, CONSTRAINT "REL_458057fa75b66e68a275647da2" UNIQUE ("user_id"), CONSTRAINT "PK_458057fa75b66e68a275647da2e" PRIMARY KEY ("user_id"))` + ) + await queryRunner.query( + `ALTER TABLE "user_preferences" ADD CONSTRAINT "FK_458057fa75b66e68a275647da2e" FOREIGN KEY ("user_id") REFERENCES "user_accounts"("id") ON DELETE NO ACTION ON UPDATE NO ACTION` + ) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "user_preferences" DROP CONSTRAINT "FK_458057fa75b66e68a275647da2e"` + ) + await queryRunner.query(`DROP TABLE "user_preferences"`) + } +} diff --git a/backend/core/src/migration/1636499724052-deposit-helper-text-creation.ts b/backend/core/src/migration/1636499724052-deposit-helper-text-creation.ts new file mode 100644 index 0000000000..97efd5201c --- /dev/null +++ b/backend/core/src/migration/1636499724052-deposit-helper-text-creation.ts @@ -0,0 +1,13 @@ +import { MigrationInterface, QueryRunner } from "typeorm" + +export class depositHelperTextCreation1636499724052 implements MigrationInterface { + name = "depositHelperTextCreation1636499724052" + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "listings" ADD "deposit_helper_text" text`) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "listings" DROP COLUMN "deposit_helper_text"`) + } +} diff --git a/backend/core/src/migration/1636990024836-dev-dis-reserved-community.ts b/backend/core/src/migration/1636990024836-dev-dis-reserved-community.ts new file mode 100644 index 0000000000..86a6b4bc48 --- /dev/null +++ b/backend/core/src/migration/1636990024836-dev-dis-reserved-community.ts @@ -0,0 +1,20 @@ +import { MigrationInterface, QueryRunner } from "typeorm" + +export class devDisReservedCommunity1636990024836 implements MigrationInterface { + reservedType = "developmentalDisability" + public async up(queryRunner: QueryRunner): Promise { + const jurisdictions = await queryRunner.query(`SELECT id from jurisdictions`) + for (const jurisdiction of jurisdictions) { + await queryRunner.query( + `INSERT INTO reserved_community_types (name, jurisdiction_id) VALUES ($1, $2)`, + [this.reservedType, jurisdiction.id] + ) + } + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DELETE FROM reserved_community_types WHERE name = $1`, [ + this.reservedType, + ]) + } +} diff --git a/backend/core/src/migration/1637680690577-add-generated-listing-translation.ts b/backend/core/src/migration/1637680690577-add-generated-listing-translation.ts new file mode 100644 index 0000000000..ad6ca34cbc --- /dev/null +++ b/backend/core/src/migration/1637680690577-add-generated-listing-translation.ts @@ -0,0 +1,15 @@ +import { MigrationInterface, QueryRunner } from "typeorm" + +export class addGeneratedListingTranslation1637680690577 implements MigrationInterface { + name = "addGeneratedListingTranslation1637680690577" + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "generated_listing_translations" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "created_at" TIMESTAMP NOT NULL DEFAULT now(), "updated_at" TIMESTAMP NOT NULL DEFAULT now(), "listing_id" character varying NOT NULL, "jurisdiction_id" character varying NOT NULL, "language" character varying NOT NULL, "translations" jsonb NOT NULL, "timestamp" TIMESTAMP NOT NULL, CONSTRAINT "PK_4059452831439aefc27c1990b20" PRIMARY KEY ("id"))` + ) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE "generated_listing_translations"`) + } +} diff --git a/backend/core/src/migration/1637700649083-alter-user-preferences.ts b/backend/core/src/migration/1637700649083-alter-user-preferences.ts new file mode 100644 index 0000000000..a833520e2b --- /dev/null +++ b/backend/core/src/migration/1637700649083-alter-user-preferences.ts @@ -0,0 +1,29 @@ +import { MigrationInterface, QueryRunner } from "typeorm" + +export class alterUserPreferences1637700649083 implements MigrationInterface { + name = "alterUserPreferences1637700649083" + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "user_preferences" DROP CONSTRAINT "FK_458057fa75b66e68a275647da2e"` + ) + await queryRunner.query( + `ALTER TABLE "user_preferences" ADD CONSTRAINT "UQ_458057fa75b66e68a275647da2e" UNIQUE ("user_id")` + ) + await queryRunner.query( + `ALTER TABLE "user_preferences" ADD CONSTRAINT "FK_458057fa75b66e68a275647da2e" FOREIGN KEY ("user_id") REFERENCES "user_accounts"("id") ON DELETE NO ACTION ON UPDATE NO ACTION` + ) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "user_preferences" DROP CONSTRAINT "FK_458057fa75b66e68a275647da2e"` + ) + await queryRunner.query( + `ALTER TABLE "user_preferences" DROP CONSTRAINT "UQ_458057fa75b66e68a275647da2e"` + ) + await queryRunner.query( + `ALTER TABLE "user_preferences" ADD CONSTRAINT "FK_458057fa75b66e68a275647da2e" FOREIGN KEY ("user_id") REFERENCES "user_accounts"("id") ON DELETE NO ACTION ON UPDATE NO ACTION` + ) + } +} diff --git a/backend/core/src/migration/1637710104539-add-listing-features.ts b/backend/core/src/migration/1637710104539-add-listing-features.ts new file mode 100644 index 0000000000..682c123833 --- /dev/null +++ b/backend/core/src/migration/1637710104539-add-listing-features.ts @@ -0,0 +1,29 @@ +import { MigrationInterface, QueryRunner } from "typeorm" + +export class addListingFeatures1637710104539 implements MigrationInterface { + name = "addListingFeatures1637710104539" + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "listing_features" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "created_at" TIMESTAMP NOT NULL DEFAULT now(), "updated_at" TIMESTAMP NOT NULL DEFAULT now(), "elevator" boolean, "wheelchair_ramp" boolean, "service_animals_allowed" boolean, "accessible_parking" boolean, "parking_on_site" boolean, "in_unit_washer_dryer" boolean, "laundry_in_building" boolean, "barrier_free_entrance" boolean, "roll_in_shower" boolean, "grab_bars" boolean, "heating_in_unit" boolean, "ac_in_unit" boolean, CONSTRAINT "PK_88e4fe3e46d21d8b4fdadeb7599" PRIMARY KEY ("id"))` + ) + await queryRunner.query(`ALTER TABLE "listings" ADD "features_id" uuid`) + await queryRunner.query( + `ALTER TABLE "listings" ADD CONSTRAINT "UQ_ac59a58a02199c57a588f045830" UNIQUE ("features_id")` + ) + await queryRunner.query( + `ALTER TABLE "listings" ADD CONSTRAINT "FK_ac59a58a02199c57a588f045830" FOREIGN KEY ("features_id") REFERENCES "listing_features"("id") ON DELETE NO ACTION ON UPDATE NO ACTION` + ) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "listings" DROP CONSTRAINT "FK_ac59a58a02199c57a588f045830"` + ) + await queryRunner.query( + `ALTER TABLE "listings" DROP CONSTRAINT "UQ_ac59a58a02199c57a588f045830"` + ) + await queryRunner.query(`ALTER TABLE "listings" DROP COLUMN "features_id"`) + await queryRunner.query(`DROP TABLE "listing_features"`) + } +} diff --git a/backend/core/src/migration/1637815805105-add-sro-to-unit-types.ts b/backend/core/src/migration/1637815805105-add-sro-to-unit-types.ts new file mode 100644 index 0000000000..cfbaa949d7 --- /dev/null +++ b/backend/core/src/migration/1637815805105-add-sro-to-unit-types.ts @@ -0,0 +1,14 @@ +import { MigrationInterface, QueryRunner } from "typeorm" + +export class addSroToUnitTypes1637815805105 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`INSERT INTO unit_types (name, num_bedrooms) VALUES ($1, $2)`, [ + "SRO", + 0, + ]) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DELETE FROM unit_types WHERE name = $1`, ["SRO"]) + } +} diff --git a/backend/core/src/migration/1637924287461-add-user-login-metadata-to-user-entity.ts b/backend/core/src/migration/1637924287461-add-user-login-metadata-to-user-entity.ts new file mode 100644 index 0000000000..3b0503ca39 --- /dev/null +++ b/backend/core/src/migration/1637924287461-add-user-login-metadata-to-user-entity.ts @@ -0,0 +1,19 @@ +import { MigrationInterface, QueryRunner } from "typeorm" + +export class addUserLoginMetadataToUserEntity1637924287461 implements MigrationInterface { + name = "addUserLoginMetadataToUserEntity1637924287461" + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "user_accounts" ADD "last_login_at" TIMESTAMP NOT NULL DEFAULT NOW()` + ) + await queryRunner.query( + `ALTER TABLE "user_accounts" ADD "failed_login_attempts_count" integer NOT NULL DEFAULT '0'` + ) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "user_accounts" DROP COLUMN "failed_login_attempts_count"`) + await queryRunner.query(`ALTER TABLE "user_accounts" DROP COLUMN "last_login_at"`) + } +} diff --git a/backend/core/src/migration/1638356116695-add-user-password-outdating-fields.ts b/backend/core/src/migration/1638356116695-add-user-password-outdating-fields.ts new file mode 100644 index 0000000000..9650fae6e2 --- /dev/null +++ b/backend/core/src/migration/1638356116695-add-user-password-outdating-fields.ts @@ -0,0 +1,19 @@ +import { MigrationInterface, QueryRunner } from "typeorm" + +export class addUserPasswordOutdatingFields1638356116695 implements MigrationInterface { + name = "addUserPasswordOutdatingFields1638356116695" + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "user_accounts" ADD "password_updated_at" TIMESTAMP NOT NULL DEFAULT NOW()` + ) + await queryRunner.query( + `ALTER TABLE "user_accounts" ADD "password_valid_for_days" integer NOT NULL DEFAULT '180'` + ) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "user_accounts" DROP COLUMN "password_valid_for_days"`) + await queryRunner.query(`ALTER TABLE "user_accounts" DROP COLUMN "password_updated_at"`) + } +} diff --git a/backend/core/src/migration/1638439254453-add-activity-log-metadata.ts b/backend/core/src/migration/1638439254453-add-activity-log-metadata.ts new file mode 100644 index 0000000000..7cafcd0651 --- /dev/null +++ b/backend/core/src/migration/1638439254453-add-activity-log-metadata.ts @@ -0,0 +1,21 @@ +import { MigrationInterface, QueryRunner } from "typeorm" + +export class addActivityLogMetadata1638439254453 implements MigrationInterface { + name = "addActivityLogMetadata1638439254453" + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "activity_logs" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "created_at" TIMESTAMP NOT NULL DEFAULT now(), "updated_at" TIMESTAMP NOT NULL DEFAULT now(), "module" character varying NOT NULL, "record_id" uuid NOT NULL, "action" character varying NOT NULL, "metadata" jsonb, "user_id" uuid, CONSTRAINT "PK_f25287b6140c5ba18d38776a796" PRIMARY KEY ("id"))` + ) + await queryRunner.query( + `ALTER TABLE "activity_logs" ADD CONSTRAINT "FK_d54f841fa5478e4734590d44036" FOREIGN KEY ("user_id") REFERENCES "user_accounts"("id") ON DELETE NO ACTION ON UPDATE NO ACTION` + ) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "activity_logs" DROP CONSTRAINT "FK_d54f841fa5478e4734590d44036"` + ) + await queryRunner.query(`DROP TABLE "activity_logs"`) + } +} diff --git a/backend/core/src/migration/1638478574456-add-tagalog.ts b/backend/core/src/migration/1638478574456-add-tagalog.ts new file mode 100644 index 0000000000..1cb7b3f6ce --- /dev/null +++ b/backend/core/src/migration/1638478574456-add-tagalog.ts @@ -0,0 +1,39 @@ +import { MigrationInterface, QueryRunner } from "typeorm" + +export class addTagalog1638478574456 implements MigrationInterface { + name = "addTagalog1638478574456" + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TYPE "jurisdictions_languages_enum" RENAME TO "jurisdictions_languages_enum_old"` + ) + await queryRunner.query( + `CREATE TYPE "jurisdictions_languages_enum" AS ENUM('en', 'es', 'vi', 'zh', 'tl')` + ) + await queryRunner.query(`ALTER TABLE "jurisdictions" ALTER COLUMN "languages" DROP DEFAULT`) + await queryRunner.query( + `ALTER TABLE "jurisdictions" ALTER COLUMN "languages" TYPE "jurisdictions_languages_enum"[] USING "languages"::"text"::"jurisdictions_languages_enum"[]` + ) + await queryRunner.query( + `ALTER TABLE "jurisdictions" ALTER COLUMN "languages" SET DEFAULT '{en}'` + ) + await queryRunner.query(`DROP TYPE "jurisdictions_languages_enum_old"`) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TYPE "jurisdictions_languages_enum_old" AS ENUM('en', 'es', 'vi', 'zh')` + ) + await queryRunner.query(`ALTER TABLE "jurisdictions" ALTER COLUMN "languages" DROP DEFAULT`) + await queryRunner.query( + `ALTER TABLE "jurisdictions" ALTER COLUMN "languages" TYPE "jurisdictions_languages_enum_old"[] USING "languages"::"text"::"jurisdictions_languages_enum_old"[]` + ) + await queryRunner.query( + `ALTER TABLE "jurisdictions" ALTER COLUMN "languages" SET DEFAULT '{en}'` + ) + await queryRunner.query(`DROP TYPE "jurisdictions_languages_enum"`) + await queryRunner.query( + `ALTER TYPE "jurisdictions_languages_enum_old" RENAME TO "jurisdictions_languages_enum"` + ) + } +} diff --git a/backend/core/src/migration/1639051722043-add-mfa-related-columns-to-user-entity.ts b/backend/core/src/migration/1639051722043-add-mfa-related-columns-to-user-entity.ts new file mode 100644 index 0000000000..b51dcd9565 --- /dev/null +++ b/backend/core/src/migration/1639051722043-add-mfa-related-columns-to-user-entity.ts @@ -0,0 +1,41 @@ +import { MigrationInterface, QueryRunner } from "typeorm" + +export class addMfaRelatedColumnsToUserEntity1639051722043 implements MigrationInterface { + name = "addMfaRelatedColumnsToUserEntity1639051722043" + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "user_accounts" ADD "mfa_enabled" boolean NOT NULL DEFAULT FALSE` + ) + await queryRunner.query(`ALTER TABLE "user_accounts" ADD "mfa_code" character varying`) + await queryRunner.query(` + UPDATE user_accounts + SET mfa_enabled = false + WHERE id IN + (SELECT id + FROM user_accounts + LEFT JOIN user_roles on user_accounts.id = user_roles.user_id WHERE user_roles.is_partner = true OR user_roles.is_admin = true)`) + + await queryRunner.query( + `ALTER TABLE "user_accounts" ADD "mfa_code_updated_at" TIMESTAMP WITH TIME ZONE` + ) + + const mfaCodeEmail = { + message: "Access token for your account has been requested.", + mfaCode: "Your access token is: %{mfaCode}", + } + const translations = await queryRunner.query(`SELECT * from translations`) + for (const t of translations) { + await queryRunner.query(`UPDATE translations SET translations = ($1) WHERE id = ($2)`, [ + { ...t.translations, mfaCodeEmail }, + t.id, + ]) + } + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "user_accounts" DROP COLUMN "mfa_code_updated_at"`) + await queryRunner.query(`ALTER TABLE "user_accounts" DROP COLUMN "mfa_code"`) + await queryRunner.query(`ALTER TABLE "user_accounts" DROP COLUMN "mfa_enabled"`) + } +} diff --git a/backend/core/src/migration/1639135587230-add-partner-terms-to-jurisdiction.ts b/backend/core/src/migration/1639135587230-add-partner-terms-to-jurisdiction.ts new file mode 100644 index 0000000000..a4d140cfbd --- /dev/null +++ b/backend/core/src/migration/1639135587230-add-partner-terms-to-jurisdiction.ts @@ -0,0 +1,13 @@ +import { MigrationInterface, QueryRunner } from "typeorm" + +export class addPartnerTermsToJurisdiction1639135587230 implements MigrationInterface { + name = "addPartnerTermsToJurisdiction1639135587230" + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "jurisdictions" ADD "partner_terms" text`) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "jurisdictions" DROP COLUMN "partner_terms"`) + } +} diff --git a/backend/core/src/migration/1639417775347-favorites_user_preference_new.ts b/backend/core/src/migration/1639417775347-favorites_user_preference_new.ts new file mode 100644 index 0000000000..8708257a3c --- /dev/null +++ b/backend/core/src/migration/1639417775347-favorites_user_preference_new.ts @@ -0,0 +1,35 @@ +import { MigrationInterface, QueryRunner } from "typeorm" + +export class favoritesUserPreferenceNew1639417775347 implements MigrationInterface { + name = "favoritesUserPreferenceNew1639417775347" + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "user_preferences_favorites_listings" ("user_preferences_user_id" uuid NOT NULL, "listings_id" uuid NOT NULL, CONSTRAINT "PK_a2e38b75e1a538e046de2fba364" PRIMARY KEY ("user_preferences_user_id", "listings_id"))` + ) + await queryRunner.query( + `CREATE INDEX "IDX_0115bda0994ab10a4c1a883504" ON "user_preferences_favorites_listings" ("user_preferences_user_id") ` + ) + await queryRunner.query( + `CREATE INDEX "IDX_c971c586f08b7fe93fcaf29ec0" ON "user_preferences_favorites_listings" ("listings_id") ` + ) + await queryRunner.query( + `ALTER TABLE "user_preferences_favorites_listings" ADD CONSTRAINT "FK_0115bda0994ab10a4c1a883504e" FOREIGN KEY ("user_preferences_user_id") REFERENCES "user_preferences"("user_id") ON DELETE CASCADE ON UPDATE CASCADE` + ) + await queryRunner.query( + `ALTER TABLE "user_preferences_favorites_listings" ADD CONSTRAINT "FK_c971c586f08b7fe93fcaf29ec05" FOREIGN KEY ("listings_id") REFERENCES "listings"("id") ON DELETE NO ACTION ON UPDATE NO ACTION` + ) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "user_preferences_favorites_listings" DROP CONSTRAINT "FK_c971c586f08b7fe93fcaf29ec05"` + ) + await queryRunner.query( + `ALTER TABLE "user_preferences_favorites_listings" DROP CONSTRAINT "FK_0115bda0994ab10a4c1a883504e"` + ) + await queryRunner.query(`DROP INDEX "IDX_c971c586f08b7fe93fcaf29ec0"`) + await queryRunner.query(`DROP INDEX "IDX_0115bda0994ab10a4c1a883504"`) + await queryRunner.query(`DROP TABLE "user_preferences_favorites_listings"`) + } +} diff --git a/backend/core/src/migration/1639561971201-add-public-url-to-jurisdiction.ts b/backend/core/src/migration/1639561971201-add-public-url-to-jurisdiction.ts new file mode 100644 index 0000000000..bb7710e9c6 --- /dev/null +++ b/backend/core/src/migration/1639561971201-add-public-url-to-jurisdiction.ts @@ -0,0 +1,90 @@ +import { MigrationInterface, QueryRunner } from "typeorm" +import { CountyCode } from "../shared/types/county-code" + +export class addPublicUrlToJurisdiction1639561971201 implements MigrationInterface { + name = "addPublicUrlToJurisdiction1639561971201" + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "jurisdictions" ADD "public_url" text NOT NULL DEFAULT ''`) + const jurisdictions: Array<{ + id: string + name: string + public_url: string + }> = await queryRunner.query(`SELECT id, name, public_url from jurisdictions`) + + const alamedaJurisdiction = jurisdictions.find((j) => j.name === CountyCode.alameda) + const sanJoseJurisdiction = jurisdictions.find((j) => j.name === CountyCode.san_jose) + const sanMateoJurisdiction = jurisdictions.find((j) => j.name === CountyCode.san_mateo) + const detroitJurisdiction = jurisdictions.find((j) => j.name === CountyCode.detroit) + + if (process.env.PARTNERS_PORTAL_URL === "https://partners.housingbayarea.bloom.exygy.dev") { + // staging + if (alamedaJurisdiction) { + alamedaJurisdiction.public_url = "https://ala.bloom.exygy.dev" + } + if (sanJoseJurisdiction) { + sanJoseJurisdiction.public_url = "https://sj.bloom.exygy.dev" + } + if (sanMateoJurisdiction) { + sanMateoJurisdiction.public_url = "https://smc.bloom.exygy.dev" + } + } else if (process.env.PARTNERS_PORTAL_URL === "https://partners.housingbayarea.org") { + // production + if (alamedaJurisdiction) { + alamedaJurisdiction.public_url = "https://housing.acgov.org" + } + if (sanJoseJurisdiction) { + sanJoseJurisdiction.public_url = "https://housing.sanjoseca.gov" + } + if (sanMateoJurisdiction) { + sanMateoJurisdiction.public_url = "https://smc.housingbayarea.org" + } + } else if (process.env.PARTNERS_PORTAL_URL === "https://dev-partners-bloom.netlify.app") { + // dev + if (alamedaJurisdiction) { + alamedaJurisdiction.public_url = "https://dev-bloom.netlify.app" + } + if (sanJoseJurisdiction) { + sanJoseJurisdiction.public_url = "https://dev-bloom.netlify.app" + } + if (sanMateoJurisdiction) { + sanMateoJurisdiction.public_url = "https://dev-bloom.netlify.app" + } + if (detroitJurisdiction) { + detroitJurisdiction.public_url = "https://detroit-public-dev.netlify.app" + } + } else if (process.env.PARTNERS_PORTAL_URL === "http://localhost:3001") { + // local + if (alamedaJurisdiction) { + alamedaJurisdiction.public_url = "http://localhost:3000" + } + if (sanJoseJurisdiction) { + sanJoseJurisdiction.public_url = "http://localhost:3000" + } + if (sanMateoJurisdiction) { + sanMateoJurisdiction.public_url = "http://localhost:3000" + } + if (detroitJurisdiction) { + detroitJurisdiction.public_url = "http://localhost:3000" + } + } + + for (const jurisdiction of [ + alamedaJurisdiction, + sanJoseJurisdiction, + sanMateoJurisdiction, + detroitJurisdiction, + ]) { + if (jurisdiction) { + await queryRunner.query(`UPDATE jurisdictions SET public_url = $1 WHERE id = $2`, [ + jurisdiction.public_url, + jurisdiction.id, + ]) + } + } + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "jurisdictions" DROP COLUMN "public_url"`) + } +} diff --git a/backend/core/src/migration/1640110170049-seed-detroit-translation-entries.ts b/backend/core/src/migration/1640110170049-seed-detroit-translation-entries.ts new file mode 100644 index 0000000000..59ad77e8b2 --- /dev/null +++ b/backend/core/src/migration/1640110170049-seed-detroit-translation-entries.ts @@ -0,0 +1,41 @@ +import { MigrationInterface, QueryRunner } from "typeorm" +import { Language } from "../shared/types/language-enum" + +export class seedDetroitTranslationEntries1640110170049 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + const listingEmail = { + newListing: { + title: "Rental opportunity at", + applicationDue: "Application Due", + addressLabel: "Address", + unitsLabel: "Units", + rentLabel: "Rent", + seeListingLabel: "See Listing And Apply", + dhcProjectLabel: "Detroit Home Connect is a project of the", + hrdLabel: "Housing & Revitalization Department of the City of Detroit", + unsubscribeMsg: "Unsubscribe from list", + }, + updateListing: { + title: "Reminder to update your listing", + verifyMsg: "Verify the following information is correct.", + listingLabel: "Listing", + addressLabel: "Address", + unitsLabel: "Units", + rentLabel: "Rent", + seeListingLabel: "See Listing", + dhcProjectLabel: "Detroit Home Connect is a project of the", + hrdLabel: "Housing & Revitalization Department of the City of Detroit", + unsubscribeMsg: "Unsubscribe from list", + }, + } + const translations = await queryRunner.query(`SELECT * from translations`) + for (const t of translations) { + await queryRunner.query(`UPDATE "translations" SET translations = ($1) WHERE id = ($2)`, [ + { ...t.translations, listingEmail }, + t.id, + ]) + } + } + + public async down(queryRunner: QueryRunner): Promise {} +} diff --git a/backend/core/src/migration/1640878095625-favoriteID.ts b/backend/core/src/migration/1640878095625-favoriteID.ts new file mode 100644 index 0000000000..2ee63690f5 --- /dev/null +++ b/backend/core/src/migration/1640878095625-favoriteID.ts @@ -0,0 +1,15 @@ +import { MigrationInterface, QueryRunner } from "typeorm" + +export class favoriteID1640878095625 implements MigrationInterface { + name = "favoriteID1640878095625" + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "user_preferences" ADD "favorite_ids" text array NOT NULL DEFAULT '{}'` + ) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "user_preferences" DROP COLUMN "favorite_ids"`) + } +} diff --git a/backend/core/src/migration/1641855882656-remove-application-due-time.ts b/backend/core/src/migration/1641855882656-remove-application-due-time.ts new file mode 100644 index 0000000000..58aa398f93 --- /dev/null +++ b/backend/core/src/migration/1641855882656-remove-application-due-time.ts @@ -0,0 +1,44 @@ +import { MigrationInterface, QueryRunner } from "typeorm" + +export class removeApplicationDueTime1641855882656 implements MigrationInterface { + name = "removeApplicationDueTime1641855882656" + + public async up(queryRunner: QueryRunner): Promise { + const listings = await queryRunner.query( + `SELECT id, application_due_time, application_due_date from listings` + ) + + for (const listing of listings) { + let dateTimeString = null + + // If existing due date, pull in existing time or set time as 5pm + if (listing["application_due_date"]) { + let dueDate = new Date(listing["application_due_date"]) + if (listing["application_due_time"]) { + const dueTime = new Date(listing["application_due_time"]) + dueDate.setHours(dueTime.getHours()) + } else { + dueDate.setHours(17, 0, 0, 0) + } + // Format date into db input format + const modifiedDateString = dueDate.toISOString() + const timeDelimiter = modifiedDateString.indexOf("T") + const dateString = modifiedDateString.substr(0, timeDelimiter) + const timeString = modifiedDateString.substr(timeDelimiter + 1) + dateTimeString = `${dateString} ${timeString}` + } + + await queryRunner.query("UPDATE listings SET application_due_date = ($1) WHERE id = ($2)", [ + dateTimeString, + listing["id"], + ]) + } + await queryRunner.query(`ALTER TABLE "listings" DROP COLUMN "application_due_time"`) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "listings" ADD "application_due_time" TIMESTAMP WITH TIME ZONE` + ) + } +} diff --git a/backend/core/src/migration/1641860318345-add-mailing-address-type.ts b/backend/core/src/migration/1641860318345-add-mailing-address-type.ts new file mode 100644 index 0000000000..ef5d0ab282 --- /dev/null +++ b/backend/core/src/migration/1641860318345-add-mailing-address-type.ts @@ -0,0 +1,59 @@ +import { MigrationInterface, QueryRunner } from "typeorm" + +export class addMailingAddressType1641860318345 implements MigrationInterface { + name = "addMailingAddressType1641860318345" + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TYPE "listings_application_mailing_address_type_enum" AS ENUM('leasingAgent')` + ) + await queryRunner.query( + `ALTER TABLE "listings" ADD "application_mailing_address_type" "listings_application_mailing_address_type_enum"` + ) + await queryRunner.query( + `ALTER TYPE "listings_application_pick_up_address_type_enum" RENAME TO "listings_application_pick_up_address_type_enum_old"` + ) + await queryRunner.query( + `CREATE TYPE "listings_application_pick_up_address_type_enum" AS ENUM('leasingAgent')` + ) + await queryRunner.query( + `ALTER TABLE "listings" ALTER COLUMN "application_pick_up_address_type" TYPE "listings_application_pick_up_address_type_enum" USING "application_pick_up_address_type"::"text"::"listings_application_pick_up_address_type_enum"` + ) + await queryRunner.query(`DROP TYPE "listings_application_pick_up_address_type_enum_old"`) + await queryRunner.query( + `ALTER TYPE "listings_application_drop_off_address_type_enum" RENAME TO "listings_application_drop_off_address_type_enum_old"` + ) + await queryRunner.query( + `CREATE TYPE "listings_application_drop_off_address_type_enum" AS ENUM('leasingAgent')` + ) + await queryRunner.query( + `ALTER TABLE "listings" ALTER COLUMN "application_drop_off_address_type" TYPE "listings_application_drop_off_address_type_enum" USING "application_drop_off_address_type"::"text"::"listings_application_drop_off_address_type_enum"` + ) + await queryRunner.query(`DROP TYPE "listings_application_drop_off_address_type_enum_old"`) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TYPE "listings_application_drop_off_address_type_enum_old" AS ENUM('leasingAgent', 'mailingAddress')` + ) + await queryRunner.query( + `ALTER TABLE "listings" ALTER COLUMN "application_drop_off_address_type" TYPE "listings_application_drop_off_address_type_enum_old" USING "application_drop_off_address_type"::"text"::"listings_application_drop_off_address_type_enum_old"` + ) + await queryRunner.query(`DROP TYPE "listings_application_drop_off_address_type_enum"`) + await queryRunner.query( + `ALTER TYPE "listings_application_drop_off_address_type_enum_old" RENAME TO "listings_application_drop_off_address_type_enum"` + ) + await queryRunner.query( + `CREATE TYPE "listings_application_pick_up_address_type_enum_old" AS ENUM('leasingAgent', 'mailingAddress')` + ) + await queryRunner.query( + `ALTER TABLE "listings" ALTER COLUMN "application_pick_up_address_type" TYPE "listings_application_pick_up_address_type_enum_old" USING "application_pick_up_address_type"::"text"::"listings_application_pick_up_address_type_enum_old"` + ) + await queryRunner.query(`DROP TYPE "listings_application_pick_up_address_type_enum"`) + await queryRunner.query( + `ALTER TYPE "listings_application_pick_up_address_type_enum_old" RENAME TO "listings_application_pick_up_address_type_enum"` + ) + await queryRunner.query(`ALTER TABLE "listings" DROP COLUMN "application_mailing_address_type"`) + await queryRunner.query(`DROP TYPE "listings_application_mailing_address_type_enum"`) + } +} diff --git a/backend/core/src/migration/1643191027392-add-dates-to-listing.ts b/backend/core/src/migration/1643191027392-add-dates-to-listing.ts new file mode 100644 index 0000000000..d369cc0f19 --- /dev/null +++ b/backend/core/src/migration/1643191027392-add-dates-to-listing.ts @@ -0,0 +1,15 @@ +import { MigrationInterface, QueryRunner } from "typeorm" + +export class addDatesToListing1643191027392 implements MigrationInterface { + name = "addDatesToListing1643191027392" + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "listings" ADD "published_at" TIMESTAMP WITH TIME ZONE`) + await queryRunner.query(`ALTER TABLE "listings" ADD "closed_at" TIMESTAMP WITH TIME ZONE`) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "listings" DROP COLUMN "closed_at"`) + await queryRunner.query(`ALTER TABLE "listings" DROP COLUMN "published_at"`) + } +} diff --git a/backend/core/src/migration/1644325106310-add-listings-closed-at-fill.ts b/backend/core/src/migration/1644325106310-add-listings-closed-at-fill.ts new file mode 100644 index 0000000000..2c524d8a04 --- /dev/null +++ b/backend/core/src/migration/1644325106310-add-listings-closed-at-fill.ts @@ -0,0 +1,11 @@ +import { MigrationInterface, QueryRunner } from "typeorm" + +export class addListingsClosedAtFill1644325106310 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `UPDATE listings SET closed_at = application_due_date WHERE closed_at is null` + ) + } + + public async down(queryRunner: QueryRunner): Promise {} +} diff --git a/backend/core/src/migration/1644365733034-add-custom-email-from.ts b/backend/core/src/migration/1644365733034-add-custom-email-from.ts new file mode 100644 index 0000000000..5528f6b252 --- /dev/null +++ b/backend/core/src/migration/1644365733034-add-custom-email-from.ts @@ -0,0 +1,34 @@ +import { MigrationInterface, QueryRunner } from "typeorm" +import { CountyCode } from "../shared/types/county-code" + +export class addCustomEmailFrom1644365733034 implements MigrationInterface { + name = "addCustomEmailFrom1644365733034" + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "jurisdictions" ADD "email_from_address" text`) + const jurisdictions: Array<{ + id: string + name: string + public_url: string + }> = await queryRunner.query(`SELECT id, name, public_url from jurisdictions`) + + const setEmailFromAddress = async (emailFromAddress: string, countyCode: CountyCode) => { + const jurisdiction = jurisdictions.find((j) => j.name === countyCode) + if (jurisdiction) { + await queryRunner.query(`UPDATE jurisdictions SET email_from_address = $1 WHERE id = $2`, [ + emailFromAddress, + jurisdiction.id, + ]) + } + } + + setEmailFromAddress("Alameda: Housing Bay Area ", CountyCode.alameda) + setEmailFromAddress("SJ: HousingBayArea.org ", CountyCode.san_jose) + setEmailFromAddress("SMC: HousingBayArea.org ", CountyCode.san_mateo) + setEmailFromAddress("Detroit Housing ", CountyCode.detroit) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "jurisdictions" DROP COLUMN "email_from_address"`) + } +} diff --git a/backend/core/src/migration/1644441969354-addPhoneNumberVerified.ts b/backend/core/src/migration/1644441969354-addPhoneNumberVerified.ts new file mode 100644 index 0000000000..6fc6978800 --- /dev/null +++ b/backend/core/src/migration/1644441969354-addPhoneNumberVerified.ts @@ -0,0 +1,15 @@ +import { MigrationInterface, QueryRunner } from "typeorm" + +export class addPhoneNumberVerified1644441969354 implements MigrationInterface { + name = "addPhoneNumberVerified1644441969354" + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "user_accounts" ADD COLUMN "phone_number_verified" BOOLEAN DEFAULT FALSE` + ) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "user_accounts" DROP COLUMN "phone_number_verified"`) + } +} diff --git a/backend/core/src/migration/1644581761889-convert-listing-image-to-array.ts b/backend/core/src/migration/1644581761889-convert-listing-image-to-array.ts new file mode 100644 index 0000000000..da6cea92da --- /dev/null +++ b/backend/core/src/migration/1644581761889-convert-listing-image-to-array.ts @@ -0,0 +1,48 @@ +import { MigrationInterface, QueryRunner } from "typeorm" + +export class convertListingImageToArray1644581761889 implements MigrationInterface { + name = "convertListingImageToArray1644581761889" + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "listings" DROP CONSTRAINT "FK_ecc271b96bd18df0efe47b85186"` + ) + await queryRunner.query( + `CREATE TABLE "listing_images" ("ordinal" integer, "listing_id" uuid NOT NULL, "image_id" uuid NOT NULL, CONSTRAINT "PK_beb1c8e9f64f578908135aa6899" PRIMARY KEY ("listing_id", "image_id"))` + ) + await queryRunner.query( + `CREATE INDEX "IDX_94041359df3c1b14c4420808d1" ON "listing_images" ("listing_id") ` + ) + await queryRunner.query( + `ALTER TABLE "listing_images" ADD CONSTRAINT "FK_94041359df3c1b14c4420808d16" FOREIGN KEY ("listing_id") REFERENCES "listings"("id") ON DELETE NO ACTION ON UPDATE NO ACTION` + ) + await queryRunner.query( + `ALTER TABLE "listing_images" ADD CONSTRAINT "FK_6fc0fefe11fb46d5ee863ed483a" FOREIGN KEY ("image_id") REFERENCES "assets"("id") ON DELETE NO ACTION ON UPDATE NO ACTION` + ) + const listings: [{ id: string; image_id: string }] = await queryRunner.query( + `SELECT id, image_id FROM listings where image_id is not null` + ) + for (const l of listings) { + await queryRunner.query(`INSERT INTO listing_images (listing_id, image_id) VALUES ($1, $2)`, [ + l.id, + l.image_id, + ]) + } + await queryRunner.query(`ALTER TABLE "listings" DROP COLUMN "image_id"`) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "listing_images" DROP CONSTRAINT "FK_6fc0fefe11fb46d5ee863ed483a"` + ) + await queryRunner.query( + `ALTER TABLE "listing_images" DROP CONSTRAINT "FK_94041359df3c1b14c4420808d16"` + ) + await queryRunner.query(`ALTER TABLE "listings" ADD "image_id" uuid`) + await queryRunner.query(`DROP INDEX "IDX_94041359df3c1b14c4420808d1"`) + await queryRunner.query(`DROP TABLE "listing_images"`) + await queryRunner.query( + `ALTER TABLE "listings" ADD CONSTRAINT "FK_ecc271b96bd18df0efe47b85186" FOREIGN KEY ("image_id") REFERENCES "assets"("id") ON DELETE NO ACTION ON UPDATE NO ACTION` + ) + } +} diff --git a/backend/core/src/migration/1645001562848-make-translations-one-to-many-with-jurisdiction.ts b/backend/core/src/migration/1645001562848-make-translations-one-to-many-with-jurisdiction.ts new file mode 100644 index 0000000000..d68c5c8714 --- /dev/null +++ b/backend/core/src/migration/1645001562848-make-translations-one-to-many-with-jurisdiction.ts @@ -0,0 +1,37 @@ +import { MigrationInterface, QueryRunner } from "typeorm" + +export class makeTranslationsOneToManyWithJurisdiction1645001562848 implements MigrationInterface { + name = "makeTranslationsOneToManyWithJurisdiction1645001562848" + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "translations" DROP CONSTRAINT "FK_181f8168d13457f0fd00b08b359"` + ) + await queryRunner.query(`DROP INDEX "IDX_4655e7b2c26deb4b8156ea8100"`) + await queryRunner.query( + `ALTER TABLE "translations" DROP CONSTRAINT "UQ_181f8168d13457f0fd00b08b359"` + ) + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_4655e7b2c26deb4b8156ea8100" ON "translations" ("jurisdiction_id", "language") ` + ) + await queryRunner.query( + `ALTER TABLE "translations" ADD CONSTRAINT "FK_181f8168d13457f0fd00b08b359" FOREIGN KEY ("jurisdiction_id") REFERENCES "jurisdictions"("id") ON DELETE NO ACTION ON UPDATE NO ACTION` + ) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "translations" DROP CONSTRAINT "FK_181f8168d13457f0fd00b08b359"` + ) + await queryRunner.query(`DROP INDEX "IDX_4655e7b2c26deb4b8156ea8100"`) + await queryRunner.query( + `ALTER TABLE "translations" ADD CONSTRAINT "UQ_181f8168d13457f0fd00b08b359" UNIQUE ("jurisdiction_id")` + ) + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_4655e7b2c26deb4b8156ea8100" ON "translations" ("language", "jurisdiction_id") ` + ) + await queryRunner.query( + `ALTER TABLE "translations" ADD CONSTRAINT "FK_181f8168d13457f0fd00b08b359" FOREIGN KEY ("jurisdiction_id") REFERENCES "jurisdictions"("id") ON DELETE NO ACTION ON UPDATE NO ACTION` + ) + } +} diff --git a/backend/core/src/migration/1645051898243-updateSanJoseEmailTranslations.ts b/backend/core/src/migration/1645051898243-updateSanJoseEmailTranslations.ts new file mode 100644 index 0000000000..19cf4b7a92 --- /dev/null +++ b/backend/core/src/migration/1645051898243-updateSanJoseEmailTranslations.ts @@ -0,0 +1,207 @@ +import { MigrationInterface, QueryRunner } from "typeorm" +import { Language } from "../shared/types/language-enum" + +export class updateSanJoseEmailTranslations1645051898243 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + // First update the existing English translation for San Jose: + let sanJoseJurisdiction = await queryRunner.query( + `SELECT id FROM jurisdictions WHERE name = 'San Jose' LIMIT 1` + ) + + if (sanJoseJurisdiction.length === 0) return + + sanJoseJurisdiction = sanJoseJurisdiction[0].id + + let sanJoseTranslation = await queryRunner.query( + `SELECT translations FROM translations WHERE jurisdiction_id = ($1) AND language = ($2)`, + [sanJoseJurisdiction, Language.en] + ) + sanJoseTranslation = sanJoseTranslation["0"]["translations"] + + if (!sanJoseTranslation.confirmation) sanJoseTranslation.confirmation = {} + sanJoseTranslation.confirmation.thankYouForApplying = + "Thanks for applying for housing from the San Jose Doorway Portal. We have received your application for" + ;(sanJoseTranslation.footer.thankYou = "Thanks!"), + await queryRunner.query( + `UPDATE "translations" SET translations = ($1) where jurisdiction_id = ($2) and language = ($3)`, + [sanJoseTranslation, sanJoseJurisdiction, Language.en] + ) + + // Now add additional translations + + // Spanish + let sanJoseSpanish = await queryRunner.query( + `SELECT translations FROM translations WHERE jurisdiction_id = ($1) AND language = ($2)`, + [sanJoseJurisdiction, Language.es] + ) + if (sanJoseSpanish.length === 0) { + sanJoseSpanish = { + t: { + hello: "Hola", + }, + confirmation: { + yourConfirmationNumber: "Aquí tiene su número de confirmación:", + shouldBeChosen: + "Si su solicitud es elegida, prepárese para llenar una solicitud más detallada y proporcionar los documentos de apoyo necesarios.", + subject: "Confirmación de su solicitud", + thankYouForApplying: + "Gracias por solicitar una vivienda desde el Portal de San José. Hemos recibido su solicitud para", + whatToExpectNext: "Qué esperar a continuación:", + whatToExpect: { + FCFS: + "El agente inmobiliario se pondrá en contacto con los solicitantes por orden de llegada hasta que se cubran las vacantes.", + noLottery: + "Los solicitantes serán contactados por el agente en orden de lista de espera hasta que se cubran las vacantes.", + }, + }, + leasingAgent: { + contactAgentToUpdateInfo: + "Si necesita actualizar la información de su solicitud, no vuelva a presentarla. Póngase en contacto con el agente. Vea a continuación la información de contacto del agente para este listado.", + officeHours: "Horario de oficina:", + }, + footer: { + footer: "Ciudad de San José, Departamento de Vivienda", + thankYou: "¡Gracias!", + }, + register: { + confirmMyAccount: "Confirmar mi cuenta", + toConfirmAccountMessage: + "Para completar la creación de su cuenta, haga clic en el siguiente enlace:", + welcome: "¡Bienvenido!", + welcomeMessage: + "Gracias por crear su cuenta en %{appUrl}. Ahora le será más fácil iniciar, guardar y enviar solicitudes en línea para los listados que aparecen en el sitio.", + }, + forgotPassword: { + callToAction: + "Si usted hizo esta solicitud, haga clic en el enlace de abajo para restablecer su contraseña:", + changePassword: "Cambiar mi contraseña", + ignoreRequest: "Si usted no lo solicitó, ignore este correo electrónico.", + passwordInfo: + "Su contraseña no cambiará hasta que acceda al enlace anterior y cree una nueva.", + resetRequest: + "Recientemente se ha solicitado el restablecimiento de su contraseña del sitio web del Portal de Vivienda Bloom para %{appUrl}.", + subject: "Forgot your password?", + }, + } + await queryRunner.query( + `INSERT into "translations" (jurisdiction_id, language, translations) VALUES ($1, $2, $3)`, + [sanJoseJurisdiction, Language.es, sanJoseSpanish] + ) + } + + // Vietnamese + let sanJoseVietnamese = await queryRunner.query( + `SELECT translations FROM translations WHERE jurisdiction_id = ($1) AND language = ($2)`, + [sanJoseJurisdiction, Language.vi] + ) + if (sanJoseVietnamese.length === 0) { + sanJoseVietnamese = { + t: { + hello: "Xin chào", + }, + confirmation: { + yourConfirmationNumber: "Đây là số xác nhận của bạn:", + shouldBeChosen: + "Nếu đơn đăng ký của bạn được chọn, hãy chuẩn bị để điền vào đơn đăng ký chi tiết hơn và cung cấp các tài liệu hỗ trợ cần thiết.", + subject: "Xác Nhận Đơn Đăng Ký Của Bạn", + thankYouForApplying: + "Cảm ơn bạn đã nộp đơn xin gia cư từ Cổng thông tin San Jose Doorway. Chúng tôi đã nhận được đơn đăng ký của bạn cho", + whatToExpectNext: "Điều gì sẽ xảy ra tiếp theo:", + whatToExpect: { + FCFS: + "Các ứng viên sẽ được đại lý tài sản liên hệ trên cơ sở ai đến trước phục vụ trước cho đến khi các chỗ trống được chiếm ngụ.", + noLottery: + "Các ứng viên sẽ được đại diện liên hệ theo thứ tự trong danh sách chờ cho đến khi các chỗ trống được chiếm ngụ.", + }, + }, + leasingAgent: { + contactAgentToUpdateInfo: + "Nếu bạn cần cập nhật thông tin trên đơn đăng ký của mình, thì đừng đăng ký lại. Liên hệ với đại lý. Xem bên dưới để biết thông tin liên hệ với Đại lý cho danh sách này.", + officeHours: "Giờ Hành Chính:", + }, + footer: { + footer: "Thành Phố San José, Sở Gia Cư", + thankYou: "Cảm ơn!", + }, + register: { + confirmMyAccount: "Xác nhận tài khoản của tôi", + toConfirmAccountMessage: + "Để hoàn tất việc tạo tài khoản của bạn, vui lòng nhấp vào liên kết bên dưới:", + welcome: "Chào mừng!", + welcomeMessage: + "Cảm ơn bạn đã thiết lập tài khoản của mình trên %{appUrl}. Giờ đây bạn sẽ dễ dàng hơn khi bắt đầu, lưu và gửi đơn đăng ký trực tuyến cho các danh sách có trên trang web.", + }, + forgotPassword: { + callToAction: + "Nếu bạn thực hiện yêu cầu này, vui lòng nhấp vào liên kết bên dưới để đặt lại mật khẩu của bạn:", + changePassword: "Thay đổi mật khẩu của tôi", + ignoreRequest: "Nếu bạn không yêu cầu điều này, vui lòng bỏ qua email này.", + passwordInfo: + "Mật khẩu của bạn sẽ không thay đổi cho đến khi bạn truy cập vào liên kết ở trên và tạo một mật khẩu mới.", + resetRequest: + "Yêu cầu đặt lại mật khẩu trang web Bloom Housing Portal của bạn cho %{appUrl} gần đây đã được thực hiện.", + subject: "Forgot your password?", + }, + } + await queryRunner.query( + `INSERT into "translations" (jurisdiction_id, language, translations) VALUES ($1, $2, $3)`, + [sanJoseJurisdiction, Language.vi, sanJoseVietnamese] + ) + } + + // Chinese + let sanJoseChinese = await queryRunner.query( + `SELECT translations FROM translations WHERE jurisdiction_id = ($1) AND language = ($2)`, + [sanJoseJurisdiction, Language.zh] + ) + if (sanJoseChinese.length === 0) { + sanJoseChinese = { + t: { + hello: "您好", + }, + confirmation: { + yourConfirmationNumber: "這是您的確認號碼:", + shouldBeChosen: + "如果選中您的申請表,請準備填寫一份更詳細的申請表,並提供所需的證明文件。", + subject: "您的申請確認", + thankYouForApplying: "感謝您透過聖荷西門戶網站申請住房。我們收到了您關於 的申請", + whatToExpectNext: "後續流程:", + whatToExpect: { + FCFS: "房地產代理人會以先到先得的方式聯繫申請人,直到額滿為止。", + noLottery: "代理人會按照候補名單順序聯繫申請人,直到額滿為止。", + }, + }, + leasingAgent: { + contactAgentToUpdateInfo: + "如果您需要更新申請資訊,請勿再次申請。請聯繫租賃代理人。請參閱本物業清單所列代理人的聯繫資訊。", + officeHours: "辦公時間:", + }, + footer: { + footer: "聖荷西市住房局", + thankYou: "謝謝您!", + }, + register: { + confirmMyAccount: "聖荷西市住房局", + toConfirmAccountMessage: "要完成建立帳戶,請按以下連結:", + welcome: "歡迎!", + welcomeMessage: + "感謝您在%{appUrl}建立帳戶。現在,您可以更輕鬆地針對網站列出的物業清單建立、儲存及提交線上申請。", + }, + forgotPassword: { + callToAction: "如果您確實提出請求,請按下方連結重設密碼:", + changePassword: "變更密碼", + ignoreRequest: "如果您沒有提出請求,請勿理會本電子郵件。", + passwordInfo: "在您按上方連結並建立一個新密碼之前,您的密碼不會變更。", + resetRequest: "最近我們收到了您為%{appUrl}重設Bloom住房門戶網站密碼的請求。", + subject: "Forgot your password?", + }, + } + await queryRunner.query( + `INSERT into "translations" (jurisdiction_id, language, translations) VALUES ($1, $2, $3)`, + [sanJoseJurisdiction, Language.zh, sanJoseChinese] + ) + } + } + + public async down(queryRunner: QueryRunner): Promise {} +} diff --git a/backend/core/src/migration/1645110873807-setProdPublishDates.ts b/backend/core/src/migration/1645110873807-setProdPublishDates.ts new file mode 100644 index 0000000000..ec04ca06b2 --- /dev/null +++ b/backend/core/src/migration/1645110873807-setProdPublishDates.ts @@ -0,0 +1,39 @@ +import { MigrationInterface, QueryRunner } from "typeorm" + +export class setProdPublishDates1645110873807 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + const toUpdate = [ + { name: "The Mix at SoHay", date: "2021-03-10T00:16:00.000Z" }, + { name: "City Center Apartments", date: "2021-05-14T00:16:00.000Z" }, + { name: "Coliseum Place", date: "2021-06-02T00:16:00.000Z" }, + { name: "1475 167th Avenue", date: "2021-06-15T00:16:00.000Z" }, + { name: "Jordan Court", date: "2021-08-25T00:16:00.000Z" }, + { name: "Foon Lok West", date: "2021-10-04T00:16:00.000Z" }, + { name: "Alexan Webster", date: "2021-07-22T00:16:00.000Z" }, + { name: "Blake at Berkeley", date: "2021-10-18T00:16:00.000Z" }, + { name: "Nova", date: "2021-06-02T00:16:00.000Z" }, + { name: "Aurora", date: "2021-06-02T00:16:00.000Z" }, + { name: "Loro Landing", date: "2021-09-27T00:16:00.000Z" }, + { name: "The Starling", date: "2021-10-04T00:16:00.000Z" }, + { name: "Corsair Flats II for Seniors 62+", date: "2022-01-05T00:16:00.000Z" }, + { name: "Rosefield Village", date: "2022-01-07T00:16:00.000Z" }, + { name: "Berkeley Way", date: "2022-02-08T00:16:00.000Z" }, + { name: "Reilly Station", date: "2020-07-30T00:16:00.000Z" }, + { name: "Monarch Homes", date: "2020-09-24T00:16:00.000Z" }, + { name: "Atlas", date: "2020-09-08T00:16:00.000Z" }, + { name: "Jones Berkeley", date: "2020-09-15T00:16:00.000Z" }, + { name: "The Logan", date: "2020-10-15T00:16:00.000Z" }, + { name: "The Skylyne at Temescal 2", date: "2020-10-06T00:16:00.000Z" }, + { name: "Avance", date: "2021-09-30T00:16:00.000Z" }, + ] + + for (const rec of toUpdate) { + await queryRunner.query(`UPDATE listings SET published_at = $1 WHERE name = $2`, [ + rec.date, + rec.name, + ]) + } + } + + public async down(queryRunner: QueryRunner): Promise {} +} diff --git a/backend/core/src/migration/1645640266273-add-units-summary-ami-levels-entity.ts b/backend/core/src/migration/1645640266273-add-units-summary-ami-levels-entity.ts new file mode 100644 index 0000000000..b7dd43f5d2 --- /dev/null +++ b/backend/core/src/migration/1645640266273-add-units-summary-ami-levels-entity.ts @@ -0,0 +1,88 @@ +import { MigrationInterface, QueryRunner } from "typeorm" + +export class addUnitsSummaryAmiLevelsEntity1645640266273 implements MigrationInterface { + name = "addUnitsSummaryAmiLevelsEntity1645640266273" + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE units_summary RENAME TO unit_group`) + await queryRunner.query( + `CREATE TYPE "unit_group_ami_levels_monthly_rent_determination_type_enum" AS ENUM('flatRent', 'percentageOfIncome')` + ) + await queryRunner.query( + `CREATE TABLE "unit_group_ami_levels" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "ami_percentage" integer NOT NULL, "monthly_rent_determination_type" "unit_group_ami_levels_monthly_rent_determination_type_enum" NOT NULL, "flat_rent_value" numeric(8,2), "percentage_of_income_value" integer, "ami_chart_id" uuid, "unit_group_id" uuid, CONSTRAINT "PK_4b540cae0d35b199c0448610378" PRIMARY KEY ("id"))` + ) + await queryRunner.query( + `CREATE TABLE "unit_group_unit_type_unit_types" ("unit_group_id" uuid NOT NULL, "unit_types_id" uuid NOT NULL, CONSTRAINT "PK_4f2d90a894495a3cb72e5f0d2c8" PRIMARY KEY ("unit_group_id", "unit_types_id"))` + ) + await queryRunner.query( + `CREATE INDEX "IDX_1951c380e8091486b980008886" ON "unit_group_unit_type_unit_types" ("unit_group_id") ` + ) + await queryRunner.query( + `CREATE INDEX "IDX_b905b8bda3171b06c7a5d4d671" ON "unit_group_unit_type_unit_types" ("unit_types_id") ` + ) + await queryRunner.query( + `ALTER TABLE "unit_group" DROP COLUMN "monthly_rent_as_percent_of_income"` + ) + await queryRunner.query(`ALTER TABLE "unit_group" DROP COLUMN "ami_percentage"`) + await queryRunner.query(`ALTER TABLE "unit_group" DROP COLUMN "minimum_income_min"`) + await queryRunner.query(`ALTER TABLE "unit_group" DROP COLUMN "minimum_income_max"`) + await queryRunner.query(`ALTER TABLE "unit_group" DROP COLUMN "unit_type_id"`) + await queryRunner.query(`ALTER TABLE "unit_group" DROP COLUMN "monthly_rent_min"`) + await queryRunner.query(`ALTER TABLE "unit_group" DROP COLUMN "monthly_rent_max"`) + await queryRunner.query(`ALTER TABLE "unit_group" ADD "bathroom_min" integer`) + await queryRunner.query(`ALTER TABLE "unit_group" ADD "bathroom_max" integer`) + await queryRunner.query( + `ALTER TABLE "unit_group" ADD "open_waitlist" boolean NOT NULL DEFAULT true` + ) + await queryRunner.query( + `ALTER TABLE "unit_group_ami_levels" ADD CONSTRAINT "FK_859a749beeb93898cfe3aa318e7" FOREIGN KEY ("ami_chart_id") REFERENCES "ami_chart"("id") ON DELETE NO ACTION ON UPDATE NO ACTION` + ) + await queryRunner.query( + `ALTER TABLE "unit_group_ami_levels" ADD CONSTRAINT "FK_c15eff18d0384540366861a1c9c" FOREIGN KEY ("unit_group_id") REFERENCES "unit_group"("id") ON DELETE NO ACTION ON UPDATE NO ACTION` + ) + await queryRunner.query( + `ALTER TABLE "unit_group_unit_type_unit_types" ADD CONSTRAINT "FK_1951c380e8091486b9800088865" FOREIGN KEY ("unit_group_id") REFERENCES "unit_group"("id") ON DELETE CASCADE ON UPDATE CASCADE` + ) + await queryRunner.query( + `ALTER TABLE "unit_group_unit_type_unit_types" ADD CONSTRAINT "FK_b905b8bda3171b06c7a5d4d6712" FOREIGN KEY ("unit_types_id") REFERENCES "unit_types"("id") ON DELETE CASCADE ON UPDATE CASCADE` + ) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE unit_group RENAME TO units_summary`) + await queryRunner.query( + `ALTER TABLE "units_summary_unit_type_unit_types" DROP CONSTRAINT "FK_b905b8bda3171b06c7a5d4d6712"` + ) + await queryRunner.query( + `ALTER TABLE "units_summary_unit_type_unit_types" DROP CONSTRAINT "FK_1951c380e8091486b9800088865"` + ) + await queryRunner.query( + `ALTER TABLE "units_summary_ami_levels" DROP CONSTRAINT "FK_c15eff18d0384540366861a1c9c"` + ) + await queryRunner.query( + `ALTER TABLE "units_summary_ami_levels" DROP CONSTRAINT "FK_859a749beeb93898cfe3aa318e7"` + ) + await queryRunner.query(`ALTER TABLE "units_summary" DROP COLUMN "open_waitlist"`) + await queryRunner.query(`ALTER TABLE "units_summary" DROP COLUMN "bathroom_max"`) + await queryRunner.query(`ALTER TABLE "units_summary" DROP COLUMN "bathroom_min"`) + await queryRunner.query(`ALTER TABLE "units_summary" ADD "monthly_rent_max" integer`) + await queryRunner.query(`ALTER TABLE "units_summary" ADD "monthly_rent_min" integer`) + await queryRunner.query(`ALTER TABLE "units_summary" ADD "unit_type_id" uuid`) + await queryRunner.query(`ALTER TABLE "units_summary" ADD "minimum_income_max" text`) + await queryRunner.query(`ALTER TABLE "units_summary" ADD "minimum_income_min" text`) + await queryRunner.query(`ALTER TABLE "units_summary" ADD "ami_percentage" integer`) + await queryRunner.query( + `ALTER TABLE "units_summary" ADD "monthly_rent_as_percent_of_income" numeric(8,2)` + ) + await queryRunner.query(`DROP INDEX "IDX_b905b8bda3171b06c7a5d4d671"`) + await queryRunner.query(`DROP INDEX "IDX_1951c380e8091486b980008886"`) + await queryRunner.query(`DROP TABLE "units_summary_unit_type_unit_types"`) + await queryRunner.query(`DROP TABLE "units_summary_ami_levels"`) + await queryRunner.query( + `DROP TYPE "units_summary_ami_levels_monthly_rent_determination_type_enum"` + ) + await queryRunner.query( + `ALTER TABLE "units_summary" ADD CONSTRAINT "FK_0eae6ec11a6109496d80d9a88f9" FOREIGN KEY ("unit_type_id") REFERENCES "unit_types"("id") ON DELETE NO ACTION ON UPDATE NO ACTION` + ) + } +} diff --git a/backend/core/src/migration/1646350628943-detroitAmiCharts.ts b/backend/core/src/migration/1646350628943-detroitAmiCharts.ts new file mode 100644 index 0000000000..7b6012ac36 --- /dev/null +++ b/backend/core/src/migration/1646350628943-detroitAmiCharts.ts @@ -0,0 +1,29 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +import { HUD2021 } from "../seeder/seeds/ami-charts/HUD2021"; +import { MSHDA2021 } from "../seeder/seeds/ami-charts/MSHDA2021"; + +export class detroitAmiCharts1646350628943 implements MigrationInterface { + + public async up(queryRunner: QueryRunner): Promise { + const [{ id }] = await queryRunner.query(`SELECT id FROM jurisdictions WHERE name = 'Detroit'`) + + await queryRunner.query( + `INSERT INTO ami_chart + (name, items, jurisdiction_id) + VALUES ('${HUD2021.name}', '${JSON.stringify(HUD2021.items)}', '${id}') + ` + ) + + await queryRunner.query( + `INSERT INTO ami_chart + (name, items, jurisdiction_id) + VALUES ('${MSHDA2021.name}', '${JSON.stringify(MSHDA2021.items)}', '${id}') + ` + ) + } + + public async down(queryRunner: QueryRunner): Promise { + + } + +} diff --git a/backend/core/src/migration/1646422264825-addCommunityPrograms.ts b/backend/core/src/migration/1646422264825-addCommunityPrograms.ts new file mode 100644 index 0000000000..9d320a4513 --- /dev/null +++ b/backend/core/src/migration/1646422264825-addCommunityPrograms.ts @@ -0,0 +1,35 @@ +import {MigrationInterface, QueryRunner} from "typeorm"; + +export class addCommunityPrograms1646422264825 implements MigrationInterface { + + public async up(queryRunner: QueryRunner): Promise { + const [{ id }] = await queryRunner.query(`SELECT id FROM jurisdictions WHERE name = 'Detroit'`) + + await queryRunner.query( + `INSERT INTO programs (title, description) + VALUES + ('Seniors 55+', 'This property offers housing for residents ages 55 and older.'), + ('Seniors 62+', 'This property offers housing for residents ages 62 and older.'), + ('Residents with Disabilities', 'This property has reserved a large portion of its units for residents with disabilities. Contact this property to see if you qualify.'), + ('Families', 'This property offers housing for families. Ask the property if there are additional requirements to qualify as a family.'), + ('Supportive Housing for the Homeless', 'This property offers housing for those experiencing homelessness, and may require additional processes that applicants need to go through in order to qualify.'), + ('Veterans', 'This property offers housing for those who have served in the military, naval, or air service.') + ` + ) + + const res = await queryRunner.query(`SELECT id from programs WHERE title in ('Seniors 55+', 'Seniors 62+', 'Residents with Disabilities', 'Families', 'Supportive Housing for the Homeless', 'Veterans')`) + + for (const program of res) { + await queryRunner.query( + `INSERT INTO jurisdictions_programs_programs (jurisdictions_id, programs_id) + VALUES ($1, $2) + `, + [id, program.id] + ) + } + } + + public async down(queryRunner: QueryRunner): Promise { + } + +} diff --git a/backend/core/src/migration/1646642069064-add-new-listing-accessibility-features.ts b/backend/core/src/migration/1646642069064-add-new-listing-accessibility-features.ts new file mode 100644 index 0000000000..00dc940290 --- /dev/null +++ b/backend/core/src/migration/1646642069064-add-new-listing-accessibility-features.ts @@ -0,0 +1,20 @@ +import {MigrationInterface, QueryRunner} from "typeorm"; + +export class addNewListingAccessibilityFeatures1646642069064 implements MigrationInterface { + name = 'addNewListingAccessibilityFeatures1646642069064' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "listing_features" ADD "hearing" boolean`); + await queryRunner.query(`ALTER TABLE "listing_features" ADD "visual" boolean`); + await queryRunner.query(`ALTER TABLE "listing_features" ADD "mobility" boolean`); + await queryRunner.query(`ALTER TABLE "listings" ADD "temporary_listing_id" integer`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "listings" DROP COLUMN "temporary_listing_id"`); + await queryRunner.query(`ALTER TABLE "listing_features" DROP COLUMN "mobility"`); + await queryRunner.query(`ALTER TABLE "listing_features" DROP COLUMN "visual"`); + await queryRunner.query(`ALTER TABLE "listing_features" DROP COLUMN "hearing"`); + } + +} diff --git a/backend/core/src/migration/1646847820396-addIsVerified.ts b/backend/core/src/migration/1646847820396-addIsVerified.ts new file mode 100644 index 0000000000..483f0b0e32 --- /dev/null +++ b/backend/core/src/migration/1646847820396-addIsVerified.ts @@ -0,0 +1,13 @@ +import { MigrationInterface, QueryRunner } from "typeorm" + +export class addIsVerified1646847820396 implements MigrationInterface { + name = "addIsVerified1646847820396" + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "listings" ADD "is_verified" boolean DEFAULT false`) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "listings" DROP COLUMN "is_verified"`) + } +} diff --git a/backend/core/src/migration/1646866933450-sqFootToNumber.ts b/backend/core/src/migration/1646866933450-sqFootToNumber.ts new file mode 100644 index 0000000000..f10639c8fd --- /dev/null +++ b/backend/core/src/migration/1646866933450-sqFootToNumber.ts @@ -0,0 +1,25 @@ +import { MigrationInterface, QueryRunner } from "typeorm" + +export class sqFootToNumber1646866933450 implements MigrationInterface { + name = "sqFootToNumber1646866933450" + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "unit_group" DROP COLUMN "sq_feet_min"`) + await queryRunner.query(`ALTER TABLE "unit_group" ADD "sq_feet_min" integer`) + await queryRunner.query(`ALTER TABLE "unit_group" DROP COLUMN "sq_feet_max"`) + await queryRunner.query(`ALTER TABLE "unit_group" ADD "sq_feet_max" integer`) + await queryRunner.query(`ALTER TABLE "unit_group_ami_levels" DROP COLUMN "flat_rent_value"`) + await queryRunner.query(`ALTER TABLE "unit_group_ami_levels" ADD "flat_rent_value" integer`) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "unit_group" DROP COLUMN "sq_feet_max"`) + await queryRunner.query(`ALTER TABLE "unit_group" ADD "sq_feet_max" numeric(8,2)`) + await queryRunner.query(`ALTER TABLE "unit_group" DROP COLUMN "sq_feet_min"`) + await queryRunner.query(`ALTER TABLE "unit_group" ADD "sq_feet_min" numeric(8,2)`) + await queryRunner.query(`ALTER TABLE "unit_group_ami_levels" DROP COLUMN "flat_rent_value"`) + await queryRunner.query( + `ALTER TABLE "unit_group_ami_levels" ADD "flat_rent_value" numeric(8,2)` + ) + } +} diff --git a/backend/core/src/migration/1646872355286-catch-up.ts b/backend/core/src/migration/1646872355286-catch-up.ts new file mode 100644 index 0000000000..a9c6424b2e --- /dev/null +++ b/backend/core/src/migration/1646872355286-catch-up.ts @@ -0,0 +1,44 @@ +import {MigrationInterface, QueryRunner} from "typeorm"; + +export class catchUp1646872355286 implements MigrationInterface { + name = 'catchUp1646872355286' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "unit_group_ami_levels" DROP CONSTRAINT "FK_c15eff18d0384540366861a1c9c"`); + await queryRunner.query(`ALTER TABLE "unit_group_ami_levels" DROP CONSTRAINT "FK_859a749beeb93898cfe3aa318e7"`); + await queryRunner.query(`ALTER TABLE "unit_group" DROP CONSTRAINT "FK_4edda29192dbc0c6a18e15437a0"`); + await queryRunner.query(`ALTER TABLE "unit_group" DROP CONSTRAINT "FK_4791099ef82551aa9819a71d8f5"`); + await queryRunner.query(`ALTER TABLE "unit_group_unit_type_unit_types" DROP CONSTRAINT "FK_b905b8bda3171b06c7a5d4d6712"`); + await queryRunner.query(`ALTER TABLE "unit_group_unit_type_unit_types" DROP CONSTRAINT "FK_1951c380e8091486b9800088865"`); + await queryRunner.query(`DROP INDEX "IDX_1951c380e8091486b980008886"`); + await queryRunner.query(`DROP INDEX "IDX_b905b8bda3171b06c7a5d4d671"`); + await queryRunner.query(`CREATE INDEX "IDX_1ea90313ee94f48800e9eef751" ON "unit_group_unit_type_unit_types" ("unit_group_id") `); + await queryRunner.query(`CREATE INDEX "IDX_0cf027359361dfd394f08686da" ON "unit_group_unit_type_unit_types" ("unit_types_id") `); + await queryRunner.query(`ALTER TABLE "unit_group_ami_levels" ADD CONSTRAINT "FK_ff3f8de67facd164607f1ef43ae" FOREIGN KEY ("ami_chart_id") REFERENCES "ami_chart"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "unit_group_ami_levels" ADD CONSTRAINT "FK_ce82398e48c10dc23920c6ff05a" FOREIGN KEY ("unit_group_id") REFERENCES "unit_group"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "unit_group" ADD CONSTRAINT "FK_926790e4013043593a3976d84bd" FOREIGN KEY ("listing_id") REFERENCES "listings"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "unit_group" ADD CONSTRAINT "FK_e2660f5da2ff575954d765d920b" FOREIGN KEY ("priority_type_id") REFERENCES "unit_accessibility_priority_types"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "unit_group_unit_type_unit_types" ADD CONSTRAINT "FK_1ea90313ee94f48800e9eef751e" FOREIGN KEY ("unit_group_id") REFERENCES "unit_group"("id") ON DELETE CASCADE ON UPDATE CASCADE`); + await queryRunner.query(`ALTER TABLE "unit_group_unit_type_unit_types" ADD CONSTRAINT "FK_0cf027359361dfd394f08686da2" FOREIGN KEY ("unit_types_id") REFERENCES "unit_types"("id") ON DELETE CASCADE ON UPDATE CASCADE`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "unit_group_unit_type_unit_types" DROP CONSTRAINT "FK_0cf027359361dfd394f08686da2"`); + await queryRunner.query(`ALTER TABLE "unit_group_unit_type_unit_types" DROP CONSTRAINT "FK_1ea90313ee94f48800e9eef751e"`); + await queryRunner.query(`ALTER TABLE "unit_group" DROP CONSTRAINT "FK_e2660f5da2ff575954d765d920b"`); + await queryRunner.query(`ALTER TABLE "unit_group" DROP CONSTRAINT "FK_926790e4013043593a3976d84bd"`); + await queryRunner.query(`ALTER TABLE "unit_group_ami_levels" DROP CONSTRAINT "FK_ce82398e48c10dc23920c6ff05a"`); + await queryRunner.query(`ALTER TABLE "unit_group_ami_levels" DROP CONSTRAINT "FK_ff3f8de67facd164607f1ef43ae"`); + await queryRunner.query(`DROP INDEX "IDX_0cf027359361dfd394f08686da"`); + await queryRunner.query(`DROP INDEX "IDX_1ea90313ee94f48800e9eef751"`); + await queryRunner.query(`CREATE INDEX "IDX_b905b8bda3171b06c7a5d4d671" ON "unit_group_unit_type_unit_types" ("unit_types_id") `); + await queryRunner.query(`CREATE INDEX "IDX_1951c380e8091486b980008886" ON "unit_group_unit_type_unit_types" ("unit_group_id") `); + await queryRunner.query(`ALTER TABLE "unit_group_unit_type_unit_types" ADD CONSTRAINT "FK_1951c380e8091486b9800088865" FOREIGN KEY ("unit_group_id") REFERENCES "unit_group"("id") ON DELETE CASCADE ON UPDATE CASCADE`); + await queryRunner.query(`ALTER TABLE "unit_group_unit_type_unit_types" ADD CONSTRAINT "FK_b905b8bda3171b06c7a5d4d6712" FOREIGN KEY ("unit_types_id") REFERENCES "unit_types"("id") ON DELETE CASCADE ON UPDATE CASCADE`); + await queryRunner.query(`ALTER TABLE "unit_group" ADD CONSTRAINT "FK_4791099ef82551aa9819a71d8f5" FOREIGN KEY ("priority_type_id") REFERENCES "unit_accessibility_priority_types"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "unit_group" ADD CONSTRAINT "FK_4edda29192dbc0c6a18e15437a0" FOREIGN KEY ("listing_id") REFERENCES "listings"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "unit_group_ami_levels" ADD CONSTRAINT "FK_859a749beeb93898cfe3aa318e7" FOREIGN KEY ("ami_chart_id") REFERENCES "ami_chart"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "unit_group_ami_levels" ADD CONSTRAINT "FK_c15eff18d0384540366861a1c9c" FOREIGN KEY ("unit_group_id") REFERENCES "unit_group"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); + } + +} 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/migration/1647917319793-adding-hit-confirmation-url.ts b/backend/core/src/migration/1647917319793-adding-hit-confirmation-url.ts new file mode 100644 index 0000000000..f51211836c --- /dev/null +++ b/backend/core/src/migration/1647917319793-adding-hit-confirmation-url.ts @@ -0,0 +1,15 @@ +import { MigrationInterface, QueryRunner } from "typeorm" + +export class addingHitConfirmationUrl1647917319793 implements MigrationInterface { + name = "addingHitConfirmationUrl1647917319793" + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "user_accounts" ADD "hit_confirmation_url" TIMESTAMP WITH TIME ZONE` + ) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "user_accounts" DROP COLUMN "hit_confirmation_url"`) + } +} diff --git a/backend/core/src/migration/1648548425458-add-listing-marketing.ts b/backend/core/src/migration/1648548425458-add-listing-marketing.ts new file mode 100644 index 0000000000..a36956a6a8 --- /dev/null +++ b/backend/core/src/migration/1648548425458-add-listing-marketing.ts @@ -0,0 +1,18 @@ +import {MigrationInterface, QueryRunner} from "typeorm"; + +export class addListingMarketing1648548425458 implements MigrationInterface { + name = 'addListingMarketing1648548425458' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE TYPE "listings_marketing_type_enum" AS ENUM('marketing', 'comingSoon')`); + await queryRunner.query(`ALTER TABLE "listings" ADD "marketing_type" "listings_marketing_type_enum" NOT NULL DEFAULT 'marketing'`); + await queryRunner.query(`ALTER TABLE "listings" ADD "marketing_date" TIMESTAMP WITH TIME ZONE`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "listings" DROP COLUMN "marketing_date"`); + await queryRunner.query(`ALTER TABLE "listings" DROP COLUMN "marketing_type"`); + await queryRunner.query(`DROP TYPE "listings_marketing_type_enum"`); + } + +} diff --git a/backend/core/src/migration/1649062179928-add-property-region.ts b/backend/core/src/migration/1649062179928-add-property-region.ts new file mode 100644 index 0000000000..9f05119c64 --- /dev/null +++ b/backend/core/src/migration/1649062179928-add-property-region.ts @@ -0,0 +1,66 @@ +import {MigrationInterface, QueryRunner} from "typeorm"; +import { Region } from "../property/types/region-enum" + +export interface Neighborhood { + name: string + region: Region +} + +export class addPropertyRegion1649062179928 implements MigrationInterface { + name = 'addPropertyRegion1649062179928' + + // NOTE: imported from https://github.com/CityOfDetroit/bloom/blob/main/ui-components/src/helpers/regionNeighborhoodMap.ts + // Issue comment: https://github.com/CityOfDetroit/bloom/issues/1015#issuecomment-1068056607 + neighborhoods: Neighborhood[] = [ + {name: "Airport Sub area", region: Region.Eastside}, + {name: "Barton McFarland area", region: Region.Westside}, + {name: "Boston-Edison/North End area", region: Region.Westside}, + {name: "Boynton", region: Region.Southwest}, + {name: "Campau/Banglatown",region: Region.Eastside}, + {name: "Dexter Linwood", region: Region.Westside}, + {name: "Farwell area", region: Region.Eastside}, + {name: "Gratiot Town/Kettering area", region: Region.Eastside}, + {name: "Gratiot/7 Mile area", region: Region.Eastside}, + {name: "Greater Corktown area", region: Region.Downtown}, + {name: "Greater Downtown area", region: Region.Downtown}, + {name: "Greater Downtown area", region: Region.Downtown}, + {name: "Islandview/Greater Villages area", region: Region.Eastside}, + {name: "Islandview/Greater Villages area", region: Region.Eastside}, + {name: "Islandview/Greater Villages area", region: Region.Westside}, + {name: "Jefferson Chalmers area", region: Region.Eastside}, + {name: "Livernois/McNichols area", region: Region.Westside}, + {name: "Livernois/McNichols area", region: Region.Westside}, + {name: "Morningside area", region: Region.Eastside}, + {name: "North Campau area", region: Region.Eastside}, + {name: "Northwest Grand River area", region: Region.Westside}, + {name: "Northwest University District area", region: Region.Westside}, + {name: "Palmer Park area", region: Region.Westside}, + {name: "Russell Woods/Nardin Park area", region: Region.Westside}, + {name: "Southwest/Vernor area", region: Region.Southwest}, + {name: "Southwest/Vernor area", region: Region.Southwest}, + {name: "Warrendale/Cody Rouge", region: Region.Westside}, + {name: "West End area", region: Region.Eastside} + ] + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE TYPE "property_region_enum" AS ENUM('Downtown', 'Eastside', 'Midtown - New Center', 'Southwest', 'Westside')`); + await queryRunner.query(`ALTER TABLE "property" ADD "region" "property_region_enum"`); + + let properties: Array<{id: string, neighborhood?: string}> = await queryRunner.query(`SELECT id, neighborhood FROM property`) + + for(let p of properties) { + const neighborhood = this.neighborhoods.find(neighborhood => neighborhood.name === p.neighborhood) + if (!neighborhood) { + console.warn(`neighborhood ${p.neighborhood} not found in neighborhood:region map`) + continue + } + await queryRunner.query(`UPDATE property SET region = $1 WHERE id = $2`, [neighborhood.region, p.id]) + } + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "property" DROP COLUMN "region"`); + await queryRunner.query(`DROP TYPE "property_region_enum"`); + } + +} diff --git a/backend/core/src/migration/1649273182040-updated-integer-listing-fields-to-numeric.ts b/backend/core/src/migration/1649273182040-updated-integer-listing-fields-to-numeric.ts new file mode 100644 index 0000000000..f3ee50cf0d --- /dev/null +++ b/backend/core/src/migration/1649273182040-updated-integer-listing-fields-to-numeric.ts @@ -0,0 +1,31 @@ +import { MigrationInterface, QueryRunner } from "typeorm" + +export class updatedIntegerListingFieldsToNumeric1649273182040 implements MigrationInterface { + name = "updatedIntegerListingFieldsToNumeric1649273182040" + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "unit_group_ami_levels" ALTER COLUMN "flat_rent_value" TYPE numeric ` + ) + await queryRunner.query( + `ALTER TABLE "unit_group_ami_levels" ALTER COLUMN "percentage_of_income_value" TYPE numeric ` + ) + await queryRunner.query(`ALTER TABLE "unit_group" ALTER COLUMN "sq_feet_min" TYPE numeric `) + await queryRunner.query(`ALTER TABLE "unit_group" ALTER COLUMN "sq_feet_max" TYPE numeric `) + await queryRunner.query(`ALTER TABLE "unit_group" ALTER COLUMN "bathroom_min" TYPE numeric `) + await queryRunner.query(`ALTER TABLE "unit_group" ALTER COLUMN "bathroom_max" TYPE numeric `) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "unit_group" ALTER COLUMN "bathroom_max" TYPE integer `) + await queryRunner.query(`ALTER TABLE "unit_group" ALTER COLUMN "bathroom_min" TYPE integer `) + await queryRunner.query(`ALTER TABLE "unit_group" ALTER COLUMN "sq_feet_max" TYPE integer `) + await queryRunner.query(`ALTER TABLE "unit_group" ALTER COLUMN "sq_feet_min" TYPE integer `) + await queryRunner.query( + `ALTER TABLE "unit_group_ami_levels" ALTER COLUMN "percentage_of_income_value" TYPE integer ` + ) + await queryRunner.query( + `ALTER TABLE "unit_group_ami_levels" ALTER COLUMN "flat_rent_value" TYPE integer ` + ) + } +} diff --git a/backend/core/src/migration/1649374032458-marketing-season.ts b/backend/core/src/migration/1649374032458-marketing-season.ts new file mode 100644 index 0000000000..6ef5877369 --- /dev/null +++ b/backend/core/src/migration/1649374032458-marketing-season.ts @@ -0,0 +1,19 @@ +import { MigrationInterface, QueryRunner } from "typeorm" + +export class marketingSeason1649374032458 implements MigrationInterface { + name = "marketingSeason1649374032458" + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TYPE "listings_marketing_season_enum" AS ENUM('spring', 'summer', 'fall', 'winter')` + ) + await queryRunner.query( + `ALTER TABLE "listings" ADD "marketing_season" "listings_marketing_season_enum"` + ) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "listings" DROP COLUMN "marketing_season"`) + await queryRunner.query(`DROP TYPE "listings_marketing_season_enum"`) + } +} diff --git a/backend/core/src/migration/1649709708991-detroit-email-updates.ts b/backend/core/src/migration/1649709708991-detroit-email-updates.ts new file mode 100644 index 0000000000..e652b9f064 --- /dev/null +++ b/backend/core/src/migration/1649709708991-detroit-email-updates.ts @@ -0,0 +1,27 @@ +import { MigrationInterface, QueryRunner } from "typeorm" + +export class detroitEmailUpdates1649709708991 implements MigrationInterface { + name = 'detroitEmailUpdates1649709708991' + + public async up(queryRunner: QueryRunner): Promise { + let jurisdiction = await queryRunner.query("SELECT id FROM jurisdictions WHERE name = 'Detroit'") + jurisdiction = jurisdiction[0].id + const translation = await queryRunner.query(`SELECT id, translations FROM translations WHERE language='en' AND jurisdiction_id = '${jurisdiction}'`) + const { id, translations } = translation[0] + if (!translations.footer) { + translations.footer = {} + } + translations.footer.footer = "City of Detroit Housing and Revitalization Department" + translations.footer.thankYou = "Thank you," + if (!translations.register) { + translations.register = {} + } + translations.register.welcomeMessage = "Thank you for setting up your account on %{appUrl}. It will now be easier to save listings that you are interested in on the site." + await queryRunner.query(`UPDATE translations SET translations = ($1) WHERE id = ($2)`, [ translations, id ]) + } + + public async down(queryRunner: QueryRunner): Promise { + + } + +} diff --git a/backend/core/src/paper-applications/dto/paper-application.dto.ts b/backend/core/src/paper-applications/dto/paper-application.dto.ts new file mode 100644 index 0000000000..609c8546bb --- /dev/null +++ b/backend/core/src/paper-applications/dto/paper-application.dto.ts @@ -0,0 +1,60 @@ +import { Expose, Type } from "class-transformer" +import { IsDate, IsOptional, IsUUID, ValidateNested } from "class-validator" +import { OmitType } from "@nestjs/swagger" +import { ValidationsGroupsEnum } from "../../shared/types/validations-groups-enum" +import { PaperApplication } from "../entities/paper-application.entity" +import { AssetCreateDto, AssetDto, AssetUpdateDto } from "../../assets/dto/asset.dto" + +export class PaperApplicationDto extends OmitType(PaperApplication, [ + "applicationMethod", + "file", +] as const) { + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => AssetDto) + file?: AssetDto | null +} + +export class PaperApplicationCreateDto extends OmitType(PaperApplicationDto, [ + "id", + "createdAt", + "updatedAt", + "file", +] as const) { + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => AssetCreateDto) + file?: AssetCreateDto | null +} + +export class PaperApplicationUpdateDto extends OmitType(PaperApplicationDto, [ + "id", + "createdAt", + "updatedAt", + "file", +] as const) { + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsUUID(4, { groups: [ValidationsGroupsEnum.default] }) + id?: string + + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsDate({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => Date) + createdAt?: Date + + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsDate({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => Date) + updatedAt?: Date + + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => AssetUpdateDto) + file?: AssetUpdateDto | null +} diff --git a/backend/core/src/paper-applications/entities/paper-application.entity.ts b/backend/core/src/paper-applications/entities/paper-application.entity.ts new file mode 100644 index 0000000000..c17d37caba --- /dev/null +++ b/backend/core/src/paper-applications/entities/paper-application.entity.ts @@ -0,0 +1,27 @@ +import { Column, Entity, ManyToOne } from "typeorm" +import { AbstractEntity } from "../../shared/entities/abstract.entity" +import { Language } from "../../shared/types/language-enum" +import { Expose, Type } from "class-transformer" +import { IsEnum, ValidateNested } from "class-validator" +import { ValidationsGroupsEnum } from "../../shared/types/validations-groups-enum" +import { ApiProperty } from "@nestjs/swagger" +import { Asset } from "../../assets/entities/asset.entity" +import { ApplicationMethod } from "../../application-methods/entities/application-method.entity" + +@Entity({ name: "paper_applications" }) +export class PaperApplication extends AbstractEntity { + @Column({ enum: Language }) + @Expose() + @IsEnum(Language, { groups: [ValidationsGroupsEnum.default] }) + @ApiProperty({ enum: Language, enumName: "Language" }) + language: Language + + @ManyToOne(() => Asset, { eager: true, cascade: true }) + @Expose() + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => Asset) + file: Asset + + @ManyToOne(() => ApplicationMethod, (am) => am.paperApplications) + applicationMethod: ApplicationMethod +} diff --git a/backend/core/src/paper-applications/paper-applications.controller.ts b/backend/core/src/paper-applications/paper-applications.controller.ts new file mode 100644 index 0000000000..a53957ca93 --- /dev/null +++ b/backend/core/src/paper-applications/paper-applications.controller.ts @@ -0,0 +1,69 @@ +import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger" +import { DefaultAuthGuard } from "../auth/guards/default.guard" +import { AuthzGuard } from "../auth/guards/authz.guard" +import { ResourceType } from "../auth/decorators/resource-type.decorator" +import { mapTo } from "../shared/mapTo" +import { + Body, + Controller, + Delete, + Get, + Param, + Post, + Put, + UseGuards, + UsePipes, + ValidationPipe, +} from "@nestjs/common" +import { defaultValidationPipeOptions } from "../shared/default-validation-pipe-options" +import { + PaperApplicationCreateDto, + PaperApplicationDto, + PaperApplicationUpdateDto, +} from "./dto/paper-application.dto" +import { PaperApplicationsService } from "./paper-applications.service" + +@Controller("paperApplications") +@ApiTags("paperApplications") +@ApiBearerAuth() +@ResourceType("paperApplication") +@UseGuards(DefaultAuthGuard, AuthzGuard) +@UsePipes(new ValidationPipe(defaultValidationPipeOptions)) +export class PaperApplicationsController { + constructor(private readonly paperApplicationsService: PaperApplicationsService) {} + + @Get() + @ApiOperation({ summary: "List paperApplications", operationId: "list" }) + async list(): Promise { + return mapTo(PaperApplicationDto, await this.paperApplicationsService.list()) + } + + @Post() + @ApiOperation({ summary: "Create paperApplication", operationId: "create" }) + async create(@Body() paperApplication: PaperApplicationCreateDto): Promise { + return mapTo(PaperApplicationDto, await this.paperApplicationsService.create(paperApplication)) + } + + @Put(`:paperApplicationId`) + @ApiOperation({ summary: "Update paperApplication", operationId: "update" }) + async update(@Body() paperApplication: PaperApplicationUpdateDto): Promise { + return mapTo(PaperApplicationDto, await this.paperApplicationsService.update(paperApplication)) + } + + @Get(`:paperApplicationId`) + @ApiOperation({ summary: "Get paperApplication by id", operationId: "retrieve" }) + async retrieve( + @Param("paperApplicationId") paperApplicationId: string + ): Promise { + return mapTo( + PaperApplicationDto, + await this.paperApplicationsService.findOne({ where: { id: paperApplicationId } }) + ) + } + + @Delete(`:paperApplicationId`) + @ApiOperation({ summary: "Delete paperApplication by id", operationId: "delete" }) + async delete(@Param("paperApplicationId") paperApplicationId: string): Promise { + return await this.paperApplicationsService.delete(paperApplicationId) + } +} diff --git a/backend/core/src/paper-applications/paper-applications.module.ts b/backend/core/src/paper-applications/paper-applications.module.ts new file mode 100644 index 0000000000..16788c3fa1 --- /dev/null +++ b/backend/core/src/paper-applications/paper-applications.module.ts @@ -0,0 +1,13 @@ +import { Module } from "@nestjs/common" +import { TypeOrmModule } from "@nestjs/typeorm" +import { AuthModule } from "../auth/auth.module" +import { PaperApplicationsController } from "./paper-applications.controller" +import { PaperApplicationsService } from "./paper-applications.service" +import { PaperApplication } from "./entities/paper-application.entity" + +@Module({ + imports: [TypeOrmModule.forFeature([PaperApplication]), AuthModule], + controllers: [PaperApplicationsController], + providers: [PaperApplicationsService], +}) +export class PaperApplicationsModule {} diff --git a/backend/core/src/paper-applications/paper-applications.service.ts b/backend/core/src/paper-applications/paper-applications.service.ts new file mode 100644 index 0000000000..a0008e7595 --- /dev/null +++ b/backend/core/src/paper-applications/paper-applications.service.ts @@ -0,0 +1,11 @@ +import { AbstractServiceFactory } from "../shared/services/abstract-service" +import { Injectable } from "@nestjs/common" +import { PaperApplicationCreateDto, PaperApplicationUpdateDto } from "./dto/paper-application.dto" +import { PaperApplication } from "./entities/paper-application.entity" + +@Injectable() +export class PaperApplicationsService extends AbstractServiceFactory< + PaperApplication, + PaperApplicationCreateDto, + PaperApplicationUpdateDto +>(PaperApplication) {} diff --git a/backend/core/src/preferences/dto/jurisdictionFilterTypeToFieldMap.ts b/backend/core/src/preferences/dto/jurisdictionFilterTypeToFieldMap.ts new file mode 100644 index 0000000000..437a01cf8a --- /dev/null +++ b/backend/core/src/preferences/dto/jurisdictionFilterTypeToFieldMap.ts @@ -0,0 +1,5 @@ +import { PreferenceFilterKeys } from "./preference-filter-keys" + +export const jurisdictionFilterTypeToFieldMap: Record = { + jurisdiction: "preferenceJurisdictions.id", +} diff --git a/backend/core/src/preferences/dto/listing-preference-update.dto.ts b/backend/core/src/preferences/dto/listing-preference-update.dto.ts new file mode 100644 index 0000000000..bd24408c91 --- /dev/null +++ b/backend/core/src/preferences/dto/listing-preference-update.dto.ts @@ -0,0 +1,17 @@ +import { OmitType } from "@nestjs/swagger" +import { Expose, Type } from "class-transformer" +import { IsDefined, IsOptional, ValidateNested } from "class-validator" +import { ValidationsGroupsEnum } from "../../shared/types/validations-groups-enum" +import { IdDto } from "../../shared/dto/id.dto" +import { ListingPreferenceDto } from "./listing-preference.dto" + +export class ListingPreferenceUpdateDto extends OmitType(ListingPreferenceDto, [ + "preference", +] as const) { + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default], each: true }) + @Type(() => IdDto) + preference: IdDto +} diff --git a/backend/core/src/preferences/dto/listing-preference.dto.ts b/backend/core/src/preferences/dto/listing-preference.dto.ts new file mode 100644 index 0000000000..ee387a3b9f --- /dev/null +++ b/backend/core/src/preferences/dto/listing-preference.dto.ts @@ -0,0 +1,18 @@ +import { ListingPreference } from "../entities/listing-preference.entity" +import { OmitType } from "@nestjs/swagger" +import { Expose, Type } from "class-transformer" +import { IsDefined, IsOptional, ValidateNested } from "class-validator" +import { ValidationsGroupsEnum } from "../../shared/types/validations-groups-enum" +import { PreferenceDto } from "./preference.dto" + +export class ListingPreferenceDto extends OmitType(ListingPreference, [ + "listing", + "preference", +] as const) { + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default], each: true }) + @Type(() => PreferenceDto) + preference: PreferenceDto +} diff --git a/backend/core/src/preferences/dto/preference-create.dto.ts b/backend/core/src/preferences/dto/preference-create.dto.ts new file mode 100644 index 0000000000..302ce4bee7 --- /dev/null +++ b/backend/core/src/preferences/dto/preference-create.dto.ts @@ -0,0 +1,8 @@ +import { OmitType } from "@nestjs/swagger" +import { PreferenceDto } from "./preference.dto" + +export class PreferenceCreateDto extends OmitType(PreferenceDto, [ + "id", + "createdAt", + "updatedAt", +] as const) {} diff --git a/backend/core/src/preferences/dto/preference-filter-keys.ts b/backend/core/src/preferences/dto/preference-filter-keys.ts new file mode 100644 index 0000000000..87db4c7795 --- /dev/null +++ b/backend/core/src/preferences/dto/preference-filter-keys.ts @@ -0,0 +1,3 @@ +export enum PreferenceFilterKeys { + jurisdiction = "jurisdiction", +} diff --git a/backend/core/src/preferences/dto/preference-update.dto.ts b/backend/core/src/preferences/dto/preference-update.dto.ts new file mode 100644 index 0000000000..164fe62124 --- /dev/null +++ b/backend/core/src/preferences/dto/preference-update.dto.ts @@ -0,0 +1,11 @@ +import { PreferenceCreateDto } from "./preference-create.dto" +import { Expose } from "class-transformer" +import { IsString, IsUUID } from "class-validator" +import { ValidationsGroupsEnum } from "../../shared/types/validations-groups-enum" + +export class PreferenceUpdateDto extends PreferenceCreateDto { + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @IsUUID() + id: string +} diff --git a/backend/core/src/preferences/dto/preference.dto.ts b/backend/core/src/preferences/dto/preference.dto.ts new file mode 100644 index 0000000000..5220bf9a8e --- /dev/null +++ b/backend/core/src/preferences/dto/preference.dto.ts @@ -0,0 +1,7 @@ +import { OmitType } from "@nestjs/swagger" +import { Preference } from "../entities/preference.entity" + +export class PreferenceDto extends OmitType(Preference, [ + "listingPreferences", + "jurisdictions", +] as const) {} diff --git a/backend/core/src/preferences/dto/preferences-filter-params.ts b/backend/core/src/preferences/dto/preferences-filter-params.ts new file mode 100644 index 0000000000..5492514e50 --- /dev/null +++ b/backend/core/src/preferences/dto/preferences-filter-params.ts @@ -0,0 +1,18 @@ +import { BaseFilter } from "../../shared/dto/filter.dto" +import { Expose } from "class-transformer" +import { ApiProperty } from "@nestjs/swagger" +import { IsOptional, IsString } from "class-validator" +import { ValidationsGroupsEnum } from "../../shared/types/validations-groups-enum" +import { PreferenceFilterKeys } from "./preference-filter-keys" + +export class PreferencesFilterParams extends BaseFilter { + @Expose() + @ApiProperty({ + type: String, + example: "uuid", + required: false, + }) + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + [PreferenceFilterKeys.jurisdiction]?: string +} diff --git a/backend/core/src/preferences/dto/preferences-list-query-params.ts b/backend/core/src/preferences/dto/preferences-list-query-params.ts new file mode 100644 index 0000000000..8dcc87bbdc --- /dev/null +++ b/backend/core/src/preferences/dto/preferences-list-query-params.ts @@ -0,0 +1,24 @@ +import { Expose, Type } from "class-transformer" +import { ApiProperty, getSchemaPath } from "@nestjs/swagger" +import { ArrayMaxSize, IsArray, IsOptional, ValidateNested } from "class-validator" +import { ValidationsGroupsEnum } from "../../shared/types/validations-groups-enum" +import { PreferencesFilterParams } from "./preferences-filter-params" + +export class PreferencesListQueryParams { + @Expose() + @ApiProperty({ + name: "filter", + required: false, + type: [String], + items: { + $ref: getSchemaPath(PreferencesFilterParams), + }, + example: { $comparison: "=", jurisdiction: "uuid" }, + }) + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsArray({ groups: [ValidationsGroupsEnum.default] }) + @ArrayMaxSize(16, { groups: [ValidationsGroupsEnum.default] }) + @Type(() => PreferencesFilterParams) + @ValidateNested({ groups: [ValidationsGroupsEnum.default], each: true }) + filter?: PreferencesFilterParams[] +} diff --git a/backend/core/src/preferences/entities/listing-preference.entity.ts b/backend/core/src/preferences/entities/listing-preference.entity.ts new file mode 100644 index 0000000000..cbc5a6c3a9 --- /dev/null +++ b/backend/core/src/preferences/entities/listing-preference.entity.ts @@ -0,0 +1,30 @@ +import { Column, Entity, ManyToOne } from "typeorm" +import { Preference } from "./preference.entity" +import { Expose, Type } from "class-transformer" +import { IsNumber, IsOptional } from "class-validator" +import { ValidationsGroupsEnum } from "../../shared/types/validations-groups-enum" +import { Listing } from "../../listings/entities/listing.entity" + +@Entity({ name: "listing_preferences" }) +export class ListingPreference { + @ManyToOne(() => Listing, (listing) => listing.listingPreferences, { + primary: true, + orphanedRowAction: "delete", + }) + @Type(() => Listing) + listing: Listing + + @ManyToOne(() => Preference, (preference) => preference.listingPreferences, { + primary: true, + eager: true, + }) + @Expose() + @Type(() => Preference) + preference: Preference + + @Column({ type: "integer", nullable: true }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsNumber({}, { groups: [ValidationsGroupsEnum.default] }) + ordinal?: number | null +} diff --git a/backend/core/src/preferences/entities/preference.entity.ts b/backend/core/src/preferences/entities/preference.entity.ts new file mode 100644 index 0000000000..c51cbc5bfb --- /dev/null +++ b/backend/core/src/preferences/entities/preference.entity.ts @@ -0,0 +1,85 @@ +import { + Column, + CreateDateColumn, + Entity, + ManyToMany, + OneToMany, + PrimaryGeneratedColumn, + UpdateDateColumn, +} from "typeorm" +import { Expose, Type } from "class-transformer" +import { IsDate, IsOptional, IsString, IsUUID, ValidateNested } from "class-validator" +import { ValidationsGroupsEnum } from "../../shared/types/validations-groups-enum" +import { FormMetadata } from "../../applications/types/form-metadata/form-metadata" +import { PreferenceLink } from "../types/preference-link" +import { ApiProperty } from "@nestjs/swagger" +import { ListingPreference } from "./listing-preference.entity" +import { Jurisdiction } from "../../jurisdictions/entities/jurisdiction.entity" + +@Entity({ name: "preferences" }) +class Preference { + @PrimaryGeneratedColumn("uuid") + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @IsUUID(4, { groups: [ValidationsGroupsEnum.default] }) + id: string + + @CreateDateColumn() + @Expose() + @IsDate({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => Date) + createdAt: Date + + @UpdateDateColumn() + @Expose() + @IsDate({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => Date) + updatedAt: Date + + @Column({ type: "text", nullable: true }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + title?: string | null + + @Column({ type: "text", nullable: true }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + subtitle?: string | null + + @Column({ type: "text", nullable: true }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + description?: string | null + + @Column({ type: "jsonb", nullable: true }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default], each: true }) + @Type(() => PreferenceLink) + @ApiProperty({ type: [PreferenceLink] }) + links?: PreferenceLink[] | null + + @OneToMany(() => ListingPreference, (listingPreference) => listingPreference.preference) + @Expose() + @ValidateNested({ groups: [ValidationsGroupsEnum.default], each: true }) + @Type(() => ListingPreference) + listingPreferences: ListingPreference[] + + @Column({ type: "jsonb", nullable: true }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => FormMetadata) + formMetadata?: FormMetadata + + @ManyToMany(() => Jurisdiction, (jurisdiction) => jurisdiction.preferences) + @Expose() + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => Jurisdiction) + jurisdictions: Jurisdiction[] +} + +export { Preference as default, Preference } diff --git a/backend/core/src/preferences/preferences.controller.ts b/backend/core/src/preferences/preferences.controller.ts new file mode 100644 index 0000000000..c0f49cbebb --- /dev/null +++ b/backend/core/src/preferences/preferences.controller.ts @@ -0,0 +1,69 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + Post, + Put, + Query, + UseGuards, + UsePipes, + ValidationPipe, +} from "@nestjs/common" +import { PreferencesService } from "../preferences/preferences.service" +import { ApiBearerAuth, ApiExtraModels, ApiOperation, ApiTags } from "@nestjs/swagger" +import { PreferenceDto } from "./dto/preference.dto" +import { AuthzGuard } from "../auth/guards/authz.guard" +import { ResourceType } from "../auth/decorators/resource-type.decorator" +import { mapTo } from "../shared/mapTo" +import { OptionalAuthGuard } from "../auth/guards/optional-auth.guard" +import { defaultValidationPipeOptions } from "../shared/default-validation-pipe-options" +import { PreferenceCreateDto } from "./dto/preference-create.dto" +import { PreferenceUpdateDto } from "./dto/preference-update.dto" +import { PreferencesListQueryParams } from "./dto/preferences-list-query-params" +import { PreferencesFilterParams } from "./dto/preferences-filter-params" + +@Controller("/preferences") +@ApiTags("preferences") +@ApiBearerAuth() +@ResourceType("preference") +@UseGuards(OptionalAuthGuard, AuthzGuard) +@UsePipes(new ValidationPipe(defaultValidationPipeOptions)) +export class PreferencesController { + constructor(private readonly preferencesService: PreferencesService) {} + + @Get() + @ApiOperation({ summary: "List preferences", operationId: "list" }) + @ApiExtraModels(PreferencesFilterParams) + async list(@Query() queryParams: PreferencesListQueryParams): Promise { + return mapTo(PreferenceDto, await this.preferencesService.list(queryParams)) + } + + @Post() + @ApiOperation({ summary: "Create preference", operationId: "create" }) + async create(@Body() preference: PreferenceCreateDto): Promise { + return mapTo(PreferenceDto, await this.preferencesService.create(preference)) + } + + @Put(`:preferenceId`) + @ApiOperation({ summary: "Update preference", operationId: "update" }) + async update(@Body() preference: PreferenceUpdateDto): Promise { + return mapTo(PreferenceDto, await this.preferencesService.update(preference)) + } + + @Get(`:preferenceId`) + @ApiOperation({ summary: "Get preference by id", operationId: "retrieve" }) + async retrieve(@Param("preferenceId") preferenceId: string): Promise { + return mapTo( + PreferenceDto, + await this.preferencesService.findOne({ where: { id: preferenceId } }) + ) + } + + @Delete(`:preferenceId`) + @ApiOperation({ summary: "Delete preference by id", operationId: "delete" }) + async delete(@Param("preferenceId") preferenceId: string): Promise { + await this.preferencesService.delete(preferenceId) + } +} diff --git a/backend/core/src/preferences/preferences.module.ts b/backend/core/src/preferences/preferences.module.ts new file mode 100644 index 0000000000..31809d1d2f --- /dev/null +++ b/backend/core/src/preferences/preferences.module.ts @@ -0,0 +1,15 @@ +import { Module } from "@nestjs/common" +import { TypeOrmModule } from "@nestjs/typeorm" +import { PreferencesController } from "../preferences/preferences.controller" +import { PreferencesService } from "./preferences.service" +import { Preference } from "./entities/preference.entity" +import { Unit } from "../units/entities/unit.entity" +import { AuthModule } from "../auth/auth.module" + +@Module({ + imports: [TypeOrmModule.forFeature([Preference, Unit]), AuthModule], + providers: [PreferencesService], + exports: [PreferencesService], + controllers: [PreferencesController], +}) +export class PreferencesModule {} diff --git a/backend/core/src/preferences/preferences.service.ts b/backend/core/src/preferences/preferences.service.ts new file mode 100644 index 0000000000..d43a9dbfb1 --- /dev/null +++ b/backend/core/src/preferences/preferences.service.ts @@ -0,0 +1,61 @@ +import { Preference } from "./entities/preference.entity" +import { PreferenceCreateDto } from "./dto/preference-create.dto" +import { PreferenceUpdateDto } from "./dto/preference-update.dto" +import { NotFoundException } from "@nestjs/common" +import { InjectRepository } from "@nestjs/typeorm" +import { FindOneOptions, Repository } from "typeorm" +import { addFilters } from "../shared/query-filter" +import { PreferencesListQueryParams } from "./dto/preferences-list-query-params" +import { PreferencesFilterParams } from "./dto/preferences-filter-params" +import { jurisdictionFilterTypeToFieldMap } from "./dto/jurisdictionFilterTypeToFieldMap" +import { assignDefined } from "../shared/utils/assign-defined" + +export class PreferencesService { + constructor(@InjectRepository(Preference) private readonly repository: Repository) {} + + list(params?: PreferencesListQueryParams): Promise { + const qb = this.repository + .createQueryBuilder("preferences") + .leftJoin("preferences.jurisdictions", "preferenceJurisdictions") + .select(["preferences", "preferenceJurisdictions.id"]) + + if (params.filter) { + addFilters, typeof jurisdictionFilterTypeToFieldMap>( + params.filter, + jurisdictionFilterTypeToFieldMap, + qb + ) + } + return qb.getMany() + } + + async create(dto: PreferenceCreateDto): Promise { + return await this.repository.save(dto) + } + + async findOne(findOneOptions: FindOneOptions): Promise { + const obj = await this.repository.findOne(findOneOptions) + if (!obj) { + throw new NotFoundException() + } + return obj + } + + async delete(objId: string) { + await this.repository.delete(objId) + } + + async update(dto: PreferenceUpdateDto) { + const obj = await this.repository.findOne({ + where: { + id: dto.id, + }, + }) + if (!obj) { + throw new NotFoundException() + } + assignDefined(obj, dto) + await this.repository.save(obj) + return obj + } +} diff --git a/backend/core/src/preferences/types/preference-link.ts b/backend/core/src/preferences/types/preference-link.ts new file mode 100644 index 0000000000..b80a2a8851 --- /dev/null +++ b/backend/core/src/preferences/types/preference-link.ts @@ -0,0 +1,16 @@ +import { Expose } from "class-transformer" +import { IsString } from "class-validator" +import { ValidationsGroupsEnum } from "../../shared/types/validations-groups-enum" +import { ApiProperty } from "@nestjs/swagger" + +export class PreferenceLink { + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @ApiProperty() + title: string + + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @ApiProperty() + url: string +} diff --git a/backend/core/src/program/dto/jurisdictionFilterTypeToFieldMap.ts b/backend/core/src/program/dto/jurisdictionFilterTypeToFieldMap.ts new file mode 100644 index 0000000000..1eb7bc0214 --- /dev/null +++ b/backend/core/src/program/dto/jurisdictionFilterTypeToFieldMap.ts @@ -0,0 +1,5 @@ +import { ProgramFilterKeys } from "./program-filter-keys" + +export const jurisdictionFilterTypeToFieldMap: Record = { + jurisdiction: "programJurisdictions.id", +} diff --git a/backend/core/src/program/dto/listing-program-update.dto.ts b/backend/core/src/program/dto/listing-program-update.dto.ts new file mode 100644 index 0000000000..994ee26e03 --- /dev/null +++ b/backend/core/src/program/dto/listing-program-update.dto.ts @@ -0,0 +1,15 @@ +import { Expose, Type } from "class-transformer" +import { ListingProgramDto } from "./listing-program.dto" +import { IsDefined, IsOptional, ValidateNested } from "class-validator" +import { OmitType } from "@nestjs/swagger" +import { IdDto } from "../../shared/dto/id.dto" +import { ValidationsGroupsEnum } from "../../shared/types/validations-groups-enum" + +export class ListingProgramUpdateDto extends OmitType(ListingProgramDto, ["program"] as const) { + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default], each: true }) + @Type(() => IdDto) + program: IdDto +} diff --git a/backend/core/src/program/dto/listing-program.dto.ts b/backend/core/src/program/dto/listing-program.dto.ts new file mode 100644 index 0000000000..54c37196cc --- /dev/null +++ b/backend/core/src/program/dto/listing-program.dto.ts @@ -0,0 +1,15 @@ +import { Expose, Type } from "class-transformer" +import { IsDefined, IsOptional, ValidateNested } from "class-validator" +import { OmitType } from "@nestjs/swagger" +import { ProgramDto } from "./program.dto" +import { ListingProgram } from "../entities/listing-program.entity" +import { ValidationsGroupsEnum } from "../../shared/types/validations-groups-enum" + +export class ListingProgramDto extends OmitType(ListingProgram, ["listing", "program"] as const) { + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default], each: true }) + @Type(() => ProgramDto) + program: ProgramDto +} diff --git a/backend/core/src/program/dto/program-create.dto.ts b/backend/core/src/program/dto/program-create.dto.ts new file mode 100644 index 0000000000..4d4b6a04ff --- /dev/null +++ b/backend/core/src/program/dto/program-create.dto.ts @@ -0,0 +1,8 @@ +import { OmitType } from "@nestjs/swagger" +import { ProgramDto } from "./program.dto" + +export class ProgramCreateDto extends OmitType(ProgramDto, [ + "id", + "createdAt", + "updatedAt", +] as const) {} diff --git a/backend/core/src/program/dto/program-filter-keys.ts b/backend/core/src/program/dto/program-filter-keys.ts new file mode 100644 index 0000000000..332871657a --- /dev/null +++ b/backend/core/src/program/dto/program-filter-keys.ts @@ -0,0 +1,3 @@ +export enum ProgramFilterKeys { + jurisdiction = "jurisdiction", +} diff --git a/backend/core/src/program/dto/program-update.dto.ts b/backend/core/src/program/dto/program-update.dto.ts new file mode 100644 index 0000000000..1045b7e41c --- /dev/null +++ b/backend/core/src/program/dto/program-update.dto.ts @@ -0,0 +1,11 @@ +import { ProgramCreateDto } from "./program-create.dto" +import { Expose } from "class-transformer" +import { IsString, IsUUID } from "class-validator" +import { ValidationsGroupsEnum } from "../../shared/types/validations-groups-enum" + +export class ProgramUpdateDto extends ProgramCreateDto { + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @IsUUID() + id: string +} diff --git a/backend/core/src/program/dto/program.dto.ts b/backend/core/src/program/dto/program.dto.ts new file mode 100644 index 0000000000..d9d5711a6f --- /dev/null +++ b/backend/core/src/program/dto/program.dto.ts @@ -0,0 +1,4 @@ +import { OmitType } from "@nestjs/swagger" +import { Program } from "../entities/program.entity" + +export class ProgramDto extends OmitType(Program, ["listingPrograms", "jurisdictions"] as const) {} diff --git a/backend/core/src/program/dto/programs-filter-params.ts b/backend/core/src/program/dto/programs-filter-params.ts new file mode 100644 index 0000000000..2f2952fffd --- /dev/null +++ b/backend/core/src/program/dto/programs-filter-params.ts @@ -0,0 +1,18 @@ +import { BaseFilter } from "../../shared/dto/filter.dto" +import { Expose } from "class-transformer" +import { ApiProperty } from "@nestjs/swagger" +import { IsOptional, IsString } from "class-validator" +import { ValidationsGroupsEnum } from "../../shared/types/validations-groups-enum" +import { ProgramFilterKeys } from "./program-filter-keys" + +export class ProgramsFilterParams extends BaseFilter { + @Expose() + @ApiProperty({ + type: String, + example: "uuid", + required: false, + }) + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + [ProgramFilterKeys.jurisdiction]?: string +} diff --git a/backend/core/src/program/dto/programs-list-query-params.ts b/backend/core/src/program/dto/programs-list-query-params.ts new file mode 100644 index 0000000000..1c8289ca44 --- /dev/null +++ b/backend/core/src/program/dto/programs-list-query-params.ts @@ -0,0 +1,24 @@ +import { Expose, Type } from "class-transformer" +import { ApiProperty, getSchemaPath } from "@nestjs/swagger" +import { ArrayMaxSize, IsArray, IsOptional, ValidateNested } from "class-validator" +import { ValidationsGroupsEnum } from "../../shared/types/validations-groups-enum" +import { ProgramsFilterParams } from "./programs-filter-params" + +export class ProgramsListQueryParams { + @Expose() + @ApiProperty({ + name: "filter", + required: false, + type: [String], + items: { + $ref: getSchemaPath(ProgramsFilterParams), + }, + example: { $comparison: "=", jurisdiction: "uuid" }, + }) + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsArray({ groups: [ValidationsGroupsEnum.default] }) + @ArrayMaxSize(16, { groups: [ValidationsGroupsEnum.default] }) + @Type(() => ProgramsFilterParams) + @ValidateNested({ groups: [ValidationsGroupsEnum.default], each: true }) + filter?: ProgramsFilterParams[] +} diff --git a/backend/core/src/program/entities/listing-program.entity.ts b/backend/core/src/program/entities/listing-program.entity.ts new file mode 100644 index 0000000000..16f259bcb5 --- /dev/null +++ b/backend/core/src/program/entities/listing-program.entity.ts @@ -0,0 +1,30 @@ +import { Column, Entity, ManyToOne } from "typeorm" +import { Program } from "./program.entity" +import { Expose, Type } from "class-transformer" +import { IsNumber, IsOptional } from "class-validator" +import { ValidationsGroupsEnum } from "../../shared/types/validations-groups-enum" +import { Listing } from "../../listings/entities/listing.entity" + +@Entity({ name: "listing_programs" }) +export class ListingProgram { + @ManyToOne(() => Listing, (listing) => listing.listingPrograms, { + primary: true, + orphanedRowAction: "delete", + }) + @Type(() => Listing) + listing: Listing + + @ManyToOne(() => Program, (program) => program.listingPrograms, { + primary: true, + eager: true, + }) + @Expose() + @Type(() => Program) + program: Program + + @Column({ type: "integer", nullable: true }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsNumber({}, { groups: [ValidationsGroupsEnum.default] }) + ordinal?: number | null +} diff --git a/backend/core/src/program/entities/program.entity.ts b/backend/core/src/program/entities/program.entity.ts new file mode 100644 index 0000000000..8fe9d4e9df --- /dev/null +++ b/backend/core/src/program/entities/program.entity.ts @@ -0,0 +1,50 @@ +import { Column, Entity, ManyToMany, OneToMany } from "typeorm" +import { Expose, Type } from "class-transformer" +import { IsOptional, IsString, ValidateNested } from "class-validator" +import { ValidationsGroupsEnum } from "../../shared/types/validations-groups-enum" +import { AbstractEntity } from "../../shared/entities/abstract.entity" +import { Jurisdiction } from "../../jurisdictions/entities/jurisdiction.entity" +import { ListingProgram } from "./listing-program.entity" +import { FormMetadata } from "../../applications/types/form-metadata/form-metadata" + +@Entity({ name: "programs" }) +class Program extends AbstractEntity { + @Column({ type: "text", nullable: true }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + title?: string | null + + @Column({ type: "text", nullable: true }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + subtitle?: string | null + + @Column({ type: "text", nullable: true }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + description?: string | null + + @OneToMany(() => ListingProgram, (listingProgram) => listingProgram.program) + @Expose() + @ValidateNested({ groups: [ValidationsGroupsEnum.default], each: true }) + @Type(() => ListingProgram) + listingPrograms: ListingProgram[] + + @ManyToMany(() => Jurisdiction, (jurisdiction) => jurisdiction.programs) + @Expose() + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => Jurisdiction) + jurisdictions: Jurisdiction[] + + @Column({ type: "jsonb", nullable: true }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => FormMetadata) + formMetadata?: FormMetadata +} + +export { Program as default, Program } diff --git a/backend/core/src/program/programs.controller.ts b/backend/core/src/program/programs.controller.ts new file mode 100644 index 0000000000..6c982854d7 --- /dev/null +++ b/backend/core/src/program/programs.controller.ts @@ -0,0 +1,66 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + Post, + Put, + Query, + UseGuards, + UsePipes, + ValidationPipe, +} from "@nestjs/common" +import { ApiBearerAuth, ApiOperation, ApiTags, ApiExtraModels } from "@nestjs/swagger" +import { AuthzGuard } from "../auth/guards/authz.guard" +import { ResourceType } from "../auth/decorators/resource-type.decorator" +import { mapTo } from "../shared/mapTo" +import { OptionalAuthGuard } from "../auth/guards/optional-auth.guard" +import { defaultValidationPipeOptions } from "../shared/default-validation-pipe-options" +import { ProgramsService } from "./programs.service" +import { ProgramDto } from "./dto/program.dto" +import { ProgramCreateDto } from "./dto/program-create.dto" +import { ProgramUpdateDto } from "./dto/program-update.dto" +import { ProgramsFilterParams } from "./dto/programs-filter-params" +import { ProgramsListQueryParams } from "./dto/programs-list-query-params" + +@Controller("/programs") +@ApiTags("programs") +@ApiBearerAuth() +@ResourceType("program") +@UseGuards(OptionalAuthGuard, AuthzGuard) +@UsePipes(new ValidationPipe(defaultValidationPipeOptions)) +export class ProgramsController { + constructor(private readonly programsService: ProgramsService) {} + + @Get() + @ApiOperation({ summary: "List programs", operationId: "list" }) + @ApiExtraModels(ProgramsFilterParams) + async list(@Query() queryParams: ProgramsListQueryParams): Promise { + return mapTo(ProgramDto, await this.programsService.list(queryParams)) + } + + @Post() + @ApiOperation({ summary: "Create program", operationId: "create" }) + async create(@Body() program: ProgramCreateDto): Promise { + return mapTo(ProgramDto, await this.programsService.create(program)) + } + + @Put(`:programId`) + @ApiOperation({ summary: "Update program", operationId: "update" }) + async update(@Body() program: ProgramUpdateDto): Promise { + return mapTo(ProgramDto, await this.programsService.update(program)) + } + + @Get(`:programId`) + @ApiOperation({ summary: "Get program by id", operationId: "retrieve" }) + async retrieve(@Param("programId") programId: string): Promise { + return mapTo(ProgramDto, await this.programsService.findOne({ where: { id: programId } })) + } + + @Delete(`:programId`) + @ApiOperation({ summary: "Delete program by id", operationId: "delete" }) + async delete(@Param("programId") programId: string): Promise { + await this.programsService.delete(programId) + } +} diff --git a/backend/core/src/program/programs.module.ts b/backend/core/src/program/programs.module.ts new file mode 100644 index 0000000000..43fc052a67 --- /dev/null +++ b/backend/core/src/program/programs.module.ts @@ -0,0 +1,15 @@ +import { Module } from "@nestjs/common" +import { TypeOrmModule } from "@nestjs/typeorm" +import { ProgramsService } from "./programs.service" +import { Listing } from "../listings/entities/listing.entity" +import { Program } from "./entities/program.entity" +import { AuthModule } from "../auth/auth.module" +import { ProgramsController } from "./programs.controller" + +@Module({ + imports: [TypeOrmModule.forFeature([Listing, Program]), AuthModule], + providers: [ProgramsService], + exports: [ProgramsService], + controllers: [ProgramsController], +}) +export class ProgramsModule {} diff --git a/backend/core/src/program/programs.service.ts b/backend/core/src/program/programs.service.ts new file mode 100644 index 0000000000..1d6001aa5e --- /dev/null +++ b/backend/core/src/program/programs.service.ts @@ -0,0 +1,61 @@ +import { Program } from "./entities/program.entity" +import { ProgramCreateDto } from "./dto/program-create.dto" +import { ProgramUpdateDto } from "./dto/program-update.dto" +import { NotFoundException } from "@nestjs/common" +import { InjectRepository } from "@nestjs/typeorm" +import { FindOneOptions, Repository } from "typeorm" +import { addFilters } from "../shared/query-filter" +import { ProgramsListQueryParams } from "./dto/programs-list-query-params" +import { ProgramsFilterParams } from "./dto/programs-filter-params" +import { jurisdictionFilterTypeToFieldMap } from "./dto/jurisdictionFilterTypeToFieldMap" +import { assignDefined } from "../shared/utils/assign-defined" + +export class ProgramsService { + constructor(@InjectRepository(Program) private readonly repository: Repository) {} + + list(params?: ProgramsListQueryParams): Promise { + const qb = this.repository + .createQueryBuilder("programs") + .leftJoin("programs.jurisdictions", "programJurisdictions") + .select(["programs", "programJurisdictions.id"]) + + if (params.filter) { + addFilters, typeof jurisdictionFilterTypeToFieldMap>( + params.filter, + jurisdictionFilterTypeToFieldMap, + qb + ) + } + return qb.getMany() + } + + async create(dto: ProgramCreateDto): Promise { + return await this.repository.save(dto) + } + + async findOne(findOneOptions: FindOneOptions): Promise { + const obj = await this.repository.findOne(findOneOptions) + if (!obj) { + throw new NotFoundException() + } + return obj + } + + async delete(objId: string) { + await this.repository.delete(objId) + } + + async update(dto: ProgramUpdateDto) { + const obj = await this.repository.findOne({ + where: { + id: dto.id, + }, + }) + if (!obj) { + throw new NotFoundException() + } + assignDefined(obj, dto) + await this.repository.save(obj) + return obj + } +} diff --git a/backend/core/src/property-groups/dto/property-group.dto.ts b/backend/core/src/property-groups/dto/property-group.dto.ts new file mode 100644 index 0000000000..afd8e8de77 --- /dev/null +++ b/backend/core/src/property-groups/dto/property-group.dto.ts @@ -0,0 +1,27 @@ +import { Expose, Type } from "class-transformer" +import { IsDefined, IsString, IsUUID, ValidateNested } from "class-validator" +import { IdDto } from "../../shared/dto/id.dto" +import { PropertyGroup } from "../entities/property-group.entity" +import { OmitType } from "@nestjs/swagger" +import { ValidationsGroupsEnum } from "../../shared/types/validations-groups-enum" + +export class PropertyGroupDto extends OmitType(PropertyGroup, ["properties"] as const) { + @Expose() + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default], each: true }) + @Type(() => IdDto) + properties: IdDto[] +} + +export class PropertyGroupCreateDto extends OmitType(PropertyGroupDto, [ + "id", + "createdAt", + "updatedAt", +] as const) {} + +export class PropertyGroupUpdateDto extends PropertyGroupCreateDto { + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @IsUUID() + id: string +} diff --git a/backend/core/src/property-groups/entities/property-group.entity.ts b/backend/core/src/property-groups/entities/property-group.entity.ts new file mode 100644 index 0000000000..4cbfb5916c --- /dev/null +++ b/backend/core/src/property-groups/entities/property-group.entity.ts @@ -0,0 +1,43 @@ +import { + Column, + CreateDateColumn, + Entity, + JoinTable, + ManyToMany, + PrimaryGeneratedColumn, + UpdateDateColumn, +} from "typeorm" +import { Expose, Type } from "class-transformer" +import { IsDate, IsString, IsUUID } from "class-validator" +import { Property } from "../../property/entities/property.entity" +import { ValidationsGroupsEnum } from "../../shared/types/validations-groups-enum" + +@Entity() +export class PropertyGroup { + @PrimaryGeneratedColumn("uuid") + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @IsUUID(4, { groups: [ValidationsGroupsEnum.default] }) + id: string + + @CreateDateColumn() + @Expose() + @IsDate({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => Date) + createdAt: Date + + @UpdateDateColumn() + @Expose() + @IsDate({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => Date) + updatedAt: Date + + @Column() + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + name: string + + @ManyToMany(() => Property) + @JoinTable() + properties: Property[] +} diff --git a/backend/core/src/property-groups/property-groups.controller.ts b/backend/core/src/property-groups/property-groups.controller.ts new file mode 100644 index 0000000000..2dc50a2df3 --- /dev/null +++ b/backend/core/src/property-groups/property-groups.controller.ts @@ -0,0 +1,67 @@ +import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger" +import { DefaultAuthGuard } from "../auth/guards/default.guard" +import { AuthzGuard } from "../auth/guards/authz.guard" +import { ResourceType } from "../auth/decorators/resource-type.decorator" +import { mapTo } from "../shared/mapTo" +import { PropertyGroupsService } from "./property-groups.service" +import { + PropertyGroupCreateDto, + PropertyGroupDto, + PropertyGroupUpdateDto, +} from "./dto/property-group.dto" +import { + Body, + Controller, + Delete, + Get, + Param, + Post, + Put, + UseGuards, + UsePipes, + ValidationPipe, +} from "@nestjs/common" +import { defaultValidationPipeOptions } from "../shared/default-validation-pipe-options" + +@Controller("propertyGroups") +@ApiTags("propertyGroups") +@ApiBearerAuth() +@ResourceType("propertyGroup") +@UseGuards(DefaultAuthGuard, AuthzGuard) +@UsePipes(new ValidationPipe(defaultValidationPipeOptions)) +export class PropertyGroupsController { + constructor(private readonly propertyGroupsService: PropertyGroupsService) {} + + @Get() + @ApiOperation({ summary: "List propertyGroups", operationId: "list" }) + async list(): Promise { + return mapTo(PropertyGroupDto, await this.propertyGroupsService.list()) + } + + @Post() + @ApiOperation({ summary: "Create propertyGroup", operationId: "create" }) + async create(@Body() propertyGroup: PropertyGroupCreateDto): Promise { + return mapTo(PropertyGroupDto, await this.propertyGroupsService.create(propertyGroup)) + } + + @Put(`:propertyGroupId`) + @ApiOperation({ summary: "Update propertyGroup", operationId: "update" }) + async update(@Body() propertyGroup: PropertyGroupUpdateDto): Promise { + return mapTo(PropertyGroupDto, await this.propertyGroupsService.update(propertyGroup)) + } + + @Get(`:propertyGroupId`) + @ApiOperation({ summary: "Get propertyGroup by id", operationId: "retrieve" }) + async retrieve(@Param("propertyGroupId") propertyGroupId: string): Promise { + return mapTo( + PropertyGroupDto, + await this.propertyGroupsService.findOne({ where: { id: propertyGroupId } }) + ) + } + + @Delete(`:propertyGroupId`) + @ApiOperation({ summary: "Delete propertyGroup by id", operationId: "delete" }) + async delete(@Param("propertyGroupId") propertyGroupId: string): Promise { + return await this.propertyGroupsService.delete(propertyGroupId) + } +} diff --git a/backend/core/src/property-groups/property-groups.module.ts b/backend/core/src/property-groups/property-groups.module.ts new file mode 100644 index 0000000000..9d311d5909 --- /dev/null +++ b/backend/core/src/property-groups/property-groups.module.ts @@ -0,0 +1,13 @@ +import { Module } from "@nestjs/common" +import { PropertyGroupsController } from "./property-groups.controller" +import { PropertyGroupsService } from "./property-groups.service" +import { TypeOrmModule } from "@nestjs/typeorm" +import { AuthModule } from "../auth/auth.module" +import { PropertyGroup } from "./entities/property-group.entity" + +@Module({ + imports: [TypeOrmModule.forFeature([PropertyGroup]), AuthModule], + controllers: [PropertyGroupsController], + providers: [PropertyGroupsService], +}) +export class PropertyGroupsModule {} diff --git a/backend/core/src/property-groups/property-groups.service.ts b/backend/core/src/property-groups/property-groups.service.ts new file mode 100644 index 0000000000..9a4772cb08 --- /dev/null +++ b/backend/core/src/property-groups/property-groups.service.ts @@ -0,0 +1,11 @@ +import { PropertyGroupCreateDto, PropertyGroupUpdateDto } from "./dto/property-group.dto" +import { PropertyGroup } from "./entities/property-group.entity" +import { AbstractServiceFactory } from "../shared/services/abstract-service" +import { Injectable } from "@nestjs/common" + +@Injectable() +export class PropertyGroupsService extends AbstractServiceFactory< + PropertyGroup, + PropertyGroupCreateDto, + PropertyGroupUpdateDto +>(PropertyGroup) {} diff --git a/backend/core/src/property/dto/property.dto.ts b/backend/core/src/property/dto/property.dto.ts new file mode 100644 index 0000000000..58b303fd5e --- /dev/null +++ b/backend/core/src/property/dto/property.dto.ts @@ -0,0 +1,89 @@ +import { ApiHideProperty, OmitType } from "@nestjs/swagger" +import { Exclude, Expose, Type } from "class-transformer" +import { IsDate, IsDefined, IsOptional, IsUUID, ValidateNested } from "class-validator" +import { Property } from "../entities/property.entity" +import { AddressDto, AddressUpdateDto } from "../../shared/dto/address.dto" +import { UnitDto } from "../../units/dto/unit.dto" +import { ValidationsGroupsEnum } from "../../shared/types/validations-groups-enum" +import { UnitCreateDto } from "../../units/dto/unit-create.dto" +import { UnitUpdateDto } from "../../units/dto/unit-update.dto" + +export class PropertyDto extends OmitType(Property, [ + "units", + "propertyGroups", + "buildingAddress", +] as const) { + @Exclude() + @ApiHideProperty() + propertyGroups + + @Expose() + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default], each: true }) + @Type(() => UnitDto) + units: UnitDto[] + + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Expose() + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => AddressDto) + buildingAddress: AddressDto +} + +export class PropertyCreateDto extends OmitType(PropertyDto, [ + "id", + "createdAt", + "updatedAt", + "buildingAddress", + "units", +] as const) { + @Expose() + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => AddressUpdateDto) + buildingAddress: AddressUpdateDto + + @Expose() + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default], each: true }) + @Type(() => UnitCreateDto) + units: UnitCreateDto[] +} + +export class PropertyUpdateDto extends OmitType(PropertyDto, [ + "id", + "createdAt", + "updatedAt", + "buildingAddress", + "units", +] as const) { + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsUUID(4, { groups: [ValidationsGroupsEnum.default] }) + id?: string + + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsDate({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => Date) + createdAt?: Date + + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsDate({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => Date) + updatedAt?: Date + + @Expose() + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => AddressUpdateDto) + buildingAddress: AddressUpdateDto + + @Expose() + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default], each: true }) + @Type(() => UnitUpdateDto) + units: UnitUpdateDto[] +} diff --git a/backend/core/src/property/entities/property.entity.ts b/backend/core/src/property/entities/property.entity.ts new file mode 100644 index 0000000000..391073da49 --- /dev/null +++ b/backend/core/src/property/entities/property.entity.ts @@ -0,0 +1,151 @@ +import { + Column, + CreateDateColumn, + Entity, + JoinColumn, + ManyToMany, + OneToMany, + OneToOne, + PrimaryGeneratedColumn, + UpdateDateColumn, +} from "typeorm" +import { Expose, Type } from "class-transformer" +import { + IsDate, + IsDefined, + IsEnum, + IsNumber, + IsOptional, + IsString, + IsUUID, + ValidateNested, +} from "class-validator" +import { Unit } from "../../units/entities/unit.entity" +import { PropertyGroup } from "../../property-groups/entities/property-group.entity" +import { Address } from "../../shared/entities/address.entity" +import { ValidationsGroupsEnum } from "../../shared/types/validations-groups-enum" +import { Region } from "../types/region-enum" +import { ApiProperty } from "@nestjs/swagger" + +@Entity() +export class Property { + @PrimaryGeneratedColumn("uuid") + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @IsUUID(4, { groups: [ValidationsGroupsEnum.default] }) + id: string + + @CreateDateColumn() + @Expose() + @IsDate({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => Date) + createdAt: Date + + @UpdateDateColumn() + @Expose() + @IsDate({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => Date) + updatedAt: Date + + @OneToMany(() => Unit, (unit) => unit.property, { eager: true, cascade: true }) + units: Unit[] + + @ManyToMany(() => PropertyGroup) + propertyGroups: PropertyGroup[] + + @Column({ type: "text", nullable: true }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + accessibility?: string | null + + @Column({ type: "text", nullable: true }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + amenities?: string | null + + @OneToOne(() => Address, { eager: true, cascade: true }) + @JoinColumn() + @Expose() + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => Address) + buildingAddress: Address + + @Column({ type: "integer", nullable: true }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsNumber({}, { groups: [ValidationsGroupsEnum.default] }) + buildingTotalUnits?: number | null + + @Column({ type: "text", nullable: true }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + developer?: string | null + + @Column({ type: "integer", nullable: true }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsNumber({}, { groups: [ValidationsGroupsEnum.default] }) + householdSizeMax?: number | null + + @Column({ type: "integer", nullable: true }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsNumber({}, { groups: [ValidationsGroupsEnum.default] }) + householdSizeMin?: number | null + + @Column({ type: "text", nullable: true }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + neighborhood?: string | null + + @Column({ type: "enum", enum: Region, nullable: true }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsEnum(Region, { groups: [ValidationsGroupsEnum.default] }) + @ApiProperty({ + enum: Region, + enumName: "Region", + }) + region?: Region | null + + @Column({ type: "text", nullable: true }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + petPolicy?: string | null + + @Column({ type: "text", nullable: true }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + smokingPolicy?: string | null + + @Column({ type: "integer", nullable: true }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsNumber({}, { groups: [ValidationsGroupsEnum.default] }) + unitsAvailable?: number | null + + @Column({ type: "text", nullable: true }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + unitAmenities?: string | null + + @Column({ type: "text", nullable: true }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + servicesOffered?: string | null + + @Column({ type: "integer", nullable: true }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsNumber({}, { groups: [ValidationsGroupsEnum.default] }) + yearBuilt?: number | null +} diff --git a/backend/core/src/property/properties.controller.ts b/backend/core/src/property/properties.controller.ts new file mode 100644 index 0000000000..1179b761d7 --- /dev/null +++ b/backend/core/src/property/properties.controller.ts @@ -0,0 +1,60 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + Post, + Put, + UseGuards, + UsePipes, + ValidationPipe, +} from "@nestjs/common" +import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger" +import { DefaultAuthGuard } from "../auth/guards/default.guard" +import { AuthzGuard } from "../auth/guards/authz.guard" +import { ResourceType } from "../auth/decorators/resource-type.decorator" +import { mapTo } from "../shared/mapTo" +import { PropertiesService } from "./properties.service" +import { PropertyCreateDto, PropertyDto, PropertyUpdateDto } from "./dto/property.dto" +import { defaultValidationPipeOptions } from "../shared/default-validation-pipe-options" + +@Controller("properties") +@ApiTags("properties") +@ApiBearerAuth() +@ResourceType("property") +@UseGuards(DefaultAuthGuard, AuthzGuard) +@UsePipes(new ValidationPipe(defaultValidationPipeOptions)) +export class PropertiesController { + constructor(private readonly propertiesService: PropertiesService) {} + + @Get() + @ApiOperation({ summary: "List properties", operationId: "list" }) + async list(): Promise { + return mapTo(PropertyDto, await this.propertiesService.list()) + } + + @Post() + @ApiOperation({ summary: "Create property", operationId: "create" }) + async create(@Body() property: PropertyCreateDto): Promise { + return mapTo(PropertyDto, await this.propertiesService.create(property)) + } + + @Put(`:propertyId`) + @ApiOperation({ summary: "Update property", operationId: "update" }) + async update(@Body() property: PropertyUpdateDto): Promise { + return mapTo(PropertyDto, await this.propertiesService.update(property)) + } + + @Get(`:propertyId`) + @ApiOperation({ summary: "Get property by id", operationId: "retrieve" }) + async retrieve(@Param("propertyId") propertyId: string): Promise { + return mapTo(PropertyDto, await this.propertiesService.findOne({ where: { id: propertyId } })) + } + + @Delete(`:propertyId`) + @ApiOperation({ summary: "Delete property by id", operationId: "delete" }) + async delete(@Param("propertyId") propertyId: string): Promise { + return await this.propertiesService.delete(propertyId) + } +} diff --git a/backend/core/src/property/properties.module.ts b/backend/core/src/property/properties.module.ts new file mode 100644 index 0000000000..dbfebdde59 --- /dev/null +++ b/backend/core/src/property/properties.module.ts @@ -0,0 +1,13 @@ +import { Module } from "@nestjs/common" +import { PropertiesController } from "./properties.controller" +import { PropertiesService } from "./properties.service" +import { TypeOrmModule } from "@nestjs/typeorm" +import { Property } from "./entities/property.entity" +import { AuthModule } from "../auth/auth.module" + +@Module({ + imports: [TypeOrmModule.forFeature([Property]), AuthModule], + controllers: [PropertiesController], + providers: [PropertiesService], +}) +export class PropertiesModule {} diff --git a/backend/core/src/property/properties.service.ts b/backend/core/src/property/properties.service.ts new file mode 100644 index 0000000000..4a70bc55a5 --- /dev/null +++ b/backend/core/src/property/properties.service.ts @@ -0,0 +1,9 @@ +import { AbstractServiceFactory } from "../shared/services/abstract-service" +import { PropertyCreateDto, PropertyUpdateDto } from "./dto/property.dto" +import { Property } from "./entities/property.entity" + +export class PropertiesService extends AbstractServiceFactory< + Property, + PropertyCreateDto, + PropertyUpdateDto +>(Property) {} diff --git a/backend/core/src/property/types/region-enum.ts b/backend/core/src/property/types/region-enum.ts new file mode 100644 index 0000000000..cdffce35cb --- /dev/null +++ b/backend/core/src/property/types/region-enum.ts @@ -0,0 +1,7 @@ +export enum Region { + Downtown = "Downtown", + Eastside = "Eastside", + MidtownNewCenter = "Midtown - New Center", + Southwest = "Southwest", + Westside = "Westside", +} diff --git a/backend/core/src/reserved-community-type/dto/reserved-community-type-list-query-params.ts b/backend/core/src/reserved-community-type/dto/reserved-community-type-list-query-params.ts new file mode 100644 index 0000000000..7e6a73f82f --- /dev/null +++ b/backend/core/src/reserved-community-type/dto/reserved-community-type-list-query-params.ts @@ -0,0 +1,16 @@ +import { Expose } from "class-transformer" +import { ApiProperty } from "@nestjs/swagger" +import { IsOptional, IsString } from "class-validator" +import { ValidationsGroupsEnum } from "../../shared/types/validations-groups-enum" + +export class ReservedCommunityTypeListQueryParams { + @Expose() + @ApiProperty({ + name: "jurisdictionName", + required: false, + type: String, + }) + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + jurisdictionName?: string +} diff --git a/backend/core/src/reserved-community-type/dto/reserved-community-type.dto.ts b/backend/core/src/reserved-community-type/dto/reserved-community-type.dto.ts new file mode 100644 index 0000000000..5666f9bfe3 --- /dev/null +++ b/backend/core/src/reserved-community-type/dto/reserved-community-type.dto.ts @@ -0,0 +1,37 @@ +import { Expose, Type } from "class-transformer" +import { IsDefined, IsString, IsUUID, ValidateNested } from "class-validator" +import { OmitType } from "@nestjs/swagger" +import { ValidationsGroupsEnum } from "../../shared/types/validations-groups-enum" +import { ReservedCommunityType } from "../entities/reserved-community-type.entity" +import { IdDto } from "../../shared/dto/id.dto" +import { JurisdictionDto } from "../../jurisdictions/dto/jurisdiction.dto" + +export class ReservedCommunityTypeDto extends OmitType(ReservedCommunityType, [ + "jurisdiction", +] as const) { + @Expose() + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => JurisdictionDto) + jurisdiction: JurisdictionDto +} + +export class ReservedCommunityTypeCreateDto extends OmitType(ReservedCommunityTypeDto, [ + "id", + "createdAt", + "updatedAt", + "jurisdiction", +] as const) { + @Expose() + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => IdDto) + jurisdiction: IdDto +} + +export class ReservedCommunityTypeUpdateDto extends ReservedCommunityTypeCreateDto { + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @IsUUID() + id: string +} diff --git a/backend/core/src/reserved-community-type/entities/reserved-community-type.entity.ts b/backend/core/src/reserved-community-type/entities/reserved-community-type.entity.ts new file mode 100644 index 0000000000..6beab5d744 --- /dev/null +++ b/backend/core/src/reserved-community-type/entities/reserved-community-type.entity.ts @@ -0,0 +1,28 @@ +import { Column, Entity, ManyToOne } from "typeorm" +import { Expose, Type } from "class-transformer" +import { IsOptional, IsString, MaxLength, ValidateNested } from "class-validator" +import { ValidationsGroupsEnum } from "../../shared/types/validations-groups-enum" +import { AbstractEntity } from "../../shared/entities/abstract.entity" +import { Jurisdiction } from "../../jurisdictions/entities/jurisdiction.entity" + +@Entity({ name: "reserved_community_types" }) +export class ReservedCommunityType extends AbstractEntity { + @Column({ type: "text" }) + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @MaxLength(256, { groups: [ValidationsGroupsEnum.default] }) + name: string + + @Column({ type: "text", nullable: true }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @MaxLength(2048, { groups: [ValidationsGroupsEnum.default] }) + description?: string | null + + @ManyToOne(() => Jurisdiction, { eager: true, nullable: false }) + @Expose() + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => Jurisdiction) + jurisdiction: Jurisdiction +} diff --git a/backend/core/src/reserved-community-type/reserved-community-types.controller.ts b/backend/core/src/reserved-community-type/reserved-community-types.controller.ts new file mode 100644 index 0000000000..4621e3801f --- /dev/null +++ b/backend/core/src/reserved-community-type/reserved-community-types.controller.ts @@ -0,0 +1,86 @@ +import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger" +import { DefaultAuthGuard } from "../auth/guards/default.guard" +import { AuthzGuard } from "../auth/guards/authz.guard" +import { ResourceType } from "../auth/decorators/resource-type.decorator" +import { mapTo } from "../shared/mapTo" +import { + Body, + Controller, + Delete, + Get, + Param, + Post, + Put, + Query, + UseGuards, + UsePipes, + ValidationPipe, +} from "@nestjs/common" +import { defaultValidationPipeOptions } from "../shared/default-validation-pipe-options" +import { + ReservedCommunityTypeCreateDto, + ReservedCommunityTypeDto, + ReservedCommunityTypeUpdateDto, +} from "./dto/reserved-community-type.dto" +import { ReservedCommunityTypesService } from "./reserved-community-types.service" +import { ReservedCommunityTypeListQueryParams } from "./dto/reserved-community-type-list-query-params" + +@Controller("reservedCommunityTypes") +@ApiTags("reservedCommunityTypes") +@ApiBearerAuth() +@ResourceType("reservedCommunityType") +@UseGuards(DefaultAuthGuard, AuthzGuard) +@UsePipes(new ValidationPipe(defaultValidationPipeOptions)) +export class ReservedCommunityTypesController { + constructor(private readonly reservedCommunityTypesService: ReservedCommunityTypesService) {} + + @Get() + @ApiOperation({ summary: "List reservedCommunityTypes", operationId: "list" }) + async list( + @Query() queryParams: ReservedCommunityTypeListQueryParams + ): Promise { + return mapTo( + ReservedCommunityTypeDto, + await this.reservedCommunityTypesService.list(queryParams) + ) + } + + @Post() + @ApiOperation({ summary: "Create reservedCommunityType", operationId: "create" }) + async create( + @Body() reservedCommunityType: ReservedCommunityTypeCreateDto + ): Promise { + return mapTo( + ReservedCommunityTypeDto, + await this.reservedCommunityTypesService.create(reservedCommunityType) + ) + } + + @Put(`:reservedCommunityTypeId`) + @ApiOperation({ summary: "Update reservedCommunityType", operationId: "update" }) + async update( + @Body() reservedCommunityType: ReservedCommunityTypeUpdateDto + ): Promise { + return mapTo( + ReservedCommunityTypeDto, + await this.reservedCommunityTypesService.update(reservedCommunityType) + ) + } + + @Get(`:reservedCommunityTypeId`) + @ApiOperation({ summary: "Get reservedCommunityType by id", operationId: "retrieve" }) + async retrieve( + @Param("reservedCommunityTypeId") reservedCommunityTypeId: string + ): Promise { + return mapTo( + ReservedCommunityTypeDto, + await this.reservedCommunityTypesService.findOne({ where: { id: reservedCommunityTypeId } }) + ) + } + + @Delete(`:reservedCommunityTypeId`) + @ApiOperation({ summary: "Delete reservedCommunityType by id", operationId: "delete" }) + async delete(@Param("reservedCommunityTypeId") reservedCommunityTypeId: string): Promise { + return await this.reservedCommunityTypesService.delete(reservedCommunityTypeId) + } +} diff --git a/backend/core/src/reserved-community-type/reserved-community-types.module.ts b/backend/core/src/reserved-community-type/reserved-community-types.module.ts new file mode 100644 index 0000000000..f1c31f1f36 --- /dev/null +++ b/backend/core/src/reserved-community-type/reserved-community-types.module.ts @@ -0,0 +1,13 @@ +import { Module } from "@nestjs/common" +import { TypeOrmModule } from "@nestjs/typeorm" +import { AuthModule } from "../auth/auth.module" +import { ReservedCommunityTypesController } from "./reserved-community-types.controller" +import { ReservedCommunityTypesService } from "./reserved-community-types.service" +import { ReservedCommunityType } from "./entities/reserved-community-type.entity" + +@Module({ + imports: [TypeOrmModule.forFeature([ReservedCommunityType]), AuthModule], + controllers: [ReservedCommunityTypesController], + providers: [ReservedCommunityTypesService], +}) +export class ReservedCommunityTypesModule {} diff --git a/backend/core/src/reserved-community-type/reserved-community-types.service.ts b/backend/core/src/reserved-community-type/reserved-community-types.service.ts new file mode 100644 index 0000000000..ca204301e8 --- /dev/null +++ b/backend/core/src/reserved-community-type/reserved-community-types.service.ts @@ -0,0 +1,63 @@ +import { NotFoundException } from "@nestjs/common" +import { ReservedCommunityType } from "./entities/reserved-community-type.entity" +import { InjectRepository } from "@nestjs/typeorm" +import { FindOneOptions, Repository } from "typeorm" +import { + ReservedCommunityTypeCreateDto, + ReservedCommunityTypeUpdateDto, +} from "./dto/reserved-community-type.dto" +import { ReservedCommunityTypeListQueryParams } from "./dto/reserved-community-type-list-query-params" +import { assignDefined } from "../shared/utils/assign-defined" + +export class ReservedCommunityTypesService { + constructor( + @InjectRepository(ReservedCommunityType) + private readonly repository: Repository + ) {} + + list(queryParams?: ReservedCommunityTypeListQueryParams): Promise { + return this.repository.find({ + join: { + alias: "rct", + leftJoinAndSelect: { jurisdiction: "rct.jurisdiction" }, + }, + where: (qb) => { + if (queryParams.jurisdictionName) { + qb.where("jurisdiction.name = :jurisdictionName", queryParams) + } + }, + }) + } + + async create(dto: ReservedCommunityTypeCreateDto): Promise { + return await this.repository.save(dto) + } + + async findOne( + findOneOptions: FindOneOptions + ): Promise { + const obj = await this.repository.findOne(findOneOptions) + if (!obj) { + throw new NotFoundException() + } + return obj + } + + async delete(objId: string) { + await this.repository.delete(objId) + } + + async update(dto: ReservedCommunityTypeUpdateDto) { + const obj = await this.repository.findOne({ + where: { + id: dto.id, + }, + }) + if (!obj) { + throw new NotFoundException() + } + assignDefined(obj, dto) + await this.repository.save(obj) + return obj + } +} diff --git a/backend/core/src/seeder/detroit-seed.ts b/backend/core/src/seeder/detroit-seed.ts new file mode 100644 index 0000000000..b49e901d27 --- /dev/null +++ b/backend/core/src/seeder/detroit-seed.ts @@ -0,0 +1,194 @@ +import { SeederModule } from "./seeder.module" +import { NestFactory } from "@nestjs/core" +import yargs from "yargs" +import { UserService } from "../auth/services/user.service" +import { plainToClass } from "class-transformer" +import { UserCreateDto } from "../auth/dto/user-create.dto" +import { Repository } from "typeorm" +import { getRepositoryToken } from "@nestjs/typeorm" +import { User } from "../auth/entities/user.entity" +import { INestApplicationContext } from "@nestjs/common" +import { ListingDefaultSeed } from "./seeds/listings/listing-default-seed" +import { Listing } from "../listings/entities/listing.entity" +import { defaultLeasingAgents } from "./seeds/listings/shared" +import { AuthContext } from "../auth/types/auth-context" +import { Listing10158Seed } from "./seeds/listings/listing-detroit-10158" +import { Listing10157Seed } from "./seeds/listings/listing-detroit-10157" +import { Listing10147Seed } from "./seeds/listings/listing-detroit-10147" +import { Listing10145Seed } from "./seeds/listings/listing-detroit-10145" +import { ListingTreymoreSeed } from "./seeds/listings/listing-detroit-treymore" +import { UserRoles } from "../auth/entities/user-roles.entity" +import { AmiChart } from "../ami-charts/entities/ami-chart.entity" +import { WayneCountyMSHDA2021 } from "./seeds/ami-charts/WayneCountyMSHDA2021" +import { Listing10151Seed } from "./seeds/listings/listing-detroit-10151" +import { Listing10153Seed } from "./seeds/listings/listing-detroit-10153" +import { Listing10154Seed } from "./seeds/listings/listing-detroit-10154" +import { Listing10155Seed } from "./seeds/listings/listing-detroit-10155" +import { Listing10159Seed } from "./seeds/listings/listing-detroit-10159" +import { Listing10168Seed } from "./seeds/listings/listing-detroit-10168" +import { createJurisdictions } from "./seeds/jurisdictions" +import { Jurisdiction } from "../jurisdictions/entities/jurisdiction.entity" +import { Listing10202Seed } from "./seeds/listings/listing-detroit-10202" +import { Listing10136Seed } from "./seeds/listings/listing-detroit-10136" + +const argv = yargs.scriptName("seed").options({ + test: { type: "boolean", default: false }, +}).argv + +// Note: if changing this list of seeds, you must also change the +// number in listings.e2e-spec.ts. +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const listingSeeds: any[] = [ + Listing10136Seed, + Listing10145Seed, + Listing10147Seed, + Listing10151Seed, + Listing10153Seed, + Listing10154Seed, + Listing10155Seed, + Listing10157Seed, + Listing10158Seed, + Listing10159Seed, + Listing10168Seed, + Listing10202Seed, + ListingTreymoreSeed, +] + +export function getSeedListingsCount() { + return listingSeeds.length +} + +export async function createLeasingAgents( + app: INestApplicationContext, + rolesRepo: Repository, + jurisdictions: Jurisdiction[] +) { + const usersService = await app.resolve(UserService) + const leasingAgents = await Promise.all( + defaultLeasingAgents.map( + async (leasingAgent) => + await usersService.createPublicUser( + plainToClass(UserCreateDto, { + ...leasingAgent, + jurisdictions: [jurisdictions.find((jurisdiction) => jurisdiction.name == "Detroit")], + }), + new AuthContext(null) + ) + ) + ) + await Promise.all([ + leasingAgents.map(async (agent: User) => { + const roles: UserRoles = { user: agent, isPartner: true } + await rolesRepo.save(roles) + await usersService.confirm({ token: agent.confirmationToken }) + }), + ]) + return leasingAgents +} + +const seedListings = async ( + app: INestApplicationContext, + rolesRepo: Repository, + jurisdictions: Jurisdiction[] +) => { + const seeds = [] + const leasingAgents = await createLeasingAgents(app, rolesRepo, jurisdictions) + + const allSeeds = listingSeeds.map((listingSeed) => app.get(listingSeed)) + const listingRepository = app.get>(getRepositoryToken(Listing)) + + for (const [index, listingSeed] of allSeeds.entries()) { + const everyOtherAgent = index % 2 ? leasingAgents[0] : leasingAgents[1] + const listing = await listingSeed.seed() + listing.jurisdiction = jurisdictions.find((jurisdiction) => jurisdiction.name == "Detroit") + listing.leasingAgents = [everyOtherAgent] + await listingRepository.save(listing) + + seeds.push(listing) + } + + return seeds +} + +async function seed() { + const app = await NestFactory.create(SeederModule.forRoot({ test: argv.test })) + // Starts listening for shutdown hooks + app.enableShutdownHooks() + const userService = await app.resolve(UserService) + + const userRepo = app.get>(getRepositoryToken(User)) + const rolesRepo = app.get>(getRepositoryToken(UserRoles)) + const jurisdictions = await createJurisdictions(app) + const listings = await seedListings(app, rolesRepo, jurisdictions) + + let user1 = await userService.findByEmail("test@example.com") + if (user1 === undefined) { + user1 = await userService.createPublicUser( + plainToClass(UserCreateDto, { + email: "test@example.com", + emailConfirmation: "test@example.com", + firstName: "First", + middleName: "Mid", + lastName: "Last", + dob: new Date(), + password: "abcdef", + passwordConfirmation: "Abcdef1!", + jurisdictions: [jurisdictions.find((jurisdiction) => jurisdiction.name == "Detroit")], + }), + new AuthContext(null) + ) + await userService.confirm({ token: user1.confirmationToken }) + } + + let user2 = await userService.findByEmail("test2@example.com") + if (user2 === undefined) { + user2 = await userService.createPublicUser( + plainToClass(UserCreateDto, { + email: "test2@example.com", + emailConfirmation: "test2@example.com", + firstName: "Second", + middleName: "Mid", + lastName: "Last", + dob: new Date(), + password: "ghijkl", + passwordConfirmation: "Ghijkl1!", + jurisdictions: [jurisdictions.find((jurisdiction) => jurisdiction.name == "Detroit")], + }), + new AuthContext(null) + ) + await userService.confirm({ token: user2.confirmationToken }) + } + + let admin = await userService.findByEmail("admin@example.com") + if (admin === undefined) { + admin = await userService.createPublicUser( + plainToClass(UserCreateDto, { + email: "admin@example.com", + emailConfirmation: "admin@example.com", + firstName: "Second", + middleName: "Mid", + lastName: "Last", + dob: new Date(), + password: "abcdef", + passwordConfirmation: "Abcdef1!", + jurisdictions, + }), + new AuthContext(null) + ) + + await userRepo.save(admin) + const roles: UserRoles = { user: admin, isPartner: true, isAdmin: true } + await rolesRepo.save(roles) + await userService.confirm({ token: admin.confirmationToken }) + } + + // Seed the Detroit AMI data, since it's not linked to any units. + const amiChartRepo = app.get>(getRepositoryToken(AmiChart)) + await amiChartRepo.save({ + ...JSON.parse(JSON.stringify(WayneCountyMSHDA2021)), + jurisdiction: jurisdictions.find((jurisdiction) => jurisdiction.name == "Detroit"), + }) + await app.close() +} + +void seed() diff --git a/backend/core/src/seeder/seed.ts b/backend/core/src/seeder/seed.ts new file mode 100644 index 0000000000..3548832c53 --- /dev/null +++ b/backend/core/src/seeder/seed.ts @@ -0,0 +1,399 @@ +import { NestFactory } from "@nestjs/core" +import yargs from "yargs" +import { plainToClass } from "class-transformer" +import { Repository } from "typeorm" +import { getRepositoryToken } from "@nestjs/typeorm" +import { INestApplicationContext } from "@nestjs/common" +import { ListingDefaultSeed } from "./seeds/listings/listing-default-seed" +import { ListingColiseumSeed } from "./seeds/listings/listing-coliseum-seed" +import { ListingDefaultOpenSoonSeed } from "./seeds/listings/listing-default-open-soon" +import { ListingDefaultOnePreferenceSeed } from "./seeds/listings/listing-default-one-preference-seed" +import { ListingDefaultNoPreferenceSeed } from "./seeds/listings/listing-default-no-preference-seed" +import { ListingDefaultSummaryWithoutAndListingWith20AmiPercentageSeed } from "./seeds/listings/listing-default-summary-without-and-listing-with-20-ami-percentage-seed" +import { ListingDefaultSummaryWith30ListingWith10AmiPercentageSeed } from "./seeds/listings/listing-default-summary-with-30-listing-with-10-ami-percentage-seed" +import { ListingDefaultSummaryWith30And60AmiPercentageSeed } from "./seeds/listings/listing-default-summary-with-30-and-60-ami-percentage-seed" +import { ListingDefaultSummaryWith10ListingWith30AmiPercentageSeed } from "./seeds/listings/listing-default-summary-with-10-listing-with-30-ami-percentage-seed" +import { Listing10158Seed } from "./seeds/listings/listing-detroit-10158" +import { Listing10157Seed } from "./seeds/listings/listing-detroit-10157" +import { Listing10147Seed } from "./seeds/listings/listing-detroit-10147" +import { Listing10145Seed } from "./seeds/listings/listing-detroit-10145" +import { CountyCode } from "../shared/types/county-code" +import { ListingTreymoreSeed } from "./seeds/listings/listing-detroit-treymore" +import { AmiChart } from "../ami-charts/entities/ami-chart.entity" +import { WayneCountyMSHDA2021 } from "./seeds/ami-charts/WayneCountyMSHDA2021" +import { ListingDefaultBmrChartSeed } from "./seeds/listings/listing-default-bmr-chart-seed" +import { ListingTritonSeed, ListingTritonSeedDetroit } from "./seeds/listings/listing-triton-seed" +import { ListingDefaultReservedSeed } from "./seeds/listings/listing-default-reserved-seed" +import { ListingDefaultFCFSSeed } from "./seeds/listings/listing-default-fcfs-seed" +import { ListingDefaultMultipleAMI } from "./seeds/listings/listing-default-multiple-ami" +import { ListingDefaultLottery } from "./seeds/listings/listing-default-lottery-results" +import { ListingDefaultLotteryPending } from "./seeds/listings/listing-default-lottery-pending" +import { ListingDefaultMultipleAMIAndPercentages } from "./seeds/listings/listing-default-multiple-ami-and-percentages" +import { ListingDefaultMissingAMI } from "./seeds/listings/listing-default-missing-ami" +import { AmiChartDefaultSeed } from "./seeds/ami-charts/default-ami-chart" +import { + defaultLeasingAgents, + getDisabilityOrMentalIllnessProgram, + getDisplaceePreference, + getHopwaPreference, + getHousingSituationProgram, + getLiveWorkPreference, + getPbvPreference, + getServedInMilitaryProgram, + getTayProgram, + getFlatRentAndRentBasedOnIncomeProgram, +} from "./seeds/listings/shared" +import { UserCreateDto } from "../auth/dto/user-create.dto" +import { AmiDefaultSanJose } from "./seeds/ami-charts/default-ami-chart-san-jose" +import { AuthContext } from "../auth/types/auth-context" +import { createJurisdictions } from "./seeds/jurisdictions" +import { AmiDefaultMissingAMI } from "./seeds/ami-charts/missing-household-ami-levels" +import { SeederModule } from "./seeder.module" +import { AmiDefaultTriton } from "./seeds/ami-charts/triton-ami-chart" +import { AmiDefaultTritonDetroit } from "./seeds/ami-charts/triton-ami-chart-detroit" +import { AmiDefaultSanMateo } from "./seeds/ami-charts/default-ami-chart-san-mateo" +import { makeNewApplication } from "./seeds/applications" +import { UserRoles } from "../auth/entities/user-roles.entity" +import { Jurisdiction } from "../jurisdictions/entities/jurisdiction.entity" +import { UserService } from "../auth/services/user.service" +import { User } from "../auth/entities/user.entity" +import { Preference } from "../preferences/entities/preference.entity" +import { Program } from "../program/entities/program.entity" +import { Listing } from "../listings/entities/listing.entity" +import { ApplicationMethodsService } from "../application-methods/application-methods.service" +import { ApplicationMethodType } from "../application-methods/types/application-method-type-enum" +import { UnitTypesService } from "../unit-types/unit-types.service" +import dayjs from "dayjs" + +const argv = yargs.scriptName("seed").options({ + test: { type: "boolean", default: false }, +}).argv + +// Note: if changing this list of seeds, you must also change the +// number in listings.e2e-spec.ts. +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const listingSeeds: any[] = [ + ListingDefaultSeed, + ListingColiseumSeed, + ListingDefaultOpenSoonSeed, + ListingDefaultOnePreferenceSeed, + ListingDefaultNoPreferenceSeed, + ListingDefaultBmrChartSeed, + ListingTritonSeed, + ListingDefaultReservedSeed, + ListingDefaultFCFSSeed, + ListingDefaultSummaryWith30And60AmiPercentageSeed, + ListingDefaultSummaryWithoutAndListingWith20AmiPercentageSeed, + ListingDefaultSummaryWith30ListingWith10AmiPercentageSeed, + ListingDefaultSummaryWith10ListingWith30AmiPercentageSeed, + Listing10145Seed, + Listing10147Seed, + Listing10157Seed, + Listing10158Seed, + ListingTreymoreSeed, + ListingDefaultMultipleAMI, + ListingDefaultMultipleAMIAndPercentages, + ListingDefaultMissingAMI, + ListingDefaultLottery, + ListingDefaultLotteryPending, + ListingTritonSeedDetroit, + ListingDefaultFCFSSeed, +] + +const amiSeeds: any[] = [ + AmiChartDefaultSeed, + AmiDefaultMissingAMI, + AmiDefaultTriton, + AmiDefaultTritonDetroit, +] + +export function getSeedListingsCount() { + return listingSeeds.length +} + +export async function createLeasingAgents( + app: INestApplicationContext, + rolesRepo: Repository, + jurisdictions: Jurisdiction[] +) { + const usersService = await app.resolve(UserService) + const leasingAgents = await Promise.all( + defaultLeasingAgents.map( + async (leasingAgent) => + await usersService.createPublicUser( + plainToClass(UserCreateDto, { + ...leasingAgent, + jurisdictions: [jurisdictions[0]], + }), + new AuthContext(null) + ) + ) + ) + await Promise.all([ + leasingAgents.map(async (agent: User) => { + const roles: UserRoles = { user: agent, isPartner: true } + await rolesRepo.save(roles) + await usersService.confirm({ token: agent.confirmationToken }) + }), + ]) + return leasingAgents +} + +export async function createPreferences( + app: INestApplicationContext, + jurisdictions: Jurisdiction[] +) { + const preferencesRepository = app.get>(getRepositoryToken(Preference)) + const preferencesToSave = [] + + jurisdictions.forEach((jurisdiction) => { + preferencesToSave.push( + getLiveWorkPreference(jurisdiction.name), + getPbvPreference(jurisdiction.name), + getHopwaPreference(jurisdiction.name), + getDisplaceePreference(jurisdiction.name) + ) + }) + + const preferences = await preferencesRepository.save(preferencesToSave) + + for (const jurisdiction of jurisdictions) { + jurisdiction.preferences = preferences.filter((preference) => { + const jurisdictionName = preference.title.split("-").pop() + return jurisdictionName === ` ${jurisdiction.name}` + }) + } + const jurisdictionsRepository = app.get>( + getRepositoryToken(Jurisdiction) + ) + await jurisdictionsRepository.save(jurisdictions) + return preferences +} + +export async function createPrograms(app: INestApplicationContext, jurisdictions: Jurisdiction[]) { + const programsRepository = app.get>(getRepositoryToken(Program)) + const programs = await programsRepository.save([ + getServedInMilitaryProgram(), + getTayProgram(), + getDisabilityOrMentalIllnessProgram(), + getHousingSituationProgram(), + getFlatRentAndRentBasedOnIncomeProgram(), + ]) + + for (const jurisdiction of jurisdictions) { + jurisdiction.programs = programs + } + const jurisdictionsRepository = app.get>( + getRepositoryToken(Jurisdiction) + ) + await jurisdictionsRepository.save(jurisdictions) + + return programs +} + +const seedAmiCharts = async (app: INestApplicationContext) => { + const allSeeds = amiSeeds.map((amiSeed) => app.get(amiSeed)) + const amiCharts = [] + for (const chart of allSeeds) { + const amiChart = await chart.seed() + amiCharts.push(amiChart) + } + return amiCharts +} + +const seedListings = async ( + app: INestApplicationContext, + rolesRepo: Repository, + jurisdictions: Jurisdiction[] +) => { + const seeds = [] + const leasingAgents = await createLeasingAgents(app, rolesRepo, jurisdictions) + await createPreferences(app, jurisdictions) + const allSeeds = listingSeeds.map((listingSeed) => app.get(listingSeed)) + const listingRepository = app.get>(getRepositoryToken(Listing)) + const applicationMethodsService = await app.resolve( + ApplicationMethodsService + ) + + for (const [index, listingSeed] of allSeeds.entries()) { + const everyOtherAgent = index % 2 ? leasingAgents[0] : leasingAgents[1] + const listing: Listing & { jurisdictionName?: string } = await listingSeed.seed() + // set jurisdiction based off of the name provided on the seed + listing.jurisdiction = jurisdictions.find( + (jurisdiction) => jurisdiction.name === listing.jurisdictionName + ) + listing.leasingAgents = [everyOtherAgent] + const applicationMethods = await applicationMethodsService.create({ + type: ApplicationMethodType.Internal, + acceptsPostmarkedApplications: false, + externalReference: "", + label: "Label", + paperApplications: [], + listing, + }) + listing.applicationMethods = [applicationMethods] + await listingRepository.save(listing) + + seeds.push(listing) + } + + return seeds +} + +async function seed() { + const app = await NestFactory.create(SeederModule.forRoot({ test: argv.test })) + // Starts listening for shutdown hooks + app.enableShutdownHooks() + const userService = await app.resolve(UserService) + + const userRepo = app.get>(getRepositoryToken(User)) + const rolesRepo = app.get>(getRepositoryToken(UserRoles)) + const jurisdictions = await createJurisdictions(app) + await createPrograms(app, jurisdictions) + await seedAmiCharts(app) + const listings = await seedListings(app, rolesRepo, jurisdictions) + + const user1 = await userService.createPublicUser( + plainToClass(UserCreateDto, { + email: "test@example.com", + emailConfirmation: "test@example.com", + firstName: "First", + middleName: "Mid", + lastName: "Last", + dob: new Date(), + password: "abcdef", + passwordConfirmation: "abcdef", + jurisdictions: [jurisdictions[0]], + }), + new AuthContext(null) + ) + + await userService.confirm({ token: user1.confirmationToken }) + + const user2 = await userService.createPublicUser( + plainToClass(UserCreateDto, { + email: "test2@example.com", + emailConfirmation: "test2@example.com", + firstName: "Second", + middleName: "Mid", + lastName: "Last", + dob: new Date(), + password: "ghijkl", + passwordConfirmation: "ghijkl", + jurisdictions: [jurisdictions[0]], + }), + new AuthContext(null) + ) + await userService.confirm({ token: user2.confirmationToken }) + + // create not confirmed user + await userService.createPublicUser( + plainToClass(UserCreateDto, { + email: "user+notconfirmed@example.com", + emailConfirmation: "user+notconfirmed@example.com", + firstName: "First", + middleName: "Mid", + lastName: "Last", + dob: new Date(), + password: "abcdef", + passwordConfirmation: "abcdef", + jurisdictions: [jurisdictions[0]], + }), + new AuthContext(null) + ) + + // create user with expired password + const userExpiredPassword = await userService.createPublicUser( + plainToClass(UserCreateDto, { + email: "user+expired@example.com", + emailConfirmation: "user+expired@example.com", + firstName: "Second", + middleName: "Mid", + lastName: "Last", + dob: new Date(), + password: "abcdef", + passwordConfirmation: "abcdef", + jurisdictions: [jurisdictions[0]], + roles: { isAdmin: false, isPartner: true }, + }), + new AuthContext(null) + ) + + await userService.confirm({ token: userExpiredPassword.confirmationToken }) + + userExpiredPassword.passwordValidForDays = 180 + userExpiredPassword.passwordUpdatedAt = new Date("2020-01-01") + userExpiredPassword.confirmedAt = new Date() + + await userRepo.save(userExpiredPassword) + + const admin = await userService.createPublicUser( + plainToClass(UserCreateDto, { + email: "admin@example.com", + emailConfirmation: "admin@example.com", + firstName: "Second", + middleName: "Mid", + lastName: "Last", + dob: new Date(), + password: "abcdef", + passwordConfirmation: "abcdef", + jurisdictions, + }), + new AuthContext(null) + ) + + const mfaUser = await userService.createPublicUser( + plainToClass(UserCreateDto, { + email: "mfaUser@bloom.com", + emailConfirmation: "mfaUser@bloom.com", + firstName: "I", + middleName: "Use", + lastName: "MFA", + dob: new Date(), + password: "abcdef12", + passwordConfirmation: "abcdef12", + jurisdictions, + }), + new AuthContext(null) + ) + + const unitTypesService = await app.resolve(UnitTypesService) + + const unitTypes = await unitTypesService.list() + + for (let i = 0; i < 10; i++) { + for (const listing of listings) { + if (listing.countyCode !== CountyCode.detroit) { + await Promise.all([ + await makeNewApplication(app, listing, unitTypes, user1), + await makeNewApplication(app, listing, unitTypes, user2), + ]) + } + } + } + + // Seed the Detroit AMI data, since it's not linked to any units. + const amiChartRepo = app.get>(getRepositoryToken(AmiChart)) + await amiChartRepo.save({ + ...JSON.parse(JSON.stringify(WayneCountyMSHDA2021)), + jurisdiction: jurisdictions.find((jurisdiction) => jurisdiction.name == "Detroit"), + }) + + await userRepo.save(admin) + await userRepo.save({ + ...mfaUser, + mfaEnabled: false, + mfaCode: "123456", + mfaCodeUpdatedAt: dayjs(new Date()).add(1, "day"), + }) + const roles: UserRoles = { user: admin, isPartner: true, isAdmin: true } + const mfaRoles: UserRoles = { user: mfaUser, isPartner: true, isAdmin: true } + await rolesRepo.save(roles) + await rolesRepo.save(mfaRoles) + + await userService.confirm({ token: admin.confirmationToken }) + await userService.confirm({ token: mfaUser.confirmationToken }) + await app.close() +} + +void seed() diff --git a/backend/core/src/seeder/seeder.module.ts b/backend/core/src/seeder/seeder.module.ts new file mode 100644 index 0000000000..ea54a943f7 --- /dev/null +++ b/backend/core/src/seeder/seeder.module.ts @@ -0,0 +1,161 @@ +import { DynamicModule, Module } from "@nestjs/common" + +import { TypeOrmModule } from "@nestjs/typeorm" +import dbOptions = require("../../ormconfig") +import testDbOptions = require("../../ormconfig.test") +import { ThrottlerModule } from "@nestjs/throttler" +import { SharedModule } from "../shared/shared.module" +import { AuthModule } from "../auth/auth.module" +import { ApplicationsModule } from "../applications/applications.module" +import { ListingsModule } from "../listings/listings.module" +import { AmiChartsModule } from "../ami-charts/ami-charts.module" +import { ListingDefaultSeed } from "../seeder/seeds/listings/listing-default-seed" +import { Listing } from "../listings/entities/listing.entity" +import { UnitAccessibilityPriorityType } from "../unit-accessbility-priority-types/entities/unit-accessibility-priority-type.entity" +import { ReservedCommunityType } from "../reserved-community-type/entities/reserved-community-type.entity" +import { UnitType } from "../unit-types/entities/unit-type.entity" +import { UnitRentType } from "../unit-rent-types/entities/unit-rent-type.entity" +import { AmiChart } from "../ami-charts/entities/ami-chart.entity" +import { Property } from "../property/entities/property.entity" +import { Unit } from "../units/entities/unit.entity" +import { User } from "../auth/entities/user.entity" +import { UserRoles } from "../auth/entities/user-roles.entity" +import { ListingColiseumSeed } from "../seeder/seeds/listings/listing-coliseum-seed" +import { ListingDefaultOnePreferenceSeed } from "../seeder/seeds/listings/listing-default-one-preference-seed" +import { ListingDefaultNoPreferenceSeed } from "../seeder/seeds/listings/listing-default-no-preference-seed" +import { Preference } from "../preferences/entities/preference.entity" +import { ListingDefaultFCFSSeed } from "../seeder/seeds/listings/listing-default-fcfs-seed" +import { ListingDefaultOpenSoonSeed } from "../seeder/seeds/listings/listing-default-open-soon" +import { + ListingTritonSeed, + ListingTritonSeedDetroit, +} from "../seeder/seeds/listings/listing-triton-seed" +import { ListingDefaultBmrChartSeed } from "../seeder/seeds/listings/listing-default-bmr-chart-seed" +import { ApplicationMethod } from "../application-methods/entities/application-method.entity" +import { PaperApplication } from "../paper-applications/entities/paper-application.entity" +import { ApplicationMethodsModule } from "../application-methods/applications-methods.module" +import { PaperApplicationsModule } from "../paper-applications/paper-applications.module" +import { AssetsModule } from "../assets/assets.module" +import { Listing10158Seed } from "./seeds/listings/listing-detroit-10158" +import { Listing10157Seed } from "./seeds/listings/listing-detroit-10157" +import { Listing10147Seed } from "./seeds/listings/listing-detroit-10147" +import { Listing10145Seed } from "./seeds/listings/listing-detroit-10145" +import { Listing10202Seed } from "./seeds/listings/listing-detroit-10202" +import { ListingTreymoreSeed } from "./seeds/listings/listing-detroit-treymore" +import { UnitGroup } from "../units-summary/entities/unit-group.entity" +import { Listing10151Seed } from "./seeds/listings/listing-detroit-10151" +import { Listing10153Seed } from "./seeds/listings/listing-detroit-10153" +import { Listing10154Seed } from "./seeds/listings/listing-detroit-10154" +import { Listing10155Seed } from "./seeds/listings/listing-detroit-10155" +import { Listing10159Seed } from "./seeds/listings/listing-detroit-10159" +import { Listing10168Seed } from "./seeds/listings/listing-detroit-10168" +import { ListingDefaultSummaryWith30And60AmiPercentageSeed } from "./seeds/listings/listing-default-summary-with-30-and-60-ami-percentage-seed" +import { ListingDefaultSummaryWithoutAndListingWith20AmiPercentageSeed } from "./seeds/listings/listing-default-summary-without-and-listing-with-20-ami-percentage-seed" +import { ListingDefaultSummaryWith30ListingWith10AmiPercentageSeed } from "./seeds/listings/listing-default-summary-with-30-listing-with-10-ami-percentage-seed" +import { ListingDefaultSummaryWith10ListingWith30AmiPercentageSeed } from "./seeds/listings/listing-default-summary-with-10-listing-with-30-ami-percentage-seed" +import { Listing10136Seed } from "./seeds/listings/listing-detroit-10136" +import { ListingDefaultReservedSeed } from "../seeder/seeds/listings/listing-default-reserved-seed" +import { ListingDefaultMultipleAMI } from "../seeder/seeds/listings/listing-default-multiple-ami" +import { ListingDefaultMultipleAMIAndPercentages } from "../seeder/seeds/listings/listing-default-multiple-ami-and-percentages" +import { ListingDefaultLottery } from "./seeds/listings/listing-default-lottery-results" +import { ListingDefaultLotteryPending } from "./seeds/listings/listing-default-lottery-pending" +import { ListingDefaultMissingAMI } from "../seeder/seeds/listings/listing-default-missing-ami" +import { UnitTypesModule } from "../unit-types/unit-types.module" +import { Jurisdiction } from "../jurisdictions/entities/jurisdiction.entity" +import { Program } from "../program/entities/program.entity" +import { AmiChartDefaultSeed } from "../seeder/seeds/ami-charts/default-ami-chart" +import { AmiDefaultMissingAMI } from "../seeder/seeds/ami-charts/missing-household-ami-levels" +import { AmiDefaultTriton } from "../seeder/seeds/ami-charts/triton-ami-chart" +import { AmiDefaultTritonDetroit } from "../seeder/seeds/ami-charts/triton-ami-chart-detroit" +import { AmiDefaultSanJose } from "../seeder/seeds/ami-charts/default-ami-chart-san-jose" +import { AmiDefaultSanMateo } from "../seeder/seeds/ami-charts/default-ami-chart-san-mateo" +import { Asset } from "../assets/entities/asset.entity" + +@Module({}) +export class SeederModule { + static forRoot(options: { test: boolean }): DynamicModule { + const dbConfig = options.test ? testDbOptions : dbOptions + return { + module: SeederModule, + imports: [ + AssetsModule, + SharedModule, + TypeOrmModule.forRoot({ + ...dbConfig, + }), + TypeOrmModule.forFeature([ + Asset, + Listing, + Preference, + UnitAccessibilityPriorityType, + UnitType, + ReservedCommunityType, + UnitRentType, + AmiChart, + Property, + Unit, + UnitGroup, + User, + UserRoles, + ApplicationMethod, + PaperApplication, + Jurisdiction, + Program, + ]), + ThrottlerModule.forRoot({ + ttl: 60, + limit: 5, + ignoreUserAgents: [/^node-superagent.*$/], + }), + ApplicationsModule, + ApplicationMethodsModule, + PaperApplicationsModule, + AuthModule, + ListingsModule, + AmiChartsModule, + UnitTypesModule, + ], + providers: [ + ListingDefaultSeed, + ListingColiseumSeed, + ListingDefaultOnePreferenceSeed, + ListingDefaultNoPreferenceSeed, + ListingDefaultFCFSSeed, + ListingDefaultOpenSoonSeed, + ListingDefaultBmrChartSeed, + ListingTritonSeed, + ListingDefaultReservedSeed, + ListingDefaultFCFSSeed, + Listing10136Seed, + Listing10158Seed, + Listing10157Seed, + Listing10147Seed, + Listing10145Seed, + Listing10151Seed, + Listing10153Seed, + Listing10154Seed, + Listing10155Seed, + Listing10159Seed, + Listing10168Seed, + Listing10202Seed, + ListingTreymoreSeed, + ListingDefaultMultipleAMI, + ListingDefaultMultipleAMIAndPercentages, + ListingDefaultMissingAMI, + ListingDefaultLottery, + ListingDefaultLotteryPending, + ListingDefaultSummaryWith30And60AmiPercentageSeed, + ListingDefaultSummaryWithoutAndListingWith20AmiPercentageSeed, + ListingDefaultSummaryWith30ListingWith10AmiPercentageSeed, + ListingDefaultSummaryWith10ListingWith30AmiPercentageSeed, + ListingTritonSeedDetroit, + AmiChartDefaultSeed, + AmiDefaultMissingAMI, + AmiDefaultTriton, + AmiDefaultTritonDetroit, + AmiDefaultSanJose, + AmiDefaultSanMateo, + ], + } + } +} diff --git a/backend/core/src/seeder/seeds/ami-charts/HUD-MSHDA2021.ts b/backend/core/src/seeder/seeds/ami-charts/HUD-MSHDA2021.ts new file mode 100644 index 0000000000..a5fc4b01aa --- /dev/null +++ b/backend/core/src/seeder/seeds/ami-charts/HUD-MSHDA2021.ts @@ -0,0 +1,609 @@ +import { AmiChartCreateDto } from "../../../ami-charts/dto/ami-chart.dto" +import { BaseEntity } from "typeorm" + +// THIS FILE WAS AUTOMATICALLY GENERATED FROM HUD-MSHDA2021.txt. +export const HUDMSHDA2021: Omit = { + name: "HUD-MSHDA2021", + items: [ + { + percentOfAmi: 20, + householdSize: 1, + income: 11200, + }, + { + percentOfAmi: 20, + householdSize: 2, + income: 12800, + }, + { + percentOfAmi: 20, + householdSize: 3, + income: 14400, + }, + { + percentOfAmi: 20, + householdSize: 4, + income: 16000, + }, + { + percentOfAmi: 20, + householdSize: 5, + income: 17280, + }, + { + percentOfAmi: 20, + householdSize: 6, + income: 18560, + }, + { + percentOfAmi: 20, + householdSize: 7, + income: 19840, + }, + { + percentOfAmi: 20, + householdSize: 8, + income: 21120, + }, + { + percentOfAmi: 25, + householdSize: 1, + income: 14000, + }, + { + percentOfAmi: 25, + householdSize: 2, + income: 16000, + }, + { + percentOfAmi: 25, + householdSize: 3, + income: 18000, + }, + { + percentOfAmi: 25, + householdSize: 4, + income: 20000, + }, + { + percentOfAmi: 25, + householdSize: 5, + income: 21600, + }, + { + percentOfAmi: 25, + householdSize: 6, + income: 23200, + }, + { + percentOfAmi: 25, + householdSize: 7, + income: 24800, + }, + { + percentOfAmi: 25, + householdSize: 8, + income: 26400, + }, + { + percentOfAmi: 30, + householdSize: 1, + income: 16800, + }, + { + percentOfAmi: 30, + householdSize: 2, + income: 19200, + }, + { + percentOfAmi: 30, + householdSize: 3, + income: 21600, + }, + { + percentOfAmi: 30, + householdSize: 4, + income: 24000, + }, + { + percentOfAmi: 30, + householdSize: 5, + income: 25950, + }, + { + percentOfAmi: 30, + householdSize: 6, + income: 27850, + }, + { + percentOfAmi: 30, + householdSize: 7, + income: 29800, + }, + { + percentOfAmi: 30, + householdSize: 8, + income: 31700, + }, + { + percentOfAmi: 35, + householdSize: 1, + income: 19600, + }, + { + percentOfAmi: 35, + householdSize: 2, + income: 22400, + }, + { + percentOfAmi: 35, + householdSize: 3, + income: 25200, + }, + { + percentOfAmi: 35, + householdSize: 4, + income: 28000, + }, + { + percentOfAmi: 35, + householdSize: 5, + income: 30240, + }, + { + percentOfAmi: 35, + householdSize: 6, + income: 32480, + }, + { + percentOfAmi: 35, + householdSize: 7, + income: 34720, + }, + { + percentOfAmi: 35, + householdSize: 8, + income: 36960, + }, + { + percentOfAmi: 40, + householdSize: 1, + income: 22400, + }, + { + percentOfAmi: 40, + householdSize: 2, + income: 25600, + }, + { + percentOfAmi: 40, + householdSize: 3, + income: 28800, + }, + { + percentOfAmi: 40, + householdSize: 4, + income: 32000, + }, + { + percentOfAmi: 40, + householdSize: 5, + income: 34560, + }, + { + percentOfAmi: 40, + householdSize: 6, + income: 37120, + }, + { + percentOfAmi: 40, + householdSize: 7, + income: 39680, + }, + { + percentOfAmi: 40, + householdSize: 8, + income: 42240, + }, + { + percentOfAmi: 45, + householdSize: 1, + income: 25200, + }, + { + percentOfAmi: 45, + householdSize: 2, + income: 28800, + }, + { + percentOfAmi: 45, + householdSize: 3, + income: 32400, + }, + { + percentOfAmi: 45, + householdSize: 4, + income: 36000, + }, + { + percentOfAmi: 45, + householdSize: 5, + income: 38880, + }, + { + percentOfAmi: 45, + householdSize: 6, + income: 41760, + }, + { + percentOfAmi: 45, + householdSize: 7, + income: 44640, + }, + { + percentOfAmi: 45, + householdSize: 8, + income: 47520, + }, + { + percentOfAmi: 50, + householdSize: 1, + income: 28000, + }, + { + percentOfAmi: 50, + householdSize: 2, + income: 32000, + }, + { + percentOfAmi: 50, + householdSize: 3, + income: 36000, + }, + { + percentOfAmi: 50, + householdSize: 4, + income: 40000, + }, + { + percentOfAmi: 50, + householdSize: 5, + income: 43200, + }, + { + percentOfAmi: 50, + householdSize: 6, + income: 46400, + }, + { + percentOfAmi: 50, + householdSize: 7, + income: 49600, + }, + { + percentOfAmi: 50, + householdSize: 8, + income: 52800, + }, + { + percentOfAmi: 55, + householdSize: 1, + income: 30800, + }, + { + percentOfAmi: 55, + householdSize: 2, + income: 35200, + }, + { + percentOfAmi: 55, + householdSize: 3, + income: 39600, + }, + { + percentOfAmi: 55, + householdSize: 4, + income: 44000, + }, + { + percentOfAmi: 55, + householdSize: 5, + income: 47520, + }, + { + percentOfAmi: 55, + householdSize: 6, + income: 51040, + }, + { + percentOfAmi: 55, + householdSize: 7, + income: 54560, + }, + { + percentOfAmi: 55, + householdSize: 8, + income: 58080, + }, + { + percentOfAmi: 60, + householdSize: 1, + income: 33600, + }, + { + percentOfAmi: 60, + householdSize: 2, + income: 38400, + }, + { + percentOfAmi: 60, + householdSize: 3, + income: 43200, + }, + { + percentOfAmi: 60, + householdSize: 4, + income: 48000, + }, + { + percentOfAmi: 60, + householdSize: 5, + income: 51840, + }, + { + percentOfAmi: 60, + householdSize: 6, + income: 55680, + }, + { + percentOfAmi: 60, + householdSize: 7, + income: 59520, + }, + { + percentOfAmi: 60, + householdSize: 8, + income: 63360, + }, + { + percentOfAmi: 70, + householdSize: 1, + income: 39200, + }, + { + percentOfAmi: 70, + householdSize: 2, + income: 44800, + }, + { + percentOfAmi: 70, + householdSize: 3, + income: 50400, + }, + { + percentOfAmi: 70, + householdSize: 4, + income: 56000, + }, + { + percentOfAmi: 70, + householdSize: 5, + income: 60480, + }, + { + percentOfAmi: 70, + householdSize: 6, + income: 64960, + }, + { + percentOfAmi: 70, + householdSize: 7, + income: 69440, + }, + { + percentOfAmi: 70, + householdSize: 8, + income: 73920, + }, + { + percentOfAmi: 80, + householdSize: 1, + income: 44800, + }, + { + percentOfAmi: 80, + householdSize: 2, + income: 51200, + }, + { + percentOfAmi: 80, + householdSize: 3, + income: 57600, + }, + { + percentOfAmi: 80, + householdSize: 4, + income: 64000, + }, + { + percentOfAmi: 80, + householdSize: 5, + income: 69150, + }, + { + percentOfAmi: 80, + householdSize: 6, + income: 74250, + }, + { + percentOfAmi: 80, + householdSize: 7, + income: 79400, + }, + { + percentOfAmi: 80, + householdSize: 8, + income: 84500, + }, + { + percentOfAmi: 100, + householdSize: 1, + income: 56000, + }, + { + percentOfAmi: 100, + householdSize: 2, + income: 64000, + }, + { + percentOfAmi: 100, + householdSize: 3, + income: 72000, + }, + { + percentOfAmi: 100, + householdSize: 4, + income: 80000, + }, + { + percentOfAmi: 100, + householdSize: 5, + income: 86400, + }, + { + percentOfAmi: 100, + householdSize: 6, + income: 92800, + }, + { + percentOfAmi: 100, + householdSize: 7, + income: 99200, + }, + { + percentOfAmi: 100, + householdSize: 8, + income: 105600, + }, + { + percentOfAmi: 120, + householdSize: 1, + income: 67200, + }, + { + percentOfAmi: 120, + householdSize: 2, + income: 76800, + }, + { + percentOfAmi: 120, + householdSize: 3, + income: 86400, + }, + { + percentOfAmi: 120, + householdSize: 4, + income: 96000, + }, + { + percentOfAmi: 120, + householdSize: 5, + income: 103680, + }, + { + percentOfAmi: 120, + householdSize: 6, + income: 111360, + }, + { + percentOfAmi: 120, + householdSize: 7, + income: 119040, + }, + { + percentOfAmi: 120, + householdSize: 8, + income: 126720, + }, + { + percentOfAmi: 125, + householdSize: 1, + income: 70000, + }, + { + percentOfAmi: 125, + householdSize: 2, + income: 80000, + }, + { + percentOfAmi: 125, + householdSize: 3, + income: 90000, + }, + { + percentOfAmi: 125, + householdSize: 4, + income: 100000, + }, + { + percentOfAmi: 125, + householdSize: 5, + income: 108000, + }, + { + percentOfAmi: 125, + householdSize: 6, + income: 116000, + }, + { + percentOfAmi: 125, + householdSize: 7, + income: 124000, + }, + { + percentOfAmi: 125, + householdSize: 8, + income: 132000, + }, + { + percentOfAmi: 140, + householdSize: 1, + income: 78400, + }, + { + percentOfAmi: 140, + householdSize: 2, + income: 89600, + }, + { + percentOfAmi: 140, + householdSize: 3, + income: 100800, + }, + { + percentOfAmi: 140, + householdSize: 4, + income: 112000, + }, + { + percentOfAmi: 140, + householdSize: 5, + income: 120960, + }, + { + percentOfAmi: 140, + householdSize: 6, + income: 129920, + }, + { + percentOfAmi: 140, + householdSize: 7, + income: 138880, + }, + { + percentOfAmi: 140, + householdSize: 8, + income: 147840, + }, + ], +} diff --git a/backend/core/src/seeder/seeds/ami-charts/HUD-MSHDA2021.txt b/backend/core/src/seeder/seeds/ami-charts/HUD-MSHDA2021.txt new file mode 100644 index 0000000000..4253521116 --- /dev/null +++ b/backend/core/src/seeder/seeds/ami-charts/HUD-MSHDA2021.txt @@ -0,0 +1,16 @@ +20% 11,200 12,800 14,400 16,000 17,280 18,560 19,840 21,120 +25% 14,000 16,000 18,000 20,000 21,600 23,200 24,800 26,400 +30% 16,800 19,200 21,600 24,000 25,950 27,850 29,800 31,700 +35% 19,600 22,400 25,200 28,000 30,240 32,480 34,720 36,960 +40% 22,400 25,600 28,800 32,000 34,560 37,120 39,680 42,240 +45% 25,200 28,800 32,400 36,000 38,880 41,760 44,640 47,520 +50% 28,000 32,000 36,000 40,000 43,200 46,400 49,600 52,800 +55% 30,800 35,200 39,600 44,000 47,520 51,040 54,560 58,080 +60% 33,600 38,400 43,200 48,000 51,840 55,680 59,520 63,360 +70% 39,200 44,800 50,400 56,000 60,480 64,960 69,440 73,920 +80% 44,800 51,200 57,600 64,000 69,150 74,250 79,400 84,500 +100% 56,000 64,000 72,000 80,000 86,400 92,800 99,200 105,600 +120% 67,200 76,800 86,400 96,000 103,680 111,360 119,040 126,720 +125% 70,000 80,000 90,000 100,000 108,000 116,000 124,000 132,000 +140% 78,400 89,600 100,800 112,000 120,960 129,920 138,880 147,840 +150% 84,000 96,000 108,000 120,000 129,600 139,200 148,800 158,400 \ No newline at end of file diff --git a/backend/core/src/seeder/seeds/ami-charts/HUD2021.ts b/backend/core/src/seeder/seeds/ami-charts/HUD2021.ts new file mode 100644 index 0000000000..53b2f9adda --- /dev/null +++ b/backend/core/src/seeder/seeds/ami-charts/HUD2021.ts @@ -0,0 +1,129 @@ +import { AmiChartCreateDto } from "../../../ami-charts/dto/ami-chart.dto" +import { BaseEntity } from "typeorm" + +// THIS FILE WAS AUTOMATICALLY GENERATED FROM HUD2021.txt. +export const HUD2021: Omit = { + name: "HUD 2021", + items: [ + { + percentOfAmi: 30, + householdSize: 1, + income: 16800, + }, + { + percentOfAmi: 30, + householdSize: 2, + income: 19200, + }, + { + percentOfAmi: 30, + householdSize: 3, + income: 21600, + }, + { + percentOfAmi: 30, + householdSize: 4, + income: 24000, + }, + { + percentOfAmi: 30, + householdSize: 5, + income: 25950, + }, + { + percentOfAmi: 30, + householdSize: 6, + income: 27850, + }, + { + percentOfAmi: 30, + householdSize: 7, + income: 29800, + }, + { + percentOfAmi: 30, + householdSize: 8, + income: 31700, + }, + { + percentOfAmi: 50, + householdSize: 1, + income: 28000, + }, + { + percentOfAmi: 50, + householdSize: 2, + income: 32000, + }, + { + percentOfAmi: 50, + householdSize: 3, + income: 36000, + }, + { + percentOfAmi: 50, + householdSize: 4, + income: 40000, + }, + { + percentOfAmi: 50, + householdSize: 5, + income: 43200, + }, + { + percentOfAmi: 50, + householdSize: 6, + income: 46400, + }, + { + percentOfAmi: 50, + householdSize: 7, + income: 49600, + }, + { + percentOfAmi: 50, + householdSize: 8, + income: 52800, + }, + { + percentOfAmi: 60, + householdSize: 1, + income: 33600, + }, + { + percentOfAmi: 60, + householdSize: 2, + income: 38400, + }, + { + percentOfAmi: 60, + householdSize: 3, + income: 43200, + }, + { + percentOfAmi: 60, + householdSize: 4, + income: 48000, + }, + { + percentOfAmi: 60, + householdSize: 5, + income: 51840, + }, + { + percentOfAmi: 60, + householdSize: 6, + income: 55680, + }, + { + percentOfAmi: 60, + householdSize: 7, + income: 59520, + }, + { + percentOfAmi: 60, + householdSize: 8, + income: 63360, + }, + ], +} diff --git a/backend/core/src/seeder/seeds/ami-charts/HUD2021.txt b/backend/core/src/seeder/seeds/ami-charts/HUD2021.txt new file mode 100644 index 0000000000..f43a0f10ba --- /dev/null +++ b/backend/core/src/seeder/seeds/ami-charts/HUD2021.txt @@ -0,0 +1,4 @@ +30% 16,800 19,200 21,600 24,000 25,950 27,850 29,800 31,700 +50% 28,000 32,000 36,000 40,000 43,200 46,400 49,600 52,800 +60% 33,600 38,400 43,200 48,000 51,840 55,680 59,520 63,360 +80% 44,800 51,200 57,600 64,000 69,150 74,250 79,400 84,500 \ No newline at end of file diff --git a/backend/core/src/seeder/seeds/ami-charts/MSHDA2021.ts b/backend/core/src/seeder/seeds/ami-charts/MSHDA2021.ts new file mode 100644 index 0000000000..0479448cb9 --- /dev/null +++ b/backend/core/src/seeder/seeds/ami-charts/MSHDA2021.ts @@ -0,0 +1,609 @@ +import { AmiChartCreateDto } from "../../../ami-charts/dto/ami-chart.dto" +import { BaseEntity } from "typeorm" + +// THIS FILE WAS AUTOMATICALLY GENERATED FROM MSHDA2021.txt. +export const MSHDA2021: Omit = { + name: "MSHDA 2021", + items: [ + { + percentOfAmi: 20, + householdSize: 1, + income: 11200, + }, + { + percentOfAmi: 20, + householdSize: 2, + income: 12800, + }, + { + percentOfAmi: 20, + householdSize: 3, + income: 14400, + }, + { + percentOfAmi: 20, + householdSize: 4, + income: 16000, + }, + { + percentOfAmi: 20, + householdSize: 5, + income: 17280, + }, + { + percentOfAmi: 20, + householdSize: 6, + income: 18560, + }, + { + percentOfAmi: 20, + householdSize: 7, + income: 19840, + }, + { + percentOfAmi: 20, + householdSize: 8, + income: 21120, + }, + { + percentOfAmi: 25, + householdSize: 1, + income: 14000, + }, + { + percentOfAmi: 25, + householdSize: 2, + income: 16000, + }, + { + percentOfAmi: 25, + householdSize: 3, + income: 18000, + }, + { + percentOfAmi: 25, + householdSize: 4, + income: 20000, + }, + { + percentOfAmi: 25, + householdSize: 5, + income: 21600, + }, + { + percentOfAmi: 25, + householdSize: 6, + income: 23200, + }, + { + percentOfAmi: 25, + householdSize: 7, + income: 24800, + }, + { + percentOfAmi: 25, + householdSize: 8, + income: 26400, + }, + { + percentOfAmi: 30, + householdSize: 1, + income: 16800, + }, + { + percentOfAmi: 30, + householdSize: 2, + income: 19200, + }, + { + percentOfAmi: 30, + householdSize: 3, + income: 21600, + }, + { + percentOfAmi: 30, + householdSize: 4, + income: 24000, + }, + { + percentOfAmi: 30, + householdSize: 5, + income: 25920, + }, + { + percentOfAmi: 30, + householdSize: 6, + income: 27840, + }, + { + percentOfAmi: 30, + householdSize: 7, + income: 29760, + }, + { + percentOfAmi: 30, + householdSize: 8, + income: 31680, + }, + { + percentOfAmi: 35, + householdSize: 1, + income: 19600, + }, + { + percentOfAmi: 35, + householdSize: 2, + income: 22400, + }, + { + percentOfAmi: 35, + householdSize: 3, + income: 25200, + }, + { + percentOfAmi: 35, + householdSize: 4, + income: 28000, + }, + { + percentOfAmi: 35, + householdSize: 5, + income: 30240, + }, + { + percentOfAmi: 35, + householdSize: 6, + income: 32480, + }, + { + percentOfAmi: 35, + householdSize: 7, + income: 34720, + }, + { + percentOfAmi: 35, + householdSize: 8, + income: 36960, + }, + { + percentOfAmi: 40, + householdSize: 1, + income: 22400, + }, + { + percentOfAmi: 40, + householdSize: 2, + income: 25600, + }, + { + percentOfAmi: 40, + householdSize: 3, + income: 28800, + }, + { + percentOfAmi: 40, + householdSize: 4, + income: 32000, + }, + { + percentOfAmi: 40, + householdSize: 5, + income: 34560, + }, + { + percentOfAmi: 40, + householdSize: 6, + income: 37120, + }, + { + percentOfAmi: 40, + householdSize: 7, + income: 39680, + }, + { + percentOfAmi: 40, + householdSize: 8, + income: 42240, + }, + { + percentOfAmi: 45, + householdSize: 1, + income: 25200, + }, + { + percentOfAmi: 45, + householdSize: 2, + income: 28800, + }, + { + percentOfAmi: 45, + householdSize: 3, + income: 32400, + }, + { + percentOfAmi: 45, + householdSize: 4, + income: 36000, + }, + { + percentOfAmi: 45, + householdSize: 5, + income: 38880, + }, + { + percentOfAmi: 45, + householdSize: 6, + income: 41760, + }, + { + percentOfAmi: 45, + householdSize: 7, + income: 44640, + }, + { + percentOfAmi: 45, + householdSize: 8, + income: 47520, + }, + { + percentOfAmi: 50, + householdSize: 1, + income: 28000, + }, + { + percentOfAmi: 50, + householdSize: 2, + income: 32000, + }, + { + percentOfAmi: 50, + householdSize: 3, + income: 36000, + }, + { + percentOfAmi: 50, + householdSize: 4, + income: 40000, + }, + { + percentOfAmi: 50, + householdSize: 5, + income: 43200, + }, + { + percentOfAmi: 50, + householdSize: 6, + income: 46400, + }, + { + percentOfAmi: 50, + householdSize: 7, + income: 49600, + }, + { + percentOfAmi: 50, + householdSize: 8, + income: 52800, + }, + { + percentOfAmi: 55, + householdSize: 1, + income: 30800, + }, + { + percentOfAmi: 55, + householdSize: 2, + income: 35200, + }, + { + percentOfAmi: 55, + householdSize: 3, + income: 39600, + }, + { + percentOfAmi: 55, + householdSize: 4, + income: 44000, + }, + { + percentOfAmi: 55, + householdSize: 5, + income: 47520, + }, + { + percentOfAmi: 55, + householdSize: 6, + income: 51040, + }, + { + percentOfAmi: 55, + householdSize: 7, + income: 54560, + }, + { + percentOfAmi: 55, + householdSize: 8, + income: 58080, + }, + { + percentOfAmi: 60, + householdSize: 1, + income: 33600, + }, + { + percentOfAmi: 60, + householdSize: 2, + income: 38400, + }, + { + percentOfAmi: 60, + householdSize: 3, + income: 43200, + }, + { + percentOfAmi: 60, + householdSize: 4, + income: 48000, + }, + { + percentOfAmi: 60, + householdSize: 5, + income: 51840, + }, + { + percentOfAmi: 60, + householdSize: 6, + income: 55680, + }, + { + percentOfAmi: 60, + householdSize: 7, + income: 59520, + }, + { + percentOfAmi: 60, + householdSize: 8, + income: 63360, + }, + { + percentOfAmi: 70, + householdSize: 1, + income: 39200, + }, + { + percentOfAmi: 70, + householdSize: 2, + income: 44800, + }, + { + percentOfAmi: 70, + householdSize: 3, + income: 50400, + }, + { + percentOfAmi: 70, + householdSize: 4, + income: 56000, + }, + { + percentOfAmi: 70, + householdSize: 5, + income: 60480, + }, + { + percentOfAmi: 70, + householdSize: 6, + income: 64960, + }, + { + percentOfAmi: 70, + householdSize: 7, + income: 69440, + }, + { + percentOfAmi: 70, + householdSize: 8, + income: 73920, + }, + { + percentOfAmi: 80, + householdSize: 1, + income: 44800, + }, + { + percentOfAmi: 80, + householdSize: 2, + income: 51200, + }, + { + percentOfAmi: 80, + householdSize: 3, + income: 57600, + }, + { + percentOfAmi: 80, + householdSize: 4, + income: 64000, + }, + { + percentOfAmi: 80, + householdSize: 5, + income: 69120, + }, + { + percentOfAmi: 80, + householdSize: 6, + income: 74240, + }, + { + percentOfAmi: 80, + householdSize: 7, + income: 79360, + }, + { + percentOfAmi: 80, + householdSize: 8, + income: 84480, + }, + { + percentOfAmi: 100, + householdSize: 1, + income: 56000, + }, + { + percentOfAmi: 100, + householdSize: 2, + income: 64000, + }, + { + percentOfAmi: 100, + householdSize: 3, + income: 72000, + }, + { + percentOfAmi: 100, + householdSize: 4, + income: 80000, + }, + { + percentOfAmi: 100, + householdSize: 5, + income: 86400, + }, + { + percentOfAmi: 100, + householdSize: 6, + income: 92800, + }, + { + percentOfAmi: 100, + householdSize: 7, + income: 99200, + }, + { + percentOfAmi: 100, + householdSize: 8, + income: 105600, + }, + { + percentOfAmi: 120, + householdSize: 1, + income: 67200, + }, + { + percentOfAmi: 120, + householdSize: 2, + income: 76800, + }, + { + percentOfAmi: 120, + householdSize: 3, + income: 86400, + }, + { + percentOfAmi: 120, + householdSize: 4, + income: 96000, + }, + { + percentOfAmi: 120, + householdSize: 5, + income: 103680, + }, + { + percentOfAmi: 120, + householdSize: 6, + income: 111360, + }, + { + percentOfAmi: 120, + householdSize: 7, + income: 119040, + }, + { + percentOfAmi: 120, + householdSize: 8, + income: 126720, + }, + { + percentOfAmi: 125, + householdSize: 1, + income: 70000, + }, + { + percentOfAmi: 125, + householdSize: 2, + income: 80000, + }, + { + percentOfAmi: 125, + householdSize: 3, + income: 90000, + }, + { + percentOfAmi: 125, + householdSize: 4, + income: 100000, + }, + { + percentOfAmi: 125, + householdSize: 5, + income: 108000, + }, + { + percentOfAmi: 125, + householdSize: 6, + income: 116000, + }, + { + percentOfAmi: 125, + householdSize: 7, + income: 124000, + }, + { + percentOfAmi: 125, + householdSize: 8, + income: 132000, + }, + { + percentOfAmi: 140, + householdSize: 1, + income: 78400, + }, + { + percentOfAmi: 140, + householdSize: 2, + income: 89600, + }, + { + percentOfAmi: 140, + householdSize: 3, + income: 100800, + }, + { + percentOfAmi: 140, + householdSize: 4, + income: 112000, + }, + { + percentOfAmi: 140, + householdSize: 5, + income: 120960, + }, + { + percentOfAmi: 140, + householdSize: 6, + income: 129920, + }, + { + percentOfAmi: 140, + householdSize: 7, + income: 138880, + }, + { + percentOfAmi: 140, + householdSize: 8, + income: 147840, + }, + ], +} diff --git a/backend/core/src/seeder/seeds/ami-charts/MSHDA2021.txt b/backend/core/src/seeder/seeds/ami-charts/MSHDA2021.txt new file mode 100644 index 0000000000..184e40548f --- /dev/null +++ b/backend/core/src/seeder/seeds/ami-charts/MSHDA2021.txt @@ -0,0 +1,16 @@ +20% 11,200 12,800 14,400 16,000 17,280 18,560 19,840 21,120 +25% 14,000 16,000 18,000 20,000 21,600 23,200 24,800 26,400 +30% 16,800 19,200 21,600 24,000 25,920 27,840 29,760 31,680 +35% 19,600 22,400 25,200 28,000 30,240 32,480 34,720 36,960 +40% 22,400 25,600 28,800 32,000 34,560 37,120 39,680 42,240 +45% 25,200 28,800 32,400 36,000 38,880 41,760 44,640 47,520 +50% 28,000 32,000 36,000 40,000 43,200 46,400 49,600 52,800 +55% 30,800 35,200 39,600 44,000 47,520 51,040 54,560 58,080 +60% 33,600 38,400 43,200 48,000 51,840 55,680 59,520 63,360 +70% 39,200 44,800 50,400 56,000 60,480 64,960 69,440 73,920 +80% 44,800 51,200 57,600 64,000 69,120 74,240 79,360 84,480 +100% 56,000 64,000 72,000 80,000 86,400 92,800 99,200 105,600 +120% 67,200 76,800 86,400 96,000 103,680 111,360 119,040 126,720 +125% 70,000 80,000 90,000 100,000 108,000 116,000 124,000 132,000 +140% 78,400 89,600 100,800 112,000 120,960 129,920 138,880 147,840 +150% 84,000 96,000 108,000 120,000 129,600 139,200 148,800 158,400 \ No newline at end of file diff --git a/backend/core/src/seeder/seeds/ami-charts/WayneCountyMSHDA2021.ts b/backend/core/src/seeder/seeds/ami-charts/WayneCountyMSHDA2021.ts new file mode 100644 index 0000000000..d58232bfd8 --- /dev/null +++ b/backend/core/src/seeder/seeds/ami-charts/WayneCountyMSHDA2021.ts @@ -0,0 +1,649 @@ +import { AmiChartCreateDto } from "../../../ami-charts/dto/ami-chart.dto" +import { BaseEntity } from "typeorm" + +// THIS FILE WAS AUTOMATICALLY GENERATED FROM WayneCountyMSHDA2021.txt. +export const WayneCountyMSHDA2021: Omit = { + name: "WayneCountyMSHDA2021", + items: [ + { + percentOfAmi: 20, + householdSize: 1, + income: 11200, + }, + { + percentOfAmi: 20, + householdSize: 2, + income: 12800, + }, + { + percentOfAmi: 20, + householdSize: 3, + income: 14400, + }, + { + percentOfAmi: 20, + householdSize: 4, + income: 16000, + }, + { + percentOfAmi: 20, + householdSize: 5, + income: 17280, + }, + { + percentOfAmi: 20, + householdSize: 6, + income: 18560, + }, + { + percentOfAmi: 20, + householdSize: 7, + income: 19840, + }, + { + percentOfAmi: 20, + householdSize: 8, + income: 21120, + }, + { + percentOfAmi: 25, + householdSize: 1, + income: 14000, + }, + { + percentOfAmi: 25, + householdSize: 2, + income: 16000, + }, + { + percentOfAmi: 25, + householdSize: 3, + income: 18000, + }, + { + percentOfAmi: 25, + householdSize: 4, + income: 20000, + }, + { + percentOfAmi: 25, + householdSize: 5, + income: 21600, + }, + { + percentOfAmi: 25, + householdSize: 6, + income: 23200, + }, + { + percentOfAmi: 25, + householdSize: 7, + income: 24800, + }, + { + percentOfAmi: 25, + householdSize: 8, + income: 26400, + }, + { + percentOfAmi: 30, + householdSize: 1, + income: 16800, + }, + { + percentOfAmi: 30, + householdSize: 2, + income: 19200, + }, + { + percentOfAmi: 30, + householdSize: 3, + income: 21600, + }, + { + percentOfAmi: 30, + householdSize: 4, + income: 24000, + }, + { + percentOfAmi: 30, + householdSize: 5, + income: 25920, + }, + { + percentOfAmi: 30, + householdSize: 6, + income: 27840, + }, + { + percentOfAmi: 30, + householdSize: 7, + income: 29760, + }, + { + percentOfAmi: 30, + householdSize: 8, + income: 31680, + }, + { + percentOfAmi: 35, + householdSize: 1, + income: 19600, + }, + { + percentOfAmi: 35, + householdSize: 2, + income: 22400, + }, + { + percentOfAmi: 35, + householdSize: 3, + income: 25200, + }, + { + percentOfAmi: 35, + householdSize: 4, + income: 28000, + }, + { + percentOfAmi: 35, + householdSize: 5, + income: 30240, + }, + { + percentOfAmi: 35, + householdSize: 6, + income: 32480, + }, + { + percentOfAmi: 35, + householdSize: 7, + income: 34720, + }, + { + percentOfAmi: 35, + householdSize: 8, + income: 36960, + }, + { + percentOfAmi: 40, + householdSize: 1, + income: 22400, + }, + { + percentOfAmi: 40, + householdSize: 2, + income: 25600, + }, + { + percentOfAmi: 40, + householdSize: 3, + income: 28800, + }, + { + percentOfAmi: 40, + householdSize: 4, + income: 32000, + }, + { + percentOfAmi: 40, + householdSize: 5, + income: 34560, + }, + { + percentOfAmi: 40, + householdSize: 6, + income: 37120, + }, + { + percentOfAmi: 40, + householdSize: 7, + income: 39680, + }, + { + percentOfAmi: 40, + householdSize: 8, + income: 42240, + }, + { + percentOfAmi: 45, + householdSize: 1, + income: 25200, + }, + { + percentOfAmi: 45, + householdSize: 2, + income: 28800, + }, + { + percentOfAmi: 45, + householdSize: 3, + income: 32400, + }, + { + percentOfAmi: 45, + householdSize: 4, + income: 36000, + }, + { + percentOfAmi: 45, + householdSize: 5, + income: 38880, + }, + { + percentOfAmi: 45, + householdSize: 6, + income: 41760, + }, + { + percentOfAmi: 45, + householdSize: 7, + income: 44640, + }, + { + percentOfAmi: 45, + householdSize: 8, + income: 47520, + }, + { + percentOfAmi: 50, + householdSize: 1, + income: 28000, + }, + { + percentOfAmi: 50, + householdSize: 2, + income: 32000, + }, + { + percentOfAmi: 50, + householdSize: 3, + income: 36000, + }, + { + percentOfAmi: 50, + householdSize: 4, + income: 40000, + }, + { + percentOfAmi: 50, + householdSize: 5, + income: 43200, + }, + { + percentOfAmi: 50, + householdSize: 6, + income: 46400, + }, + { + percentOfAmi: 50, + householdSize: 7, + income: 49600, + }, + { + percentOfAmi: 50, + householdSize: 8, + income: 52800, + }, + { + percentOfAmi: 55, + householdSize: 1, + income: 30800, + }, + { + percentOfAmi: 55, + householdSize: 2, + income: 35200, + }, + { + percentOfAmi: 55, + householdSize: 3, + income: 39600, + }, + { + percentOfAmi: 55, + householdSize: 4, + income: 44000, + }, + { + percentOfAmi: 55, + householdSize: 5, + income: 47520, + }, + { + percentOfAmi: 55, + householdSize: 6, + income: 51040, + }, + { + percentOfAmi: 55, + householdSize: 7, + income: 54560, + }, + { + percentOfAmi: 55, + householdSize: 8, + income: 58080, + }, + { + percentOfAmi: 60, + householdSize: 1, + income: 33600, + }, + { + percentOfAmi: 60, + householdSize: 2, + income: 38400, + }, + { + percentOfAmi: 60, + householdSize: 3, + income: 43200, + }, + { + percentOfAmi: 60, + householdSize: 4, + income: 48000, + }, + { + percentOfAmi: 60, + householdSize: 5, + income: 51840, + }, + { + percentOfAmi: 60, + householdSize: 6, + income: 55680, + }, + { + percentOfAmi: 60, + householdSize: 7, + income: 59520, + }, + { + percentOfAmi: 60, + householdSize: 8, + income: 63360, + }, + { + percentOfAmi: 70, + householdSize: 1, + income: 39200, + }, + { + percentOfAmi: 70, + householdSize: 2, + income: 44800, + }, + { + percentOfAmi: 70, + householdSize: 3, + income: 50400, + }, + { + percentOfAmi: 70, + householdSize: 4, + income: 56000, + }, + { + percentOfAmi: 70, + householdSize: 5, + income: 60480, + }, + { + percentOfAmi: 70, + householdSize: 6, + income: 64960, + }, + { + percentOfAmi: 70, + householdSize: 7, + income: 69440, + }, + { + percentOfAmi: 70, + householdSize: 8, + income: 73920, + }, + { + percentOfAmi: 80, + householdSize: 1, + income: 44800, + }, + { + percentOfAmi: 80, + householdSize: 2, + income: 51200, + }, + { + percentOfAmi: 80, + householdSize: 3, + income: 57600, + }, + { + percentOfAmi: 80, + householdSize: 4, + income: 64000, + }, + { + percentOfAmi: 80, + householdSize: 5, + income: 69120, + }, + { + percentOfAmi: 80, + householdSize: 6, + income: 74240, + }, + { + percentOfAmi: 80, + householdSize: 7, + income: 79360, + }, + { + percentOfAmi: 80, + householdSize: 8, + income: 84480, + }, + { + percentOfAmi: 100, + householdSize: 1, + income: 56000, + }, + { + percentOfAmi: 100, + householdSize: 2, + income: 64000, + }, + { + percentOfAmi: 100, + householdSize: 3, + income: 72000, + }, + { + percentOfAmi: 100, + householdSize: 4, + income: 80000, + }, + { + percentOfAmi: 100, + householdSize: 5, + income: 86400, + }, + { + percentOfAmi: 100, + householdSize: 6, + income: 92800, + }, + { + percentOfAmi: 100, + householdSize: 7, + income: 99200, + }, + { + percentOfAmi: 100, + householdSize: 8, + income: 105600, + }, + { + percentOfAmi: 120, + householdSize: 1, + income: 67200, + }, + { + percentOfAmi: 120, + householdSize: 2, + income: 76800, + }, + { + percentOfAmi: 120, + householdSize: 3, + income: 86400, + }, + { + percentOfAmi: 120, + householdSize: 4, + income: 96000, + }, + { + percentOfAmi: 120, + householdSize: 5, + income: 103680, + }, + { + percentOfAmi: 120, + householdSize: 6, + income: 111360, + }, + { + percentOfAmi: 120, + householdSize: 7, + income: 119040, + }, + { + percentOfAmi: 120, + householdSize: 8, + income: 126720, + }, + { + percentOfAmi: 125, + householdSize: 1, + income: 70000, + }, + { + percentOfAmi: 125, + householdSize: 2, + income: 80000, + }, + { + percentOfAmi: 125, + householdSize: 3, + income: 90000, + }, + { + percentOfAmi: 125, + householdSize: 4, + income: 100000, + }, + { + percentOfAmi: 125, + householdSize: 5, + income: 108000, + }, + { + percentOfAmi: 125, + householdSize: 6, + income: 116000, + }, + { + percentOfAmi: 125, + householdSize: 7, + income: 124000, + }, + { + percentOfAmi: 125, + householdSize: 8, + income: 132000, + }, + { + percentOfAmi: 140, + householdSize: 1, + income: 78400, + }, + { + percentOfAmi: 140, + householdSize: 2, + income: 89600, + }, + { + percentOfAmi: 140, + householdSize: 3, + income: 100800, + }, + { + percentOfAmi: 140, + householdSize: 4, + income: 112000, + }, + { + percentOfAmi: 140, + householdSize: 5, + income: 120960, + }, + { + percentOfAmi: 140, + householdSize: 6, + income: 129920, + }, + { + percentOfAmi: 140, + householdSize: 7, + income: 138880, + }, + { + percentOfAmi: 140, + householdSize: 8, + income: 147840, + }, + { + percentOfAmi: 150, + householdSize: 1, + income: 84000, + }, + { + percentOfAmi: 150, + householdSize: 2, + income: 96000, + }, + { + percentOfAmi: 150, + householdSize: 3, + income: 108000, + }, + { + percentOfAmi: 150, + householdSize: 4, + income: 120000, + }, + { + percentOfAmi: 150, + householdSize: 5, + income: 129600, + }, + { + percentOfAmi: 150, + householdSize: 6, + income: 139200, + }, + { + percentOfAmi: 150, + householdSize: 7, + income: 148800, + }, + { + percentOfAmi: 150, + householdSize: 8, + income: 158400, + }, + ], +} diff --git a/backend/core/src/seeder/seeds/ami-charts/WayneCountyMSHDA2021.txt b/backend/core/src/seeder/seeds/ami-charts/WayneCountyMSHDA2021.txt new file mode 100644 index 0000000000..e3157e3071 --- /dev/null +++ b/backend/core/src/seeder/seeds/ami-charts/WayneCountyMSHDA2021.txt @@ -0,0 +1,16 @@ +20% 11,200 12,800 14,400 16,000 17,280 18,560 19,840 21,120 +25% 14,000 16,000 18,000 20,000 21,600 23,200 24,800 26,400 +30% 16,800 19,200 21,600 24,000 25,920 27,840 29,760 31,680 +35% 19,600 22,400 25,200 28,000 30,240 32,480 34,720 36,960 +40% 22,400 25,600 28,800 32,000 34,560 37,120 39,680 42,240 +45% 25,200 28,800 32,400 36,000 38,880 41,760 44,640 47,520 +50% 28,000 32,000 36,000 40,000 43,200 46,400 49,600 52,800 +55% 30,800 35,200 39,600 44,000 47,520 51,040 54,560 58,080 +60% 33,600 38,400 43,200 48,000 51,840 55,680 59,520 63,360 +70% 39,200 44,800 50,400 56,000 60,480 64,960 69,440 73,920 +80% 44,800 51,200 57,600 64,000 69,120 74,240 79,360 84,480 +100% 56,000 64,000 72,000 80,000 86,400 92,800 99,200 105,600 +120% 67,200 76,800 86,400 96,000 103,680 111,360 119,040 126,720 +125% 70,000 80,000 90,000 100,000 108,000 116,000 124,000 132,000 +140% 78,400 89,600 100,800 112,000 120,960 129,920 138,880 147,840 +150% 84,000 96,000 108,000 120,000 129,600 139,200 148,800 158,400 diff --git a/backend/core/src/seeder/seeds/ami-charts/default-ami-chart-san-jose.ts b/backend/core/src/seeder/seeds/ami-charts/default-ami-chart-san-jose.ts new file mode 100644 index 0000000000..c30e396c35 --- /dev/null +++ b/backend/core/src/seeder/seeds/ami-charts/default-ami-chart-san-jose.ts @@ -0,0 +1,15 @@ +import { AmiChartDefaultSeed, getDefaultAmiChart } from "./default-ami-chart" +import { CountyCode } from "../../../shared/types/county-code" + +export class AmiDefaultSanJose extends AmiChartDefaultSeed { + async seed() { + const sanjoseJurisdiction = await this.jurisdictionRepository.findOneOrFail({ + name: CountyCode.san_jose, + }) + return await this.amiChartRepository.save({ + ...getDefaultAmiChart(), + name: "San Jose TCAC 2021", + jurisdiction: sanjoseJurisdiction, + }) + } +} diff --git a/backend/core/src/seeder/seeds/ami-charts/default-ami-chart-san-mateo.ts b/backend/core/src/seeder/seeds/ami-charts/default-ami-chart-san-mateo.ts new file mode 100644 index 0000000000..5849db2da7 --- /dev/null +++ b/backend/core/src/seeder/seeds/ami-charts/default-ami-chart-san-mateo.ts @@ -0,0 +1,15 @@ +import { AmiChartDefaultSeed, getDefaultAmiChart } from "./default-ami-chart" +import { CountyCode } from "../../../shared/types/county-code" + +export class AmiDefaultSanMateo extends AmiChartDefaultSeed { + async seed() { + const sanMateoJurisdiction = await this.jurisdictionRepository.findOneOrFail({ + name: CountyCode.san_mateo, + }) + return await this.amiChartRepository.save({ + ...getDefaultAmiChart(), + name: "San mateo TCAC 2021", + jurisdiction: sanMateoJurisdiction, + }) + } +} diff --git a/backend/core/src/seeder/seeds/ami-charts/default-ami-chart.ts b/backend/core/src/seeder/seeds/ami-charts/default-ami-chart.ts new file mode 100644 index 0000000000..ef6f1a12bd --- /dev/null +++ b/backend/core/src/seeder/seeds/ami-charts/default-ami-chart.ts @@ -0,0 +1,318 @@ +import { InjectRepository } from "@nestjs/typeorm" +import { Repository } from "typeorm" +import { AmiChartCreateDto } from "../../../ami-charts/dto/ami-chart.dto" +import { AmiChart } from "../../../ami-charts/entities/ami-chart.entity" +import { Jurisdiction } from "../../../jurisdictions/entities/jurisdiction.entity" +import { CountyCode } from "../../../shared/types/county-code" + +export function getDefaultAmiChart() { + return JSON.parse(JSON.stringify(defaultAmiChart)) +} + +export const defaultAmiChart: Omit = { + name: "AlamedaCountyTCAC2021", + items: [ + { income: 140900, percentOfAmi: 120, householdSize: 3 }, + { income: 156600, percentOfAmi: 120, householdSize: 4 }, + { income: 169140, percentOfAmi: 120, householdSize: 5 }, + { + percentOfAmi: 80, + householdSize: 1, + income: 76720, + }, + { + percentOfAmi: 80, + householdSize: 2, + income: 87680, + }, + { + percentOfAmi: 80, + householdSize: 3, + income: 98640, + }, + { + percentOfAmi: 80, + householdSize: 4, + income: 109600, + }, + { + percentOfAmi: 80, + householdSize: 5, + income: 11840, + }, + { + percentOfAmi: 80, + householdSize: 6, + income: 127200, + }, + { + percentOfAmi: 80, + householdSize: 7, + income: 135920, + }, + { + percentOfAmi: 80, + householdSize: 8, + income: 144720, + }, + { + percentOfAmi: 60, + householdSize: 1, + income: 57540, + }, + { + percentOfAmi: 60, + householdSize: 2, + income: 65760, + }, + { + percentOfAmi: 60, + householdSize: 3, + income: 73980, + }, + { + percentOfAmi: 60, + householdSize: 4, + income: 82200, + }, + { + percentOfAmi: 60, + householdSize: 5, + income: 88800, + }, + { + percentOfAmi: 60, + householdSize: 6, + income: 95400, + }, + { + percentOfAmi: 60, + householdSize: 7, + income: 101940, + }, + { + percentOfAmi: 60, + householdSize: 8, + income: 108540, + }, + { + percentOfAmi: 50, + householdSize: 1, + income: 47950, + }, + { + percentOfAmi: 50, + householdSize: 2, + income: 54800, + }, + { + percentOfAmi: 50, + householdSize: 3, + income: 61650, + }, + { + percentOfAmi: 50, + householdSize: 4, + income: 68500, + }, + { + percentOfAmi: 50, + householdSize: 5, + income: 74000, + }, + { + percentOfAmi: 50, + householdSize: 6, + income: 79500, + }, + { + percentOfAmi: 50, + householdSize: 7, + income: 84950, + }, + { + percentOfAmi: 50, + householdSize: 8, + income: 90450, + }, + { + percentOfAmi: 45, + householdSize: 1, + income: 43155, + }, + { + percentOfAmi: 45, + householdSize: 2, + income: 49320, + }, + { + percentOfAmi: 45, + householdSize: 3, + income: 55485, + }, + { + percentOfAmi: 45, + householdSize: 4, + income: 61650, + }, + { + percentOfAmi: 45, + householdSize: 5, + income: 66600, + }, + { + percentOfAmi: 45, + householdSize: 6, + income: 71550, + }, + { + percentOfAmi: 45, + householdSize: 7, + income: 76455, + }, + { + percentOfAmi: 45, + householdSize: 8, + income: 81405, + }, + { + percentOfAmi: 40, + householdSize: 1, + income: 38360, + }, + { + percentOfAmi: 40, + householdSize: 2, + income: 43840, + }, + { + percentOfAmi: 40, + householdSize: 3, + income: 49320, + }, + { + percentOfAmi: 40, + householdSize: 4, + income: 54800, + }, + { + percentOfAmi: 40, + householdSize: 5, + income: 59200, + }, + { + percentOfAmi: 40, + householdSize: 6, + income: 63600, + }, + { + percentOfAmi: 40, + householdSize: 7, + income: 67960, + }, + { + percentOfAmi: 40, + householdSize: 8, + income: 72360, + }, + { + percentOfAmi: 30, + householdSize: 1, + income: 28770, + }, + { + percentOfAmi: 30, + householdSize: 2, + income: 32880, + }, + { + percentOfAmi: 30, + householdSize: 3, + income: 36990, + }, + { + percentOfAmi: 30, + householdSize: 4, + income: 41100, + }, + { + percentOfAmi: 30, + householdSize: 5, + income: 44400, + }, + { + percentOfAmi: 30, + householdSize: 6, + income: 47700, + }, + { + percentOfAmi: 30, + householdSize: 7, + income: 50970, + }, + { + percentOfAmi: 30, + householdSize: 8, + income: 54270, + }, + { + percentOfAmi: 20, + householdSize: 1, + income: 19180, + }, + { + percentOfAmi: 20, + householdSize: 2, + income: 21920, + }, + { + percentOfAmi: 20, + householdSize: 3, + income: 24660, + }, + { + percentOfAmi: 20, + householdSize: 4, + income: 27400, + }, + { + percentOfAmi: 20, + householdSize: 5, + income: 29600, + }, + { + percentOfAmi: 20, + householdSize: 6, + income: 31800, + }, + { + percentOfAmi: 20, + householdSize: 7, + income: 33980, + }, + { + percentOfAmi: 20, + householdSize: 8, + income: 36180, + }, + ], +} + +export class AmiChartDefaultSeed { + constructor( + @InjectRepository(AmiChart) + protected readonly amiChartRepository: Repository, + @InjectRepository(Jurisdiction) + protected readonly jurisdictionRepository: Repository + ) {} + + async seed() { + const alamedaJurisdiction = await this.jurisdictionRepository.findOneOrFail({ + name: CountyCode.alameda, + }) + return await this.amiChartRepository.save({ + ...getDefaultAmiChart(), + jurisdiction: alamedaJurisdiction, + }) + } +} diff --git a/backend/core/src/seeder/seeds/ami-charts/missing-household-ami-levels.ts b/backend/core/src/seeder/seeds/ami-charts/missing-household-ami-levels.ts new file mode 100644 index 0000000000..6c5b460e83 --- /dev/null +++ b/backend/core/src/seeder/seeds/ami-charts/missing-household-ami-levels.ts @@ -0,0 +1,46 @@ +import { AmiChartDefaultSeed } from "./default-ami-chart" +import { CountyCode } from "../../../shared/types/county-code" + +export class AmiDefaultMissingAMI extends AmiChartDefaultSeed { + async seed() { + const alamedaJurisdiction = await this.jurisdictionRepository.findOneOrFail({ + name: CountyCode.alameda, + }) + return await this.amiChartRepository.save({ + name: "Missing Household Ami Levels", + items: [ + { + percentOfAmi: 50, + householdSize: 3, + income: 65850, + }, + { + percentOfAmi: 50, + householdSize: 4, + income: 73150, + }, + { + percentOfAmi: 50, + householdSize: 5, + income: 79050, + }, + { + percentOfAmi: 50, + householdSize: 6, + income: 84900, + }, + { + percentOfAmi: 50, + householdSize: 7, + income: 90750, + }, + { + percentOfAmi: 50, + householdSize: 8, + income: 96600, + }, + ], + jurisdiction: alamedaJurisdiction, + }) + } +} diff --git a/backend/core/src/seeder/seeds/ami-charts/triton-ami-chart-detroit.ts b/backend/core/src/seeder/seeds/ami-charts/triton-ami-chart-detroit.ts new file mode 100644 index 0000000000..6fd344a5a4 --- /dev/null +++ b/backend/core/src/seeder/seeds/ami-charts/triton-ami-chart-detroit.ts @@ -0,0 +1,16 @@ +import { AmiChartDefaultSeed } from "./default-ami-chart" +import { itemInfo } from "./triton-ami-chart" +import { CountyCode } from "../../../shared/types/county-code" + +export class AmiDefaultTritonDetroit extends AmiChartDefaultSeed { + async seed() { + const detroitJurisdiction = await this.jurisdictionRepository.findOneOrFail({ + name: CountyCode.detroit, + }) + return await this.amiChartRepository.save({ + name: "Detroit TCAC 2019", + items: itemInfo, + jurisdiction: detroitJurisdiction, + }) + } +} diff --git a/backend/core/src/seeder/seeds/ami-charts/triton-ami-chart.ts b/backend/core/src/seeder/seeds/ami-charts/triton-ami-chart.ts new file mode 100644 index 0000000000..cd61fc9e8c --- /dev/null +++ b/backend/core/src/seeder/seeds/ami-charts/triton-ami-chart.ts @@ -0,0 +1,568 @@ +import { AmiChartDefaultSeed } from "./default-ami-chart" +import { CountyCode } from "../../../shared/types/county-code" + +export const itemInfo = [ + { + percentOfAmi: 120, + householdSize: 1, + income: 110400, + }, + { + percentOfAmi: 120, + householdSize: 2, + income: 126150, + }, + { + percentOfAmi: 120, + householdSize: 3, + income: 141950, + }, + { + percentOfAmi: 120, + householdSize: 4, + income: 157700, + }, + { + percentOfAmi: 120, + householdSize: 5, + income: 170300, + }, + { + percentOfAmi: 120, + householdSize: 6, + income: 182950, + }, + { + percentOfAmi: 120, + householdSize: 7, + income: 195550, + }, + { + percentOfAmi: 120, + householdSize: 8, + income: 208150, + }, + { + percentOfAmi: 110, + householdSize: 1, + income: 101200, + }, + { + percentOfAmi: 110, + householdSize: 2, + income: 115610, + }, + { + percentOfAmi: 110, + householdSize: 3, + income: 130075, + }, + { + percentOfAmi: 110, + householdSize: 4, + income: 144540, + }, + { + percentOfAmi: 110, + householdSize: 5, + income: 156090, + }, + { + percentOfAmi: 110, + householdSize: 6, + income: 167640, + }, + { + percentOfAmi: 110, + householdSize: 7, + income: 179245, + }, + { + percentOfAmi: 110, + householdSize: 8, + income: 190795, + }, + { + percentOfAmi: 100, + householdSize: 1, + income: 92000, + }, + { + percentOfAmi: 100, + householdSize: 2, + income: 105100, + }, + { + percentOfAmi: 100, + householdSize: 3, + income: 118250, + }, + { + percentOfAmi: 100, + householdSize: 4, + income: 131400, + }, + { + percentOfAmi: 100, + householdSize: 5, + income: 141900, + }, + { + percentOfAmi: 100, + householdSize: 6, + income: 152400, + }, + { + percentOfAmi: 100, + householdSize: 7, + income: 162950, + }, + { + percentOfAmi: 100, + householdSize: 8, + income: 173450, + }, + { + percentOfAmi: 80, + householdSize: 1, + income: 72750, + }, + { + percentOfAmi: 80, + householdSize: 2, + income: 83150, + }, + { + percentOfAmi: 80, + householdSize: 3, + income: 93550, + }, + { + percentOfAmi: 80, + householdSize: 4, + income: 103900, + }, + { + percentOfAmi: 80, + householdSize: 5, + income: 112250, + }, + { + percentOfAmi: 80, + householdSize: 6, + income: 120550, + }, + { + percentOfAmi: 80, + householdSize: 7, + income: 128850, + }, + { + percentOfAmi: 80, + householdSize: 8, + income: 137150, + }, + { + percentOfAmi: 60, + householdSize: 1, + income: 61500, + }, + { + percentOfAmi: 60, + householdSize: 2, + income: 70260, + }, + { + percentOfAmi: 60, + householdSize: 3, + income: 79020, + }, + { + percentOfAmi: 60, + householdSize: 4, + income: 87780, + }, + { + percentOfAmi: 60, + householdSize: 5, + income: 94860, + }, + { + percentOfAmi: 60, + householdSize: 6, + income: 101880, + }, + { + percentOfAmi: 60, + householdSize: 7, + income: 108900, + }, + { + percentOfAmi: 60, + householdSize: 8, + income: 115920, + }, + { + percentOfAmi: 55, + householdSize: 1, + income: 56375, + }, + { + percentOfAmi: 55, + householdSize: 2, + income: 64405, + }, + { + percentOfAmi: 55, + householdSize: 3, + income: 72435, + }, + { + percentOfAmi: 55, + householdSize: 4, + income: 80465, + }, + { + percentOfAmi: 55, + householdSize: 5, + income: 86955, + }, + { + percentOfAmi: 55, + householdSize: 6, + income: 93390, + }, + { + percentOfAmi: 55, + householdSize: 7, + income: 99825, + }, + { + percentOfAmi: 55, + householdSize: 8, + income: 106260, + }, + { + percentOfAmi: 50, + householdSize: 1, + income: 51250, + }, + { + percentOfAmi: 50, + householdSize: 2, + income: 58550, + }, + { + percentOfAmi: 50, + householdSize: 3, + income: 65850, + }, + { + percentOfAmi: 50, + householdSize: 4, + income: 73150, + }, + { + percentOfAmi: 50, + householdSize: 5, + income: 79050, + }, + { + percentOfAmi: 50, + householdSize: 6, + income: 84900, + }, + { + percentOfAmi: 50, + householdSize: 7, + income: 90750, + }, + { + percentOfAmi: 50, + householdSize: 8, + income: 96600, + }, + { + percentOfAmi: 45, + householdSize: 1, + income: 46125, + }, + { + percentOfAmi: 45, + householdSize: 2, + income: 52695, + }, + { + percentOfAmi: 45, + householdSize: 3, + income: 59265, + }, + { + percentOfAmi: 45, + householdSize: 4, + income: 65835, + }, + { + percentOfAmi: 45, + householdSize: 5, + income: 71145, + }, + { + percentOfAmi: 45, + householdSize: 6, + income: 76410, + }, + { + percentOfAmi: 45, + householdSize: 7, + income: 81675, + }, + { + percentOfAmi: 40, + householdSize: 1, + income: 41000, + }, + { + percentOfAmi: 40, + householdSize: 2, + income: 46840, + }, + { + percentOfAmi: 40, + householdSize: 3, + income: 52680, + }, + { + percentOfAmi: 40, + householdSize: 4, + income: 58520, + }, + { + percentOfAmi: 40, + householdSize: 5, + income: 63240, + }, + { + percentOfAmi: 40, + householdSize: 6, + income: 67920, + }, + { + percentOfAmi: 40, + householdSize: 7, + income: 72600, + }, + { + percentOfAmi: 40, + householdSize: 8, + income: 77280, + }, + { + percentOfAmi: 35, + householdSize: 1, + income: 35875, + }, + { + percentOfAmi: 35, + householdSize: 2, + income: 40985, + }, + { + percentOfAmi: 35, + householdSize: 3, + income: 46095, + }, + { + percentOfAmi: 35, + householdSize: 4, + income: 51205, + }, + { + percentOfAmi: 35, + householdSize: 5, + income: 55335, + }, + { + percentOfAmi: 35, + householdSize: 6, + income: 59430, + }, + { + percentOfAmi: 35, + householdSize: 7, + income: 63525, + }, + { + percentOfAmi: 35, + householdSize: 8, + income: 67620, + }, + { + percentOfAmi: 30, + householdSize: 1, + income: 30750, + }, + { + percentOfAmi: 30, + householdSize: 2, + income: 35130, + }, + { + percentOfAmi: 30, + householdSize: 3, + income: 39510, + }, + { + percentOfAmi: 30, + householdSize: 4, + income: 43890, + }, + { + percentOfAmi: 30, + householdSize: 5, + income: 47430, + }, + { + percentOfAmi: 30, + householdSize: 6, + income: 50940, + }, + { + percentOfAmi: 30, + householdSize: 7, + income: 54450, + }, + { + percentOfAmi: 25, + householdSize: 1, + income: 25625, + }, + { + percentOfAmi: 25, + householdSize: 2, + income: 29275, + }, + { + percentOfAmi: 25, + householdSize: 3, + income: 32925, + }, + { + percentOfAmi: 25, + householdSize: 4, + income: 36575, + }, + { + percentOfAmi: 25, + householdSize: 5, + income: 39525, + }, + { + percentOfAmi: 25, + householdSize: 6, + income: 42450, + }, + { + percentOfAmi: 25, + householdSize: 7, + income: 45375, + }, + { + percentOfAmi: 25, + householdSize: 8, + income: 48300, + }, + { + percentOfAmi: 20, + householdSize: 1, + income: 20500, + }, + { + percentOfAmi: 20, + householdSize: 2, + income: 23420, + }, + { + percentOfAmi: 20, + householdSize: 3, + income: 26340, + }, + { + percentOfAmi: 20, + householdSize: 4, + income: 29260, + }, + { + percentOfAmi: 20, + householdSize: 5, + income: 31620, + }, + { + percentOfAmi: 20, + householdSize: 6, + income: 33960, + }, + { + percentOfAmi: 20, + householdSize: 7, + income: 36300, + }, + { + percentOfAmi: 20, + householdSize: 8, + income: 38640, + }, + { + percentOfAmi: 15, + householdSize: 1, + income: 15375, + }, + { + percentOfAmi: 15, + householdSize: 2, + income: 17565, + }, + { + percentOfAmi: 15, + householdSize: 3, + income: 19755, + }, + { + percentOfAmi: 15, + householdSize: 4, + income: 21945, + }, + { + percentOfAmi: 15, + householdSize: 5, + income: 23715, + }, + { + percentOfAmi: 15, + householdSize: 6, + income: 25470, + }, + { + percentOfAmi: 15, + householdSize: 7, + income: 27225, + }, + { + percentOfAmi: 15, + householdSize: 8, + income: 28980, + }, +] + +export class AmiDefaultTriton extends AmiChartDefaultSeed { + async seed() { + const alamedaJurisdiction = await this.jurisdictionRepository.findOneOrFail({ + name: CountyCode.alameda, + }) + return await this.amiChartRepository.save({ + name: "San Jose TCAC 2019", + items: itemInfo, + jurisdiction: alamedaJurisdiction, + }) + } +} diff --git a/backend/core/src/seeder/seeds/applications.ts b/backend/core/src/seeder/seeds/applications.ts new file mode 100644 index 0000000000..a637ec199c --- /dev/null +++ b/backend/core/src/seeder/seeds/applications.ts @@ -0,0 +1,240 @@ +import { INestApplicationContext } from "@nestjs/common" +import { Repository } from "typeorm" +import { getRepositoryToken } from "@nestjs/typeorm" +import { IncomePeriod } from "../../applications/types/income-period-enum" +import { Language } from "../../shared/types/language-enum" +import { InputType } from "../../shared/types/input-type" +import { ApplicationStatus } from "../../applications/types/application-status-enum" +import { ApplicationSubmissionType } from "../../applications/types/application-submission-type-enum" +import { Listing } from "../../listings/entities/listing.entity" +import { UnitType } from "../../unit-types/entities/unit-type.entity" +import { User } from "../../auth/entities/user.entity" +import { Application } from "../../applications/entities/application.entity" +import { ApplicationsService } from "../../applications/services/applications.service" +import { ApplicationCreateDto } from "../../applications/dto/application-create.dto" + +const applicationCreateDtoTemplate: Omit< + ApplicationCreateDto, + "user" | "listing" | "listingId" | "preferredUnit" +> = { + acceptedTerms: true, + accessibility: { + hearing: false, + mobility: false, + vision: false, + }, + additionalPhone: false, + additionalPhoneNumber: undefined, + additionalPhoneNumberType: undefined, + alternateAddress: { + city: "city", + county: "county", + latitude: 52.0, + longitude: 50, + placeName: "Place Name", + state: "state", + street: "street", + street2: "street2", + zipCode: "zip code", + }, + alternateContact: { + agency: "agency", + emailAddress: "test@example.com", + firstName: "First", + lastName: "Last", + mailingAddress: { + city: "city", + county: "county", + latitude: 52.0, + longitude: 50, + placeName: "Place Name", + state: "state", + street: "street", + street2: "street2", + zipCode: "zip code", + }, + otherType: "other", + phoneNumber: "(123) 123-1231", + type: "friend", + }, + appUrl: "", + applicant: { + address: { + city: "city", + county: "county", + latitude: 52.0, + longitude: 50, + placeName: "Place Name", + state: "state", + street: "street", + street2: "street2", + zipCode: "zip code", + }, + birthDay: "03", + birthMonth: "04", + birthYear: "1990", + emailAddress: "test@example.com", + firstName: "First", + lastName: "Last", + middleName: "Middle", + noEmail: false, + noPhone: false, + phoneNumber: "(123) 123-1231", + phoneNumberType: "cell", + workAddress: { + city: "city", + county: "county", + latitude: 52.0, + longitude: 50, + placeName: "Place Name", + state: "state", + street: "street", + street2: "street2", + zipCode: "zip code", + }, + workInRegion: "no", + }, + contactPreferences: [], + demographics: { + ethnicity: null, + gender: null, + howDidYouHear: ["email", "facebook"], + race: ["asian", "filipino"], + sexualOrientation: null, + }, + householdMembers: [ + { + address: { + city: "city", + county: "county", + latitude: 52.0, + longitude: 50, + placeName: "Place Name", + state: "state", + street: "street", + street2: "street2", + zipCode: "zip code", + }, + birthDay: "30", + birthMonth: "01", + birthYear: "1960", + emailAddress: "household@example.com", + firstName: "First", + lastName: "Last", + middleName: "Middle", + noEmail: false, + noPhone: false, + orderId: 1, + phoneNumber: "(123) 123-1231", + phoneNumberType: "cell", + relationship: "parent", + sameAddress: "no", + workAddress: { + city: "city", + county: "county", + latitude: 52.0, + longitude: 50, + placeName: "Place Name", + state: "state", + street: "street", + street2: "street2", + zipCode: "zip code", + }, + workInRegion: "no", + }, + ], + householdSize: 2, + housingStatus: "status", + income: "5000.00", + incomePeriod: IncomePeriod.perMonth, + incomeVouchers: false, + householdExpectingChanges: false, + householdStudent: false, + language: Language.en, + mailingAddress: { + city: "city", + county: "county", + latitude: 52.0, + longitude: 50, + placeName: "Place Name", + state: "state", + street: "street", + street2: "street2", + zipCode: "zip code", + }, + preferences: [ + { + key: "liveWork", + claimed: true, + options: [ + { + key: "live", + checked: true, + extraData: [], + }, + { + key: "work", + checked: false, + extraData: [], + }, + ], + }, + { + key: "displacedTenant", + claimed: true, + options: [ + { + key: "general", + checked: true, + extraData: [ + { + key: "name", + type: InputType.text, + value: "Roger Thornhill", + }, + { + key: "address", + type: InputType.address, + value: { + street: "Street", + street2: "Street2", + city: "City", + state: "state", + zipCode: "100200", + county: "Alameda", + latitude: null, + longitude: null, + }, + }, + ], + }, + { + key: "missionCorridor", + checked: false, + extraData: [], + }, + ], + }, + ], + sendMailToMailingAddress: true, + status: ApplicationStatus.submitted, + submissionDate: new Date(), + submissionType: ApplicationSubmissionType.electronical, +} + +export const makeNewApplication = async ( + app: INestApplicationContext, + listing: Listing, + unitTypes: UnitType[], + user?: User +) => { + const dto: ApplicationCreateDto = JSON.parse(JSON.stringify(applicationCreateDtoTemplate)) + dto.listing = listing + dto.preferredUnit = unitTypes + const applicationRepo = app.get>(getRepositoryToken(Application)) + return await applicationRepo.save({ + ...dto, + user, + confirmationCode: ApplicationsService.generateConfirmationCode(), + }) +} diff --git a/backend/core/src/seeder/seeds/jurisdictions.ts b/backend/core/src/seeder/seeds/jurisdictions.ts new file mode 100644 index 0000000000..803b238b92 --- /dev/null +++ b/backend/core/src/seeder/seeds/jurisdictions.ts @@ -0,0 +1,29 @@ +import { INestApplicationContext } from "@nestjs/common" +import { JurisdictionCreateDto } from "../../jurisdictions/dto/jurisdiction-create.dto" +import { Language } from "../../shared/types/language-enum" +import { JurisdictionsService } from "../../jurisdictions/services/jurisdictions.service" + +export const defaultJurisdictions: JurisdictionCreateDto[] = [ + { + name: "Detroit", + preferences: [], + languages: [Language.en], + programs: [], + publicUrl: "", + emailFromAddress: "Detroit Housing", + }, +] + +export async function createJurisdictions(app: INestApplicationContext) { + const jurisdictionService = await app.resolve(JurisdictionsService) + // some jurisdictions are added via previous migrations + const jurisdictions = await jurisdictionService.list() + const toInsert = defaultJurisdictions.filter( + (rec) => jurisdictions.findIndex((item) => item.name === rec.name) === -1 + ) + const inserted = await Promise.all( + toInsert.map(async (jurisdiction) => await jurisdictionService.create(jurisdiction)) + ) + // names are unique + return jurisdictions.concat(inserted).sort((a, b) => (a.name < b.name ? -1 : 1)) +} diff --git a/backend/core/src/seeder/seeds/listings/listing-coliseum-seed.ts b/backend/core/src/seeder/seeds/listings/listing-coliseum-seed.ts new file mode 100644 index 0000000000..5678ad8d61 --- /dev/null +++ b/backend/core/src/seeder/seeds/listings/listing-coliseum-seed.ts @@ -0,0 +1,1070 @@ +import { ListingSeedType, PropertySeedType, UnitSeedType } from "./listings" +import { + getDate, + getDefaultAssets, + getHopwaPreference, + getLiveWorkPreference, + getPbvPreference, + getServedInMilitaryProgram, + getTayProgram, + PriorityTypes, +} from "./shared" +import { BaseEntity, DeepPartial } from "typeorm" +import { ListingDefaultSeed } from "./listing-default-seed" +import { CountyCode } from "../../../shared/types/county-code" +import { ListingReviewOrder } from "../../../listings/types/listing-review-order-enum" +import { ListingStatus } from "../../../listings/types/listing-status-enum" +import { UnitStatus } from "../../../units/types/unit-status-enum" +import { UnitCreateDto } from "../../../units/dto/unit-create.dto" +import { Listing } from "../../../listings/entities/listing.entity" +import { ListingMarketingTypeEnum } from "../../../listings/types/listing-marketing-type-enum" + +const coliseumProperty: PropertySeedType = { + accessibility: + "Fifteen (15) units are designed for residents with mobility impairments per HUD/U.F.A.S. guidelines with one (1) of these units further designed for residents with auditory or visual impairments. There are two (2) additional units with features for those with auditory or visual impairments. All the other units are adaptable. Accessible features in the property include: * 36” wide entries and doorways * Kitchens built to the accessibility standards of the California Building Code, including appliance controls and switch outlets within reach, and work surfaces and storage at accessible heights * Bathrooms built to the accessibility standards of the California Building Code, including grab bars, flexible shower spray hose, switch outlets within reach, and in-tub seats. * Closet rods and shelves at mobility height. * Window blinds/shades able to be used without grasping or twisting * Units for the Hearing & Visually Impaired will have a horn & strobe for fire alarm and a flashing light doorbell. The 44 non-ADA units are built to Adaptable standards.", + amenities: "Community room, bike parking, courtyard off the community room, 2nd floor courtyard.", + buildingAddress: { + county: "Alameda", + city: "Oakland", + street: "3300 Hawley Street", + zipCode: "94621", + state: "CA", + latitude: 37.7549632, + longitude: -122.1968792, + }, + buildingTotalUnits: 58, + developer: "Resources for Community Development", + neighborhood: "Coliseum", + petPolicy: "Permitted", + servicesOffered: + "Residential supportive services are provided to all residents on a volunteer basis.", + smokingPolicy: "No Smoking", + unitAmenities: null, + unitsAvailable: 46, + yearBuilt: 2021, +} + +const coliseumListing: ListingSeedType = { + jurisdictionName: "Alameda", + digitalApplication: false, + commonDigitalApplication: false, + paperApplication: false, + referralOpportunity: false, + countyCode: CountyCode.alameda, + applicationDropOffAddress: null, + applicationDropOffAddressOfficeHours: null, + applicationMailingAddress: null, + applicationDueDate: new Date(getDate(1).setHours(17, 0, 0, 0)), + applicationFee: "12", + applicationOpenDate: getDate(-10), + applicationOrganization: "John Stewart Company", + applicationPickUpAddress: { + county: "Alameda", + city: "Oakland", + street: "1701 Martin Luther King Way", + zipCode: "94621", + state: "CA", + latitude: 37.7549632, + longitude: -122.1968792, + }, + images: [], + applicationPickUpAddressOfficeHours: null, + buildingSelectionCriteria: null, + costsNotIncluded: + "Electricity, phone, TV, internet, and cable not included. For the PBV units, deposit is one month of the tenant-paid portion of rent (30% of income).", + creditHistory: + "Management staff will request credit histories on each adult member of each applicant household. It is the applicant’s responsibility that at least one household member can demonstrate utilities can be put in their name. For this to be demonstrated, at least one household member must have a credit report that shows no utility accounts in default. Applicants who cannot have utilities put in their name will be considered ineligible. Any currently open bankruptcy proceeding of any of the household members will be considered a disqualifying condition. Applicants will not be considered to have a poor credit history when they were delinquent in rent because they were withholding rent due to substandard housing conditions in a manner consistent with local ordinance; or had a poor rent paying history clearly related to an excessive rent relative to their income, and responsible efforts were made to resolve the non-payment problem. If there is a finding of any kind which would negatively impact an application, the applicant will be notified in writing. The applicant then shall have 14 calendar days in which such a finding may be appealed to staff for consideration.", + criminalBackground: null, + depositMax: "200", + depositMin: "100", + disableUnitsAccordion: true, + displayWaitlistSize: false, + leasingAgentAddress: { + county: "Alameda", + city: "Oakland", + street: "1701 Martin Luther King Way", + zipCode: "94621", + state: "CA", + latitude: 37.7549632, + longitude: -122.1968792, + }, + leasingAgentEmail: "coliseum@jsco.net", + leasingAgentName: "Leasing agent name", + leasingAgentOfficeHours: + "Tuesdays & Thursdays, 9:00am to 5:00pm | Persons with disabilities who are unable to access the on-line application may request a Reasonable Accommodation by calling (510) 649-5739 for assistance. A TDD line is available at (415) 345-4470.", + leasingAgentPhone: "(510) 625-1632", + leasingAgentTitle: "Property Manager", + listingPreferences: [], + listingPrograms: [], + name: "Test: Coliseum", + postmarkedApplicationsReceivedByDate: null, + programRules: null, + rentalAssistance: "Rental assistance", + rentalHistory: "Two years' landlord history or homeless verification", + requiredDocuments: + "Application Document Checklist: https://org-housingbayarea-public-assets.s3-us-west-1.amazonaws.com/Tax+Credit+Application+Interview+Checklist.pdf", + reviewOrderType: "firstComeFirstServe" as ListingReviewOrder, + specialNotes: + "Priority Units: 3 apartments are set-aside for households eligible for the HOPWA program (Housing Opportunities for Persons with AIDS), which are households where a person has been medically diagnosed with HIV/AIDS. These 3 apartments also have Project-Based Section rental subsidies (tenant pays 30% of household income). 15 apartments are for those with mobility impairments and one of these units also has features for the hearing/visually impaired. Two additional apartments have features for the hearing/visually impaired. All units require eligibility requirements beyond income qualification: The waiting list will be ordered by incorporating the Alameda County preference for eligible households in which at least one member lives or works in the County. Three (3) apartments are restricted to households eligible under the HOPWA (Housing Opportunities for Persons with AIDS), which are households where a person has been medically diagnosed with HIV/AIDS. These apartments also receive PBV’s from OHA. For the twenty-five (25) apartments that have Project-Based Section 8 Vouchers from OHA, applicants will be called for an interview in the order according to the site-based waiting list compiled from the initial application and lotter process specifically for the PBV units. The waiting list order for these apartments will also incorporate the local preferences required by OHA. These preferences are: * A Residency preference (Applicants who live or work in the City of Oakland at the time of the application interview and/or applicants that lived or worked in the City of Oakland at the time of submitting their initial application and can verify their previous residency/employment at the applicant interview, qualify for this preference). * A Family preference (Applicant families with two or more persons, or a single person applicant that is 62 years of age or older, or a single person applicant with a disability, qualify for this preference). * A Veteran and active members of the military preference. Per OHA policy, a Veteran is a person who served in the active military, naval, or air service and who was discharged or released from such service under conditions other than dishonorable. * A Homeless preference. Applicant families who meet the McKinney-Vento Act definition of homeless qualify for this preference (see definition below). Each PBV applicant will receive one point for each preference for which it is eligible and the site-based PBV waiting list will be prioritized by the number of points applicants have from these preferences. Applicants for the PBV units must comply with OHA’s policy regarding Social Security Numbers. The applicant and all members of the applicant’s household must disclose the complete and accurate social security number (SSN) assigned to each household member, and they must provide the documentation necessary to verify each SSN. As an EveryOne Home partner, each applicant’s individual circumstances will be evaluated, alternative forms of verification and additional information submitted by the applicant will considered, and reasonable accommodations will be provided when requested and if verified and necessary. Persons with disabilities are encouraged to apply.", + status: ListingStatus.active, + waitlistCurrentSize: 0, + waitlistMaxSize: 3000, + waitlistOpenSpots: 3000, + isWaitlistOpen: true, + whatToExpect: null, + marketingType: ListingMarketingTypeEnum.Marketing, +} + +export class ListingColiseumSeed extends ListingDefaultSeed { + async seed() { + const priorityTypeMobilityAndHearingWithVisual = await this.unitAccessibilityPriorityTypeRepository.findOneOrFail( + { + name: PriorityTypes.mobilityHearingVisual, + } + ) + const priorityTypeMobilityAndMobilityWithHearingAndVisual = await this.unitAccessibilityPriorityTypeRepository.findOneOrFail( + { + name: PriorityTypes.mobilityHearingVisual, + } + ) + const priorityTypeMobilityAndHearing = await this.unitAccessibilityPriorityTypeRepository.findOneOrFail( + { + name: PriorityTypes.mobilityHearing, + } + ) + const priorityMobility = await this.unitAccessibilityPriorityTypeRepository.findOneOrFail({ + name: PriorityTypes.mobility, + }) + const unitTypeOneBdrm = await this.unitTypeRepository.findOneOrFail({ name: "oneBdrm" }) + const unitTypeTwoBdrm = await this.unitTypeRepository.findOneOrFail({ name: "twoBdrm" }) + const unitTypeThreeBdrm = await this.unitTypeRepository.findOneOrFail({ name: "threeBdrm" }) + + const alamedaJurisdiction = await this.jurisdictionRepository.findOneOrFail({ + name: CountyCode.alameda, + }) + const amiChart = await this.amiChartRepository.findOneOrFail({ + name: "AlamedaCountyTCAC2021", + jurisdiction: alamedaJurisdiction, + }) + + const property = await this.propertyRepository.save({ + ...coliseumProperty, + }) + + const coliseumUnits: Array = [ + { + amiChart: amiChart, + amiPercentage: "30", + annualIncomeMax: "36990", + annualIncomeMin: "0", + bmrProgramChart: false, + floor: 1, + maxOccupancy: 3, + minOccupancy: 1, + monthlyIncomeMin: "0", + monthlyRent: null, + monthlyRentAsPercentOfIncome: "30", + numBathrooms: 1, + numBedrooms: 1, + number: null, + sqFeet: "486", + status: UnitStatus.available, + }, + { + amiChart: amiChart, + amiPercentage: "30", + annualIncomeMax: "36990", + annualIncomeMin: "0", + bmrProgramChart: false, + floor: 1, + maxOccupancy: 3, + minOccupancy: 1, + monthlyIncomeMin: "0", + monthlyRent: null, + monthlyRentAsPercentOfIncome: "30", + numBathrooms: 1, + numBedrooms: 1, + number: null, + sqFeet: "491", + status: UnitStatus.available, + }, + { + amiChart: amiChart, + amiPercentage: "30", + annualIncomeMax: "36990", + annualIncomeMin: "0", + bmrProgramChart: false, + floor: 1, + maxOccupancy: 3, + minOccupancy: 1, + monthlyIncomeMin: "0", + monthlyRent: null, + monthlyRentAsPercentOfIncome: "30", + numBathrooms: 1, + numBedrooms: 1, + number: null, + sqFeet: "491", + status: UnitStatus.available, + }, + { + amiChart: amiChart, + amiPercentage: "50", + annualIncomeMax: "61650", + annualIncomeMin: "38520", + bmrProgramChart: false, + floor: 1, + maxOccupancy: 3, + minOccupancy: 1, + monthlyIncomeMin: "3210", + monthlyRent: "1284", + monthlyRentAsPercentOfIncome: null, + numBathrooms: 1, + numBedrooms: 1, + number: null, + sqFeet: "491", + status: UnitStatus.available, + }, + { + amiChart: amiChart, + amiPercentage: "30", + annualIncomeMax: "44400", + annualIncomeMin: "0", + bmrProgramChart: false, + floor: 1, + maxOccupancy: 4, + minOccupancy: 2, + monthlyIncomeMin: "0", + monthlyRent: null, + monthlyRentAsPercentOfIncome: "30", + numBathrooms: 1, + numBedrooms: 2, + number: null, + sqFeet: "748", + status: UnitStatus.available, + }, + { + amiChart: amiChart, + amiPercentage: "30", + annualIncomeMax: "44400", + annualIncomeMin: "0", + bmrProgramChart: false, + floor: 1, + maxOccupancy: 4, + minOccupancy: 2, + monthlyIncomeMin: "0", + monthlyRent: null, + monthlyRentAsPercentOfIncome: "30", + numBathrooms: 1, + numBedrooms: 2, + number: null, + sqFeet: "785", + status: UnitStatus.available, + }, + { + amiChart: amiChart, + amiPercentage: "30", + annualIncomeMax: "44400", + annualIncomeMin: "0", + bmrProgramChart: false, + floor: 1, + maxOccupancy: 4, + minOccupancy: 2, + monthlyIncomeMin: "0", + monthlyRent: null, + monthlyRentAsPercentOfIncome: "30", + numBathrooms: 1, + numBedrooms: 2, + number: null, + sqFeet: "785", + status: UnitStatus.available, + }, + { + amiChart: amiChart, + amiPercentage: "30", + annualIncomeMax: "44400", + annualIncomeMin: "0", + bmrProgramChart: false, + floor: 1, + maxOccupancy: 4, + minOccupancy: 2, + monthlyIncomeMin: "0", + monthlyRent: null, + monthlyRentAsPercentOfIncome: "30", + numBathrooms: 1, + numBedrooms: 2, + number: null, + sqFeet: "785", + status: UnitStatus.available, + }, + { + amiChart: amiChart, + amiPercentage: "30", + annualIncomeMax: "44400", + annualIncomeMin: "0", + bmrProgramChart: false, + floor: 1, + maxOccupancy: 4, + minOccupancy: 2, + monthlyIncomeMin: "0", + monthlyRent: null, + monthlyRentAsPercentOfIncome: "30", + numBathrooms: 1, + numBedrooms: 2, + number: null, + sqFeet: "785", + status: UnitStatus.available, + }, + { + amiChart: amiChart, + amiPercentage: "30", + annualIncomeMax: "44400", + annualIncomeMin: "0", + bmrProgramChart: false, + floor: 1, + maxOccupancy: 4, + minOccupancy: 2, + monthlyIncomeMin: "0", + monthlyRent: null, + monthlyRentAsPercentOfIncome: "30", + numBathrooms: 1, + numBedrooms: 2, + number: null, + sqFeet: "785", + status: UnitStatus.available, + }, + { + amiChart: amiChart, + amiPercentage: "30", + annualIncomeMax: "44400", + annualIncomeMin: "0", + bmrProgramChart: false, + floor: 1, + maxOccupancy: 4, + minOccupancy: 2, + monthlyIncomeMin: "0", + monthlyRent: null, + monthlyRentAsPercentOfIncome: "30", + numBathrooms: 1, + numBedrooms: 2, + number: null, + sqFeet: "785", + status: UnitStatus.available, + }, + { + amiChart: amiChart, + amiPercentage: "30", + annualIncomeMax: "44400", + annualIncomeMin: "0", + bmrProgramChart: false, + floor: 1, + maxOccupancy: 4, + minOccupancy: 2, + monthlyIncomeMin: "0", + monthlyRent: null, + monthlyRentAsPercentOfIncome: "30", + numBathrooms: 1, + numBedrooms: 2, + number: null, + sqFeet: "785", + status: UnitStatus.available, + }, + { + amiChart: amiChart, + amiPercentage: "30", + annualIncomeMax: "44400", + annualIncomeMin: "0", + bmrProgramChart: false, + floor: 1, + maxOccupancy: 4, + minOccupancy: 2, + monthlyIncomeMin: "0", + monthlyRent: null, + monthlyRentAsPercentOfIncome: "30", + numBathrooms: 1, + numBedrooms: 2, + number: null, + sqFeet: "785", + status: UnitStatus.available, + }, + { + amiChart: amiChart, + amiPercentage: "30", + annualIncomeMax: "44400", + annualIncomeMin: "0", + bmrProgramChart: false, + floor: 1, + maxOccupancy: 4, + minOccupancy: 2, + monthlyIncomeMin: "0", + monthlyRent: null, + monthlyRentAsPercentOfIncome: "30", + numBathrooms: 1, + numBedrooms: 2, + number: null, + sqFeet: "785", + status: UnitStatus.available, + }, + { + amiChart: amiChart, + amiPercentage: "45", + annualIncomeMax: "66600", + annualIncomeMin: "41616", + bmrProgramChart: false, + floor: 1, + maxOccupancy: 5, + minOccupancy: 2, + monthlyIncomeMin: "3468", + monthlyRent: "1387", + monthlyRentAsPercentOfIncome: null, + numBathrooms: 1, + numBedrooms: 2, + number: null, + sqFeet: "748", + status: UnitStatus.available, + }, + { + amiChart: amiChart, + amiPercentage: "45", + annualIncomeMax: "66600", + annualIncomeMin: "41616", + bmrProgramChart: false, + floor: 1, + maxOccupancy: 5, + minOccupancy: 2, + monthlyIncomeMin: "3468", + monthlyRent: "1387", + monthlyRentAsPercentOfIncome: null, + numBathrooms: 1, + numBedrooms: 2, + number: null, + sqFeet: "748", + status: UnitStatus.available, + }, + { + amiChart: amiChart, + amiPercentage: "50", + annualIncomeMax: "74000", + annualIncomeMin: "46236", + bmrProgramChart: false, + floor: 1, + maxOccupancy: 5, + minOccupancy: 2, + monthlyIncomeMin: "3853", + monthlyRent: "1541", + monthlyRentAsPercentOfIncome: null, + numBathrooms: 1, + numBedrooms: 2, + number: null, + sqFeet: "748", + status: UnitStatus.available, + }, + { + amiChart: amiChart, + amiPercentage: "50", + annualIncomeMax: "74000", + annualIncomeMin: "46236", + bmrProgramChart: false, + floor: 1, + maxOccupancy: 5, + minOccupancy: 2, + monthlyIncomeMin: "3853", + monthlyRent: "1541", + monthlyRentAsPercentOfIncome: null, + numBathrooms: 1, + numBedrooms: 2, + number: null, + sqFeet: "748", + status: UnitStatus.available, + }, + { + amiChart: amiChart, + amiPercentage: "50", + annualIncomeMax: "74000", + annualIncomeMin: "46236", + bmrProgramChart: false, + floor: 1, + maxOccupancy: 5, + minOccupancy: 2, + monthlyIncomeMin: "3853", + monthlyRent: "1541", + monthlyRentAsPercentOfIncome: null, + numBathrooms: 1, + numBedrooms: 2, + number: null, + sqFeet: "748", + status: UnitStatus.available, + }, + { + amiChart: amiChart, + amiPercentage: "50", + annualIncomeMax: "74000", + annualIncomeMin: "46236", + bmrProgramChart: false, + floor: 1, + maxOccupancy: 5, + minOccupancy: 2, + monthlyIncomeMin: "3853", + monthlyRent: "1541", + monthlyRentAsPercentOfIncome: null, + numBathrooms: 1, + numBedrooms: 2, + number: null, + sqFeet: "748", + status: UnitStatus.available, + }, + { + amiChart: amiChart, + amiPercentage: "50", + annualIncomeMax: "74000", + annualIncomeMin: "46236", + bmrProgramChart: false, + floor: 1, + maxOccupancy: 5, + minOccupancy: 2, + monthlyIncomeMin: "3853", + monthlyRent: "1541", + monthlyRentAsPercentOfIncome: null, + numBathrooms: 1, + numBedrooms: 2, + number: null, + sqFeet: "748", + status: UnitStatus.available, + }, + { + amiChart: amiChart, + amiPercentage: "50", + annualIncomeMax: "74000", + annualIncomeMin: "46236", + bmrProgramChart: false, + floor: 1, + maxOccupancy: 5, + minOccupancy: 2, + monthlyIncomeMin: "3853", + monthlyRent: "1541", + monthlyRentAsPercentOfIncome: null, + numBathrooms: 1, + numBedrooms: 2, + number: null, + sqFeet: "748", + status: UnitStatus.available, + }, + { + amiChart: amiChart, + amiPercentage: "50", + annualIncomeMax: "74000", + annualIncomeMin: "46236", + bmrProgramChart: false, + floor: 1, + maxOccupancy: 5, + minOccupancy: 2, + monthlyIncomeMin: "3853", + monthlyRent: "1541", + monthlyRentAsPercentOfIncome: null, + numBathrooms: 1, + numBedrooms: 2, + number: null, + sqFeet: "748", + status: UnitStatus.available, + }, + { + amiChart: amiChart, + amiPercentage: "50", + annualIncomeMax: "74000", + annualIncomeMin: "46236", + bmrProgramChart: false, + floor: 1, + maxOccupancy: 5, + minOccupancy: 2, + monthlyIncomeMin: "3853", + monthlyRent: "1541", + monthlyRentAsPercentOfIncome: null, + numBathrooms: 1, + numBedrooms: 2, + number: null, + sqFeet: "748", + status: UnitStatus.available, + }, + { + amiChart: amiChart, + amiPercentage: "50", + annualIncomeMax: "74000", + annualIncomeMin: "46236", + bmrProgramChart: false, + floor: 1, + maxOccupancy: 5, + minOccupancy: 2, + monthlyIncomeMin: "3853", + monthlyRent: "1541", + monthlyRentAsPercentOfIncome: null, + numBathrooms: 1, + numBedrooms: 2, + number: null, + sqFeet: "748", + status: UnitStatus.available, + }, + { + amiChart: amiChart, + amiPercentage: "50", + annualIncomeMax: "74000", + annualIncomeMin: "46236", + bmrProgramChart: false, + floor: 1, + maxOccupancy: 5, + minOccupancy: 2, + monthlyIncomeMin: "3853", + monthlyRent: "1541", + monthlyRentAsPercentOfIncome: null, + numBathrooms: 1, + numBedrooms: 2, + number: null, + sqFeet: "748", + status: UnitStatus.available, + }, + { + amiChart: amiChart, + amiPercentage: "50", + annualIncomeMax: "74000", + annualIncomeMin: "46236", + bmrProgramChart: false, + floor: 1, + maxOccupancy: 5, + minOccupancy: 2, + monthlyIncomeMin: "3853", + monthlyRent: "1541", + monthlyRentAsPercentOfIncome: null, + numBathrooms: 1, + numBedrooms: 2, + number: null, + sqFeet: "748", + status: UnitStatus.available, + }, + { + amiChart: amiChart, + amiPercentage: "20", + annualIncomeMax: "31800", + annualIncomeMin: "0", + bmrProgramChart: false, + floor: 2, + maxOccupancy: 6, + minOccupancy: 4, + monthlyIncomeMin: "0", + monthlyRent: null, + monthlyRentAsPercentOfIncome: "30", + numBathrooms: 2, + numBedrooms: 3, + number: null, + sqFeet: "1029", + status: UnitStatus.available, + }, + { + amiChart: amiChart, + amiPercentage: "20", + annualIncomeMax: "31800", + annualIncomeMin: "0", + bmrProgramChart: false, + floor: 6, + maxOccupancy: 6, + minOccupancy: 4, + monthlyIncomeMin: "0", + monthlyRent: null, + monthlyRentAsPercentOfIncome: "30", + numBathrooms: 2, + numBedrooms: 3, + number: null, + sqFeet: "1080", + status: UnitStatus.available, + }, + { + amiChart: amiChart, + amiPercentage: "20", + annualIncomeMax: "31800", + annualIncomeMin: "0", + bmrProgramChart: false, + floor: 2, + maxOccupancy: 6, + minOccupancy: 4, + monthlyIncomeMin: "0", + monthlyRent: null, + monthlyRentAsPercentOfIncome: "30", + numBathrooms: 2, + numBedrooms: 3, + number: null, + sqFeet: "1029", + status: UnitStatus.available, + }, + { + amiChart: amiChart, + amiPercentage: "45", + annualIncomeMax: "71550", + annualIncomeMin: "0", + bmrProgramChart: false, + floor: 2, + maxOccupancy: 6, + minOccupancy: 4, + monthlyIncomeMin: "0", + monthlyRent: null, + monthlyRentAsPercentOfIncome: "30", + numBathrooms: 2, + numBedrooms: 3, + number: null, + sqFeet: "1029", + status: UnitStatus.available, + }, + { + amiChart: amiChart, + amiPercentage: "45", + annualIncomeMax: "71550", + annualIncomeMin: "0", + bmrProgramChart: false, + floor: 2, + maxOccupancy: 6, + minOccupancy: 4, + monthlyIncomeMin: "0", + monthlyRent: null, + monthlyRentAsPercentOfIncome: "30", + numBathrooms: 2, + numBedrooms: 3, + number: null, + sqFeet: "1029", + status: UnitStatus.available, + }, + { + amiChart: amiChart, + amiPercentage: "45", + annualIncomeMax: "71550", + annualIncomeMin: "0", + bmrProgramChart: false, + floor: 2, + maxOccupancy: 6, + minOccupancy: 4, + monthlyIncomeMin: "0", + monthlyRent: null, + monthlyRentAsPercentOfIncome: "30", + numBathrooms: 2, + numBedrooms: 3, + number: null, + sqFeet: "1029", + status: UnitStatus.available, + }, + { + amiChart: amiChart, + amiPercentage: "50", + annualIncomeMax: "79500", + annualIncomeMin: "0", + bmrProgramChart: false, + floor: 2, + maxOccupancy: 6, + minOccupancy: 4, + monthlyIncomeMin: "0", + monthlyRent: null, + monthlyRentAsPercentOfIncome: "30", + numBathrooms: 2, + numBedrooms: 3, + number: null, + sqFeet: "1029", + status: UnitStatus.available, + }, + { + amiChart: amiChart, + amiPercentage: "50", + annualIncomeMax: "79500", + annualIncomeMin: "0", + bmrProgramChart: false, + floor: 2, + maxOccupancy: 6, + minOccupancy: 4, + monthlyIncomeMin: "0", + monthlyRent: null, + monthlyRentAsPercentOfIncome: "30", + numBathrooms: 2, + numBedrooms: 3, + number: null, + sqFeet: "1029", + status: UnitStatus.available, + }, + { + amiChart: amiChart, + amiPercentage: "50", + annualIncomeMax: "79500", + annualIncomeMin: "0", + bmrProgramChart: false, + floor: 2, + maxOccupancy: 6, + minOccupancy: 4, + monthlyIncomeMin: "0", + monthlyRent: null, + monthlyRentAsPercentOfIncome: "30", + numBathrooms: 2, + numBedrooms: 3, + number: null, + sqFeet: "1029", + status: UnitStatus.available, + }, + { + amiChart: amiChart, + amiPercentage: "50", + annualIncomeMax: "79500", + annualIncomeMin: "0", + bmrProgramChart: false, + floor: 2, + maxOccupancy: 6, + minOccupancy: 4, + monthlyIncomeMin: "0", + monthlyRent: null, + monthlyRentAsPercentOfIncome: "30", + numBathrooms: 2, + numBedrooms: 3, + number: null, + sqFeet: "1029", + status: UnitStatus.available, + }, + { + amiChart: amiChart, + amiPercentage: "50", + annualIncomeMax: "79500", + annualIncomeMin: "0", + bmrProgramChart: false, + floor: 2, + maxOccupancy: 6, + minOccupancy: 4, + monthlyIncomeMin: "0", + monthlyRent: null, + monthlyRentAsPercentOfIncome: "30", + numBathrooms: 2, + numBedrooms: 3, + number: null, + sqFeet: "1029", + status: UnitStatus.available, + }, + { + amiChart: amiChart, + amiPercentage: "50", + annualIncomeMax: "79500", + annualIncomeMin: "0", + bmrProgramChart: false, + floor: 2, + maxOccupancy: 6, + minOccupancy: 4, + monthlyIncomeMin: "0", + monthlyRent: null, + monthlyRentAsPercentOfIncome: "30", + numBathrooms: 2, + numBedrooms: 3, + number: null, + sqFeet: "1029", + status: UnitStatus.available, + }, + { + amiChart: amiChart, + amiPercentage: "50", + annualIncomeMax: "84950", + annualIncomeMin: "53436", + bmrProgramChart: false, + floor: 2, + maxOccupancy: 7, + minOccupancy: 4, + monthlyIncomeMin: "4453", + monthlyRent: "1781", + monthlyRentAsPercentOfIncome: null, + numBathrooms: 2, + numBedrooms: 3, + number: null, + sqFeet: "1029", + status: UnitStatus.available, + }, + { + amiChart: amiChart, + amiPercentage: "50", + annualIncomeMax: "84950", + annualIncomeMin: "53436", + bmrProgramChart: false, + floor: 2, + maxOccupancy: 7, + minOccupancy: 4, + monthlyIncomeMin: "4453", + monthlyRent: "1781", + monthlyRentAsPercentOfIncome: null, + numBathrooms: 2, + numBedrooms: 3, + number: null, + sqFeet: "1029", + status: UnitStatus.available, + }, + { + amiChart: amiChart, + amiPercentage: "50", + annualIncomeMax: "84950", + annualIncomeMin: "53436", + bmrProgramChart: false, + floor: 2, + maxOccupancy: 7, + minOccupancy: 4, + monthlyIncomeMin: "4453", + monthlyRent: "1781", + monthlyRentAsPercentOfIncome: null, + numBathrooms: 2, + numBedrooms: 3, + number: null, + sqFeet: "1029", + status: UnitStatus.available, + }, + { + amiChart: amiChart, + amiPercentage: "50", + annualIncomeMax: "84950", + annualIncomeMin: "53436", + bmrProgramChart: false, + floor: 2, + maxOccupancy: 7, + minOccupancy: 4, + monthlyIncomeMin: "4453", + monthlyRent: "1781", + monthlyRentAsPercentOfIncome: null, + numBathrooms: 2, + numBedrooms: 3, + number: null, + sqFeet: "1029", + status: UnitStatus.available, + }, + { + amiChart: amiChart, + amiPercentage: "50", + annualIncomeMax: "84950", + annualIncomeMin: "53436", + bmrProgramChart: false, + floor: 2, + maxOccupancy: 7, + minOccupancy: 4, + monthlyIncomeMin: "4453", + monthlyRent: "1781", + monthlyRentAsPercentOfIncome: null, + numBathrooms: 2, + numBedrooms: 3, + number: null, + sqFeet: "1029", + status: UnitStatus.available, + }, + { + amiChart: amiChart, + amiPercentage: "50", + annualIncomeMax: "84950", + annualIncomeMin: "53436", + bmrProgramChart: false, + floor: 2, + maxOccupancy: 7, + minOccupancy: 4, + monthlyIncomeMin: "4453", + monthlyRent: "1781", + monthlyRentAsPercentOfIncome: null, + numBathrooms: 2, + numBedrooms: 3, + number: null, + sqFeet: "1029", + status: UnitStatus.available, + }, + { + amiChart: amiChart, + amiPercentage: "50", + annualIncomeMax: "84950", + annualIncomeMin: "53436", + bmrProgramChart: false, + floor: 2, + maxOccupancy: 7, + minOccupancy: 4, + monthlyIncomeMin: "4453", + monthlyRent: "1781", + monthlyRentAsPercentOfIncome: null, + numBathrooms: 2, + numBedrooms: 3, + number: null, + sqFeet: "1029", + status: UnitStatus.available, + }, + ] + + const unitsToBeCreated: Array> = coliseumUnits.map( + (unit) => { + return { + ...unit, + property: { + id: property.id, + }, + amiChart, + } + } + ) + + // Assign priorityTypes + for (let i = 0; i < 3; i++) { + unitsToBeCreated[i].priorityType = priorityTypeMobilityAndMobilityWithHearingAndVisual + } + for (let i = 3; i < 14; i++) { + unitsToBeCreated[i].priorityType = priorityTypeMobilityAndHearingWithVisual + } + for (let i = 14; i < 27; i++) { + unitsToBeCreated[i].priorityType = priorityTypeMobilityAndHearing + } + for (let i = 27; i < 46; i++) { + unitsToBeCreated[i].priorityType = priorityMobility + } + + // Assign unit types + for (let i = 0; i < 4; i++) { + unitsToBeCreated[i].unitType = unitTypeOneBdrm + } + for (let i = 4; i < 27; i++) { + unitsToBeCreated[i].unitType = unitTypeTwoBdrm + } + for (let i = 27; i < 46; i++) { + unitsToBeCreated[i].unitType = unitTypeThreeBdrm + } + + await this.unitsRepository.save(unitsToBeCreated) + + const listingCreateDto: Omit< + DeepPartial, + keyof BaseEntity | "urlSlug" | "showWaitlist" + > = { + ...coliseumListing, + property: property, + assets: getDefaultAssets(), + listingPreferences: [ + { + preference: await this.preferencesRepository.findOneOrFail({ + title: getLiveWorkPreference(alamedaJurisdiction.name).title, + }), + ordinal: 1, + }, + { + preference: await this.preferencesRepository.findOneOrFail({ + title: getPbvPreference(alamedaJurisdiction.name).title, + }), + ordinal: 2, + }, + { + preference: await this.preferencesRepository.findOneOrFail({ + title: getHopwaPreference(alamedaJurisdiction.name).title, + }), + ordinal: 3, + }, + ], + events: [], + listingPrograms: [ + { + program: await this.programsRepository.findOneOrFail({ + title: getServedInMilitaryProgram().title, + }), + ordinal: 1, + }, + { + program: await this.programsRepository.findOneOrFail({ + title: getTayProgram().title, + }), + ordinal: 2, + }, + ], + } + + return await this.listingRepository.save(listingCreateDto) + } +} diff --git a/backend/core/src/seeder/seeds/listings/listing-default-bmr-chart-seed.ts b/backend/core/src/seeder/seeds/listings/listing-default-bmr-chart-seed.ts new file mode 100644 index 0000000000..957ec2eefc --- /dev/null +++ b/backend/core/src/seeder/seeds/listings/listing-default-bmr-chart-seed.ts @@ -0,0 +1,55 @@ +import { ListingDefaultSeed } from "./listing-default-seed" +import { getDefaultUnits, getDefaultProperty } from "./shared" +import { BaseEntity } from "typeorm" +import { defaultAmiChart } from "../ami-charts/default-ami-chart" +import { UnitCreateDto } from "../../../units/dto/unit-create.dto" +import { CountyCode } from "../../../shared/types/county-code" + +export class ListingDefaultBmrChartSeed extends ListingDefaultSeed { + async seed() { + const listing = await super.seed() + const defaultUnits = getDefaultUnits() + + const unitTypeOneBdrm = await this.unitTypeRepository.findOneOrFail({ name: "oneBdrm" }) + const unitTypeTwoBdrm = await this.unitTypeRepository.findOneOrFail({ name: "twoBdrm" }) + + const alamedaJurisdiction = await this.jurisdictionRepository.findOneOrFail({ + name: CountyCode.alameda, + }) + const amiChart = await this.amiChartRepository.findOneOrFail({ + name: defaultAmiChart.name, + jurisdiction: alamedaJurisdiction, + }) + + const property = await this.propertyRepository.save({ + ...getDefaultProperty(), + }) + + const bmrUnits = [ + { ...defaultUnits[0], bmrProgramChart: true, monthlyIncomeMin: "700", monthlyRent: "350" }, + { ...defaultUnits[1], bmrProgramChart: true, monthlyIncomeMin: "800", monthlyRent: "400" }, + ] + + const unitsToBeCreated: Array> = bmrUnits.map((unit) => { + return { + ...unit, + property: { + id: property.id, + }, + amiChart, + } + }) + + unitsToBeCreated[0].unitType = unitTypeOneBdrm + unitsToBeCreated[1].unitType = unitTypeTwoBdrm + + await this.unitsRepository.save(unitsToBeCreated) + + return await this.listingRepository.save({ + ...listing, + name: "Test: Default, BMR Chart", + preferences: [], + property, + }) + } +} diff --git a/backend/core/src/seeder/seeds/listings/listing-default-fcfs-seed.ts b/backend/core/src/seeder/seeds/listings/listing-default-fcfs-seed.ts new file mode 100644 index 0000000000..33a31d3020 --- /dev/null +++ b/backend/core/src/seeder/seeds/listings/listing-default-fcfs-seed.ts @@ -0,0 +1,15 @@ +import { ListingDefaultSeed } from "./listing-default-seed" +import { ListingReviewOrder } from "../../../listings/types/listing-review-order-enum" + +export class ListingDefaultFCFSSeed extends ListingDefaultSeed { + async seed() { + const listing = await super.seed() + return await this.listingRepository.save({ + ...listing, + name: "Test: Default, FCFS", + reviewOrderType: "firstComeFirstServe" as ListingReviewOrder, + applicationDueDate: null, + events: [], + }) + } +} diff --git a/backend/core/src/seeder/seeds/listings/listing-default-lottery-pending.ts b/backend/core/src/seeder/seeds/listings/listing-default-lottery-pending.ts new file mode 100644 index 0000000000..b27cfbe91a --- /dev/null +++ b/backend/core/src/seeder/seeds/listings/listing-default-lottery-pending.ts @@ -0,0 +1,52 @@ +import { ListingEventType } from "../../../../types/src/backend-swagger" +import { ListingDefaultSeed } from "./listing-default-seed" +import { getDate } from "./shared" + +export class ListingDefaultLotteryPending extends ListingDefaultSeed { + async seed() { + const listing = await super.seed() + return await this.listingRepository.save({ + ...listing, + name: "Test: Default, Lottery Results Pending", + applicationOpenDate: getDate(30), + applicationDueDate: getDate(60), + events: [ + { + startTime: getDate(10), + endTime: getDate(10), + note: + "Custom public lottery event note. This is a long note and should take up more space.", + type: ListingEventType.openHouse, + url: "https://www.example.com", + label: "Custom Event URL Label", + }, + { + startTime: getDate(15), + endTime: getDate(15), + type: ListingEventType.openHouse, + }, + { + startTime: getDate(20), + endTime: getDate(20), + note: "Custom open house event note", + type: ListingEventType.openHouse, + url: "https://www.example.com", + label: "Custom Event URL Label", + }, + { + startTime: getDate(-10), + endTime: getDate(-10), + type: ListingEventType.publicLottery, + url: "https://www.example2.com", + label: "Custom Event URL Label", + }, + { + startTime: getDate(15), + endTime: getDate(15), + type: ListingEventType.lotteryResults, + label: "Custom Event URL Label", + }, + ], + }) + } +} diff --git a/backend/core/src/seeder/seeds/listings/listing-default-lottery-results.ts b/backend/core/src/seeder/seeds/listings/listing-default-lottery-results.ts new file mode 100644 index 0000000000..8aab9fe5f0 --- /dev/null +++ b/backend/core/src/seeder/seeds/listings/listing-default-lottery-results.ts @@ -0,0 +1,53 @@ +import { ListingEventType } from "../../../../types/src/backend-swagger" +import { ListingDefaultSeed } from "./listing-default-seed" +import { getDate } from "./shared" + +export class ListingDefaultLottery extends ListingDefaultSeed { + async seed() { + const listing = await super.seed() + return await this.listingRepository.save({ + ...listing, + name: "Test: Default, Lottery Results", + applicationOpenDate: getDate(30), + applicationDueDate: getDate(60), + events: [ + { + startTime: getDate(10), + endTime: getDate(10), + note: + "Custom public lottery event note. This is a long note and should take up more space.", + type: ListingEventType.openHouse, + url: "https://www.example.com", + label: "Custom Event URL Label", + }, + { + startTime: getDate(15), + endTime: getDate(15), + type: ListingEventType.openHouse, + }, + { + startTime: getDate(20), + endTime: getDate(20), + note: "Custom open house event note", + type: ListingEventType.openHouse, + url: "https://www.example.com", + label: "Custom Event URL Label", + }, + { + startTime: getDate(10), + endTime: getDate(10), + type: ListingEventType.publicLottery, + url: "https://www.example2.com", + label: "Custom Event URL Label", + }, + { + startTime: getDate(15), + endTime: getDate(15), + type: ListingEventType.lotteryResults, + url: "https://www.example2.com", + label: "Custom Event URL Label", + }, + ], + }) + } +} diff --git a/backend/core/src/seeder/seeds/listings/listing-default-missing-ami.ts b/backend/core/src/seeder/seeds/listings/listing-default-missing-ami.ts new file mode 100644 index 0000000000..0a924d619d --- /dev/null +++ b/backend/core/src/seeder/seeds/listings/listing-default-missing-ami.ts @@ -0,0 +1,146 @@ +import { ListingDefaultSeed } from "./listing-default-seed" +import { getDefaultProperty } from "./shared" +import { BaseEntity } from "typeorm" +import { UnitSeedType } from "./listings" +import { CountyCode } from "../../../shared/types/county-code" +import { UnitStatus } from "../../../units/types/unit-status-enum" +import { UnitCreateDto } from "../../../units/dto/unit-create.dto" + +export class ListingDefaultMissingAMI extends ListingDefaultSeed { + async seed() { + const listing = await super.seed() + + const unitTypeOneBdrm = await this.unitTypeRepository.findOneOrFail({ name: "oneBdrm" }) + + const alamedaJurisdiction = await this.jurisdictionRepository.findOneOrFail({ + name: CountyCode.alameda, + }) + + const amiChart = await this.amiChartRepository.findOneOrFail({ + name: "Missing Household Ami Levels", + jurisdiction: alamedaJurisdiction, + }) + + const property = await this.propertyRepository.save({ + ...getDefaultProperty(), + }) + + const missingAmiLevelsUnits: Array = [ + { + amiChart: amiChart, + amiPercentage: "50", + annualIncomeMax: "177300.0", + annualIncomeMin: "84696.0", + floor: 1, + maxOccupancy: 5, + minOccupancy: 2, + monthlyIncomeMin: "7058.0", + monthlyRent: "3340.0", + monthlyRentAsPercentOfIncome: null, + numBathrooms: null, + numBedrooms: 2, + number: null, + priorityType: null, + sqFeet: "1100", + status: UnitStatus.occupied, + }, + { + amiChart: amiChart, + amiPercentage: "50", + annualIncomeMax: "103350.0", + annualIncomeMin: "58152.0", + floor: 1, + maxOccupancy: 2, + minOccupancy: 1, + monthlyIncomeMin: "4858.0", + monthlyRent: "2624.0", + monthlyRentAsPercentOfIncome: null, + numBathrooms: null, + numBedrooms: 1, + number: null, + priorityType: null, + sqFeet: "750", + status: UnitStatus.occupied, + }, + { + amiChart: amiChart, + amiPercentage: "50", + annualIncomeMax: "103350.0", + annualIncomeMin: "58152.0", + floor: 1, + maxOccupancy: 2, + minOccupancy: 1, + monthlyIncomeMin: "4858.0", + monthlyRent: "2624.0", + monthlyRentAsPercentOfIncome: null, + numBathrooms: null, + numBedrooms: 1, + number: null, + priorityType: null, + sqFeet: "750", + status: UnitStatus.occupied, + }, + { + amiChart: amiChart, + amiPercentage: "50", + annualIncomeMax: "103350.0", + annualIncomeMin: "58152.0", + floor: 1, + maxOccupancy: 2, + minOccupancy: 1, + monthlyIncomeMin: "4858.0", + monthlyRent: "2624.0", + monthlyRentAsPercentOfIncome: null, + numBathrooms: null, + numBedrooms: 1, + number: null, + priorityType: null, + sqFeet: "750", + status: UnitStatus.occupied, + }, + { + amiChart: amiChart, + amiPercentage: "50", + annualIncomeMax: "103350.0", + annualIncomeMin: "38952.0", + floor: 1, + maxOccupancy: 2, + minOccupancy: 1, + monthlyIncomeMin: "3246.0", + monthlyRent: "1575.0", + monthlyRentAsPercentOfIncome: null, + numBathrooms: null, + numBedrooms: 1, + number: null, + priorityType: null, + sqFeet: "750", + status: UnitStatus.occupied, + }, + ] + + const unitsToBeCreated: Array> = missingAmiLevelsUnits.map((unit) => { + return { + ...unit, + property: { + id: property.id, + }, + amiChart, + } + }) + + unitsToBeCreated.forEach((unit) => { + unit.unitType = unitTypeOneBdrm + }) + + await this.unitsRepository.save(unitsToBeCreated) + + return await this.listingRepository.save({ + ...listing, + property: property, + name: "Test: Default, Missing Household Levels in AMI", + }) + } +} diff --git a/backend/core/src/seeder/seeds/listings/listing-default-multiple-ami-and-percentages.ts b/backend/core/src/seeder/seeds/listings/listing-default-multiple-ami-and-percentages.ts new file mode 100644 index 0000000000..b3aa396419 --- /dev/null +++ b/backend/core/src/seeder/seeds/listings/listing-default-multiple-ami-and-percentages.ts @@ -0,0 +1,130 @@ +import { ListingDefaultSeed } from "./listing-default-seed" +import { getDefaultProperty } from "./shared" +import { BaseEntity } from "typeorm" +import { UnitSeedType } from "./listings" +import { CountyCode } from "../../../shared/types/county-code" +import { UnitStatus } from "../../../units/types/unit-status-enum" +import { UnitCreateDto } from "../../../units/dto/unit-create.dto" + +export class ListingDefaultMultipleAMIAndPercentages extends ListingDefaultSeed { + async seed() { + const listing = await super.seed() + + const unitTypeOneBdrm = await this.unitTypeRepository.findOneOrFail({ name: "oneBdrm" }) + + const alamedaJurisdiction = await this.jurisdictionRepository.findOneOrFail({ + name: CountyCode.alameda, + }) + const amiChartOne = await this.amiChartRepository.findOneOrFail({ + name: "San Jose TCAC 2019", + jurisdiction: alamedaJurisdiction, + }) + const amiChartTwo = await this.amiChartRepository.findOneOrFail({ + name: "AlamedaCountyTCAC2021", + jurisdiction: alamedaJurisdiction, + }) + + const property = await this.propertyRepository.save({ + ...getDefaultProperty(), + }) + + const multipleAMIUnits: Array = [ + { + amiChart: amiChartOne, + amiPercentage: "30", + annualIncomeMax: "45600", + annualIncomeMin: "36168", + bmrProgramChart: false, + floor: 1, + maxOccupancy: 3, + minOccupancy: 1, + monthlyIncomeMin: "3014", + monthlyRent: "1219", + monthlyRentAsPercentOfIncome: null, + numBathrooms: 1, + numBedrooms: 1, + number: null, + sqFeet: "635", + status: UnitStatus.available, + }, + { + amiChart: amiChartTwo, + amiPercentage: "30", + annualIncomeMax: "45600", + annualIncomeMin: "36168", + bmrProgramChart: false, + floor: 1, + maxOccupancy: 3, + minOccupancy: 1, + monthlyIncomeMin: "3014", + monthlyRent: "1219", + monthlyRentAsPercentOfIncome: null, + numBathrooms: 1, + numBedrooms: 1, + number: null, + sqFeet: "635", + status: UnitStatus.available, + }, + { + amiChart: amiChartOne, + amiPercentage: "50", + annualIncomeMax: "66600", + annualIncomeMin: "41616", + bmrProgramChart: false, + floor: 2, + maxOccupancy: 5, + minOccupancy: 2, + monthlyIncomeMin: "3468", + monthlyRent: "1387", + monthlyRentAsPercentOfIncome: null, + numBathrooms: 1, + numBedrooms: 2, + number: null, + sqFeet: "748", + status: UnitStatus.available, + }, + { + amiChart: amiChartTwo, + amiPercentage: "50", + annualIncomeMax: "66600", + annualIncomeMin: "41616", + bmrProgramChart: false, + floor: 2, + maxOccupancy: 5, + minOccupancy: 2, + monthlyIncomeMin: "3468", + monthlyRent: "1387", + monthlyRentAsPercentOfIncome: null, + numBathrooms: 1, + numBedrooms: 2, + number: null, + sqFeet: "748", + status: UnitStatus.available, + }, + ] + + const unitsToBeCreated: Array> = multipleAMIUnits.map( + (unit) => { + return { + ...unit, + property: { + id: property.id, + }, + } + } + ) + + unitsToBeCreated[0].unitType = unitTypeOneBdrm + unitsToBeCreated[1].unitType = unitTypeOneBdrm + unitsToBeCreated[2].unitType = unitTypeOneBdrm + unitsToBeCreated[3].unitType = unitTypeOneBdrm + + await this.unitsRepository.save(unitsToBeCreated) + + return await this.listingRepository.save({ + ...listing, + property: property, + name: "Test: Default, Multiple AMI and Percentages", + }) + } +} diff --git a/backend/core/src/seeder/seeds/listings/listing-default-multiple-ami.ts b/backend/core/src/seeder/seeds/listings/listing-default-multiple-ami.ts new file mode 100644 index 0000000000..c3443dbfdd --- /dev/null +++ b/backend/core/src/seeder/seeds/listings/listing-default-multiple-ami.ts @@ -0,0 +1,52 @@ +import { ListingDefaultSeed } from "./listing-default-seed" +import { getDefaultUnits, getDefaultProperty } from "./shared" +import { BaseEntity } from "typeorm" +import { CountyCode } from "../../../shared/types/county-code" +import { UnitCreateDto } from "../../../units/dto/unit-create.dto" + +export class ListingDefaultMultipleAMI extends ListingDefaultSeed { + async seed() { + const listing = await super.seed() + + const unitTypeOneBdrm = await this.unitTypeRepository.findOneOrFail({ name: "oneBdrm" }) + + const alamedaJurisdiction = await this.jurisdictionRepository.findOneOrFail({ + name: CountyCode.alameda, + }) + const amiChartOne = await this.amiChartRepository.findOneOrFail({ + name: "San Jose TCAC 2019", + jurisdiction: alamedaJurisdiction, + }) + const amiChartTwo = await this.amiChartRepository.findOneOrFail({ + name: "AlamedaCountyTCAC2021", + jurisdiction: alamedaJurisdiction, + }) + + const property = await this.propertyRepository.save({ + ...getDefaultProperty(), + }) + + const unitsToBeCreated: Array> = getDefaultUnits().map( + (unit, index) => { + return { + ...unit, + property: { + id: property.id, + }, + amiChart: index % 2 === 0 ? amiChartOne : amiChartTwo, + } + } + ) + + unitsToBeCreated[0].unitType = unitTypeOneBdrm + unitsToBeCreated[1].unitType = unitTypeOneBdrm + + await this.unitsRepository.save(unitsToBeCreated) + + return await this.listingRepository.save({ + ...listing, + property: property, + name: "Test: Default, Multiple AMI", + }) + } +} diff --git a/backend/core/src/seeder/seeds/listings/listing-default-no-preference-seed.ts b/backend/core/src/seeder/seeds/listings/listing-default-no-preference-seed.ts new file mode 100644 index 0000000000..cdd09ea43b --- /dev/null +++ b/backend/core/src/seeder/seeds/listings/listing-default-no-preference-seed.ts @@ -0,0 +1,15 @@ +import { ListingDefaultSeed } from "./listing-default-seed" +import { getDate } from "./shared" + +export class ListingDefaultNoPreferenceSeed extends ListingDefaultSeed { + async seed() { + const listing = await super.seed() + return await this.listingRepository.save({ + ...listing, + name: "Test: Default, No Preferences", + listingPreferences: [], + applicationDueDate: getDate(5), + applicationOpenDate: getDate(-5), + }) + } +} diff --git a/backend/core/src/seeder/seeds/listings/listing-default-one-preference-seed.ts b/backend/core/src/seeder/seeds/listings/listing-default-one-preference-seed.ts new file mode 100644 index 0000000000..720fd9c926 --- /dev/null +++ b/backend/core/src/seeder/seeds/listings/listing-default-one-preference-seed.ts @@ -0,0 +1,21 @@ +import { getLiveWorkPreference } from "./shared" +import { ListingDefaultSeed } from "./listing-default-seed" + +export class ListingDefaultOnePreferenceSeed extends ListingDefaultSeed { + async seed() { + const listing = await super.seed() + return await this.listingRepository.save({ + ...listing, + name: "Test: Default, One Preference", + listingPreferences: [ + { + preference: await this.preferencesRepository.findOneOrFail({ + title: getLiveWorkPreference(listing.jurisdiction.name).title, + }), + ordinal: 1, + page: 1, + }, + ], + }) + } +} diff --git a/backend/core/src/seeder/seeds/listings/listing-default-open-soon.ts b/backend/core/src/seeder/seeds/listings/listing-default-open-soon.ts new file mode 100644 index 0000000000..53d67f592d --- /dev/null +++ b/backend/core/src/seeder/seeds/listings/listing-default-open-soon.ts @@ -0,0 +1,14 @@ +import { ListingDefaultSeed } from "./listing-default-seed" +import { getDate } from "./shared" + +export class ListingDefaultOpenSoonSeed extends ListingDefaultSeed { + async seed() { + const listing = await super.seed() + return await this.listingRepository.save({ + ...listing, + name: "Test: Default, Open Soon", + applicationOpenDate: getDate(30), + applicationDueDate: getDate(60), + }) + } +} diff --git a/backend/core/src/seeder/seeds/listings/listing-default-reserved-seed.ts b/backend/core/src/seeder/seeds/listings/listing-default-reserved-seed.ts new file mode 100644 index 0000000000..b3ae6c20aa --- /dev/null +++ b/backend/core/src/seeder/seeds/listings/listing-default-reserved-seed.ts @@ -0,0 +1,16 @@ +import { ListingDefaultSeed } from "./listing-default-seed" + +export class ListingDefaultReservedSeed extends ListingDefaultSeed { + async seed() { + const listing = await super.seed() + + const reservedType = await this.reservedTypeRepository.findOneOrFail({ name: "senior62" }) + + return await this.listingRepository.save({ + ...listing, + name: "Test: Default, Reserved", + reservedCommunityDescription: "Custom reserved community type description", + reservedCommunityType: reservedType, + }) + } +} diff --git a/backend/core/src/seeder/seeds/listings/listing-default-sanjose-seed.ts b/backend/core/src/seeder/seeds/listings/listing-default-sanjose-seed.ts new file mode 100644 index 0000000000..213bd33700 --- /dev/null +++ b/backend/core/src/seeder/seeds/listings/listing-default-sanjose-seed.ts @@ -0,0 +1,113 @@ +import { InjectRepository } from "@nestjs/typeorm" +import { BaseEntity, DeepPartial, Repository } from "typeorm" + +import { + getDefaultAssets, + getDefaultListing, + getDefaultListingEvents, + getDefaultProperty, + getDefaultUnits, + getDisplaceePreference, + getLiveWorkPreference, + PriorityTypes, +} from "./shared" +import { Listing } from "../../../listings/entities/listing.entity" +import { UnitAccessibilityPriorityType } from "../../../unit-accessbility-priority-types/entities/unit-accessibility-priority-type.entity" +import { UnitType } from "../../../unit-types/entities/unit-type.entity" +import { ReservedCommunityType } from "../../../reserved-community-type/entities/reserved-community-type.entity" +import { AmiChart } from "../../../ami-charts/entities/ami-chart.entity" +import { Property } from "../../../property/entities/property.entity" +import { Unit } from "../../../units/entities/unit.entity" +import { User } from "../../../auth/entities/user.entity" +import { ApplicationMethod } from "../../../application-methods/entities/application-method.entity" +import { Jurisdiction } from "../../../jurisdictions/entities/jurisdiction.entity" +import { CountyCode } from "../../../shared/types/county-code" +import { UnitCreateDto } from "../../../units/dto/unit-create.dto" + +export class ListingDefaultSanJoseSeed { + constructor( + @InjectRepository(Listing) protected readonly listingRepository: Repository, + @InjectRepository(UnitAccessibilityPriorityType) + protected readonly unitAccessibilityPriorityTypeRepository: Repository< + UnitAccessibilityPriorityType + >, + @InjectRepository(UnitType) protected readonly unitTypeRepository: Repository, + @InjectRepository(ReservedCommunityType) + protected readonly reservedTypeRepository: Repository, + @InjectRepository(AmiChart) protected readonly amiChartRepository: Repository, + @InjectRepository(Property) protected readonly propertyRepository: Repository, + @InjectRepository(Unit) protected readonly unitsRepository: Repository, + @InjectRepository(User) protected readonly userRepository: Repository, + @InjectRepository(ApplicationMethod) + protected readonly applicationMethodRepository: Repository, + @InjectRepository(Jurisdiction) + protected readonly jurisdictionRepository: Repository + ) {} + + async seed() { + const priorityTypeMobilityAndHearing = await this.unitAccessibilityPriorityTypeRepository.findOneOrFail( + { name: PriorityTypes.mobilityHearing } + ) + const unitTypeOneBdrm = await this.unitTypeRepository.findOneOrFail({ name: "oneBdrm" }) + const unitTypeTwoBdrm = await this.unitTypeRepository.findOneOrFail({ name: "twoBdrm" }) + const alamedaJurisdiction = await this.jurisdictionRepository.findOneOrFail({ + name: CountyCode.alameda, + }) + const amiChart = await this.amiChartRepository.findOneOrFail({ + name: "AlamedaCountyTCAC2021", + jurisdiction: alamedaJurisdiction, + }) + + const property = await this.propertyRepository.save({ + ...getDefaultProperty(), + }) + + const unitsToBeCreated: Array> = getDefaultUnits().map( + (unit) => { + return { + ...unit, + property: { + id: property.id, + }, + amiChart, + } + } + ) + + unitsToBeCreated[0].priorityType = priorityTypeMobilityAndHearing + unitsToBeCreated[1].priorityType = priorityTypeMobilityAndHearing + unitsToBeCreated[0].unitType = unitTypeOneBdrm + unitsToBeCreated[1].unitType = unitTypeTwoBdrm + const newUnits = await this.unitsRepository.save(unitsToBeCreated) + + const listingCreateDto: Omit< + DeepPartial, + keyof BaseEntity | "urlSlug" | "showWaitlist" + > = { + ...getDefaultListing(), + amiChartOverrides: [ + { + unit: { id: newUnits[0].id }, + items: [ + { + percentOfAmi: 80, + householdSize: 1, + income: 777777, + }, + ], + }, + ], + name: "Test: Default, Two Preferences (San Jose)", + property: property, + assets: getDefaultAssets(), + preferences: [ + getLiveWorkPreference(alamedaJurisdiction.name), + { ...getDisplaceePreference(alamedaJurisdiction.name), ordinal: 2 }, + ], + events: getDefaultListingEvents(), + jurisdictionName: "San Jose", + } + + return await this.listingRepository.save(listingCreateDto) + } +} diff --git a/backend/core/src/seeder/seeds/listings/listing-default-seed.ts b/backend/core/src/seeder/seeds/listings/listing-default-seed.ts new file mode 100644 index 0000000000..e1503667f1 --- /dev/null +++ b/backend/core/src/seeder/seeds/listings/listing-default-seed.ts @@ -0,0 +1,185 @@ +import { InjectRepository } from "@nestjs/typeorm" +import { BaseEntity, DeepPartial, Repository } from "typeorm" + +import { + getDefaultAssets, + getDefaultListing, + getDefaultListingEvents, + getDefaultProperty, + getDefaultUnits, + getDisabilityOrMentalIllnessProgram, + getDisplaceePreference, + getHousingSituationProgram, + getLiveWorkPreference, + getServedInMilitaryProgram, + getTayProgram, + PriorityTypes, + getFlatRentAndRentBasedOnIncomeProgram, +} from "./shared" +import { Listing } from "../../../listings/entities/listing.entity" +import { UnitAccessibilityPriorityType } from "../../../unit-accessbility-priority-types/entities/unit-accessibility-priority-type.entity" +import { UnitType } from "../../../unit-types/entities/unit-type.entity" +import { ReservedCommunityType } from "../../../reserved-community-type/entities/reserved-community-type.entity" +import { AmiChart } from "../../../ami-charts/entities/ami-chart.entity" +import { Property } from "../../../property/entities/property.entity" +import { Unit } from "../../../units/entities/unit.entity" +import { UnitGroup } from "../../../units-summary/entities/unit-group.entity" +import { User } from "../../../auth/entities/user.entity" +import { ApplicationMethod } from "../../../application-methods/entities/application-method.entity" +import { Jurisdiction } from "../../../jurisdictions/entities/jurisdiction.entity" +import { Preference } from "../../../preferences/entities/preference.entity" +import { Program } from "../../../program/entities/program.entity" +import { CountyCode } from "../../../shared/types/county-code" +import { UnitCreateDto } from "../../../units/dto/unit-create.dto" +import { Asset } from "../../../assets/entities/asset.entity" +import { UnitGroupAmiLevel } from "../../../units-summary/entities/unit-group-ami-level.entity" + +export class ListingDefaultSeed { + constructor( + @InjectRepository(Listing) protected readonly listingRepository: Repository, + @InjectRepository(UnitAccessibilityPriorityType) + protected readonly unitAccessibilityPriorityTypeRepository: Repository< + UnitAccessibilityPriorityType + >, + @InjectRepository(UnitType) protected readonly unitTypeRepository: Repository, + @InjectRepository(ReservedCommunityType) + protected readonly reservedTypeRepository: Repository, + @InjectRepository(AmiChart) protected readonly amiChartRepository: Repository, + @InjectRepository(Property) protected readonly propertyRepository: Repository, + @InjectRepository(Unit) protected readonly unitsRepository: Repository, + @InjectRepository(UnitGroup) + protected readonly unitGroupRepository: Repository, + @InjectRepository(UnitGroup) + protected readonly unitGroupAmiLevelRepository: Repository, + @InjectRepository(User) protected readonly userRepository: Repository, + @InjectRepository(ApplicationMethod) + protected readonly applicationMethodRepository: Repository, + @InjectRepository(Jurisdiction) + protected readonly jurisdictionRepository: Repository, + @InjectRepository(Preference) + protected readonly preferencesRepository: Repository, + @InjectRepository(Program) + protected readonly programsRepository: Repository, + @InjectRepository(Asset) protected readonly assetsRepository: Repository + ) {} + + async seed() { + const priorityTypeMobilityAndHearing = await this.unitAccessibilityPriorityTypeRepository.findOneOrFail( + { name: PriorityTypes.mobilityHearing } + ) + const unitTypeOneBdrm = await this.unitTypeRepository.findOneOrFail({ name: "oneBdrm" }) + const unitTypeTwoBdrm = await this.unitTypeRepository.findOneOrFail({ name: "twoBdrm" }) + const alamedaJurisdiction = await this.jurisdictionRepository.findOneOrFail({ + name: CountyCode.alameda, + }) + const amiChart = await this.amiChartRepository.findOneOrFail({ + name: "AlamedaCountyTCAC2021", + jurisdiction: alamedaJurisdiction, + }) + + const property = await this.propertyRepository.save({ + ...getDefaultProperty(), + }) + + const unitsToBeCreated: Array> = getDefaultUnits().map( + (unit) => { + return { + ...unit, + property: { + id: property.id, + }, + amiChart, + } + } + ) + + unitsToBeCreated[0].priorityType = priorityTypeMobilityAndHearing + unitsToBeCreated[1].priorityType = priorityTypeMobilityAndHearing + unitsToBeCreated[0].unitType = unitTypeOneBdrm + unitsToBeCreated[1].unitType = unitTypeTwoBdrm + const newUnits = await this.unitsRepository.save(unitsToBeCreated) + + const defaultImage = await this.assetsRepository.save(getDefaultAssets()[0]) + + const listingCreateDto: Omit< + DeepPartial, + keyof BaseEntity | "urlSlug" | "showWaitlist" + > = { + ...getDefaultListing(), + amiChartOverrides: [ + { + unit: { id: newUnits[0].id }, + items: [ + { + percentOfAmi: 80, + householdSize: 1, + income: 777777, + }, + ], + }, + ], + name: "Test: Default, Two Preferences", + property: property, + assets: getDefaultAssets(), + listingPreferences: [ + { + preference: await this.preferencesRepository.findOneOrFail({ + title: getLiveWorkPreference(alamedaJurisdiction.name).title, + }), + ordinal: 1, + page: 1, + }, + { + preference: await this.preferencesRepository.findOneOrFail({ + title: getDisplaceePreference(alamedaJurisdiction.name).title, + }), + ordinal: 2, + page: 1, + }, + ], + events: getDefaultListingEvents(), + listingPrograms: [ + { + program: await this.programsRepository.findOneOrFail({ + title: getServedInMilitaryProgram().title, + }), + ordinal: 1, + }, + { + program: await this.programsRepository.findOneOrFail({ + title: getTayProgram().title, + }), + ordinal: 2, + }, + { + program: await this.programsRepository.findOneOrFail({ + title: getDisabilityOrMentalIllnessProgram().title, + }), + ordinal: 3, + }, + { + program: await this.programsRepository.findOneOrFail({ + title: getHousingSituationProgram().title, + }), + ordinal: 4, + }, + { + program: await this.programsRepository.findOneOrFail({ + title: getFlatRentAndRentBasedOnIncomeProgram().title, + }), + ordinal: 5, + }, + ], + images: [ + { + image: defaultImage, + ordinal: 1, + }, + ], + jurisdictionName: "Alameda", + jurisdiction: alamedaJurisdiction, + } + + return await this.listingRepository.save(listingCreateDto) + } +} diff --git a/backend/core/src/seeder/seeds/listings/listing-default-summary-with-10-listing-with-30-ami-percentage-seed.ts b/backend/core/src/seeder/seeds/listings/listing-default-summary-with-10-listing-with-30-ami-percentage-seed.ts new file mode 100644 index 0000000000..293c4ff09c --- /dev/null +++ b/backend/core/src/seeder/seeds/listings/listing-default-summary-with-10-listing-with-30-ami-percentage-seed.ts @@ -0,0 +1,38 @@ +import { ListingDefaultSeed } from "./listing-default-seed" +import { UnitGroup } from "../../../units-summary/entities/unit-group.entity" +import { DeepPartial } from "typeorm" +import { MonthlyRentDeterminationType } from "../../../units-summary/types/monthly-rent-determination.enum" + +export class ListingDefaultSummaryWith10ListingWith30AmiPercentageSeed extends ListingDefaultSeed { + async seed() { + const listing = await super.seed() + + const newListing = await this.listingRepository.save({ + ...listing, + name: "Test: Default, Summary With 10 Listing With 30 Ami Percentage", + amiPercentageMax: 30, + }) + + const unitTypeTwoBdrm = await this.unitTypeRepository.findOneOrFail({ name: "twoBdrm" }) + + const unitGroupToBeCreated: Array> = [] + + const twoBdrm30AmiUnitGroup: DeepPartial = { + unitType: [unitTypeTwoBdrm], + totalCount: 8, + listing: listing, + amiLevels: [ + { + amiPercentage: 10, + monthlyRentDeterminationType: MonthlyRentDeterminationType.flatRent, + flatRentValue: 1000, + }, + ], + } + unitGroupToBeCreated.push(twoBdrm30AmiUnitGroup) + + await this.unitGroupRepository.save(unitGroupToBeCreated) + + return newListing + } +} diff --git a/backend/core/src/seeder/seeds/listings/listing-default-summary-with-30-and-60-ami-percentage-seed.ts b/backend/core/src/seeder/seeds/listings/listing-default-summary-with-30-and-60-ami-percentage-seed.ts new file mode 100644 index 0000000000..14354bce10 --- /dev/null +++ b/backend/core/src/seeder/seeds/listings/listing-default-summary-with-30-and-60-ami-percentage-seed.ts @@ -0,0 +1,50 @@ +import { ListingDefaultSeed } from "./listing-default-seed" +import { DeepPartial } from "typeorm" +import { UnitGroup } from "../../../units-summary/entities/unit-group.entity" +import { MonthlyRentDeterminationType } from "../../../units-summary/types/monthly-rent-determination.enum" + +export class ListingDefaultSummaryWith30And60AmiPercentageSeed extends ListingDefaultSeed { + async seed() { + const listing = await super.seed() + + const newListing = await this.listingRepository.save({ + ...listing, + name: "Test: Default, Summary With 30 and 60 Ami Percentage", + }) + + const unitTypeTwoBdrm = await this.unitTypeRepository.findOneOrFail({ name: "twoBdrm" }) + + const unitGroupToBeCreated: Array> = [] + + const twoBdrm30AmiUnitGroup: DeepPartial = { + unitType: [unitTypeTwoBdrm], + totalCount: 8, + amiLevels: [ + { + amiPercentage: 30, + monthlyRentDeterminationType: MonthlyRentDeterminationType.flatRent, + flatRentValue: 1000, + }, + ], + } + unitGroupToBeCreated.push(twoBdrm30AmiUnitGroup) + + const twoBdrm60AmiUnitGroup: DeepPartial = { + unitType: [unitTypeTwoBdrm], + totalCount: 8, + listing: listing, + amiLevels: [ + { + amiPercentage: 60, + monthlyRentDeterminationType: MonthlyRentDeterminationType.flatRent, + flatRentValue: 1000, + }, + ], + } + unitGroupToBeCreated.push(twoBdrm60AmiUnitGroup) + + await this.unitGroupRepository.save(unitGroupToBeCreated) + + return newListing + } +} diff --git a/backend/core/src/seeder/seeds/listings/listing-default-summary-with-30-listing-with-10-ami-percentage-seed.ts b/backend/core/src/seeder/seeds/listings/listing-default-summary-with-30-listing-with-10-ami-percentage-seed.ts new file mode 100644 index 0000000000..487314dfb8 --- /dev/null +++ b/backend/core/src/seeder/seeds/listings/listing-default-summary-with-30-listing-with-10-ami-percentage-seed.ts @@ -0,0 +1,38 @@ +import { ListingDefaultSeed } from "./listing-default-seed" +import { DeepPartial } from "typeorm" +import { UnitGroup } from "../../../units-summary/entities/unit-group.entity" +import { MonthlyRentDeterminationType } from "../../../units-summary/types/monthly-rent-determination.enum" + +export class ListingDefaultSummaryWith30ListingWith10AmiPercentageSeed extends ListingDefaultSeed { + async seed() { + const listing = await super.seed() + + const newListing = await this.listingRepository.save({ + ...listing, + name: "Test: Default, Summary With 30 Listing With 10 Ami Percentage", + amiPercentageMax: 10, + }) + + const unitTypeTwoBdrm = await this.unitTypeRepository.findOneOrFail({ name: "twoBdrm" }) + + const unitGroupToBeCreated: Array> = [] + + const twoBdrm30AmiUnitGroup: DeepPartial = { + unitType: [unitTypeTwoBdrm], + totalCount: 8, + listing: listing, + amiLevels: [ + { + amiPercentage: 30, + monthlyRentDeterminationType: MonthlyRentDeterminationType.flatRent, + flatRentValue: 1000, + }, + ], + } + unitGroupToBeCreated.push(twoBdrm30AmiUnitGroup) + + await this.unitGroupRepository.save(unitGroupToBeCreated) + + return newListing + } +} diff --git a/backend/core/src/seeder/seeds/listings/listing-default-summary-without-and-listing-with-20-ami-percentage-seed.ts b/backend/core/src/seeder/seeds/listings/listing-default-summary-without-and-listing-with-20-ami-percentage-seed.ts new file mode 100644 index 0000000000..76af32bf43 --- /dev/null +++ b/backend/core/src/seeder/seeds/listings/listing-default-summary-without-and-listing-with-20-ami-percentage-seed.ts @@ -0,0 +1,37 @@ +import { ListingDefaultSeed } from "./listing-default-seed" +import { DeepPartial } from "typeorm" +import { UnitGroup } from "../../../units-summary/entities/unit-group.entity" + +export class ListingDefaultSummaryWithoutAndListingWith20AmiPercentageSeed extends ListingDefaultSeed { + async seed() { + const listing = await super.seed() + + const newListing = await this.listingRepository.save({ + ...listing, + name: "Test: Default, Summary Without And Listing With 20 Ami Percentage", + amiPercentageMax: 20, + }) + + const unitTypeTwoBdrm = await this.unitTypeRepository.findOneOrFail({ name: "twoBdrm" }) + + const unitGroupToBeCreated: Array> = [] + + const twoBdrm30AmiUnitGroup: DeepPartial = { + unitType: [unitTypeTwoBdrm], + totalCount: 8, + listing: listing, + } + unitGroupToBeCreated.push(twoBdrm30AmiUnitGroup) + + const twoBdrm60AmiUnitGroup: DeepPartial = { + unitType: [unitTypeTwoBdrm], + totalCount: 8, + listing: listing, + } + unitGroupToBeCreated.push(twoBdrm60AmiUnitGroup) + + await this.unitGroupRepository.save(unitGroupToBeCreated) + + return newListing + } +} diff --git a/backend/core/src/seeder/seeds/listings/listing-detroit-10136.ts b/backend/core/src/seeder/seeds/listings/listing-detroit-10136.ts new file mode 100644 index 0000000000..30702d65fe --- /dev/null +++ b/backend/core/src/seeder/seeds/listings/listing-detroit-10136.ts @@ -0,0 +1,343 @@ +import { AssetDtoSeedType, ListingSeedType, PropertySeedType } from "./listings" +import { ListingStatus } from "../../../listings/types/listing-status-enum" +import { CountyCode } from "../../../shared/types/county-code" +import { ListingDefaultSeed } from "./listing-default-seed" +import { BaseEntity, DeepPartial } from "typeorm" +import { Listing } from "../../../listings/entities/listing.entity" +import { UnitGroup } from "../../../units-summary/entities/unit-group.entity" +import { UnitGroupAmiLevel } from "../../../units-summary/entities/unit-group-ami-level.entity" +import { MonthlyRentDeterminationType } from "../../../units-summary/types/monthly-rent-determination.enum" +import { ListingMarketingTypeEnum } from "../../../listings/types/listing-marketing-type-enum" + +const propertySeed: PropertySeedType = { + buildingAddress: { + city: "Detroit", + state: "MI", + street: "1854 Lafayette", + zipCode: "48207", + latitude: 42.339165, + longitude: -83.030315, + }, + buildingTotalUnits: 312, + neighborhood: "Elmwood Park", +} + +const listingSeed: ListingSeedType = { + amiPercentageMax: 60, + amiPercentageMin: 30, + applicationDropOffAddress: null, + applicationMailingAddress: null, + countyCode: CountyCode.detroit, + disableUnitsAccordion: true, + displayWaitlistSize: false, + hrdId: "HRD10136", + leasingAgentName: "James Harrigan", + leasingAgentPhone: "810-750-7000", + managementCompany: "Independent Management Service", + managementWebsite: "https://www.imsproperties.net/michigan", + name: "Martin Luther King II", + status: ListingStatus.pending, + images: [], + digitalApplication: undefined, + paperApplication: undefined, + referralOpportunity: undefined, + depositMin: undefined, + depositMax: undefined, + leasingAgentEmail: undefined, + rentalAssistance: undefined, + reviewOrderType: undefined, + isWaitlistOpen: undefined, + features: { + elevator: false, + wheelchairRamp: false, + serviceAnimalsAllowed: false, + accessibleParking: false, + parkingOnSite: false, + inUnitWasherDryer: false, + laundryInBuilding: false, + barrierFreeEntrance: false, + rollInShower: false, + grabBars: false, + heatingInUnit: false, + acInUnit: false, + }, + listingPreferences: [], + jurisdictionName: "Detroit", + marketingType: ListingMarketingTypeEnum.Marketing, +} + +export class Listing10136Seed extends ListingDefaultSeed { + async seed() { + const unitTypeStudio = await this.unitTypeRepository.findOneOrFail({ name: "studio" }) + const unitTypeOneBdrm = await this.unitTypeRepository.findOneOrFail({ name: "oneBdrm" }) + const unitTypeTwoBdrm = await this.unitTypeRepository.findOneOrFail({ name: "twoBdrm" }) + const unitTypeThreeBdrm = await this.unitTypeRepository.findOneOrFail({ name: "threeBdrm" }) + const unitTypeFourBdrm = await this.unitTypeRepository.findOneOrFail({ name: "fourBdrm" }) + + const property = await this.propertyRepository.save({ + ...propertySeed, + }) + + const assets: Array = [] + + const listingCreateDto: Omit< + DeepPartial, + keyof BaseEntity | "urlSlug" | "showWaitlist" + > = { + ...listingSeed, + applicationMethods: [], + assets: assets, + events: [], + property: property, + } + + const listing = await this.listingRepository.save(listingCreateDto) + + const detroitJurisdiction = await this.jurisdictionRepository.findOneOrFail({ + name: CountyCode.detroit, + }) + + const unitGroups: Omit[] = [ + { + amiLevels: [], + unitType: [unitTypeStudio, unitTypeOneBdrm], + floorMin: 1, + floorMax: 5, + minOccupancy: 1, + maxOccupancy: 2, + bathroomMin: 1, + bathroomMax: 1, + sqFeetMin: 500, + sqFeetMax: 550, + openWaitlist: true, + listing, + totalAvailable: 2, + }, + { + amiLevels: [], + unitType: [unitTypeOneBdrm], + floorMin: 1, + floorMax: 5, + minOccupancy: 1, + maxOccupancy: 3, + bathroomMin: 1, + bathroomMax: 1, + sqFeetMin: 600, + sqFeetMax: 600, + openWaitlist: true, + listing, + }, + { + amiLevels: [], + unitType: [unitTypeThreeBdrm], + floorMin: 1, + floorMax: 5, + minOccupancy: 1, + maxOccupancy: 3, + bathroomMin: 1, + bathroomMax: 1, + sqFeetMin: 600, + sqFeetMax: 600, + openWaitlist: false, + listing, + }, + { + amiLevels: [], + unitType: [unitTypeFourBdrm], + floorMin: 1, + floorMax: 5, + minOccupancy: 1, + maxOccupancy: 3, + bathroomMin: 1, + bathroomMax: 1, + sqFeetMin: 600, + sqFeetMax: 600, + openWaitlist: true, + listing, + }, + { + amiLevels: [], + unitType: [unitTypeTwoBdrm], + floorMin: 1, + floorMax: 5, + minOccupancy: 2, + maxOccupancy: 6, + bathroomMin: 1, + bathroomMax: 1, + sqFeetMin: 600, + sqFeetMax: 600, + openWaitlist: true, + listing, + }, + { + amiLevels: [], + unitType: [unitTypeTwoBdrm], + floorMin: 1, + floorMax: 5, + minOccupancy: 2, + maxOccupancy: null, + bathroomMin: 1, + bathroomMax: 1, + sqFeetMin: 600, + sqFeetMax: 600, + openWaitlist: true, + listing, + }, + { + amiLevels: [], + unitType: [unitTypeTwoBdrm], + floorMin: 1, + floorMax: 5, + minOccupancy: null, + maxOccupancy: 2, + bathroomMin: 1, + bathroomMax: 1, + sqFeetMin: 600, + sqFeetMax: 600, + openWaitlist: true, + listing, + }, + { + amiLevels: [], + unitType: [unitTypeTwoBdrm], + floorMin: 1, + floorMax: 5, + minOccupancy: 1, + maxOccupancy: 1, + bathroomMin: 1, + bathroomMax: 1, + sqFeetMin: 600, + sqFeetMax: 600, + openWaitlist: true, + listing, + }, + { + amiLevels: [], + unitType: [unitTypeThreeBdrm], + floorMin: 1, + floorMax: 5, + minOccupancy: 3, + maxOccupancy: 3, + bathroomMin: 1, + bathroomMax: 1, + sqFeetMin: 600, + sqFeetMax: 600, + openWaitlist: true, + listing, + }, + { + amiLevels: [], + unitType: [unitTypeFourBdrm], + floorMin: 1, + floorMax: 5, + minOccupancy: null, + maxOccupancy: null, + bathroomMin: 1, + bathroomMax: 1, + sqFeetMin: 600, + sqFeetMax: 600, + openWaitlist: true, + listing, + }, + { + amiLevels: [], + unitType: [unitTypeTwoBdrm, unitTypeOneBdrm], + floorMin: 1, + floorMax: 5, + minOccupancy: 1, + maxOccupancy: 7, + bathroomMin: 1, + bathroomMax: 1, + sqFeetMin: 600, + sqFeetMax: 600, + openWaitlist: true, + listing, + }, + ] + + const savedUnitGroups = await this.unitGroupRepository.save(unitGroups) + + const MSHDA = await this.amiChartRepository.findOneOrFail({ + name: "MSHDA 2021", + jurisdiction: detroitJurisdiction, + }) + const HUD = await this.amiChartRepository.findOneOrFail({ + name: "HUD 2021", + jurisdiction: detroitJurisdiction, + }) + + await this.unitGroupRepository.save({ + ...savedUnitGroups[0], + amiLevels: [ + { + amiChart: MSHDA, + amiChartId: MSHDA.id, + amiPercentage: 30, + monthlyRentDeterminationType: MonthlyRentDeterminationType.flatRent, + flatRentValue: 2500, + unitGroup: savedUnitGroups[0], + }, + { + amiChart: HUD, + amiChartId: HUD.id, + amiPercentage: 40, + monthlyRentDeterminationType: MonthlyRentDeterminationType.percentageOfIncome, + percentageOfIncomeValue: 30, + unitGroup: savedUnitGroups[0], + }, + ], + }) + + await this.unitGroupRepository.save({ + ...savedUnitGroups[1], + amiLevels: [ + { + amiChart: MSHDA, + amiChartId: MSHDA.id, + amiPercentage: 30, + monthlyRentDeterminationType: MonthlyRentDeterminationType.flatRent, + flatRentValue: 2500, + unitGroup: savedUnitGroups[1], + }, + { + amiChart: MSHDA, + amiChartId: MSHDA.id, + amiPercentage: 40, + monthlyRentDeterminationType: MonthlyRentDeterminationType.percentageOfIncome, + percentageOfIncomeValue: 30, + unitGroup: savedUnitGroups[1], + }, + ], + }) + + await this.unitGroupRepository.save({ + ...savedUnitGroups[2], + amiLevels: [ + { + amiChart: MSHDA, + amiChartId: MSHDA.id, + amiPercentage: 55, + monthlyRentDeterminationType: MonthlyRentDeterminationType.flatRent, + flatRentValue: 1200, + unitGroup: savedUnitGroups[2], + }, + ], + }) + + await this.unitGroupRepository.save({ + ...savedUnitGroups[3], + amiLevels: [ + { + amiChart: MSHDA, + amiChartId: MSHDA.id, + amiPercentage: 55, + monthlyRentDeterminationType: MonthlyRentDeterminationType.percentageOfIncome, + percentageOfIncomeValue: 25, + unitGroup: savedUnitGroups[3], + }, + ], + }) + + return listing + } +} diff --git a/backend/core/src/seeder/seeds/listings/listing-detroit-10145.ts b/backend/core/src/seeder/seeds/listings/listing-detroit-10145.ts new file mode 100644 index 0000000000..9a9a74c239 --- /dev/null +++ b/backend/core/src/seeder/seeds/listings/listing-detroit-10145.ts @@ -0,0 +1,124 @@ +import { AssetDtoSeedType, ListingSeedType, PropertySeedType } from "./listings" +import { ListingStatus } from "../../../listings/types/listing-status-enum" +import { CountyCode } from "../../../shared/types/county-code" +import { ListingDefaultSeed } from "./listing-default-seed" +import { BaseEntity, DeepPartial } from "typeorm" +import { Listing } from "../../../listings/entities/listing.entity" +import { UnitGroup } from "../../../units-summary/entities/unit-group.entity" +import { ListingMarketingTypeEnum } from "../../../listings/types/listing-marketing-type-enum" + +const mcvProperty: PropertySeedType = { + buildingAddress: { + city: "Detroit", + state: "MI", + street: "4701 Chrysler Drive", + zipCode: "48201", + latitude: 42.35923, + longitude: -83.054134, + }, + buildingTotalUnits: 194, + neighborhood: "Forest Park", +} + +const mcvListing: ListingSeedType = { + amiPercentageMax: 60, + amiPercentageMin: null, + applicationDropOffAddress: null, + applicationMailingAddress: null, + countyCode: CountyCode.detroit, + disableUnitsAccordion: true, + displayWaitlistSize: false, + hrdId: "HRD10145", + leasingAgentName: "Janelle Henderson", + leasingAgentPhone: "313-831-1725", + managementCompany: "Associated Management Co", + managementWebsite: "https://associated-management.rentlinx.com/listings", + name: "Medical Center Village", + status: ListingStatus.active, + images: [], + digitalApplication: undefined, + paperApplication: undefined, + referralOpportunity: undefined, + depositMin: undefined, + depositMax: undefined, + leasingAgentEmail: undefined, + rentalAssistance: undefined, + reviewOrderType: undefined, + isWaitlistOpen: undefined, + features: { + elevator: true, + wheelchairRamp: true, + serviceAnimalsAllowed: false, + accessibleParking: false, + parkingOnSite: false, + inUnitWasherDryer: false, + laundryInBuilding: true, + barrierFreeEntrance: true, + rollInShower: false, + grabBars: false, + heatingInUnit: false, + acInUnit: true, + }, + listingPreferences: [], + jurisdictionName: "Detroit", + marketingType: ListingMarketingTypeEnum.Marketing, +} + +export class Listing10145Seed extends ListingDefaultSeed { + async seed() { + const unitTypeOneBdrm = await this.unitTypeRepository.findOneOrFail({ name: "oneBdrm" }) + const unitTypeTwoBdrm = await this.unitTypeRepository.findOneOrFail({ name: "twoBdrm" }) + const unitTypeThreeBdrm = await this.unitTypeRepository.findOneOrFail({ name: "threeBdrm" }) + + const property = await this.propertyRepository.save({ + ...mcvProperty, + }) + + const reservedType = await this.reservedTypeRepository.findOneOrFail({ name: "senior62" }) + + const assets: Array = [] + + const listingCreateDto: Omit< + DeepPartial, + keyof BaseEntity | "urlSlug" | "showWaitlist" + > = { + ...mcvListing, + applicationMethods: [], + assets: assets, + events: [], + property: property, + reservedCommunityType: reservedType, + // If a reservedCommunityType is specified, a reservedCommunityDescription MUST also be specified + reservedCommunityDescription: "", + } + + const listing = await this.listingRepository.save(listingCreateDto) + + const mcvUnitGroupToBeCreated: Array> = [] + + const oneBdrmUnitGroup: DeepPartial = { + unitType: [unitTypeOneBdrm], + totalCount: 28, + listing: listing, + } + mcvUnitGroupToBeCreated.push(oneBdrmUnitGroup) + + const twoBdrmUnitGroup: DeepPartial = { + unitType: [unitTypeTwoBdrm], + totalCount: 142, + listing: listing, + } + mcvUnitGroupToBeCreated.push(twoBdrmUnitGroup) + + const threeBdrmUnitGroup: DeepPartial = { + unitType: [unitTypeThreeBdrm], + totalCount: 24, + listing: listing, + } + mcvUnitGroupToBeCreated.push(threeBdrmUnitGroup) + + await this.unitGroupRepository.save(mcvUnitGroupToBeCreated) + + return listing + } +} diff --git a/backend/core/src/seeder/seeds/listings/listing-detroit-10147.ts b/backend/core/src/seeder/seeds/listings/listing-detroit-10147.ts new file mode 100644 index 0000000000..0c9641fe3d --- /dev/null +++ b/backend/core/src/seeder/seeds/listings/listing-detroit-10147.ts @@ -0,0 +1,107 @@ +import { AssetDtoSeedType, ListingSeedType, PropertySeedType } from "./listings" +import { ListingStatus } from "../../../listings/types/listing-status-enum" +import { CountyCode } from "../../../shared/types/county-code" +import { ListingDefaultSeed } from "./listing-default-seed" +import { BaseEntity, DeepPartial } from "typeorm" +import { Listing } from "../../../listings/entities/listing.entity" +import { UnitGroup } from "../../../units-summary/entities/unit-group.entity" +import { ListingMarketingTypeEnum } from "../../../listings/types/listing-marketing-type-enum" + +const mshProperty: PropertySeedType = { + buildingAddress: { + city: "Detroit", + state: "MI", + street: "7335 Melrose St", + zipCode: "48211", + latitude: 42.37442, + longitude: -83.06363, + }, + buildingTotalUnits: 24, + neighborhood: "North End", +} + +const mshListing: ListingSeedType = { + applicationDropOffAddress: null, + applicationMailingAddress: null, + countyCode: CountyCode.detroit, + disableUnitsAccordion: true, + displayWaitlistSize: false, + hrdId: "HRD10147", + leasingAgentName: "Kim Hagood", + leasingAgentPhone: "248-228-1340", + managementCompany: "Elite Property Management LLC", + managementWebsite: "https://www.elitep-m.com", + name: "Melrose Square Homes", + status: ListingStatus.active, + images: [], + digitalApplication: undefined, + paperApplication: undefined, + referralOpportunity: undefined, + depositMin: undefined, + depositMax: undefined, + leasingAgentEmail: undefined, + rentalAssistance: undefined, + reviewOrderType: undefined, + isWaitlistOpen: undefined, + features: { + elevator: false, + wheelchairRamp: true, + serviceAnimalsAllowed: true, + accessibleParking: true, + parkingOnSite: true, + inUnitWasherDryer: true, + laundryInBuilding: false, + barrierFreeEntrance: true, + rollInShower: false, + grabBars: false, + heatingInUnit: true, + acInUnit: true, + }, + listingPreferences: [], + jurisdictionName: "Detroit", + marketingType: ListingMarketingTypeEnum.Marketing, +} + +export class Listing10147Seed extends ListingDefaultSeed { + async seed() { + const unitTypeThreeBdrm = await this.unitTypeRepository.findOneOrFail({ name: "threeBdrm" }) + const unitTypeFourBdrm = await this.unitTypeRepository.findOneOrFail({ name: "fourBdrm" }) + + const property = await this.propertyRepository.save({ + ...mshProperty, + }) + + const listingCreateDto: Omit< + DeepPartial, + keyof BaseEntity | "urlSlug" | "showWaitlist" + > = { + ...mshListing, + applicationMethods: [], + assets: [], + events: [], + property: property, + } + + const listing = await this.listingRepository.save(listingCreateDto) + + const mshUnitGroupToBeCreated: Array> = [] + + const fourBdrmUnitGroup: DeepPartial = { + unitType: [unitTypeFourBdrm], + totalCount: 15, + listing: listing, + } + mshUnitGroupToBeCreated.push(fourBdrmUnitGroup) + + const threeBdrmUnitGroup: DeepPartial = { + unitType: [unitTypeThreeBdrm], + totalCount: 9, + listing: listing, + } + mshUnitGroupToBeCreated.push(threeBdrmUnitGroup) + + await this.unitGroupRepository.save(mshUnitGroupToBeCreated) + + return listing + } +} diff --git a/backend/core/src/seeder/seeds/listings/listing-detroit-10151.ts b/backend/core/src/seeder/seeds/listings/listing-detroit-10151.ts new file mode 100644 index 0000000000..2e2789c1a4 --- /dev/null +++ b/backend/core/src/seeder/seeds/listings/listing-detroit-10151.ts @@ -0,0 +1,116 @@ +import { AssetDtoSeedType, ListingSeedType, PropertySeedType } from "./listings" +import { ListingStatus } from "../../../listings/types/listing-status-enum" +import { CountyCode } from "../../../shared/types/county-code" +import { ListingDefaultSeed } from "./listing-default-seed" +import { BaseEntity, DeepPartial } from "typeorm" +import { Listing } from "../../../listings/entities/listing.entity" +import { UnitGroup } from "../../../units-summary/entities/unit-group.entity" +import { ListingMarketingTypeEnum } from "../../../listings/types/listing-marketing-type-enum" + +const propertySeed: PropertySeedType = { + buildingAddress: { + city: "Detroit", + state: "MI", + street: "2515 W Forest Ave", + zipCode: "48208", + latitude: 42.34547, + longitude: -83.08877, + }, + buildingTotalUnits: 45, + neighborhood: "Core City", +} + +const listingSeed: ListingSeedType = { + amiPercentageMax: 60, + amiPercentageMin: 30, + applicationDropOffAddress: null, + applicationMailingAddress: null, + countyCode: CountyCode.detroit, + disableUnitsAccordion: true, + displayWaitlistSize: false, + hrdId: "HRD10151", + leasingAgentName: "Natasha Gaston", + leasingAgentPhone: "313-926-8509", + managementCompany: "NRP Group", + managementWebsite: "https://www.nrpgroup.com/Home/Communities", + name: "MLK Homes", + status: ListingStatus.active, + images: [], + digitalApplication: undefined, + paperApplication: undefined, + referralOpportunity: undefined, + depositMin: undefined, + depositMax: undefined, + leasingAgentEmail: undefined, + rentalAssistance: undefined, + reviewOrderType: undefined, + isWaitlistOpen: undefined, + features: { + elevator: false, + wheelchairRamp: true, + serviceAnimalsAllowed: true, + accessibleParking: true, + parkingOnSite: true, + inUnitWasherDryer: true, + laundryInBuilding: false, + barrierFreeEntrance: true, + rollInShower: false, + grabBars: false, + heatingInUnit: true, + acInUnit: true, + }, + listingPreferences: [], + jurisdictionName: "Detroit", + marketingType: ListingMarketingTypeEnum.Marketing, +} + +export class Listing10151Seed extends ListingDefaultSeed { + async seed() { + const unitTypeThreeBdrm = await this.unitTypeRepository.findOneOrFail({ name: "threeBdrm" }) + const unitTypeFourBdrm = await this.unitTypeRepository.findOneOrFail({ name: "fourBdrm" }) + + const property = await this.propertyRepository.save({ + ...propertySeed, + }) + + const reservedType = await this.reservedTypeRepository.findOneOrFail({ name: "specialNeeds" }) + + const assets: Array = [] + + const listingCreateDto: Omit< + DeepPartial, + keyof BaseEntity | "urlSlug" | "showWaitlist" + > = { + ...listingSeed, + applicationMethods: [], + assets: assets, + events: [], + property: property, + reservedCommunityType: reservedType, + // If a reservedCommunityType is specified, a reservedCommunityDescription MUST also be specified + reservedCommunityDescription: "", + } + + const listing = await this.listingRepository.save(listingCreateDto) + + const unitGroupToBeCreated: DeepPartial[] = [] + + const threeBdrmUnitGroup: DeepPartial = { + unitType: [unitTypeThreeBdrm], + totalCount: 16, + listing: listing, + } + unitGroupToBeCreated.push(threeBdrmUnitGroup) + + const fourBdrmUnitGroup: DeepPartial = { + unitType: [unitTypeFourBdrm], + totalCount: 29, + listing: listing, + } + unitGroupToBeCreated.push(fourBdrmUnitGroup) + + await this.unitGroupRepository.save(unitGroupToBeCreated) + + return listing + } +} diff --git a/backend/core/src/seeder/seeds/listings/listing-detroit-10153.ts b/backend/core/src/seeder/seeds/listings/listing-detroit-10153.ts new file mode 100644 index 0000000000..b1f909e104 --- /dev/null +++ b/backend/core/src/seeder/seeds/listings/listing-detroit-10153.ts @@ -0,0 +1,99 @@ +import { AssetDtoSeedType, ListingSeedType, PropertySeedType } from "./listings" +import { ListingStatus } from "../../../listings/types/listing-status-enum" +import { CountyCode } from "../../../shared/types/county-code" +import { ListingDefaultSeed } from "./listing-default-seed" +import { BaseEntity, DeepPartial } from "typeorm" +import { Listing } from "../../../listings/entities/listing.entity" +import { UnitGroup } from "../../../units-summary/entities/unit-group.entity" +import { ListingMarketingTypeEnum } from "../../../listings/types/listing-marketing-type-enum" + +const propertySeed: PropertySeedType = { + buildingAddress: { + city: "Detroit", + state: "MI", + street: "12026 Morang", + zipCode: "48224", + latitude: 42.42673, + longitude: -82.95126, + }, + buildingTotalUnits: 40, + neighborhood: "Moross-Morang", +} + +const listingSeed: ListingSeedType = { + amiPercentageMax: 60, + amiPercentageMin: 30, + applicationDropOffAddress: null, + applicationMailingAddress: null, + countyCode: CountyCode.detroit, + disableUnitsAccordion: true, + displayWaitlistSize: false, + hrdId: "HRD10153", + leasingAgentPhone: "313-999-1268", + managementCompany: "Smiley Management", + name: "Morang Apartments", + status: ListingStatus.active, + images: [], + digitalApplication: undefined, + paperApplication: undefined, + referralOpportunity: undefined, + depositMin: undefined, + depositMax: undefined, + leasingAgentEmail: undefined, + leasingAgentName: undefined, + rentalAssistance: undefined, + reviewOrderType: undefined, + isWaitlistOpen: undefined, + features: { + elevator: true, + wheelchairRamp: true, + serviceAnimalsAllowed: true, + accessibleParking: true, + parkingOnSite: true, + inUnitWasherDryer: false, + laundryInBuilding: false, + barrierFreeEntrance: true, + rollInShower: true, + grabBars: true, + heatingInUnit: true, + acInUnit: true, + }, + listingPreferences: [], + jurisdictionName: "Detroit", + marketingType: ListingMarketingTypeEnum.Marketing, +} + +export class Listing10153Seed extends ListingDefaultSeed { + async seed() { + const unitTypeOneBdrm = await this.unitTypeRepository.findOneOrFail({ name: "oneBdrm" }) + + const property = await this.propertyRepository.save({ + ...propertySeed, + }) + + const assets: Array = [] + + const listingCreateDto: Omit< + DeepPartial, + keyof BaseEntity | "urlSlug" | "showWaitlist" + > = { + ...listingSeed, + applicationMethods: [], + assets: assets, + events: [], + property: property, + } + + const listing = await this.listingRepository.save(listingCreateDto) + + const oneBdrmUnitGroup: DeepPartial = { + unitType: [unitTypeOneBdrm], + totalCount: 40, + listing: listing, + } + + await this.unitGroupRepository.save([oneBdrmUnitGroup]) + + return listing + } +} diff --git a/backend/core/src/seeder/seeds/listings/listing-detroit-10154.ts b/backend/core/src/seeder/seeds/listings/listing-detroit-10154.ts new file mode 100644 index 0000000000..753de6947d --- /dev/null +++ b/backend/core/src/seeder/seeds/listings/listing-detroit-10154.ts @@ -0,0 +1,119 @@ +import { AssetDtoSeedType, ListingSeedType, PropertySeedType } from "./listings" +import { ListingStatus } from "../../../listings/types/listing-status-enum" +import { CountyCode } from "../../../shared/types/county-code" +import { ListingDefaultSeed } from "./listing-default-seed" +import { BaseEntity, DeepPartial } from "typeorm" +import { Listing } from "../../../listings/entities/listing.entity" +import { UnitGroup } from "../../../units-summary/entities/unit-group.entity" +import { ListingMarketingTypeEnum } from "../../../listings/types/listing-marketing-type-enum" + +const propertySeed: PropertySeedType = { + buildingAddress: { + city: "Detroit", + state: "MI", + street: "4000-4100 Blocks Alter Rd & Wayburn St.", + zipCode: "48224", + latitude: 42.39175, + longitude: -82.95057, + }, + buildingTotalUnits: 64, + neighborhood: "Morningside", +} + +const listingSeed: ListingSeedType = { + amiPercentageMax: 50, + amiPercentageMin: 30, + applicationDropOffAddress: null, + applicationMailingAddress: null, + countyCode: CountyCode.detroit, + disableUnitsAccordion: true, + displayWaitlistSize: false, + hrdId: "HRD10154", + leasingAgentName: "Kristy Schornak", + leasingAgentPhone: "313-821-0469", + managementCompany: "Continental Management", + managementWebsite: "https://www.continentalmgt.com", + name: "Morningside Commons Multi", + status: ListingStatus.active, + images: [], + digitalApplication: undefined, + paperApplication: undefined, + referralOpportunity: undefined, + depositMin: undefined, + depositMax: undefined, + leasingAgentEmail: undefined, + rentalAssistance: undefined, + reviewOrderType: undefined, + isWaitlistOpen: undefined, + features: { + elevator: false, + wheelchairRamp: true, + serviceAnimalsAllowed: true, + accessibleParking: true, + parkingOnSite: true, + inUnitWasherDryer: true, + laundryInBuilding: false, + barrierFreeEntrance: true, + rollInShower: false, + grabBars: false, + heatingInUnit: true, + acInUnit: true, + }, + listingPreferences: [], + jurisdictionName: "Detroit", + marketingType: ListingMarketingTypeEnum.Marketing, +} + +export class Listing10154Seed extends ListingDefaultSeed { + async seed() { + const unitTypeTwoBdrm = await this.unitTypeRepository.findOneOrFail({ name: "twoBdrm" }) + const unitTypeThreeBdrm = await this.unitTypeRepository.findOneOrFail({ name: "threeBdrm" }) + const unitTypeFourBdrm = await this.unitTypeRepository.findOneOrFail({ name: "fourBdrm" }) + + const property = await this.propertyRepository.save({ + ...propertySeed, + }) + + const assets: Array = [] + + const listingCreateDto: Omit< + DeepPartial, + keyof BaseEntity | "urlSlug" | "showWaitlist" + > = { + ...listingSeed, + applicationMethods: [], + assets: assets, + events: [], + property: property, + } + + const listing = await this.listingRepository.save(listingCreateDto) + + const unitGroupToBeCreated: DeepPartial[] = [] + + const twoBdrmUnitGroup: DeepPartial = { + unitType: [unitTypeTwoBdrm], + totalCount: 8, + listing: listing, + } + unitGroupToBeCreated.push(twoBdrmUnitGroup) + + const threeBdrmUnitGroup: DeepPartial = { + unitType: [unitTypeThreeBdrm], + totalCount: 38, + listing: listing, + } + unitGroupToBeCreated.push(threeBdrmUnitGroup) + + const fourBdrmUnitGroup: DeepPartial = { + unitType: [unitTypeFourBdrm], + totalCount: 18, + listing: listing, + } + unitGroupToBeCreated.push(fourBdrmUnitGroup) + + await this.unitGroupRepository.save(unitGroupToBeCreated) + + return listing + } +} diff --git a/backend/core/src/seeder/seeds/listings/listing-detroit-10155.ts b/backend/core/src/seeder/seeds/listings/listing-detroit-10155.ts new file mode 100644 index 0000000000..faa508494c --- /dev/null +++ b/backend/core/src/seeder/seeds/listings/listing-detroit-10155.ts @@ -0,0 +1,98 @@ +import { AssetDtoSeedType, ListingSeedType, PropertySeedType } from "./listings" +import { ListingStatus } from "../../../listings/types/listing-status-enum" +import { CountyCode } from "../../../shared/types/county-code" +import { ListingDefaultSeed } from "./listing-default-seed" +import { BaseEntity, DeepPartial } from "typeorm" +import { Listing } from "../../../listings/entities/listing.entity" +import { UnitGroup } from "../../../units-summary/entities/unit-group.entity" +import { ListingMarketingTypeEnum } from "../../../listings/types/listing-marketing-type-enum" + +const propertySeed: PropertySeedType = { + buildingAddress: { + city: "Detroit", + state: "MI", + street: "20000 Dequindre St", + zipCode: "48234", + latitude: 42.44133, + longitude: -83.08308, + }, + buildingTotalUnits: 151, + neighborhood: "Nolan", +} + +const listingSeed: ListingSeedType = { + amiPercentageMax: 50, + amiPercentageMin: 30, + applicationDropOffAddress: null, + applicationMailingAddress: null, + countyCode: CountyCode.detroit, + disableUnitsAccordion: true, + displayWaitlistSize: false, + hrdId: "HRD10155", + leasingAgentName: "Ryan Beale", + leasingAgentPhone: "313-366-1616", + managementCompany: "Premier Property Management", + name: "Morton Manor", + status: ListingStatus.active, + images: [], + digitalApplication: undefined, + paperApplication: undefined, + referralOpportunity: undefined, + depositMin: undefined, + depositMax: undefined, + leasingAgentEmail: undefined, + rentalAssistance: undefined, + reviewOrderType: undefined, + isWaitlistOpen: undefined, + features: { + elevator: true, + wheelchairRamp: true, + serviceAnimalsAllowed: true, + accessibleParking: true, + parkingOnSite: true, + inUnitWasherDryer: false, + laundryInBuilding: false, + barrierFreeEntrance: true, + rollInShower: true, + grabBars: true, + heatingInUnit: true, + acInUnit: true, + }, + listingPreferences: [], + jurisdictionName: "Detroit", + marketingType: ListingMarketingTypeEnum.ComingSoon, +} + +export class Listing10155Seed extends ListingDefaultSeed { + async seed() { + const unitTypeOneBdrm = await this.unitTypeRepository.findOneOrFail({ name: "oneBdrm" }) + + const property = await this.propertyRepository.save({ + ...propertySeed, + }) + + const assets: Array = [] + + const listingCreateDto: Omit< + DeepPartial, + keyof BaseEntity | "urlSlug" | "showWaitlist" + > = { + ...listingSeed, + applicationMethods: [], + assets: assets, + events: [], + property: property, + } + + const listing = await this.listingRepository.save(listingCreateDto) + + const oneBdrmUnitsSummary: DeepPartial = { + unitType: [unitTypeOneBdrm], + totalCount: 150, + listing: listing, + } + await this.unitGroupRepository.save([oneBdrmUnitsSummary]) + + return listing + } +} diff --git a/backend/core/src/seeder/seeds/listings/listing-detroit-10157.ts b/backend/core/src/seeder/seeds/listings/listing-detroit-10157.ts new file mode 100644 index 0000000000..5b17adeb0b --- /dev/null +++ b/backend/core/src/seeder/seeds/listings/listing-detroit-10157.ts @@ -0,0 +1,153 @@ +import { AssetDtoSeedType, ListingSeedType, PropertySeedType } from "./listings" +import { ListingStatus } from "../../../listings/types/listing-status-enum" +import { CountyCode } from "../../../shared/types/county-code" +import { ListingDefaultSeed } from "./listing-default-seed" +import { BaseEntity, DeepPartial } from "typeorm" +import { Listing } from "../../../listings/entities/listing.entity" +import { UnitGroup } from "../../../units-summary/entities/unit-group.entity" +import { MonthlyRentDeterminationType } from "../../../units-summary/types/monthly-rent-determination.enum" +import { ListingMarketingTypeEnum } from "../../../listings/types/listing-marketing-type-enum" + +const nccProperty: PropertySeedType = { + // See http://rentlinx.kmgprestige.com/640-Delaware-Street-Detroit-MI-48202 + amenities: "Parking, Elevator in Building", + buildingAddress: { + city: "Detroit", + state: "MI", + street: "640 Delaware St", + zipCode: "48202", + latitude: 42.37273, + longitude: -83.07981, + }, + buildingTotalUnits: 71, + neighborhood: "New Center Commons", + petPolicy: "No Pets Allowed", + unitAmenities: "Air Conditioning, Dishwasher, Garbage Disposal, Range, Refrigerator", + unitsAvailable: 5, + yearBuilt: 1929, +} + +const nccListing: ListingSeedType = { + applicationDropOffAddress: null, + applicationFee: "25", + applicationMailingAddress: null, + countyCode: CountyCode.detroit, + costsNotIncluded: + "Water Included, Resident Pays Electricity, Resident Pays Gas, Resident Pays Heat(Heat is gas.)", + disableUnitsAccordion: true, + displayWaitlistSize: false, + hrdId: "HRD10157", + leasingAgentPhone: "313-873-1022", + managementCompany: "KMG Prestige", + managementWebsite: "https://www.kmgprestige.com/communities/", + name: "New Center Commons", + status: ListingStatus.active, + images: [], + digitalApplication: undefined, + paperApplication: undefined, + referralOpportunity: undefined, + depositMin: undefined, + depositMax: undefined, + leasingAgentEmail: undefined, + leasingAgentName: undefined, + rentalAssistance: undefined, + reviewOrderType: undefined, + isWaitlistOpen: undefined, + features: { + elevator: true, + wheelchairRamp: true, + serviceAnimalsAllowed: true, + accessibleParking: true, + parkingOnSite: true, + inUnitWasherDryer: false, + laundryInBuilding: false, + barrierFreeEntrance: true, + rollInShower: true, + grabBars: true, + heatingInUnit: true, + acInUnit: true, + }, + listingPreferences: [], + jurisdictionName: "Detroit", + marketingType: ListingMarketingTypeEnum.Marketing, +} + +export class Listing10157Seed extends ListingDefaultSeed { + async seed() { + const unitTypeStudio = await this.unitTypeRepository.findOneOrFail({ name: "studio" }) + const unitTypeOneBdrm = await this.unitTypeRepository.findOneOrFail({ name: "oneBdrm" }) + const unitTypeTwoBdrm = await this.unitTypeRepository.findOneOrFail({ name: "twoBdrm" }) + + const property = await this.propertyRepository.save({ + ...nccProperty, + }) + + const assets: Array = [] + + const listingCreateDto: Omit< + DeepPartial, + keyof BaseEntity | "urlSlug" | "showWaitlist" + > = { + ...nccListing, + applicationMethods: [], + assets: JSON.parse(JSON.stringify(assets)), + events: [], + property: property, + } + + const listing = await this.listingRepository.save(listingCreateDto) + + const nccUnitGroupToBeCreated: DeepPartial[] = [] + + const zeroBdrmUnitGroup: DeepPartial = { + unitType: [unitTypeStudio], + totalCount: 1, + amiLevels: [ + { + amiPercentage: 10, + monthlyRentDeterminationType: MonthlyRentDeterminationType.flatRent, + flatRentValue: 650, + }, + ], + listing: listing, + sqFeetMax: 550, + } + nccUnitGroupToBeCreated.push(zeroBdrmUnitGroup) + + const oneBdrmUnitGroup: DeepPartial = { + unitType: [unitTypeOneBdrm], + totalCount: 2, + amiLevels: [ + { + amiPercentage: 10, + monthlyRentDeterminationType: MonthlyRentDeterminationType.flatRent, + flatRentValue: 650, + }, + ], + listing: listing, + sqFeetMin: 800, + sqFeetMax: 1000, + } + nccUnitGroupToBeCreated.push(oneBdrmUnitGroup) + + const twoBdrmUnitGroup: DeepPartial = { + unitType: [unitTypeTwoBdrm], + totalCount: 2, + amiLevels: [ + { + amiPercentage: 10, + monthlyRentDeterminationType: MonthlyRentDeterminationType.flatRent, + flatRentValue: 750, + }, + ], + listing: listing, + sqFeetMin: 900, + sqFeetMax: 1100, + } + nccUnitGroupToBeCreated.push(twoBdrmUnitGroup) + + await this.unitGroupRepository.save(nccUnitGroupToBeCreated) + + return listing + } +} diff --git a/backend/core/src/seeder/seeds/listings/listing-detroit-10158.ts b/backend/core/src/seeder/seeds/listings/listing-detroit-10158.ts new file mode 100644 index 0000000000..0b839f7435 --- /dev/null +++ b/backend/core/src/seeder/seeds/listings/listing-detroit-10158.ts @@ -0,0 +1,113 @@ +import { AssetDtoSeedType, ListingSeedType, PropertySeedType } from "./listings" +import { ListingStatus } from "../../../listings/types/listing-status-enum" +import { CountyCode } from "../../../shared/types/county-code" +import { ListingDefaultSeed } from "./listing-default-seed" +import { BaseEntity, DeepPartial } from "typeorm" +import { Listing } from "../../../listings/entities/listing.entity" +import { UnitGroup } from "../../../units-summary/entities/unit-group.entity" +import { ListingMarketingTypeEnum } from "../../../listings/types/listing-marketing-type-enum" + +const ncpProperty: PropertySeedType = { + amenities: "Parking, Elevator in Building", + buildingAddress: { + city: "Detroit", + state: "MI", + street: "666 W Bethune St", + zipCode: "48202", + latitude: 42.37056, + longitude: -83.07968, + }, + buildingTotalUnits: 76, + neighborhood: "New Center Commons", + unitAmenities: "Air Conditioning (Wall unit), Garbage Disposal, Range, Refrigerator", + yearBuilt: 1971, +} + +const ncpListing: ListingSeedType = { + applicationDropOffAddress: null, + applicationMailingAddress: null, + countyCode: CountyCode.detroit, + costsNotIncluded: "Electricity Included Gas Included Water Included", + disableUnitsAccordion: true, + displayWaitlistSize: false, + hrdId: "HRD10158", + isWaitlistOpen: false, + leasingAgentPhone: "313-872-7717", + managementCompany: "KMG Prestige", + managementWebsite: "https://www.kmgprestige.com/communities/", + name: "New Center Pavilion", + status: ListingStatus.active, + images: [], + digitalApplication: undefined, + paperApplication: undefined, + referralOpportunity: undefined, + depositMin: undefined, + depositMax: undefined, + leasingAgentEmail: undefined, + leasingAgentName: undefined, + rentalAssistance: undefined, + reviewOrderType: undefined, + features: { + elevator: true, + wheelchairRamp: true, + serviceAnimalsAllowed: true, + accessibleParking: true, + parkingOnSite: true, + inUnitWasherDryer: false, + laundryInBuilding: false, + barrierFreeEntrance: true, + rollInShower: true, + grabBars: true, + heatingInUnit: true, + acInUnit: true, + }, + listingPreferences: [], + jurisdictionName: "Detroit", + marketingType: ListingMarketingTypeEnum.Marketing, +} + +export class Listing10158Seed extends ListingDefaultSeed { + async seed() { + const unitTypeOneBdrm = await this.unitTypeRepository.findOneOrFail({ name: "oneBdrm" }) + const unitTypeTwoBdrm = await this.unitTypeRepository.findOneOrFail({ name: "twoBdrm" }) + + const property = await this.propertyRepository.save({ + ...ncpProperty, + }) + + const assets: Array = [] + + const listingCreateDto: Omit< + DeepPartial, + keyof BaseEntity | "urlSlug" | "showWaitlist" + > = { + ...ncpListing, + applicationMethods: [], + assets: assets, + events: [], + property: property, + } + + const listing = await this.listingRepository.save(listingCreateDto) + + const ncpUnitGroupToBeCreated: DeepPartial[] = [] + + const oneBdrmUnitGroup: DeepPartial = { + unitType: [unitTypeOneBdrm], + totalCount: 40, + listing: listing, + } + ncpUnitGroupToBeCreated.push(oneBdrmUnitGroup) + + const twoBdrmUnitGroup: DeepPartial = { + unitType: [unitTypeTwoBdrm], + totalCount: 36, + listing: listing, + } + ncpUnitGroupToBeCreated.push(twoBdrmUnitGroup) + + await this.unitGroupRepository.save(ncpUnitGroupToBeCreated) + + return listing + } +} diff --git a/backend/core/src/seeder/seeds/listings/listing-detroit-10159.ts b/backend/core/src/seeder/seeds/listings/listing-detroit-10159.ts new file mode 100644 index 0000000000..639e6fb795 --- /dev/null +++ b/backend/core/src/seeder/seeds/listings/listing-detroit-10159.ts @@ -0,0 +1,101 @@ +import { AssetDtoSeedType, ListingSeedType, PropertySeedType } from "./listings" +import { ListingStatus } from "../../../listings/types/listing-status-enum" +import { CountyCode } from "../../../shared/types/county-code" +import { ListingDefaultSeed } from "./listing-default-seed" +import { BaseEntity, DeepPartial } from "typeorm" +import { Listing } from "../../../listings/entities/listing.entity" +import { UnitGroup } from "../../../units-summary/entities/unit-group.entity" +import { ListingMarketingTypeEnum } from "../../../listings/types/listing-marketing-type-enum" + +const propertySeed: PropertySeedType = { + buildingAddress: { + city: "Detroit", + state: "MI", + street: "112 Seward Avenue", + zipCode: "48202", + latitude: 42.373219, + longitude: -83.079147, + }, + buildingTotalUnits: 49, + neighborhood: "New Center Commons", +} + +const listingSeed: ListingSeedType = { + amiPercentageMax: 60, + amiPercentageMin: 30, + applicationDropOffAddress: null, + applicationMailingAddress: null, + countyCode: CountyCode.detroit, + disableUnitsAccordion: true, + displayWaitlistSize: false, + isWaitlistOpen: true, + waitlistCurrentSize: 20, + waitlistMaxSize: 50, + hrdId: "HRD10159", + leasingAgentName: "Kim Hagood", + leasingAgentPhone: "313-656-4146", + managementCompany: "Elite Property Management LLC", + managementWebsite: "https://www.elitep-m.com", + name: "New Center Square", + status: ListingStatus.active, + images: [], + digitalApplication: undefined, + paperApplication: undefined, + referralOpportunity: undefined, + depositMin: undefined, + depositMax: undefined, + leasingAgentEmail: undefined, + rentalAssistance: undefined, + reviewOrderType: undefined, + features: { + elevator: false, + wheelchairRamp: true, + serviceAnimalsAllowed: true, + accessibleParking: true, + parkingOnSite: true, + inUnitWasherDryer: true, + laundryInBuilding: false, + barrierFreeEntrance: true, + rollInShower: false, + grabBars: false, + heatingInUnit: true, + acInUnit: true, + }, + listingPreferences: [], + jurisdictionName: "Detroit", + marketingType: ListingMarketingTypeEnum.Marketing, +} + +export class Listing10159Seed extends ListingDefaultSeed { + async seed() { + const unitTypeThreeBdrm = await this.unitTypeRepository.findOneOrFail({ name: "threeBdrm" }) + + const property = await this.propertyRepository.save({ + ...propertySeed, + }) + + const assets: Array = [] + + const listingCreateDto: Omit< + DeepPartial, + keyof BaseEntity | "urlSlug" | "showWaitlist" + > = { + ...listingSeed, + applicationMethods: [], + assets: JSON.parse(JSON.stringify(assets)), + events: [], + property: property, + } + + const listing = await this.listingRepository.save(listingCreateDto) + + const threeBdrmUnitGroup: DeepPartial = { + unitType: [unitTypeThreeBdrm], + totalCount: 49, + listing: listing, + } + await this.unitGroupRepository.save([threeBdrmUnitGroup]) + + return listing + } +} diff --git a/backend/core/src/seeder/seeds/listings/listing-detroit-10168.ts b/backend/core/src/seeder/seeds/listings/listing-detroit-10168.ts new file mode 100644 index 0000000000..28b7ed397f --- /dev/null +++ b/backend/core/src/seeder/seeds/listings/listing-detroit-10168.ts @@ -0,0 +1,114 @@ +import { AssetDtoSeedType, ListingSeedType, PropertySeedType } from "./listings" +import { ListingStatus } from "../../../listings/types/listing-status-enum" +import { CountyCode } from "../../../shared/types/county-code" +import { ApplicationMethod } from "../../../application-methods/entities/application-method.entity" +import { ApplicationMethodType } from "../../../application-methods/types/application-method-type-enum" +import { ListingDefaultSeed } from "./listing-default-seed" +import { BaseEntity, DeepPartial } from "typeorm" +import { Listing } from "../../../listings/entities/listing.entity" +import { UnitGroup } from "../../../units-summary/entities/unit-group.entity" +import { ListingMarketingTypeEnum } from "../../../listings/types/listing-marketing-type-enum" + +const propertySeed: PropertySeedType = { + buildingAddress: { + city: "Detroit", + state: "MI", + street: "4401 Burlingame St", + zipCode: "48204", + latitude: 42.37704, + longitude: -83.12847, + }, + buildingTotalUnits: 10, + neighborhood: "Nardin Park", + unitAmenities: + "Professional Management Team, Smoke-free building, Gated community, Entry control system, Community room, Nicely appointed lobby area, On-site laundry with fully accessible washers and dryers, Lovely patio area to relax, 24-hour emergency maintenance, Cable-ready", +} + +const listingSeed: ListingSeedType = { + amiPercentageMax: 30, + amiPercentageMin: 30, + applicationDropOffAddress: null, + applicationMailingAddress: null, + countyCode: CountyCode.detroit, + disableUnitsAccordion: true, + displayWaitlistSize: false, + hrdId: "HRD10168", + leasingAgentName: "Chris Garland", + leasingAgentPhone: "313-934-0010", + leasingAgentEmail: "OakVillageIndependenceHouse@voami.org", + managementCompany: "Detroit Voa Elderly Nonprofit Housing Corporation", + managementWebsite: "https://www.voa.org/housing_properties/oak-village-independence-house", + name: "Oak Village Independence", + status: ListingStatus.active, + images: [], + digitalApplication: undefined, + paperApplication: undefined, + referralOpportunity: undefined, + depositMin: undefined, + depositMax: undefined, + rentalAssistance: undefined, + reviewOrderType: undefined, + isWaitlistOpen: undefined, + features: { + elevator: true, + wheelchairRamp: true, + serviceAnimalsAllowed: true, + accessibleParking: true, + parkingOnSite: true, + inUnitWasherDryer: true, + laundryInBuilding: true, + barrierFreeEntrance: true, + rollInShower: true, + grabBars: true, + heatingInUnit: true, + acInUnit: true, + }, + listingPreferences: [], + jurisdictionName: "Detroit", + marketingType: ListingMarketingTypeEnum.Marketing, +} + +export class Listing10168Seed extends ListingDefaultSeed { + async seed() { + const applicationMethod: ApplicationMethod = await this.applicationMethodRepository.save({ + type: ApplicationMethodType.ExternalLink, + acceptsPostmarkedApplications: false, + externalReference: + "https://voa-production.s3.amazonaws.com/uploads/pdf_file/file/1118/Oak_Village_Independence_House_Resident_Selection_Guidelines.pdf", + }) + + const assets: Array = [] + + const unitTypeOneBdrm = await this.unitTypeRepository.findOneOrFail({ name: "oneBdrm" }) + + const property = await this.propertyRepository.save({ + ...propertySeed, + }) + const reservedType = await this.reservedTypeRepository.findOneOrFail({ name: "specialNeeds" }) + + const listingCreateDto: Omit< + DeepPartial, + keyof BaseEntity | "urlSlug" | "showWaitlist" + > = { + ...listingSeed, + applicationMethods: [applicationMethod], + assets: JSON.parse(JSON.stringify(assets)), + events: [], + property: property, + reservedCommunityType: reservedType, + // If a reservedCommunityType is specified, a reservedCommunityDescription MUST also be specified + reservedCommunityDescription: "Persons with Disabilities", + } + + const listing = await this.listingRepository.save(listingCreateDto) + + const oneBdrmUnitGroup: DeepPartial = { + unitType: [unitTypeOneBdrm], + totalCount: 10, + listing: listing, + } + await this.unitGroupRepository.save([oneBdrmUnitGroup]) + + return listing + } +} diff --git a/backend/core/src/seeder/seeds/listings/listing-detroit-10169.ts b/backend/core/src/seeder/seeds/listings/listing-detroit-10169.ts new file mode 100644 index 0000000000..a775602934 --- /dev/null +++ b/backend/core/src/seeder/seeds/listings/listing-detroit-10169.ts @@ -0,0 +1,156 @@ +import { AssetDtoSeedType, ListingSeedType, PropertySeedType } from "./listings" +import { ListingStatus } from "../../../listings/types/listing-status-enum" +import { CountyCode } from "../../../shared/types/county-code" +import { ListingDefaultSeed } from "./listing-default-seed" +import { BaseEntity, DeepPartial } from "typeorm" +import { Listing } from "../../../listings/entities/listing.entity" +import { getDate } from "./shared" +import { UnitGroup } from "../../../units-summary/entities/unit-group.entity" +import { MonthlyRentDeterminationType } from "../../../units-summary/types/monthly-rent-determination.enum" +import { ListingMarketingTypeEnum } from "../../../listings/types/listing-marketing-type-enum" + +const grandRivProperty: PropertySeedType = { + // See http://rentlinx.kmgprestige.com/640-Delaware-Street-Detroit-MI-48202 + amenities: "Parking, Elevator in Building", + buildingAddress: { + city: "Detroit", + state: "MI", + street: "28 W. Grand River", + zipCode: "48226", + latitude: 42.334007, + longitude: -83.04893, + }, + buildingTotalUnits: 175, + neighborhood: "Downtown", + petPolicy: "No Pets Allowed", + unitAmenities: "Air Conditioning, Dishwasher, Garbage Disposal, Range, Refrigerator", + unitsAvailable: 5, + yearBuilt: 1929, +} + +const grandRivListing: ListingSeedType = { + applicationDropOffAddress: null, + applicationOpenDate: getDate(1000), + applicationDueDate: getDate(1500), + applicationFee: "25", + applicationMailingAddress: null, + countyCode: CountyCode.detroit, + costsNotIncluded: + "Water Included, Resident Pays Electricity, Resident Pays Gas, Resident Pays Heat(Heat is gas.)", + disableUnitsAccordion: true, + displayWaitlistSize: false, + hrdId: "HRD10157", + leasingAgentPhone: "313-545-8720", + managementCompany: "Rock Management Company", + managementWebsite: "https://www.28granddetroit.com", + name: "Capitol Park Micro Units", + status: ListingStatus.active, + images: [], + digitalApplication: undefined, + paperApplication: undefined, + referralOpportunity: undefined, + depositMin: undefined, + depositMax: undefined, + leasingAgentEmail: undefined, + leasingAgentName: undefined, + rentalAssistance: undefined, + reviewOrderType: undefined, + isWaitlistOpen: undefined, + features: { + elevator: true, + wheelchairRamp: true, + serviceAnimalsAllowed: true, + accessibleParking: true, + parkingOnSite: true, + inUnitWasherDryer: false, + laundryInBuilding: false, + barrierFreeEntrance: true, + rollInShower: true, + grabBars: true, + heatingInUnit: true, + acInUnit: true, + }, + listingPreferences: [], + jurisdictionName: "Detroit", + marketingType: ListingMarketingTypeEnum.Marketing, +} + +export class Listing10157Seed extends ListingDefaultSeed { + async seed() { + const unitTypeStudio = await this.unitTypeRepository.findOneOrFail({ name: "studio" }) + const unitTypeOneBdrm = await this.unitTypeRepository.findOneOrFail({ name: "oneBdrm" }) + const unitTypeTwoBdrm = await this.unitTypeRepository.findOneOrFail({ name: "twoBdrm" }) + + const property = await this.propertyRepository.save({ + ...grandRivProperty, + }) + + const assets: Array = [] + + const listingCreateDto: Omit< + DeepPartial, + keyof BaseEntity | "urlSlug" | "showWaitlist" + > = { + ...grandRivListing, + applicationMethods: [], + assets: JSON.parse(JSON.stringify(assets)), + events: [], + property: property, + } + + const listing = await this.listingRepository.save(listingCreateDto) + + const nccUnitGroupToBeCreated: DeepPartial[] = [] + + const zeroBdrmUnitGroup: DeepPartial = { + unitType: [unitTypeStudio], + totalCount: 1, + amiLevels: [ + { + amiPercentage: 30, + monthlyRentDeterminationType: MonthlyRentDeterminationType.flatRent, + flatRentValue: 470, + }, + ], + listing: listing, + sqFeetMax: 550, + } + nccUnitGroupToBeCreated.push(zeroBdrmUnitGroup) + + const oneBdrmUnitGroup: DeepPartial = { + unitType: [unitTypeOneBdrm], + totalCount: 2, + amiLevels: [ + { + amiPercentage: 30, + monthlyRentDeterminationType: MonthlyRentDeterminationType.flatRent, + flatRentValue: 650, + }, + ], + listing: listing, + sqFeetMin: 800, + sqFeetMax: 1000, + } + nccUnitGroupToBeCreated.push(oneBdrmUnitGroup) + + const twoBdrmUnitGroup: DeepPartial = { + unitType: [unitTypeTwoBdrm], + totalCount: 2, + amiLevels: [ + { + amiPercentage: 30, + monthlyRentDeterminationType: MonthlyRentDeterminationType.flatRent, + flatRentValue: 750, + }, + ], + listing: listing, + sqFeetMin: 900, + sqFeetMax: 1100, + } + nccUnitGroupToBeCreated.push(twoBdrmUnitGroup) + + await this.unitGroupRepository.save(nccUnitGroupToBeCreated) + + return listing + } +} diff --git a/backend/core/src/seeder/seeds/listings/listing-detroit-10202.ts b/backend/core/src/seeder/seeds/listings/listing-detroit-10202.ts new file mode 100644 index 0000000000..d2482bf579 --- /dev/null +++ b/backend/core/src/seeder/seeds/listings/listing-detroit-10202.ts @@ -0,0 +1,112 @@ +import { AssetDtoSeedType, ListingSeedType, PropertySeedType } from "./listings" +import { ListingStatus } from "../../../listings/types/listing-status-enum" +import { CountyCode } from "../../../shared/types/county-code" +import { ListingDefaultSeed } from "./listing-default-seed" +import { BaseEntity, DeepPartial } from "typeorm" +import { Listing } from "../../../listings/entities/listing.entity" +import { UnitGroup } from "../../../units-summary/entities/unit-group.entity" +import { ListingMarketingTypeEnum } from "../../../listings/types/listing-marketing-type-enum" + +// +const mcvProperty: PropertySeedType = { + buildingAddress: { + city: "Detroit", + state: "MI", + street: "7800 E Jefferson Ave", + zipCode: "48214", + latitude: 42.35046, + longitude: -82.99615, + }, + buildingTotalUnits: 469, + neighborhood: "Gold Coast", +} + +const mcvListing: ListingSeedType = { + amiPercentageMax: 60, + amiPercentageMin: null, + applicationDropOffAddress: null, + applicationMailingAddress: null, + countyCode: CountyCode.detroit, + disableUnitsAccordion: true, + displayWaitlistSize: false, + hrdId: "HRD10202", + leasingAgentName: "Janelle Henderson", + leasingAgentPhone: "313-824-2244", + managementCompany: "Associated Management Co", + managementWebsite: "https://associated-management.rentlinx.com/listings", + name: "River Towers", + status: ListingStatus.pending, + images: [], + digitalApplication: undefined, + paperApplication: undefined, + referralOpportunity: undefined, + depositMin: undefined, + depositMax: undefined, + leasingAgentEmail: undefined, + rentalAssistance: undefined, + reviewOrderType: undefined, + isWaitlistOpen: undefined, + features: { + elevator: true, + wheelchairRamp: false, + serviceAnimalsAllowed: true, + accessibleParking: true, + parkingOnSite: true, + inUnitWasherDryer: true, + laundryInBuilding: false, + barrierFreeEntrance: true, + rollInShower: false, + grabBars: false, + heatingInUnit: true, + acInUnit: true, + }, + listingPreferences: [], + jurisdictionName: "Detroit", + marketingType: ListingMarketingTypeEnum.Marketing, +} + +export class Listing10202Seed extends ListingDefaultSeed { + async seed() { + const unitTypeOneBdrm = await this.unitTypeRepository.findOneOrFail({ name: "oneBdrm" }) + const unitTypeTwoBdrm = await this.unitTypeRepository.findOneOrFail({ name: "twoBdrm" }) + + const property = await this.propertyRepository.save({ + ...mcvProperty, + }) + + const assets: Array = [] + + const listingCreateDto: Omit< + DeepPartial, + keyof BaseEntity | "urlSlug" | "showWaitlist" + > = { + ...mcvListing, + applicationMethods: [], + assets: assets, + events: [], + property: property, + } + + const listing = await this.listingRepository.save(listingCreateDto) + + const mcvUnitGroupToBeCreated: DeepPartial[] = [] + + const oneBdrmUnitGroup: DeepPartial = { + unitType: [unitTypeOneBdrm], + totalCount: 376, + listing: listing, + } + mcvUnitGroupToBeCreated.push(oneBdrmUnitGroup) + + const twoBdrmUnitGroup: DeepPartial = { + unitType: [unitTypeTwoBdrm], + totalCount: 96, + listing: listing, + } + mcvUnitGroupToBeCreated.push(twoBdrmUnitGroup) + + await this.unitGroupRepository.save(mcvUnitGroupToBeCreated) + + return listing + } +} diff --git a/backend/core/src/seeder/seeds/listings/listing-detroit-treymore.ts b/backend/core/src/seeder/seeds/listings/listing-detroit-treymore.ts new file mode 100644 index 0000000000..4a6e097065 --- /dev/null +++ b/backend/core/src/seeder/seeds/listings/listing-detroit-treymore.ts @@ -0,0 +1,125 @@ +import { AssetDtoSeedType, ListingSeedType, PropertySeedType } from "./listings" +import { ListingStatus } from "../../../listings/types/listing-status-enum" +import { CountyCode } from "../../../shared/types/county-code" +import { ListingDefaultSeed } from "./listing-default-seed" +import { BaseEntity, DeepPartial } from "typeorm" +import { Listing } from "../../../listings/entities/listing.entity" +import { UnitGroup } from "../../../units-summary/entities/unit-group.entity" +import { MonthlyRentDeterminationType } from "../../../units-summary/types/monthly-rent-determination.enum" +import { ListingMarketingTypeEnum } from "../../../listings/types/listing-marketing-type-enum" + +const treymoreProperty: PropertySeedType = { + // See http://rentlinx.kmgprestige.com/457-Brainard-Street-Detroit-MI-48201 + amenities: "Parking, Elevator in Building, Community Room", + buildingAddress: { + city: "Detroit", + state: "MI", + street: "457 Brainard St", + zipCode: "48201", + latitude: 42.3461357, + longitude: -83.0645436, + }, + petPolicy: "No Pets Allowed", + unitAmenities: + "Air Conditioning (Central Air Conditioning), Garbage Disposal, Range, Refrigerator, Coin Laundry Room in building", + unitsAvailable: 4, + yearBuilt: 1916, + accessibility: "2 units are barrier free; 2 units are bi-level 1.5 bath", +} + +const treymoreListing: ListingSeedType = { + applicationDropOffAddress: null, + applicationMailingAddress: null, + countyCode: CountyCode.detroit, + costsNotIncluded: "Water Included Resident Pays Electricity Resident Pays Gas Resident Pays Heat", + disableUnitsAccordion: true, + displayWaitlistSize: false, + isWaitlistOpen: false, + leasingAgentPhone: "313-462-4123", + managementCompany: "KMG Prestige", + managementWebsite: "http://rentlinx.kmgprestige.com/Company.aspx?CompanyID=107", + name: "Treymore Apartments", + status: ListingStatus.pending, + images: [], + digitalApplication: undefined, + paperApplication: undefined, + referralOpportunity: undefined, + depositMin: undefined, + depositMax: undefined, + leasingAgentEmail: undefined, + leasingAgentName: undefined, + rentalAssistance: undefined, + reviewOrderType: undefined, + features: { + elevator: false, + wheelchairRamp: false, + serviceAnimalsAllowed: false, + accessibleParking: false, + parkingOnSite: false, + inUnitWasherDryer: false, + laundryInBuilding: false, + barrierFreeEntrance: false, + rollInShower: false, + grabBars: false, + heatingInUnit: false, + acInUnit: false, + }, + listingPreferences: [], + jurisdictionName: "Detroit", + marketingType: ListingMarketingTypeEnum.Marketing, +} + +export class ListingTreymoreSeed extends ListingDefaultSeed { + async seed() { + const unitTypeStudio = await this.unitTypeRepository.findOneOrFail({ name: "studio" }) + const unitTypeTwoBdrm = await this.unitTypeRepository.findOneOrFail({ name: "twoBdrm" }) + + const property = await this.propertyRepository.save({ + ...treymoreProperty, + }) + + const listingCreateDto: Omit< + DeepPartial, + keyof BaseEntity | "urlSlug" | "showWaitlist" + > = { + ...treymoreListing, + applicationMethods: [], + assets: [], + events: [], + property: property, + } + + const listing = await this.listingRepository.save(listingCreateDto) + + const treymoreUnitGroupToBeCreated: DeepPartial[] = [] + + const studioUnitGroup: DeepPartial = { + unitType: [unitTypeStudio], + totalCount: 2, + listing: listing, + totalAvailable: 0, + } + treymoreUnitGroupToBeCreated.push(studioUnitGroup) + + const twoBdrmUnitGroup: DeepPartial = { + unitType: [unitTypeTwoBdrm], + totalCount: 4, + amiLevels: [ + { + amiPercentage: 10, + monthlyRentDeterminationType: MonthlyRentDeterminationType.flatRent, + flatRentValue: 707, + }, + ], + listing: listing, + sqFeetMin: 720, + sqFeetMax: 1003, + totalAvailable: 4, + } + treymoreUnitGroupToBeCreated.push(twoBdrmUnitGroup) + + await this.unitGroupRepository.save(treymoreUnitGroupToBeCreated) + + return listing + } +} diff --git a/backend/core/src/seeder/seeds/listings/listing-triton-seed.ts b/backend/core/src/seeder/seeds/listings/listing-triton-seed.ts new file mode 100644 index 0000000000..5b8176a505 --- /dev/null +++ b/backend/core/src/seeder/seeds/listings/listing-triton-seed.ts @@ -0,0 +1,406 @@ +import { ListingSeedType, PropertySeedType, UnitSeedType } from "./listings" +import { getDate, getDefaultAssets, getLiveWorkPreference } from "./shared" +import { ListingDefaultSeed } from "./listing-default-seed" +import { BaseEntity, DeepPartial } from "typeorm" +import { CountyCode } from "../../../shared/types/county-code" +import { ListingReviewOrder } from "../../../listings/types/listing-review-order-enum" +import { ListingStatus } from "../../../listings/types/listing-status-enum" +import { UnitStatus } from "../../../units/types/unit-status-enum" +import { UnitCreateDto } from "../../../units/dto/unit-create.dto" +import { Listing } from "../../../listings/entities/listing.entity" +import { ListingMarketingTypeEnum } from "../../../listings/types/listing-marketing-type-enum" + +const tritonProperty: PropertySeedType = { + accessibility: + "Accessibility features in common areas like lobby – wheelchair ramps, wheelchair accessible bathrooms and elevators.", + amenities: "Gym, Clubhouse, Business Lounge, View Lounge, Pool, Spa", + buildingAddress: { + city: "Foster City", + county: "San Mateo", + state: "CA", + street: "55 Triton Park Lane", + zipCode: "94404", + latitude: 37.5658152, + longitude: -122.2704286, + }, + buildingTotalUnits: 48, + developer: "Thompson Dorfman, LLC", + neighborhood: "Foster City", + petPolicy: + "Pets allowed except the following; pit bull, malamute, akita, rottweiler, doberman, staffordshire terrier, presa canario, chowchow, american bull dog, karelian bear dog, st bernard, german shepherd, husky, great dane, any hybrid or mixed breed of the aforementioned breeds. 50 pound weight limit. 2 pets per household limit. $500 pet deposit per pet. $60 pet rent per pet.", + servicesOffered: null, + smokingPolicy: "Non-Smoking", + unitAmenities: "Washer and dryer, AC and Heater, Gas Stove", + unitsAvailable: 4, + yearBuilt: 2021, +} + +const tritonListing: ListingSeedType = { + jurisdictionName: "Alameda", + digitalApplication: false, + commonDigitalApplication: false, + paperApplication: false, + referralOpportunity: false, + countyCode: CountyCode.alameda, + applicationDropOffAddress: null, + applicationDropOffAddressOfficeHours: null, + applicationMailingAddress: null, + applicationDueDate: getDate(5), + applicationFee: "38.0", + applicationOpenDate: getDate(-10), + applicationOrganization: "Triton", + applicationPickUpAddress: { + city: "Foster City", + state: "CA", + street: "55 Triton Park Lane", + zipCode: "94404", + latitude: 37.5658152, + longitude: -122.2704286, + }, + images: [], + applicationPickUpAddressOfficeHours: null, + buildingSelectionCriteria: + "https://regional-dahlia-staging.s3-us-west-1.amazonaws.com/listings/triton/The_Triton_BMR_rental_information.pdf", + costsNotIncluded: + "Residents responsible for PG&E, Internet, Utilities - water, sewer, trash, admin fee. Pet Deposit is $500 with a $60 monthly pet rent. Residents required to maintain a renter's insurance policy as outlined in the lease agreement. Rent is due by the 3rd of each month. Late fee is $50.00. Resident to pay $25 for each returned check or rejected electronic payment. For additional returned checks, resident will pay a charge of $50.00.", + creditHistory: + "No collections, no bankruptcy, income is twice monthly rent A credit report will be completed on all applicants to verify credit ratings.\n\nIncome plus verified credit history will be entered into a credit scoring model to determine rental eligibility and security deposit levels. All decisions for residency are based on a system which considers credit history, rent history, income qualifications, and employment history. An approved decision based on the system does not automatically constittute an approval of residency. Applicant(s) and occupant(s) aged 18 years or older MUST also pass the criminal background check based on the criteria contained herein to be approved for residency. \n\nCredit recommendations other than an accept decision, will require a rental verification. Applications for residency will automatically be denied for the following reasons:\n\n- a. An outstanding debt to a previous landlord or an outstanding NSF check must be paid in full\n- b. An unsatisfied breach of a prior lease or a prior eviction of any applicant or occupant\n- c. More than four (4) late pays and two (2) NSF's in the last twenty-four (24) months", + criminalBackground: null, + depositMax: "800", + depositMin: "500", + disableUnitsAccordion: true, + displayWaitlistSize: false, + leasingAgentAddress: { + city: "Foster City", + state: "CA", + street: "55 Triton Park Lane", + zipCode: "94404", + latitude: 37.5658152, + longitude: -122.2704286, + }, + leasingAgentEmail: "thetriton@legacypartners.com", + leasingAgentName: "Francis Santos", + leasingAgentOfficeHours: "Monday - Friday, 9:00 am - 5:00 pm", + leasingAgentPhone: "650-437-2039", + leasingAgentTitle: "Business Manager", + listingPreferences: [], + listingPrograms: [], + name: "Test: Triton", + postmarkedApplicationsReceivedByDate: null, + programRules: null, + rentalAssistance: "Rental assistance", + rentalHistory: "No evictions", + requiredDocuments: + "Due at interview - Paystubs, 3 months’ bank statements, recent tax returns or non-tax affidavit, recent retirement statement, application to lease, application qualifying criteria, social security card, state or nation ID. For self-employed, copy of IRS Tax Return including schedule C and current or most recent clients. Unemployment if applicable. Child support/Alimony; current notice from DA office, a court order or a letter from the provider with copies of last two checks. Any other income etc", + reviewOrderType: "firstComeFirstServe" as ListingReviewOrder, + specialNotes: null, + status: ListingStatus.active, + waitlistCurrentSize: 400, + waitlistMaxSize: 600, + waitlistOpenSpots: 200, + isWaitlistOpen: true, + whatToExpect: null, + marketingType: ListingMarketingTypeEnum.Marketing, +} + +export class ListingTritonSeed extends ListingDefaultSeed { + async seed() { + const unitTypeOneBdrm = await this.unitTypeRepository.findOneOrFail({ name: "oneBdrm" }) + const unitTypeTwoBdrm = await this.unitTypeRepository.findOneOrFail({ name: "twoBdrm" }) + + const alamedaJurisdiction = await this.jurisdictionRepository.findOneOrFail({ + name: CountyCode.alameda, + }) + + const amiChart = await this.amiChartRepository.findOneOrFail({ + name: "San Jose TCAC 2019", + jurisdiction: alamedaJurisdiction, + }) + + const property = await this.propertyRepository.save({ + ...tritonProperty, + }) + + const tritonUnits: Array = [ + { + amiChart: amiChart, + amiPercentage: "120.0", + annualIncomeMax: "177300.0", + annualIncomeMin: "84696.0", + floor: 1, + maxOccupancy: 5, + minOccupancy: 2, + monthlyIncomeMin: "7058.0", + monthlyRent: "3340.0", + monthlyRentAsPercentOfIncome: null, + numBathrooms: null, + numBedrooms: 2, + number: null, + priorityType: null, + sqFeet: "1100", + status: UnitStatus.occupied, + }, + { + amiChart: amiChart, + amiPercentage: "80.0", + annualIncomeMax: "103350.0", + annualIncomeMin: "58152.0", + floor: 1, + maxOccupancy: 2, + minOccupancy: 1, + monthlyIncomeMin: "4858.0", + monthlyRent: "2624.0", + monthlyRentAsPercentOfIncome: null, + numBathrooms: null, + numBedrooms: 1, + number: null, + priorityType: null, + sqFeet: "750", + status: UnitStatus.occupied, + }, + { + amiChart: amiChart, + amiPercentage: "80.0", + annualIncomeMax: "103350.0", + annualIncomeMin: "58152.0", + floor: 1, + maxOccupancy: 2, + minOccupancy: 1, + monthlyIncomeMin: "4858.0", + monthlyRent: "2624.0", + monthlyRentAsPercentOfIncome: null, + numBathrooms: null, + numBedrooms: 1, + number: null, + priorityType: null, + sqFeet: "750", + status: UnitStatus.occupied, + }, + { + amiChart: amiChart, + amiPercentage: "80.0", + annualIncomeMax: "103350.0", + annualIncomeMin: "58152.0", + floor: 1, + maxOccupancy: 2, + minOccupancy: 1, + monthlyIncomeMin: "4858.0", + monthlyRent: "2624.0", + monthlyRentAsPercentOfIncome: null, + numBathrooms: null, + numBedrooms: 1, + number: null, + priorityType: null, + sqFeet: "750", + status: UnitStatus.occupied, + }, + { + amiChart: amiChart, + amiPercentage: "50.0", + annualIncomeMax: "103350.0", + annualIncomeMin: "38952.0", + floor: 1, + maxOccupancy: 2, + minOccupancy: 1, + monthlyIncomeMin: "3246.0", + monthlyRent: "1575.0", + monthlyRentAsPercentOfIncome: null, + numBathrooms: null, + numBedrooms: 1, + number: null, + priorityType: null, + sqFeet: "750", + status: UnitStatus.occupied, + }, + ] + + const unitsToBeCreated: Array> = tritonUnits.map( + (unit) => { + return { + ...unit, + property: { + id: property.id, + }, + amiChart, + } + } + ) + + unitsToBeCreated[0].unitType = unitTypeTwoBdrm + unitsToBeCreated[1].unitType = unitTypeOneBdrm + unitsToBeCreated[2].unitType = unitTypeOneBdrm + unitsToBeCreated[3].unitType = unitTypeOneBdrm + unitsToBeCreated[4].unitType = unitTypeOneBdrm + + await this.unitsRepository.save(unitsToBeCreated) + + const listingCreateDto: Omit< + DeepPartial, + keyof BaseEntity | "urlSlug" | "showWaitlist" + > = { + ...tritonListing, + name: "Test: Triton 2", + property: property, + assets: getDefaultAssets(), + listingPreferences: [ + { + preference: await this.preferencesRepository.findOneOrFail({ + title: getLiveWorkPreference(alamedaJurisdiction.name).title, + }), + ordinal: 2, + }, + ], + events: [], + } + + return await this.listingRepository.save(listingCreateDto) + } +} + +export class ListingTritonSeedDetroit extends ListingDefaultSeed { + async seed() { + const unitTypeOneBdrm = await this.unitTypeRepository.findOneOrFail({ name: "oneBdrm" }) + const unitTypeTwoBdrm = await this.unitTypeRepository.findOneOrFail({ name: "twoBdrm" }) + + const detroitJurisdiction = await this.jurisdictionRepository.findOneOrFail({ + name: CountyCode.detroit, + }) + const amiChart = await this.amiChartRepository.findOneOrFail({ + name: "Detroit TCAC 2019", + jurisdiction: detroitJurisdiction, + }) + + const property = await this.propertyRepository.findOneOrFail({ + developer: "Thompson Dorfman, LLC", + neighborhood: "Foster City", + smokingPolicy: "Non-Smoking", + }) + + const tritonUnits: Array = [ + { + amiChart: amiChart, + amiPercentage: "120.0", + annualIncomeMax: "177300.0", + annualIncomeMin: "84696.0", + floor: 1, + maxOccupancy: 5, + minOccupancy: 2, + monthlyIncomeMin: "7058.0", + monthlyRent: "3340.0", + monthlyRentAsPercentOfIncome: null, + numBathrooms: null, + numBedrooms: 2, + number: null, + priorityType: null, + sqFeet: "1100", + status: UnitStatus.occupied, + }, + { + amiChart: amiChart, + amiPercentage: "80.0", + annualIncomeMax: "103350.0", + annualIncomeMin: "58152.0", + floor: 1, + maxOccupancy: 2, + minOccupancy: 1, + monthlyIncomeMin: "4858.0", + monthlyRent: "2624.0", + monthlyRentAsPercentOfIncome: null, + numBathrooms: null, + numBedrooms: 1, + number: null, + priorityType: null, + sqFeet: "750", + status: UnitStatus.occupied, + }, + { + amiChart: amiChart, + amiPercentage: "80.0", + annualIncomeMax: "103350.0", + annualIncomeMin: "58152.0", + floor: 1, + maxOccupancy: 2, + minOccupancy: 1, + monthlyIncomeMin: "4858.0", + monthlyRent: "2624.0", + monthlyRentAsPercentOfIncome: null, + numBathrooms: null, + numBedrooms: 1, + number: null, + priorityType: null, + sqFeet: "750", + status: UnitStatus.occupied, + }, + { + amiChart: amiChart, + amiPercentage: "80.0", + annualIncomeMax: "103350.0", + annualIncomeMin: "58152.0", + floor: 1, + maxOccupancy: 2, + minOccupancy: 1, + monthlyIncomeMin: "4858.0", + monthlyRent: "2624.0", + monthlyRentAsPercentOfIncome: null, + numBathrooms: null, + numBedrooms: 1, + number: null, + priorityType: null, + sqFeet: "750", + status: UnitStatus.occupied, + }, + { + amiChart: amiChart, + amiPercentage: "50.0", + annualIncomeMax: "103350.0", + annualIncomeMin: "38952.0", + floor: 1, + maxOccupancy: 2, + minOccupancy: 1, + monthlyIncomeMin: "3246.0", + monthlyRent: "1575.0", + monthlyRentAsPercentOfIncome: null, + numBathrooms: null, + numBedrooms: 1, + number: null, + priorityType: null, + sqFeet: "750", + status: UnitStatus.occupied, + }, + ] + + const unitsToBeCreated: Array> = tritonUnits.map( + (unit) => { + return { + ...unit, + property: { + id: property.id, + }, + amiChart, + } + } + ) + + unitsToBeCreated[0].unitType = unitTypeTwoBdrm + unitsToBeCreated[1].unitType = unitTypeOneBdrm + unitsToBeCreated[2].unitType = unitTypeOneBdrm + unitsToBeCreated[3].unitType = unitTypeOneBdrm + unitsToBeCreated[4].unitType = unitTypeOneBdrm + + await this.unitsRepository.save(unitsToBeCreated) + + const listingCreateDto: Omit< + DeepPartial, + keyof BaseEntity | "urlSlug" | "showWaitlist" + > = { + ...tritonListing, + name: "Test: Triton 1", + property: property, + applicationOpenDate: getDate(-5), + assets: getDefaultAssets(), + events: [], + } + + return await this.listingRepository.save(listingCreateDto) + } +} diff --git a/backend/core/src/seeder/seeds/listings/listings.ts b/backend/core/src/seeder/seeds/listings/listings.ts new file mode 100644 index 0000000000..a8fb1ff5a5 --- /dev/null +++ b/backend/core/src/seeder/seeds/listings/listings.ts @@ -0,0 +1,78 @@ +import { BaseEntity } from "typeorm" +import { PropertyCreateDto } from "../../../property/dto/property.dto" +import { UnitCreateDto } from "../../../units/dto/unit-create.dto" +import { ApplicationMethodCreateDto } from "../../../application-methods/dto/application-method.dto" +import { ListingPublishedCreateDto } from "../../../listings/dto/listing-published-create.dto" +import { PreferenceCreateDto } from "../../../preferences/dto/preference-create.dto" +import { ProgramCreateDto } from "../../../program/dto/program-create.dto" +import { AssetCreateDto } from "../../../assets/dto/asset.dto" +import { AmiChartCreateDto } from "../../../ami-charts/dto/ami-chart.dto" +import { ListingEventCreateDto } from "../../../listings/dto/listing-event.dto" +import { UserCreateDto } from "../../../auth/dto/user-create.dto" + +export type PropertySeedType = Omit< + PropertyCreateDto, + | "propertyGroups" + | "listings" + | "units" + | "unitSummaries" + | "householdSizeMin" + | "householdSizeMax" +> + +export type UnitSeedType = Omit + +export type ApplicationMethodSeedType = ApplicationMethodCreateDto + +export type ListingSeedType = Omit< + ListingPublishedCreateDto, + | keyof BaseEntity + | "property" + | "urlSlug" + | "applicationMethods" + | "events" + | "assets" + | "preferences" + | "leasingAgents" + | "showWaitlist" + | "units" + | "propertyGroups" + | "accessibility" + | "amenities" + | "buildingAddress" + | "buildingTotalUnits" + | "developer" + | "householdSizeMax" + | "householdSizeMin" + | "neighborhood" + | "petPolicy" + | "smokingPolicy" + | "unitsAvailable" + | "unitAmenities" + | "servicesOffered" + | "yearBuilt" + | "unitGroups" + | "unitSummaries" + | "amiChartOverrides" + | "jurisdiction" +> & { + jurisdictionName: string +} + +export type PreferenceSeedType = PreferenceCreateDto +export type ProgramSeedType = Omit + +export type AssetDtoSeedType = Omit + +// Properties that are ommited in DTOS derived types are relations and getters +export interface ListingSeed { + amiChart: AmiChartCreateDto + units: Array + applicationMethods: Array + property: PropertySeedType + preferences: Array + listingEvents: Array + assets: Array + listing: ListingSeedType + leasingAgents: UserCreateDto[] +} diff --git a/backend/core/src/seeder/seeds/listings/shared.ts b/backend/core/src/seeder/seeds/listings/shared.ts new file mode 100644 index 0000000000..b8c85ecf55 --- /dev/null +++ b/backend/core/src/seeder/seeds/listings/shared.ts @@ -0,0 +1,554 @@ +// AMI Charts +import { + AssetDtoSeedType, + ListingSeedType, + PreferenceSeedType, + ProgramSeedType, + PropertySeedType, + UnitSeedType, +} from "./listings" +import { defaultAmiChart } from "../ami-charts/default-ami-chart" +import { ListingEventCreateDto } from "../../../listings/dto/listing-event.dto" +import { ListingEventType } from "../../../listings/types/listing-event-type-enum" +import { AmiChart } from "../../../ami-charts/entities/ami-chart.entity" +import { UnitStatus } from "../../../units/types/unit-status-enum" +import { UserCreateDto } from "../../../auth/dto/user-create.dto" +import { CountyCode } from "../../../shared/types/county-code" +import { ListingReviewOrder } from "../../../listings/types/listing-review-order-enum" +import { ListingStatus } from "../../../listings/types/listing-status-enum" +import { InputType } from "../../../shared/types/input-type" +import { FormMetaDataType } from "../../../applications/types/form-metadata/form-metadata" +import { ListingMarketingTypeEnum } from "../../../listings/types/listing-marketing-type-enum" +export const getDate = (days: number) => { + const someDate = new Date() + someDate.setDate(someDate.getDate() + days) + return someDate +} + +export enum PriorityTypes { + mobility = "Mobility", + hearing = "Hearing", + visual = "Visual", + hearingVisual = "Hearing and Visual", + mobilityHearing = "Mobility and Hearing", + mobilityVisual = "Mobility and Visual", + mobilityHearingVisual = "Mobility, Hearing and Visual", +} + +// Events +export function getDefaultListingEvents() { + return JSON.parse(JSON.stringify(defaultListingEvents)) +} + +export const defaultListingEvents: Array = [ + { + startTime: getDate(10), + endTime: getDate(10), + note: "Custom open house event note", + type: ListingEventType.openHouse, + url: "https://www.example.com", + label: "Custom Event URL Label", + }, + { + startTime: getDate(10), + endTime: getDate(10), + note: "Custom public lottery event note", + type: ListingEventType.publicLottery, + url: "https://www.example2.com", + label: "Custom Event URL Label", + }, +] + +// Assets +export function getDefaultAssets() { + return JSON.parse(JSON.stringify(defaultAssets)) +} + +export const defaultAssets: Array = [ + { + label: "building", + fileId: + "https://regional-dahlia-staging.s3-us-west-1.amazonaws.com/listings/triton/thetriton.png", + }, +] +// Properties +export function getDefaultProperty() { + return JSON.parse(JSON.stringify(defaultProperty)) +} + +export const defaultProperty: PropertySeedType = { + accessibility: "Custom accessibility text", + amenities: "Custom property amenities text", + buildingAddress: { + city: "San Francisco", + state: "CA", + street: "548 Market Street", + street2: "Suite #59930", + zipCode: "94104", + latitude: 37.789673, + longitude: -122.40151, + }, + buildingTotalUnits: 100, + developer: "Developer", + neighborhood: "Custom neighborhood text", + petPolicy: "Custom pet text", + servicesOffered: "Custom services offered text", + smokingPolicy: "Custom smoking text", + unitAmenities: "Custom unit amenities text", + unitsAvailable: 2, + yearBuilt: 2021, +} + +// Unit Sets +export function getDefaultUnits() { + return JSON.parse(JSON.stringify(defaultUnits)) +} + +export const defaultUnits: Array = [ + { + amiChart: defaultAmiChart as AmiChart, + amiPercentage: "30", + annualIncomeMax: "45600", + annualIncomeMin: "36168", + bmrProgramChart: false, + floor: 1, + maxOccupancy: 3, + minOccupancy: 1, + monthlyIncomeMin: "3014", + monthlyRent: "1219", + monthlyRentAsPercentOfIncome: null, + numBathrooms: 1, + numBedrooms: 1, + number: null, + sqFeet: "635", + status: UnitStatus.available, + }, + { + amiChart: defaultAmiChart as AmiChart, + amiPercentage: "30", + annualIncomeMax: "66600", + annualIncomeMin: "41616", + bmrProgramChart: false, + floor: 2, + maxOccupancy: 5, + minOccupancy: 2, + monthlyIncomeMin: "3468", + monthlyRent: "1387", + monthlyRentAsPercentOfIncome: null, + numBathrooms: 1, + numBedrooms: 2, + number: null, + sqFeet: "748", + status: UnitStatus.available, + }, +] + +export const defaultLeasingAgents: Omit[] = [ + { + firstName: "First", + lastName: "Last", + middleName: "Middle", + email: "leasing-agent-1@example.com", + emailConfirmation: "leasing-agent-1@example.com", + password: "abcdef", + passwordConfirmation: "Abcdef1", + dob: new Date(), + }, + { + firstName: "First", + lastName: "Last", + middleName: "Middle", + email: "leasing-agent-2@example.com", + emailConfirmation: "leasing-agent-2@example.com", + password: "abcdef", + passwordConfirmation: "Abcdef1", + dob: new Date(), + }, +] + +// Listings +export function getDefaultListing() { + return JSON.parse(JSON.stringify(defaultListing)) +} + +export const defaultListing: ListingSeedType = { + jurisdictionName: "Alameda", + countyCode: CountyCode.alameda, + applicationDropOffAddress: null, + applicationDropOffAddressOfficeHours: null, + applicationMailingAddress: null, + digitalApplication: false, + commonDigitalApplication: false, + paperApplication: false, + referralOpportunity: false, + applicationDueDate: getDate(10), + applicationFee: "20", + applicationOpenDate: getDate(-10), + applicationOrganization: "Application Organization", + applicationPickUpAddress: { + city: "San Francisco", + state: "CA", + street: "548 Market Street", + street2: "Suite #59930", + zipCode: "94104", + latitude: 37.789673, + longitude: -122.40151, + }, + applicationPickUpAddressOfficeHours: "Custom pick up address office hours text", + buildingSelectionCriteria: "https://www.example.com", + costsNotIncluded: "Custom costs not included text", + creditHistory: "Custom credit history text", + criminalBackground: "Custom criminal background text", + depositMax: "500", + depositMin: "500", + disableUnitsAccordion: true, + displayWaitlistSize: false, + images: [], + leasingAgentAddress: { + city: "San Francisco", + state: "CA", + street: "548 Market Street", + street2: "Suite #59930", + zipCode: "94104", + latitude: 37.789673, + longitude: -122.40151, + }, + leasingAgentEmail: "hello@exygy.com", + leasingAgentName: "Leasing Agent Name", + leasingAgentOfficeHours: "Custom leasing agent office hours", + leasingAgentPhone: "(415) 992-7251", + leasingAgentTitle: "Leasing Agent Title", + listingPreferences: [], + listingPrograms: [], + name: "Default Listing Seed", + postmarkedApplicationsReceivedByDate: null, + programRules: "Custom program rules text", + rentalAssistance: "Custom rental assistance text", + rentalHistory: "Custom rental history text", + requiredDocuments: "Custom required documents text", + reviewOrderType: "lottery" as ListingReviewOrder, + specialNotes: "Custom special notes text", + status: ListingStatus.active, + waitlistCurrentSize: null, + waitlistOpenSpots: null, + isWaitlistOpen: false, + waitlistMaxSize: null, + whatToExpect: "Custom what to expect text", + marketingType: ListingMarketingTypeEnum.Marketing, +} + +// Preferences +export function getLiveWorkPreference(jurisdictionName) { + const preference = { ...liveWorkPreference } + preference.title += ` - ${jurisdictionName}` + return preference +} + +export const liveWorkPreference: PreferenceSeedType = { + title: "Live/Work in County", + subtitle: "Live/Work in County subtitle", + description: "At least one household member lives or works in County", + links: [ + { + title: "Link Title", + url: "https://www.example.com", + }, + ], + formMetadata: { + key: "liveWork", + options: [ + { + key: "live", + extraData: [], + }, + { + key: "work", + extraData: [], + }, + ], + }, +} +export function getDisplaceePreference(jurisdictionName) { + const preference = { ...displaceePreference } + preference.title += ` - ${jurisdictionName}` + return preference +} + +export const displaceePreference: PreferenceSeedType = { + title: "Displacee Tenant Housing", + subtitle: "Displacee Tenant Housing subtitle", + description: + "At least one member of my household was displaced from a residential property due to redevelopment activity by Housing Authority or City.", + links: [], + formMetadata: { + key: "displacedTenant", + options: [ + { + key: "general", + extraData: [ + { + key: "name", + type: InputType.text, + }, + { + key: "address", + type: InputType.address, + }, + ], + }, + { + key: "missionCorridor", + extraData: [ + { + key: "name", + type: InputType.text, + }, + { + key: "address", + type: InputType.address, + }, + ], + }, + ], + }, +} + +export function getPbvPreference(jurisdictionName) { + const preference = { ...pbvPreference } + preference.title += ` - ${jurisdictionName}` + return preference +} + +export const pbvPreference: PreferenceSeedType = { + title: "Housing Authority Project-Based Voucher", + subtitle: "", + description: + "You are currently applying to be in a general applicant waiting list. Of the total apartments available in this application process, several have Project-Based Vouchers for rental subsidy assistance from the Housing Authority. With that subsidy, tenant households pay 30% of their income as rent. These tenants are required to verify their income annually with the property manager as well as the Housing Authority.", + links: [], + formMetadata: { + key: "PBV", + customSelectText: "Please select any of the following that apply to you", + hideGenericDecline: true, + hideFromListing: true, + options: [ + { + key: "residency", + extraData: [], + }, + { + key: "family", + extraData: [], + }, + { + key: "veteran", + extraData: [], + }, + { + key: "homeless", + extraData: [], + }, + { + key: "noneApplyButConsider", + exclusive: true, + description: false, + extraData: [], + }, + { + key: "doNotConsider", + exclusive: true, + description: false, + extraData: [], + }, + ], + }, +} + +export function getHopwaPreference(jurisdictionName) { + const preference = { ...hopwaPreference } + preference.title += ` - ${jurisdictionName}` + return preference +} + +export const hopwaPreference: PreferenceSeedType = { + title: "Housing Opportunities for Persons with AIDS", + subtitle: "", + description: + "There are apartments set-aside for households eligible for the HOPWA program (Housing Opportunities for Persons with AIDS), which are households where a person has been medically diagnosed with HIV/AIDS. These apartments also have Project-Based Section rental subsidies (tenant pays 30% of household income).", + links: [], + formMetadata: { + key: "HOPWA", + customSelectText: + "Please indicate if you are interested in applying for one of these HOPWA apartments", + hideGenericDecline: true, + hideFromListing: true, + options: [ + { + key: "hopwa", + extraData: [], + }, + { + key: "doNotConsider", + exclusive: true, + description: false, + extraData: [], + }, + ], + }, +} + +// programs + +export function getServedInMilitaryProgram() { + return JSON.parse(JSON.stringify(servedInMilitaryProgram)) +} + +export function getFlatRentAndRentBasedOnIncomeProgram() { + return JSON.parse(JSON.stringify(flatRentAndRentBasedOnIncomeProgram)) +} + +export const servedInMilitaryProgram: ProgramSeedType = { + title: "Veteran", + subtitle: "Should your application be chosen, be prepared to provide supporting documentation.", + description: "Have you or anyone in your household served in the US military?", + formMetadata: { + key: "servedInMilitary", + options: [ + { + key: "servedInMilitary", + description: false, + extraData: [], + }, + { + key: "doNotConsider", + description: false, + extraData: [], + }, + { + key: "preferNotToSay", + description: false, + extraData: [], + }, + ], + }, +} + +export const flatRentAndRentBasedOnIncomeProgram: ProgramSeedType = { + title: "Flat Rent & Rent Based on Income", + subtitle: + "This property includes two types of affordable housing programs. You can choose to apply for one or both programs. Each program will have its own applicant list. Your choice will tell us which list(s) to put your name on. Additional information on each of the two types of housing opportunities are below.", + description: "Do you want to apply for apartments with flat rent and rent based on income?", + formMetadata: { + key: "rentBasedOnIncome", + type: FormMetaDataType.checkbox, + options: [ + { + key: "flatRent", + description: true, + extraData: [], + }, + { + key: "30Percent", + description: true, + extraData: [], + }, + ], + }, +} + +export function getTayProgram() { + return JSON.parse(JSON.stringify(tayProgram)) +} + +export const tayProgram: ProgramSeedType = { + title: "Transition Age Youth", + subtitle: "Should your application be chosen, be prepared to provide supporting documentation.", + description: + "Are you or anyone in your household a transition age youth (TAY) aging out of foster care?", + formMetadata: { + key: "tay", + options: [ + { + key: "tay", + description: false, + extraData: [], + }, + { + key: "doNotConsider", + description: false, + extraData: [], + }, + { + key: "preferNotToSay", + description: false, + extraData: [], + }, + ], + }, +} + +export function getDisabilityOrMentalIllnessProgram() { + return JSON.parse(JSON.stringify(disabilityOrMentalIllnessProgram)) +} + +export const disabilityOrMentalIllnessProgram: ProgramSeedType = { + title: "Developmental Disability", + subtitle: "Should your application be chosen, be prepared to provide supporting documentation.", + description: + "Do you or anyone in your household have a developmental disability or mental illness?", + formMetadata: { + key: "disabilityOrMentalIllness", + options: [ + { + key: "disabilityOrMentalIllness", + description: false, + extraData: [], + }, + { + key: "doNotConsider", + description: false, + extraData: [], + }, + { + key: "preferNotToSay", + description: false, + extraData: [], + }, + ], + }, +} + +export function getHousingSituationProgram() { + return JSON.parse(JSON.stringify(housingSituationProgram)) +} + +export const housingSituationProgram: ProgramSeedType = { + title: "Housing Situation", + subtitle: "", + description: + "Thinking about the past 30 days, do either of these describe your housing situation?", + formMetadata: { + key: "housingSituation", + options: [ + { + key: "notPermanent", + description: true, + extraData: [], + }, + { + key: "homeless", + description: true, + extraData: [], + }, + { + key: "doNotConsider", + description: false, + extraData: [], + }, + { + key: "preferNotToSay", + description: false, + extraData: [], + }, + ], + }, +} diff --git a/backend/core/src/shared/decorators/enforceLowerCase.decorator.ts b/backend/core/src/shared/decorators/enforceLowerCase.decorator.ts new file mode 100644 index 0000000000..432ae8152a --- /dev/null +++ b/backend/core/src/shared/decorators/enforceLowerCase.decorator.ts @@ -0,0 +1,5 @@ +import { Transform } from "class-transformer" + +export function EnforceLowerCase() { + return Transform((value: string) => (value ? value.toLowerCase() : value)) +} diff --git a/backend/core/src/shared/decorators/match.decorator.ts b/backend/core/src/shared/decorators/match.decorator.ts new file mode 100644 index 0000000000..87ab611203 --- /dev/null +++ b/backend/core/src/shared/decorators/match.decorator.ts @@ -0,0 +1,28 @@ +import { + registerDecorator, + ValidationArguments, + ValidationOptions, + ValidatorConstraint, + ValidatorConstraintInterface, +} from "class-validator" + +export function Match(property: string, validationOptions?: ValidationOptions) { + return (object: unknown, propertyName: string) => { + registerDecorator({ + target: object.constructor, + propertyName, + options: validationOptions, + constraints: [property], + validator: MatchConstraint, + }) + } +} + +@ValidatorConstraint({ name: "Match" }) +export class MatchConstraint implements ValidatorConstraintInterface { + validate(value: unknown, args: ValidationArguments) { + const [relatedPropertyName] = args.constraints + const relatedValue = (args.object as unknown)[relatedPropertyName] + return value === relatedValue + } +} diff --git a/backend/core/src/shared/default-validation-pipe-options.ts b/backend/core/src/shared/default-validation-pipe-options.ts new file mode 100644 index 0000000000..29c4f602a3 --- /dev/null +++ b/backend/core/src/shared/default-validation-pipe-options.ts @@ -0,0 +1,12 @@ +import { ValidationPipeOptions } from "@nestjs/common" +import { ValidationsGroupsEnum } from "./types/validations-groups-enum" + +export const defaultValidationPipeOptions: ValidationPipeOptions = { + transform: true, + transformOptions: { + excludeExtraneousValues: true, + enableImplicitConversion: false, + }, + groups: [ValidationsGroupsEnum.default], + forbidUnknownValues: true, +} diff --git a/backend/core/src/shared/dto/address.dto.ts b/backend/core/src/shared/dto/address.dto.ts new file mode 100644 index 0000000000..de9a2f5bd3 --- /dev/null +++ b/backend/core/src/shared/dto/address.dto.ts @@ -0,0 +1,27 @@ +import { Expose, Type } from "class-transformer" +import { IsDate, IsOptional, IsUUID } from "class-validator" +import { OmitType } from "@nestjs/swagger" +import { Address } from "../entities/address.entity" +import { ValidationsGroupsEnum } from "../types/validations-groups-enum" + +export class AddressDto extends OmitType(Address, []) {} +export class AddressCreateDto extends OmitType(Address, ["id", "createdAt", "updatedAt"]) {} + +export class AddressUpdateDto extends OmitType(Address, ["id", "createdAt", "updatedAt"]) { + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsUUID(4, { groups: [ValidationsGroupsEnum.default] }) + id?: string + + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsDate({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => Date) + createdAt?: Date + + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsDate({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => Date) + updatedAt?: Date +} diff --git a/backend/core/src/shared/dto/filter.dto.ts b/backend/core/src/shared/dto/filter.dto.ts new file mode 100644 index 0000000000..8920574a54 --- /dev/null +++ b/backend/core/src/shared/dto/filter.dto.ts @@ -0,0 +1,41 @@ +import { ApiProperty } from "@nestjs/swagger" +import { Expose, Transform } from "class-transformer" +import { IsEnum, IsOptional, IsBoolean } from "class-validator" +import { ValidationsGroupsEnum } from "../types/validations-groups-enum" + +// Add other comparisons as needed (>, <, etc) +export enum Compare { + "=" = "=", + "<>" = "<>", + "IN" = "IN", + ">=" = ">=", + "<=" = "<=", + "NA" = "NA", // For filters that don't use the comparison param +} + +export class BaseFilter { + @Expose() + @ApiProperty({ + enum: Object.keys(Compare), + example: "=", + default: Compare["="], + }) + @IsEnum(Compare, { groups: [ValidationsGroupsEnum.default] }) + $comparison: Compare + + @Expose() + @ApiProperty({ + type: Boolean, + example: "true", + required: false, + }) + @Transform( + (value?: string) => { + return value === "true" + }, + { toClassOnly: true } + ) + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsBoolean({ groups: [ValidationsGroupsEnum.default] }) + $include_nulls?: boolean +} diff --git a/backend/core/src/shared/dto/id.dto.ts b/backend/core/src/shared/dto/id.dto.ts new file mode 100644 index 0000000000..82efa282e2 --- /dev/null +++ b/backend/core/src/shared/dto/id.dto.ts @@ -0,0 +1,10 @@ +import { IsString, IsUUID } from "class-validator" +import { Expose } from "class-transformer" +import { ValidationsGroupsEnum } from "../types/validations-groups-enum" + +export class IdDto { + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @IsUUID(4, { groups: [ValidationsGroupsEnum.default] }) + id: string +} diff --git a/backend/core/src/shared/dto/idName.dto.ts b/backend/core/src/shared/dto/idName.dto.ts new file mode 100644 index 0000000000..51d017784f --- /dev/null +++ b/backend/core/src/shared/dto/idName.dto.ts @@ -0,0 +1,14 @@ +import { IsString, IsUUID } from "class-validator" +import { Expose } from "class-transformer" +import { ValidationsGroupsEnum } from "../types/validations-groups-enum" + +export class IdNameDto { + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @IsUUID(4, { groups: [ValidationsGroupsEnum.default] }) + id: string + + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + name: string +} diff --git a/backend/core/src/shared/dto/pagination.dto.ts b/backend/core/src/shared/dto/pagination.dto.ts new file mode 100644 index 0000000000..490f469f43 --- /dev/null +++ b/backend/core/src/shared/dto/pagination.dto.ts @@ -0,0 +1,124 @@ +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger" +import { IPaginationMeta } from "nestjs-typeorm-paginate/dist/interfaces" +import { Expose, Transform, Type } from "class-transformer" +import { IsNumber, IsOptional, registerDecorator, ValidationOptions } from "class-validator" +import { ValidationsGroupsEnum } from "../types/validations-groups-enum" +import { ClassType } from "class-transformer/ClassTransformer" + +export class PaginationMeta implements IPaginationMeta { + @Expose() + currentPage: number + @Expose() + itemCount: number + @Expose() + itemsPerPage: number + @Expose() + totalItems: number + @Expose() + totalPages: number +} + +export interface Pagination { + items: T[] + meta: PaginationMeta +} + +export function PaginationFactory(classType: ClassType): ClassType> { + class PaginationHost implements Pagination { + @ApiProperty({ type: () => classType, isArray: true }) + @Expose() + @Type(() => classType) + items: T[] + @Expose() + meta: PaginationMeta + } + return PaginationHost +} + +export class PaginationQueryParams { + @Expose() + @ApiPropertyOptional({ + type: Number, + example: 1, + required: false, + default: 1, + }) + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsNumber({}, { groups: [ValidationsGroupsEnum.default] }) + @Transform((value: string | undefined) => (value ? parseInt(value) : 1), { + toClassOnly: true, + }) + page?: number + + @Expose() + @ApiPropertyOptional({ + type: Number, + example: 10, + required: false, + default: 10, + }) + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsNumber({}, { groups: [ValidationsGroupsEnum.default] }) + @Transform((value: string | undefined) => (value ? parseInt(value) : 10), { + toClassOnly: true, + }) + limit?: number +} + +export class PaginationAllowsAllQueryParams { + @Expose() + @ApiPropertyOptional({ + type: Number, + example: 1, + required: false, + default: 1, + }) + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsNumber({}, { groups: [ValidationsGroupsEnum.default] }) + @Transform((value: string | undefined) => (value ? parseInt(value) : 1), { + toClassOnly: true, + }) + page?: number + + @Expose() + @ApiPropertyOptional({ + type: "number | 'all'", + example: 10, + required: false, + default: 10, + }) + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsNumberOrAll({ message: "test", groups: [ValidationsGroupsEnum.default] }) + @Transform( + (value: string | undefined) => { + if (value === "all") { + return value + } + return value ? parseInt(value) : 10 + }, + { + toClassOnly: true, + } + ) + limit?: number | "all" +} + +function IsNumberOrAll(validationOptions?: ValidationOptions) { + // eslint-disable-next-line @typescript-eslint/ban-types + return function (object: Object, propertyName: string) { + registerDecorator({ + name: "isNumberOrAll", + target: object.constructor, + propertyName: propertyName, + options: validationOptions, + validator: { + validate(value: unknown) { + return ( + (typeof value === "number" && !isNaN(value)) || + (typeof value === "string" && value === "all") + ) + }, + }, + }) + } +} diff --git a/backend/core/src/shared/dto/status.dto.ts b/backend/core/src/shared/dto/status.dto.ts new file mode 100644 index 0000000000..6718313e28 --- /dev/null +++ b/backend/core/src/shared/dto/status.dto.ts @@ -0,0 +1,9 @@ +import { IsString } from "class-validator" +import { Expose } from "class-transformer" +import { ValidationsGroupsEnum } from "../types/validations-groups-enum" + +export class StatusDto { + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + status: string +} diff --git a/backend/core/src/shared/entities/abstract.entity.ts b/backend/core/src/shared/entities/abstract.entity.ts new file mode 100644 index 0000000000..c7e7f70171 --- /dev/null +++ b/backend/core/src/shared/entities/abstract.entity.ts @@ -0,0 +1,24 @@ +import { CreateDateColumn, PrimaryGeneratedColumn, UpdateDateColumn } from "typeorm" +import { Expose, Type } from "class-transformer" +import { IsDate, IsString, IsUUID } from "class-validator" +import { ValidationsGroupsEnum } from "../types/validations-groups-enum" + +export class AbstractEntity { + @PrimaryGeneratedColumn("uuid") + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @IsUUID(4, { groups: [ValidationsGroupsEnum.default] }) + id: string + + @CreateDateColumn() + @Expose() + @IsDate({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => Date) + createdAt: Date + + @UpdateDateColumn() + @Expose() + @IsDate({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => Date) + updatedAt: Date +} diff --git a/backend/core/src/shared/entities/address.entity.ts b/backend/core/src/shared/entities/address.entity.ts new file mode 100644 index 0000000000..d7cc9cb8f2 --- /dev/null +++ b/backend/core/src/shared/entities/address.entity.ts @@ -0,0 +1,84 @@ +import { Column, CreateDateColumn, Entity, PrimaryGeneratedColumn, UpdateDateColumn } from "typeorm" +import { Expose, Type } from "class-transformer" +import { IsDate, IsNumber, IsOptional, IsString, IsUUID, MaxLength } from "class-validator" +import { ValidationsGroupsEnum } from "../types/validations-groups-enum" + +@Entity() +export class Address { + @PrimaryGeneratedColumn("uuid") + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @IsUUID(4, { groups: [ValidationsGroupsEnum.default] }) + id: string + + @CreateDateColumn() + @Expose() + @IsDate({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => Date) + createdAt: Date + + @UpdateDateColumn() + @Expose() + @IsDate({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => Date) + updatedAt: Date + + @Column({ type: "text", nullable: true }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @MaxLength(64, { groups: [ValidationsGroupsEnum.default] }) + placeName?: string + + @Column({ type: "text", nullable: true }) + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @MaxLength(64, { groups: [ValidationsGroupsEnum.default] }) + city: string + + @Column({ type: "text", nullable: true }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @MaxLength(64, { groups: [ValidationsGroupsEnum.default] }) + county?: string | null + + @Column({ type: "text", nullable: true }) + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @MaxLength(64, { groups: [ValidationsGroupsEnum.default] }) + state: string + + @Column({ type: "text", nullable: true }) + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @MaxLength(64, { groups: [ValidationsGroupsEnum.default] }) + street: string + + @Column({ type: "text", nullable: true }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @MaxLength(128, { groups: [ValidationsGroupsEnum.default] }) + street2?: string | null + + @Column({ type: "text", nullable: true }) + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @MaxLength(64, { groups: [ValidationsGroupsEnum.default] }) + zipCode: string + + @Column({ type: "numeric", nullable: true }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsNumber({}, { groups: [ValidationsGroupsEnum.default] }) + @Type(() => Number) + latitude?: number | null + + @Column({ type: "numeric", nullable: true }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsNumber({}, { groups: [ValidationsGroupsEnum.default] }) + @Type(() => Number) + longitude?: number | null +} diff --git a/backend/core/src/shared/filters/catch-all-filter.ts b/backend/core/src/shared/filters/catch-all-filter.ts new file mode 100644 index 0000000000..dfd06276cc --- /dev/null +++ b/backend/core/src/shared/filters/catch-all-filter.ts @@ -0,0 +1,21 @@ +import { ArgumentsHost, Catch } from "@nestjs/common" +import { BaseExceptionFilter } from "@nestjs/core" + +@Catch() +export class CatchAllFilter extends BaseExceptionFilter { + catch(exception: any, host: ArgumentsHost) { + console.error({ message: exception?.response?.message, stack: exception.stack, exception }) + if (exception.name === "EntityNotFound") { + const response = host.switchToHttp().getResponse() + response.status(404).json({ message: exception.message }) + } else if (exception.message === "tokenExpired") { + const response = host.switchToHttp().getResponse() + response.status(404).json({ message: exception.message }) + } else if (exception.response === "emailInUse") { + const response = host.switchToHttp().getResponse() + response.status(409).json({ message: "That email is already in use" }) + } else { + super.catch(exception, host) + } + } +} diff --git a/backend/core/src/shared/http-methods-to-actions.ts b/backend/core/src/shared/http-methods-to-actions.ts new file mode 100644 index 0000000000..0cec7a3ebc --- /dev/null +++ b/backend/core/src/shared/http-methods-to-actions.ts @@ -0,0 +1,9 @@ +import { authzActions } from "../auth/enum/authz-actions.enum" + +export const httpMethodsToAction = { + PUT: authzActions.update, + PATCH: authzActions.update, + DELETE: authzActions.delete, + POST: authzActions.create, + GET: authzActions.read, +} diff --git a/backend/core/src/shared/interceptors/listing-lang-cache.interceptor.ts b/backend/core/src/shared/interceptors/listing-lang-cache.interceptor.ts new file mode 100644 index 0000000000..526d14fdc0 --- /dev/null +++ b/backend/core/src/shared/interceptors/listing-lang-cache.interceptor.ts @@ -0,0 +1,45 @@ +import { + Injectable, + NestInterceptor, + ExecutionContext, + CallHandler, + Inject, + CACHE_MANAGER, +} from "@nestjs/common" +import { Observable, of } from "rxjs" +import { tap } from "rxjs/operators" +import { Cache } from "cache-manager" + +@Injectable() +export class ListingLangCacheInterceptor implements NestInterceptor { + constructor(@Inject(CACHE_MANAGER) private cacheManager: Cache) {} + + async intercept(context: ExecutionContext, next: CallHandler): Promise> { + try { + const [ + { + headers: { language }, + params: { listingId }, + query: { view }, + }, + ] = context.getArgs() + let cacheKey = language ? `${language}-${listingId}` : listingId + if (view) { + cacheKey = `${cacheKey}-${view}` + } + const cacheResult = await this.cacheManager.get(cacheKey) + if (cacheResult !== null) { + return of(cacheResult) + } else { + return next.handle().pipe( + tap((response) => { + void this.cacheManager.set(cacheKey, response) + }) + ) + } + } catch (e) { + console.log("Get Cache Error = ", e) + return next.handle() + } + } +} diff --git a/backend/core/src/shared/listings-loader.ts b/backend/core/src/shared/listings-loader.ts new file mode 100644 index 0000000000..0db799599e --- /dev/null +++ b/backend/core/src/shared/listings-loader.ts @@ -0,0 +1,32 @@ +import path from "path" +import fs from "fs" + +const parseJsonFile = (filePath: string) => { + const data = fs.readFileSync(filePath) + + return JSON.parse(data.toString()) +} + +export default (folderName: string) => { + return new Promise((resolve, reject) => { + const listings = [] + const directoryPath = path.join(__dirname, "..", "..", folderName) + + fs.readdir(directoryPath, (err, files) => { + if (err) { + console.log("Unable to scan directory: " + err.message) + reject() + } + + for (const file of files) { + const filePath = path.join(__dirname, "..", "..", folderName, file) + if (/\.json$/.exec(filePath)) { + const listingJSON = parseJsonFile(filePath) + listings.push(listingJSON) + } + } + + resolve(listings) + }) + }) +} diff --git a/backend/core/src/shared/mapTo.ts b/backend/core/src/shared/mapTo.ts new file mode 100644 index 0000000000..48d7dc32eb --- /dev/null +++ b/backend/core/src/shared/mapTo.ts @@ -0,0 +1,12 @@ +import { ClassTransformOptions, plainToClass } from "class-transformer" +import { ClassType } from "class-transformer/ClassTransformer" + +export function mapTo(cls: ClassType, plain: V[], options?: ClassTransformOptions): T[] +export function mapTo(cls: ClassType, plain: V, options?: ClassTransformOptions): T + +export function mapTo(cls: ClassType, plain, options?: ClassTransformOptions) { + return plainToClass(cls, plain, { + ...options, + excludeExtraneousValues: true, + }) +} diff --git a/backend/core/src/shared/middlewares/logger.middleware.ts b/backend/core/src/shared/middlewares/logger.middleware.ts new file mode 100644 index 0000000000..3f3d7453b9 --- /dev/null +++ b/backend/core/src/shared/middlewares/logger.middleware.ts @@ -0,0 +1,8 @@ +import { Logger } from "@nestjs/common" +import { Request, Response, NextFunction } from "express" + +export function logger(req: Request, res: Response, next: NextFunction) { + const dateStr = new Date().toISOString() + Logger.log(`[${req.method}] ${dateStr}: ${req.path}`) + next() +} diff --git a/backend/core/src/shared/password-regex.ts b/backend/core/src/shared/password-regex.ts new file mode 100644 index 0000000000..fd4c20fdf1 --- /dev/null +++ b/backend/core/src/shared/password-regex.ts @@ -0,0 +1 @@ +export const passwordRegex = /^(?=.*[0-9])(?=.*[a-zA-Z])([a-zA-Z0-9]+).{7,}$/ diff --git a/backend/core/src/shared/query-filter/base-query-filter.ts b/backend/core/src/shared/query-filter/base-query-filter.ts new file mode 100644 index 0000000000..dbaa83cbe9 --- /dev/null +++ b/backend/core/src/shared/query-filter/base-query-filter.ts @@ -0,0 +1,91 @@ +import { HttpException, HttpStatus } from "@nestjs/common" +import { Compare } from "../dto/filter.dto" +import { WhereExpression } from "typeorm" +import { IBaseQueryFilter } from "./index" + +export class BaseQueryFilter implements IBaseQueryFilter { + protected static _shouldSkipKey(filter, filterKey) { + return ( + filter[filterKey] === undefined || filter[filterKey] === null || filterKey === "$comparison" + ) + } + protected static _isSupportedFilterTypeOrThrow( + filterType, + filterTypeToFieldMap: FilterFieldMap + ) { + if (!(filterType in filterTypeToFieldMap)) { + throw new HttpException("Filter Not Implemented", HttpStatus.NOT_IMPLEMENTED) + } + } + protected static _getComparisonOperator(filter) { + return filter["$comparison"] + } + + protected static _getFilterField( + filterKey, + filterTypeToFieldMap: FilterFieldMap + ) { + return filterTypeToFieldMap[filterKey] + } + + protected static _getFilterValue(filter, filterKey) { + return filter[filterKey] + } + + protected static _getWhereParameterName(filterKey, index) { + return `${filterKey}_${index}` + } + + protected static _compare(qb, filter, filterKey, filterTypeToFieldMap, index) { + const whereParameterName = BaseQueryFilter._getWhereParameterName(filterKey, index) + const filterField = BaseQueryFilter._getFilterField(filterKey, filterTypeToFieldMap) + const filterValue = BaseQueryFilter._getFilterValue(filter, filterKey) + const comparison = BaseQueryFilter._getComparisonOperator(filter) + switch (comparison) { + case Compare.IN: + qb.andWhere(`LOWER(CAST(${filterField} as text)) IN (:...${whereParameterName})`, { + [whereParameterName]: filterValue + .split(",") + .map((s) => s.trim().toLowerCase()) + .filter((s) => s.length !== 0), + }) + break + case Compare["<>"]: + case Compare["="]: + case Compare[">="]: + qb.andWhere( + `LOWER(CAST(${filterField} as text)) ${comparison} LOWER(:${whereParameterName})`, + { + [whereParameterName]: filterValue, + } + ) + break + case Compare.NA: + // If we're here, it's because we expected this filter to be handled by a custom filter handler + // that ignores the $comparison param, but it was not. + throw new HttpException( + `Filter "${filter}" expected to be handled by a custom filter handler, but one was not implemented.`, + HttpStatus.NOT_IMPLEMENTED + ) + default: + throw new HttpException("Comparison Not Implemented", HttpStatus.NOT_IMPLEMENTED) + } + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + addFilters( + filters: FilterParams, + filterTypeToFieldMap: FilterFieldMap, + qb: WhereExpression + ) { + for (const [index, filter] of filters.entries()) { + for (const filterKey in filter) { + if (BaseQueryFilter._shouldSkipKey(filter, filterKey)) { + continue + } + BaseQueryFilter._isSupportedFilterTypeOrThrow(filterKey, filterTypeToFieldMap) + BaseQueryFilter._compare(qb, filter, filterKey, filterTypeToFieldMap, index) + } + } + } +} diff --git a/backend/core/src/shared/query-filter/custom_filters.ts b/backend/core/src/shared/query-filter/custom_filters.ts new file mode 100644 index 0000000000..46f9c05e4c --- /dev/null +++ b/backend/core/src/shared/query-filter/custom_filters.ts @@ -0,0 +1,104 @@ +import { getMetadataArgsStorage, WhereExpression } from "typeorm" +import { AvailabilityFilterEnum } from "../../listings/types/listing-filter-keys-enum" +import { UnitGroup } from "../../units-summary/entities/unit-group.entity" +import { UnitType } from "../../unit-types/entities/unit-type.entity" + +export function addAvailabilityQuery( + qb: WhereExpression, + filterValue: AvailabilityFilterEnum, + includeNulls?: boolean +) { + const whereParameterName = "availability" + switch (filterValue) { + case AvailabilityFilterEnum.hasAvailability: + qb.andWhere( + `(unitGroups.total_available >= :${whereParameterName}${ + includeNulls ? ` OR unitGroups.total_available IS NULL` : "" + })`, + { + [whereParameterName]: 1, + } + ) + return + case AvailabilityFilterEnum.noAvailability: + qb.andWhere( + `(unitGroups.total_available = :${whereParameterName}${ + includeNulls ? ` OR unitGroups.total_available IS NULL` : "" + })`, + { + [whereParameterName]: 0, + } + ) + return + case AvailabilityFilterEnum.waitlist: + qb.andWhere( + `(listings.is_waitlist_open = :${whereParameterName}${ + includeNulls ? ` OR listings.is_waitlist_open is NULL` : "" + })`, + { + [whereParameterName]: true, + } + ) + return + default: + return + } +} + +export function addBedroomsQuery(qb: WhereExpression, filterValue: number[]) { + const typeOrmMetadata = getMetadataArgsStorage() + const unitGroupEntityMetadata = typeOrmMetadata.tables.find((table) => table.target === UnitGroup) + const unitTypeEntityMetadata = typeOrmMetadata.tables.find((table) => table.target === UnitType) + const whereParameterName = "unitGroups_numBedrooms" + + const unitGroupUnitTypeJoinTableName = `${unitGroupEntityMetadata.name}_unit_type_${unitTypeEntityMetadata.name}` + qb.andWhere( + ` + ( + SELECT bool_or(num_bedrooms IN (:...${whereParameterName})) FROM ${unitGroupEntityMetadata.name} + LEFT JOIN ${unitGroupUnitTypeJoinTableName} ON ${unitGroupUnitTypeJoinTableName}.unit_group_id = ${unitGroupEntityMetadata.name}.id + LEFT JOIN ${unitTypeEntityMetadata.name} ON ${unitTypeEntityMetadata.name}.id = ${unitGroupUnitTypeJoinTableName}.unit_types_id + WHERE ${unitGroupEntityMetadata.name}.listing_id = listings.id + ) = true + `, + { + [whereParameterName]: filterValue, + } + ) + return +} + +export function addMinAmiPercentageFilter( + qb: WhereExpression, + filterValue: number, + includeNulls?: boolean +) { + const whereParameterName = "amiPercentage_unitGroups" + const whereParameterName2 = "amiPercentage_listings" + + // Check the listing.ami_percentage field iff the field is not set on the Unit Groups table. + qb.andWhere( + `(("unitGroupsAmiLevels"."ami_percentage" IS NOT NULL AND "unitGroupsAmiLevels"."ami_percentage" >= :${whereParameterName}) ` + + `OR ("unitGroupsAmiLevels"."ami_percentage" IS NULL AND listings.ami_percentage_max >= :${whereParameterName2}) + ${ + includeNulls + ? `OR "unitGroupsAmiLevels"."ami_percentage" is NULL AND listings.ami_percentage_max is NULL` + : "" + })`, + { + [whereParameterName]: filterValue, + [whereParameterName2]: filterValue, + } + ) + return +} + +export function addFavoritedFilter(qb: WhereExpression, filterValue: string) { + const val = filterValue.split(",").filter((elem) => !!elem) + if (val.length) { + qb.andWhere("listings.id IN (:...favoritedListings) ", { + favoritedListings: val, + }) + } + return +} diff --git a/backend/core/src/shared/query-filter/index.spec.ts b/backend/core/src/shared/query-filter/index.spec.ts new file mode 100644 index 0000000000..c580323cbf --- /dev/null +++ b/backend/core/src/shared/query-filter/index.spec.ts @@ -0,0 +1,73 @@ +import { addFilters } from "." +import { HttpException } from "@nestjs/common" +import { filterTypeToFieldMap } from "../../listings/dto/filter-type-to-field-map" + +const mockQueryBuilder = { + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + orWhere: jest.fn().mockReturnThis(), + whereInIds: jest.fn().mockReturnThis(), + andWhereInIds: jest.fn().mockReturnThis(), + orWhereInIds: jest.fn().mockReturnThis(), +} + +describe("FilterAdder", () => { + afterEach(() => { + jest.clearAllMocks() + }) + + describe("addFilter", () => { + it("should not call where when no filters are passed", () => { + const filter = {} + + addFilters([filter], filterTypeToFieldMap, mockQueryBuilder) + + expect(mockQueryBuilder.where).not.toHaveBeenCalled() + expect(mockQueryBuilder.andWhere).not.toHaveBeenCalled() + expect(mockQueryBuilder.orWhere).not.toHaveBeenCalled() + }) + + it("should add where clause when name is passed", () => { + const filter = { + $comparison: "=", + name: "Coliseum", + } + + addFilters([filter], filterTypeToFieldMap, mockQueryBuilder) + + expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith(expect.stringContaining("="), { + name_0: expect.stringContaining("Coliseum"), + }) + }) + + it("should throw an exception when filter is not supported", () => { + const filter = { + $comparison: "=", + abc: "Test", + } + + // This extra function wrapper is needed to catch the exception. + expect(() => { + addFilters([filter], filterTypeToFieldMap, mockQueryBuilder) + }).toThrow(HttpException) + expect(() => { + addFilters([filter], filterTypeToFieldMap, mockQueryBuilder) + }).toThrow("Filter Not Implemented") + }) + + it("should throw an exception when comparison is not supported", () => { + const filter = { + $comparison: "abc", + name: "Test", + } + + // This extra function wrapper is needed to catch the exception. + expect(() => { + addFilters([filter], filterTypeToFieldMap, mockQueryBuilder) + }).toThrow(HttpException) + expect(() => { + addFilters([filter], filterTypeToFieldMap, mockQueryBuilder) + }).toThrow("Comparison Not Implemented") + }) + }) +}) diff --git a/backend/core/src/shared/query-filter/index.ts b/backend/core/src/shared/query-filter/index.ts new file mode 100644 index 0000000000..3efce0165f --- /dev/null +++ b/backend/core/src/shared/query-filter/index.ts @@ -0,0 +1,138 @@ +import { HttpException, HttpStatus } from "@nestjs/common" +import { WhereExpression } from "typeorm" +import { Compare } from "../dto/filter.dto" +import { + AvailabilityFilterEnum, + ListingFilterKeys, +} from "../../listings/types/listing-filter-keys-enum" +import { + addAvailabilityQuery, + addBedroomsQuery, + addMinAmiPercentageFilter, + addFavoritedFilter, +} from "./custom_filters" +import { UserFilterKeys } from "../../auth/types/user-filter-keys" +import { addIsPortalUserQuery } from "../../auth/filters/user-query-filter" + +export interface IBaseQueryFilter { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + addFilters( + filters: FilterParams, + filterTypeToFieldMap: FilterFieldMap, + qb: WhereExpression + ) +} + +/** + * + * @param filterParams + * @param filterTypeToFieldMap + * @param qb The query on which filters are applied. + */ +/** + * Add filters to provided QueryBuilder, using the provided map to find the field name. + * The order of the params matters: + * - A $comparison must be first. + * - Comparisons in $comparison will be applied to each filter in order. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function addFilters, FilterFieldMap>( + filters: FilterParams, + filterTypeToFieldMap: FilterFieldMap, + qb: WhereExpression +): void { + for (const [index, filter] of filters.entries()) { + const comparison = filter["$comparison"] + const includeNulls = filter["$include_nulls"] + for (const filterKey in filter) { + if ( + filter[filterKey] === undefined || + filter[filterKey] === null || + filterKey === "$comparison" || + filterKey === "$include_nulls" + ) { + continue + } + // Throw if this is not a supported filter type + if (!(filterKey in ListingFilterKeys || filterKey in UserFilterKeys)) { + throw new HttpException("Filter Not Implemented", HttpStatus.NOT_IMPLEMENTED) + } + + const filterValue = filter[filterKey] + // Handle custom filters here, before dropping into generic filter handler + switch (filterKey) { + // custom listing filters + case ListingFilterKeys.availability: + addAvailabilityQuery(qb, filterValue as AvailabilityFilterEnum, includeNulls) + continue + case ListingFilterKeys.bedrooms: + addBedroomsQuery( + qb, + typeof filterValue === "string" + ? filterValue.split(",").map((val) => Number(val)) + : [filterValue] + ) + continue + case ListingFilterKeys.minAmiPercentage: + addMinAmiPercentageFilter(qb, parseInt(filterValue), includeNulls) + continue + case ListingFilterKeys.favorited: + addFavoritedFilter(qb, filterValue) + continue + // custom user filters + case UserFilterKeys.isPortalUser: + addIsPortalUserQuery(qb, filterValue) + continue + } + + const whereParameterName = `${filterKey}_${index}` + const filterField = filterTypeToFieldMap[filterKey] + switch (comparison) { + case Compare.IN: + qb.andWhere( + `(LOWER(CAST(${filterField} as text)) IN (:...${whereParameterName})${ + includeNulls ? ` OR ${filterField} IS NULL` : "" + })`, + { + [whereParameterName]: String(filterValue) + .split(",") + .map((s) => s.trim().toLowerCase()) + .filter((s) => s.length !== 0), + } + ) + break + case Compare["<>"]: + case Compare["="]: + qb.andWhere( + `(LOWER(CAST(${filterField} as text)) ${comparison} LOWER(:${whereParameterName})${ + includeNulls ? ` OR ${filterField} IS NULL` : "" + })`, + { + [whereParameterName]: filterValue, + } + ) + break + case Compare[">="]: + case Compare["<="]: + qb.andWhere( + `(${filterField} ${comparison} :${whereParameterName}${ + includeNulls ? ` OR ${filterField} IS NULL` : "" + })`, + { + [whereParameterName]: filterValue, + } + ) + break + case Compare.NA: + // If we're here, it's because we expected this filter to be handled by a custom filter handler + // that ignores the $comparison param, but it was not. + throw new HttpException( + `Filter "${filter}" expected to be handled by a custom filter handler, but one was not implemented.`, + HttpStatus.NOT_IMPLEMENTED + ) + default: + throw new HttpException("Comparison Not Implemented", HttpStatus.NOT_IMPLEMENTED) + } + } + } +} diff --git a/backend/core/src/shared/services/abstract-service.ts b/backend/core/src/shared/services/abstract-service.ts new file mode 100644 index 0000000000..2b463f0ed1 --- /dev/null +++ b/backend/core/src/shared/services/abstract-service.ts @@ -0,0 +1,61 @@ +import { FindManyOptions, FindOneOptions, Repository } from "typeorm" +import { Inject, NotFoundException } from "@nestjs/common" +import { getRepositoryToken } from "@nestjs/typeorm" +import { EntityClassOrSchema } from "@nestjs/typeorm/dist/interfaces/entity-class-or-schema.type" +import { ClassType } from "class-transformer/ClassTransformer" +import { assignDefined } from "../utils/assign-defined" + +export interface GenericUpdateDto { + id?: string +} + +export interface AbstractService { + list(findConditions?: FindManyOptions): Promise + create(dto: TCreateDto): Promise + findOne(findConditions: FindOneOptions): Promise + delete(objId: string): Promise + update(dto: TUpdateDto): Promise +} + +export function AbstractServiceFactory( + entity: EntityClassOrSchema & ClassType +): ClassType> { + class AbstractServiceHost implements AbstractService { + @Inject(getRepositoryToken(entity)) repository: Repository + + list(findConditions?: FindManyOptions): Promise { + return this.repository.find(findConditions) + } + + async create(dto: TCreateDto): Promise { + return await this.repository.save(dto) + } + + async findOne(findConditions: FindOneOptions): Promise { + const obj = await this.repository.findOne(findConditions) + if (!obj) { + throw new NotFoundException() + } + return obj + } + + async delete(objId: string) { + await this.repository.delete(objId) + } + + async update(dto: TUpdateDto) { + const obj = await this.repository.findOne({ + where: { + id: dto.id, + }, + }) + if (!obj) { + throw new NotFoundException() + } + assignDefined(obj, dto) + await this.repository.save(obj) + return obj + } + } + return AbstractServiceHost +} diff --git a/backend/core/src/shared/services/county-code-resolver.service.ts b/backend/core/src/shared/services/county-code-resolver.service.ts new file mode 100644 index 0000000000..03109dd02a --- /dev/null +++ b/backend/core/src/shared/services/county-code-resolver.service.ts @@ -0,0 +1,17 @@ +import { Inject, Injectable, Scope } from "@nestjs/common" +import { REQUEST } from "@nestjs/core" +import { Request as ExpressRequest } from "express" +import { CountyCode } from "../types/county-code" + +@Injectable({ scope: Scope.REQUEST }) +export class CountyCodeResolverService { + constructor(@Inject(REQUEST) private req: ExpressRequest) {} + + getCountyCode(): CountyCode { + const countyCode: CountyCode | undefined = CountyCode[this.req.get("county-code")] + if (!countyCode) { + return CountyCode.detroit + } + return countyCode + } +} diff --git a/backend/core/src/shared/shared.module.ts b/backend/core/src/shared/shared.module.ts new file mode 100644 index 0000000000..d878195615 --- /dev/null +++ b/backend/core/src/shared/shared.module.ts @@ -0,0 +1,36 @@ +import { Module } from "@nestjs/common" +import { ConfigModule, ConfigService } from "@nestjs/config" +import Joi from "joi" + +@Module({ + imports: [ + ConfigModule.forRoot({ + validationSchema: Joi.object({ + PORT: Joi.number().default(3100).required(), + NODE_ENV: Joi.string() + .valid("development", "staging", "production", "test") + .default("development"), + EMAIL_API_KEY: Joi.string().required(), + DATABASE_URL: Joi.string().required(), + REDIS_TLS_URL: Joi.string().required(), + REDIS_USE_TLS: Joi.number().required(), + THROTTLE_TTL: Joi.number().default(1), + THROTTLE_LIMIT: Joi.number().default(100), + APP_SECRET: Joi.string().required().min(16), + CLOUDINARY_SECRET: Joi.string().required(), + CLOUDINARY_KEY: Joi.string().required(), + PARTNERS_PORTAL_URL: Joi.string().required(), + MFA_CODE_LENGTH: Joi.number().default(6), + MFA_CODE_VALID_MS: Joi.number().default(1000 * 60 * 5), + TWILIO_ACCOUNT_SID: Joi.string().default("AC_dummy_account_sid"), + TWILIO_AUTH_TOKEN: Joi.string().default("dummy_auth_token"), + TWILIO_PHONE_NUMBER: Joi.string().default("dummy_phone_number"), + AUTH_LOCK_LOGIN_AFTER_FAILED_ATTEMPTS: Joi.number().default(5), + AUTH_LOCK_LOGIN_COOLDOWN_MS: Joi.number().default(1000 * 60 * 30), + }), + }), + ], + providers: [ConfigService], + exports: [ConfigService], +}) +export class SharedModule {} diff --git a/backend/core/src/shared/types/county-code.ts b/backend/core/src/shared/types/county-code.ts new file mode 100644 index 0000000000..778dae8f05 --- /dev/null +++ b/backend/core/src/shared/types/county-code.ts @@ -0,0 +1,7 @@ +// necessary for older migrations +export enum CountyCode { + alameda = "Alameda", + san_mateo = "San Mateo", + san_jose = "San Jose", + detroit = "Detroit", +} diff --git a/backend/core/src/shared/types/input-type.ts b/backend/core/src/shared/types/input-type.ts new file mode 100644 index 0000000000..b1dd10658f --- /dev/null +++ b/backend/core/src/shared/types/input-type.ts @@ -0,0 +1,6 @@ +export enum InputType { + boolean = "boolean", + text = "text", + address = "address", + hhMemberSelect = "hhMemberSelect", +} diff --git a/backend/core/src/shared/types/language-enum.ts b/backend/core/src/shared/types/language-enum.ts new file mode 100644 index 0000000000..245b5c8110 --- /dev/null +++ b/backend/core/src/shared/types/language-enum.ts @@ -0,0 +1,7 @@ +export enum Language { + en = "en", + es = "es", + vi = "vi", + zh = "zh", + tl = "tl", +} diff --git a/backend/core/src/shared/types/redis-types.ts b/backend/core/src/shared/types/redis-types.ts new file mode 100644 index 0000000000..1867aeb2a9 --- /dev/null +++ b/backend/core/src/shared/types/redis-types.ts @@ -0,0 +1,12 @@ +import { Store } from "cache-manager" +import Redis from "redis" + +interface RedisStore extends Store { + name: "redis" + getClient: () => Redis.RedisClient + isCacheableValue: (value: unknown) => boolean +} + +export interface RedisCache extends Cache { + store: RedisStore +} diff --git a/backend/core/src/shared/types/validations-groups-enum.ts b/backend/core/src/shared/types/validations-groups-enum.ts new file mode 100644 index 0000000000..aa70c135c3 --- /dev/null +++ b/backend/core/src/shared/types/validations-groups-enum.ts @@ -0,0 +1,5 @@ +export enum ValidationsGroupsEnum { + default = "default", + partners = "partners", + applicants = "applicants", +} diff --git a/backend/core/src/shared/unit-transformation-helpers.spec.ts b/backend/core/src/shared/unit-transformation-helpers.spec.ts new file mode 100644 index 0000000000..76ca3cc036 --- /dev/null +++ b/backend/core/src/shared/unit-transformation-helpers.spec.ts @@ -0,0 +1,74 @@ +import { MinMax } from "../units/types/min-max" +import { setMinMax } from "./unit-transformation-helpers" + +describe("Unit Transformation Helpers", () => { + it("setsMinMax when range is undefined", () => { + let testRange: MinMax + + // eslint-disable-next-line prefer-const + testRange = setMinMax(testRange, 5) + + expect(testRange.min).toBe(5) + expect(testRange.max).toBe(5) + }) + + it("setsMinMax updates max when range is already set", () => { + let testRange: MinMax = { + min: 1, + max: 5, + } + + testRange = setMinMax(testRange, 7) + + expect(testRange.min).toBe(1) + expect(testRange.max).toBe(7) + }) + + it("SetsMinMax updates min when range is already set", () => { + let testRange: MinMax = { + min: 3, + max: 5, + } + + testRange = setMinMax(testRange, 1) + + expect(testRange.min).toBe(1) + expect(testRange.max).toBe(5) + }) + + it("SetsMinMax doesn't update if value already set as min", () => { + let testRange: MinMax = { + min: 1, + max: 5, + } + + testRange = setMinMax(testRange, 1) + + expect(testRange.min).toBe(1) + expect(testRange.max).toBe(5) + }) + + it("SetsMinMax doesn't update if value already set as max", () => { + let testRange: MinMax = { + min: 1, + max: 5, + } + + testRange = setMinMax(testRange, 5) + + expect(testRange.min).toBe(1) + expect(testRange.max).toBe(5) + }) + + it("SetsMinMax returns range if value is null", () => { + let testRange: MinMax = { + min: 1, + max: 5, + } + + testRange = setMinMax(testRange, null) + + expect(testRange.min).toBe(1) + expect(testRange.max).toBe(5) + }) +}) diff --git a/backend/core/src/shared/unit-transformation-helpers.ts b/backend/core/src/shared/unit-transformation-helpers.ts new file mode 100644 index 0000000000..b44f96aa54 --- /dev/null +++ b/backend/core/src/shared/unit-transformation-helpers.ts @@ -0,0 +1,16 @@ +import { MinMax } from "../units/types/min-max" + +export function setMinMax(range: MinMax, value: number): MinMax { + if (value === null) return range + if (range === undefined) { + return { + min: value, + max: value, + } + } else { + range.min = Math.min(range.min, value) + range.max = Math.max(range.max, value) + + return range + } +} diff --git a/backend/core/src/shared/units-transformations.spec.ts b/backend/core/src/shared/units-transformations.spec.ts new file mode 100644 index 0000000000..92bb5a9a92 --- /dev/null +++ b/backend/core/src/shared/units-transformations.spec.ts @@ -0,0 +1,13 @@ +import { getUnitGroupSummary, getHouseholdMaxIncomeSummary } from "./units-transformations" + +describe("getUnitGroupSummary", () => { + it("plaeholder", () => { + // + }) +}) + +describe("getHouseholdMaxIncomeSummary", () => { + it("placeholder", () => { + // + }) +}) diff --git a/backend/core/src/shared/units-transformations.ts b/backend/core/src/shared/units-transformations.ts new file mode 100644 index 0000000000..08da68e6d8 --- /dev/null +++ b/backend/core/src/shared/units-transformations.ts @@ -0,0 +1,163 @@ +import { UnitGroupSummary } from "../units/types/unit-group-summary" +import { UnitTypeSummary } from "../units/types/unit-type-summary" + +import { HouseholdMaxIncomeSummary } from "../units/types/household-max-income-summary" +import { UnitSummaries } from "../units/types/unit-summaries" + +import { AmiChart } from "../ami-charts/entities/ami-chart.entity" + +import { UnitGroup } from "../units-summary/entities/unit-group.entity" +import { MinMax } from "../units/types/min-max" +import { MonthlyRentDeterminationType } from "../units-summary/types/monthly-rent-determination.enum" +import { HUDMSHDA2021 } from "../seeder/seeds/ami-charts/HUD-MSHDA2021" +import { setMinMax } from "./unit-transformation-helpers" + +// One row for every unit group, with rent and ami ranges across all ami levels +// Used to display the main pricing table +export const getUnitGroupSummary = (unitGroups: UnitGroup[] = []): UnitGroupSummary[] => { + const summary: UnitGroupSummary[] = [] + + const sortedUnitGroups = unitGroups?.sort( + (a, b) => + a.unitType.sort((c, d) => c.numBedrooms - d.numBedrooms)[0].numBedrooms - + b.unitType.sort((e, f) => e.numBedrooms - f.numBedrooms)[0].numBedrooms + ) + + sortedUnitGroups.forEach((group) => { + let rentAsPercentIncomeRange: MinMax, rentRange: MinMax, amiPercentageRange: MinMax + group.amiLevels.forEach((level) => { + if (level.monthlyRentDeterminationType === MonthlyRentDeterminationType.flatRent) { + rentRange = setMinMax(rentRange, level.flatRentValue) + } else { + rentAsPercentIncomeRange = setMinMax( + rentAsPercentIncomeRange, + level.percentageOfIncomeValue + ) + } + + amiPercentageRange = setMinMax(amiPercentageRange, level.amiPercentage) + }) + const groupSummary: UnitGroupSummary = { + unitTypes: group.unitType + .sort((a, b) => (a.numBedrooms < b.numBedrooms ? -1 : 1)) + .map((type) => type.name), + rentAsPercentIncomeRange, + rentRange: rentRange && { + min: rentRange.min ? `$${rentRange.min}` : "", + max: rentRange.max ? `$${rentRange.max}` : "", + }, + amiPercentageRange, + openWaitlist: group.openWaitlist, + unitVacancies: group.totalAvailable, + bathroomRange: { + min: group.bathroomMin, + max: group.bathroomMax, + }, + floorRange: { + min: group.floorMin, + max: group.floorMax, + }, + sqFeetRange: { + min: group.sqFeetMin, + max: group.sqFeetMax, + }, + } + summary.push(groupSummary) + }) + + return summary +} + +// One row for every household size, with max income ranged pulled from all ami charts +// Used to display the maximum income table +export const getHouseholdMaxIncomeSummary = ( + unitGroups: UnitGroup[] = [], + amiCharts: AmiChart[] +): HouseholdMaxIncomeSummary => { + const columns = { + householdSize: "householdSize", + } + const rows = [] + + if (!amiCharts || (amiCharts && amiCharts.length === 0)) { + return { + columns, + rows, + } + } + + // if there are two amiCharts (Detroit only has two), then we can use HUD-MSHDA2021, which is a merge of the two, with the max values of both (HUD had higher values for 30 and 80%) + const amiChartItems = amiCharts.length === 2 ? HUDMSHDA2021.items : amiCharts[0].items + + let occupancyRange: MinMax + const amiPercentages = new Set() + // aggregate household sizes across all groups based off of the occupancy range + unitGroups.forEach((group) => { + if (occupancyRange === undefined) { + occupancyRange = { + min: group.minOccupancy, + max: group.maxOccupancy, + } + } else { + occupancyRange.min = Math.min(occupancyRange.min, group.minOccupancy) + occupancyRange.max = Math.max(occupancyRange.max, group.maxOccupancy) + } + + group.amiLevels.forEach((level) => { + amiPercentages.add(level.amiPercentage) + }) + }) + + Array.from(amiPercentages) + .filter((percentage) => percentage !== null) + .sort() + .forEach((percentage) => { + // preface with percentage to keep insertion order + columns[`percentage${percentage}`] = percentage + }) + + const hmiMap = {} + + // for the occupancy range, get the max income per percentage of AMI across the AMI charts + amiChartItems.forEach((item) => { + if ( + item.householdSize >= occupancyRange.min && + item.householdSize <= occupancyRange.max && + amiPercentages.has(item.percentOfAmi) + ) { + if (hmiMap[item.householdSize] === undefined) { + hmiMap[item.householdSize] = {} + } + + hmiMap[item.householdSize][item.percentOfAmi] = item.income + } + }) + + // set rows from hmiMap + for (const householdSize in hmiMap) { + const obj = { + householdSize, + } + for (const ami in hmiMap[householdSize]) { + obj[`percentage${ami}`] = hmiMap[householdSize][ami] + } + rows.push(obj) + } + + return { + columns, + rows, + } +} + +export const summarizeUnits = (units: UnitGroup[], amiCharts: AmiChart[]): UnitSummaries => { + const data = {} as UnitSummaries + + if (!units || (units && units.length === 0)) { + return data + } + + data.unitGroupSummary = getUnitGroupSummary(units) + data.householdMaxIncomeSummary = getHouseholdMaxIncomeSummary(units, amiCharts) + return data +} diff --git a/backend/core/src/shared/url-helper.ts b/backend/core/src/shared/url-helper.ts new file mode 100644 index 0000000000..521dbd2fde --- /dev/null +++ b/backend/core/src/shared/url-helper.ts @@ -0,0 +1,32 @@ +/** + * Formats the input string as a URL slug. + * This includes the following transformations: + * - All lowercase + * - Remove special characters + * - snake_case + * @param input + */ +import { Listing } from "../listings/entities/listing.entity" + +export const formatUrlSlug = (input: string): string => { + return ( + ( + (input || "") + // Divide into words based on upper case letters followed by lower case letters + .match(/[A-Z]{2,}(?=[A-Z][a-z]+[0-9]*|\b)|[A-Z]?[a-z]+[0-9]*|[A-Z]+|[0-9]+/g) || [] + ) + .join("_") + .toLowerCase() + ) +} + +export const listingUrlSlug = (listing: Listing): string => { + const { name } = listing + + if (listing?.property?.buildingAddress) { + const { city, street, state } = listing?.property?.buildingAddress + return formatUrlSlug([name, street, city, state].join(" ")) + } else { + return formatUrlSlug(name) + } +} diff --git a/backend/core/src/shared/utils/array-index.ts b/backend/core/src/shared/utils/array-index.ts new file mode 100644 index 0000000000..f373449c51 --- /dev/null +++ b/backend/core/src/shared/utils/array-index.ts @@ -0,0 +1,6 @@ +export function arrayIndex(array: Array, key: string): Partial { + return array.reduce((object: Partial, element: unknown) => { + object[element[key]] = element + return object + }, {}) +} diff --git a/backend/core/src/shared/utils/assign-defined.ts b/backend/core/src/shared/utils/assign-defined.ts new file mode 100644 index 0000000000..d2ce1de41e --- /dev/null +++ b/backend/core/src/shared/utils/assign-defined.ts @@ -0,0 +1,12 @@ +// https://stackoverflow.com/a/39514270/2429333 +export function assignDefined(target, ...sources) { + for (const source of sources) { + for (const key of Object.keys(source)) { + const val = source[key] + if (val !== undefined) { + target[key] = val + } + } + } + return target +} diff --git a/backend/core/src/shared/utils/cap-and-split.ts b/backend/core/src/shared/utils/cap-and-split.ts new file mode 100644 index 0000000000..74927f7262 --- /dev/null +++ b/backend/core/src/shared/utils/cap-and-split.ts @@ -0,0 +1,7 @@ +import { capitalizeFirstLetter } from "./capitalize-first-letter" + +export function capAndSplit(str: string): string { + let newStr = capitalizeFirstLetter(str) + newStr = newStr.split(/(?=[A-Z])/).join(" ") + return newStr +} diff --git a/backend/core/src/shared/utils/capitalize-first-letter.ts b/backend/core/src/shared/utils/capitalize-first-letter.ts new file mode 100644 index 0000000000..13061c721e --- /dev/null +++ b/backend/core/src/shared/utils/capitalize-first-letter.ts @@ -0,0 +1,3 @@ +export function capitalizeFirstLetter(str: string): string { + return str.charAt(0).toUpperCase() + str.slice(1) +} diff --git a/backend/core/src/shared/utils/deep-find.ts b/backend/core/src/shared/utils/deep-find.ts new file mode 100644 index 0000000000..6022e37556 --- /dev/null +++ b/backend/core/src/shared/utils/deep-find.ts @@ -0,0 +1,13 @@ +export function deepFind(obj, path) { + const paths = path.split(".") + let current = obj + + for (let i = 0; i < paths.length; ++i) { + if (current[paths[i]] == undefined) { + return undefined + } else { + current = current[paths[i]] + } + } + return current +} diff --git a/backend/core/src/shared/utils/format-boolean.ts b/backend/core/src/shared/utils/format-boolean.ts new file mode 100644 index 0000000000..cf91c5b89e --- /dev/null +++ b/backend/core/src/shared/utils/format-boolean.ts @@ -0,0 +1,3 @@ +export function formatBoolean(val) { + return val ? "Yes" : "No" +} diff --git a/backend/core/src/shared/utils/get-birthday.ts b/backend/core/src/shared/utils/get-birthday.ts new file mode 100644 index 0000000000..dd8b2e4c29 --- /dev/null +++ b/backend/core/src/shared/utils/get-birthday.ts @@ -0,0 +1,7 @@ +export function getBirthday(day, month, year) { + let birthday = "" + if (day && month && year) { + birthday = `${month}/${day}/${year}` + } + return birthday +} diff --git a/backend/core/src/shared/utils/is-empty.ts b/backend/core/src/shared/utils/is-empty.ts new file mode 100644 index 0000000000..092f6898c2 --- /dev/null +++ b/backend/core/src/shared/utils/is-empty.ts @@ -0,0 +1,15 @@ +/** + * Determines if object is an empty array, empty object, undefined, null or an + * empty string. + */ +export function isEmpty(obj: unknown): boolean { + return ( + obj === undefined || + obj === null || + (obj instanceof Array && obj.length === 0) || + (obj instanceof Object && + Object.getPrototypeOf(obj) === Object.prototype && + Object.keys(obj).length === 0) || + obj === "" + ) +} diff --git a/backend/core/src/shared/views/change-email.hbs b/backend/core/src/shared/views/change-email.hbs new file mode 100644 index 0000000000..d1d321efb9 --- /dev/null +++ b/backend/core/src/shared/views/change-email.hbs @@ -0,0 +1,11 @@ +

{{t "t.hello"}} {{> user-name }},

+

+ {{t "changeEmail.message" appOptions}} +

+

+ {{t "changeEmail.onChangeEmailMessage"}} +

+

+ {{t "changeEmail.changeMyEmail"}} +

+{{> footer }} diff --git a/backend/core/src/shared/views/confirmation.hbs b/backend/core/src/shared/views/confirmation.hbs new file mode 100644 index 0000000000..9cc04f3cea --- /dev/null +++ b/backend/core/src/shared/views/confirmation.hbs @@ -0,0 +1,7 @@ +

{{t "t.hello"}} {{> user-name }},

+

{{t "confirmation.thankYouForApplying"}} {{listing.name}}

+

{{t "confirmation.yourConfirmationNumber"}} {{application.confirmationCode}}

+

{{t "confirmation.whatToExpectNext"}}
{{whatToExpectText}}

+

{{t "confirmation.shouldBeChosen"}}

+{{> leasing-agent }} +{{> footer }} diff --git a/backend/core/src/shared/views/forgot-password.hbs b/backend/core/src/shared/views/forgot-password.hbs new file mode 100644 index 0000000000..5b2f9fa2f9 --- /dev/null +++ b/backend/core/src/shared/views/forgot-password.hbs @@ -0,0 +1,15 @@ +

{{t "t.hello"}} {{> user-name }},

+

+ {{t "forgotPassword.resetRequest" resetOptions}} +

+

+ {{t "forgotPassword.ignoreRequest"}} +

+

+ {{t "forgotPassword.callToAction"}} + {{t "forgotPassword.changePassword"}} +

+

+ {{t "forgotPassword.passwordInfo"}} +

+{{> footer }} diff --git a/backend/core/src/shared/views/invite.hbs b/backend/core/src/shared/views/invite.hbs new file mode 100644 index 0000000000..00a6fce315 --- /dev/null +++ b/backend/core/src/shared/views/invite.hbs @@ -0,0 +1,11 @@ +

{{t "invite.hello"}} {{> user-name }}

+

+ {{t "invite.inviteMessage" appOptions}} +

+

+ {{t "invite.toCompleteAccountCreation"}} +

+

+ {{t "invite.confirmMyAccount"}} +

+{{> footer }} diff --git a/backend/core/src/shared/views/mfa-code.hbs b/backend/core/src/shared/views/mfa-code.hbs new file mode 100644 index 0000000000..38d0008144 --- /dev/null +++ b/backend/core/src/shared/views/mfa-code.hbs @@ -0,0 +1,8 @@ +

{{t "t.hello"}} {{> user-name }}

+

+ {{t "mfaCodeEmail.message" }} +

+

+ {{t "mfaCodeEmail.mfaCode" mfaCodeOptions}} +

+{{> footer }} diff --git a/backend/core/src/shared/views/partials/feedback.hbs b/backend/core/src/shared/views/partials/feedback.hbs new file mode 100644 index 0000000000..367e6f64b7 --- /dev/null +++ b/backend/core/src/shared/views/partials/feedback.hbs @@ -0,0 +1,3 @@ +

+ {{t "footer.callToAction"}} {{t "footer.feedback"}}. +

diff --git a/backend/core/src/shared/views/partials/footer.hbs b/backend/core/src/shared/views/partials/footer.hbs new file mode 100644 index 0000000000..fdb0898cb8 --- /dev/null +++ b/backend/core/src/shared/views/partials/footer.hbs @@ -0,0 +1,4 @@ +

+ {{t "footer.thankYou"}}
+ {{t "footer.footer"}} +

\ No newline at end of file diff --git a/backend/core/src/shared/views/partials/leasing-agent.hbs b/backend/core/src/shared/views/partials/leasing-agent.hbs new file mode 100644 index 0000000000..2a2eac9336 --- /dev/null +++ b/backend/core/src/shared/views/partials/leasing-agent.hbs @@ -0,0 +1,12 @@ +

+ {{t "leasingAgent.contactAgentToUpdateInfo"}} +

+

+ {{listing.leasingAgentName}}
+ {{listing.leasingAgentTitle}}
+ {{listing.leasingAgentPhone}}
+ {{listing.leasingAgentEmail}}
+

+ +

{{t "leasingAgent.officeHours"}}

+

{{listing.leasingAgentOfficeHours}}

\ No newline at end of file diff --git a/backend/core/src/shared/views/partials/user-name.hbs b/backend/core/src/shared/views/partials/user-name.hbs new file mode 100644 index 0000000000..52e44c0cf3 --- /dev/null +++ b/backend/core/src/shared/views/partials/user-name.hbs @@ -0,0 +1,4 @@ +{{user.firstName}} +{{#if user.middleName}} +{{user.middleName}} +{{/if}} {{user.lastName}} \ No newline at end of file diff --git a/backend/core/src/shared/views/register-email.hbs b/backend/core/src/shared/views/register-email.hbs new file mode 100644 index 0000000000..afe9514dea --- /dev/null +++ b/backend/core/src/shared/views/register-email.hbs @@ -0,0 +1,11 @@ +

{{t "t.hello"}} {{> user-name }},

+

+ {{t "register.welcomeMessage" appOptions}} +

+

+ {{t "register.toConfirmAccountMessage"}} +

+

+ {{t "register.confirmMyAccount"}} +

+{{> footer }} diff --git a/backend/core/src/sms/controllers/sms.controller.spec.ts b/backend/core/src/sms/controllers/sms.controller.spec.ts new file mode 100644 index 0000000000..57f8e2f2b2 --- /dev/null +++ b/backend/core/src/sms/controllers/sms.controller.spec.ts @@ -0,0 +1,55 @@ +import { Test, TestingModule } from "@nestjs/testing" +import { SmsService } from "../services/sms.service" +import { SmsController } from "./sms.controller" +import { AuthzService } from "../../auth/services/authz.service" +import { HttpException } from "@nestjs/common" + +// Cypress brings in Chai types for the global expect, but we want to use jest +// expect here so we need to re-declare it. +// see: https://github.com/cypress-io/cypress/issues/1319#issuecomment-593500345 +declare const expect: jest.Expect + +const mockedSmsService = { send: jest.fn() } + +describe("SmsController", () => { + let controller: SmsController + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + { provide: AuthzService, useValue: {} }, + { provide: SmsService, useValue: mockedSmsService }, + ], + controllers: [SmsController], + }).compile() + + controller = module.get(SmsController) + }) + + it("should be defined", () => { + expect(controller).toBeDefined() + }) + + describe("send", () => { + it("as non-admin throws exception", async () => { + await expect( + controller.send( + { user: { roles: { isAdmin: false } } }, + { body: "test body", phoneNumber: "+15555555555" } + ) + ).rejects.toThrow(HttpException) + }) + + it("as admin sends to service", async () => { + await controller.send( + { user: { roles: { isAdmin: true } } }, + { body: "test body", phoneNumber: "+15555555555" } + ) + + expect(mockedSmsService.send).toHaveBeenCalledWith({ + body: "test body", + phoneNumber: "+15555555555", + }) + }) + }) +}) diff --git a/backend/core/src/sms/controllers/sms.controller.ts b/backend/core/src/sms/controllers/sms.controller.ts new file mode 100644 index 0000000000..2a14a0b581 --- /dev/null +++ b/backend/core/src/sms/controllers/sms.controller.ts @@ -0,0 +1,41 @@ +import { + Body, + Controller, + HttpException, + HttpStatus, + Post, + Request, + UseGuards, + UsePipes, + ValidationPipe, +} from "@nestjs/common" +import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger" +import { ResourceType } from "../../auth/decorators/resource-type.decorator" +import { User } from "../../auth/entities/user.entity" +import { AuthzGuard } from "../../auth/guards/authz.guard" +import { OptionalAuthGuard } from "../../auth/guards/optional-auth.guard" +import { AuthContext } from "../../auth/types/auth-context" +import { defaultValidationPipeOptions } from "../../shared/default-validation-pipe-options" +import { StatusDto } from "../../shared/dto/status.dto" +import { SmsDto } from "../dto/sms.dto" +import { SmsService } from "../services/sms.service" + +@Controller("sms") +@ApiBearerAuth() +@ApiTags("sms") +@ResourceType("user") +@UsePipes(new ValidationPipe(defaultValidationPipeOptions)) +export class SmsController { + constructor(private readonly smsService: SmsService) {} + + @Post() + @UseGuards(OptionalAuthGuard, AuthzGuard) + @ApiOperation({ summary: "Send an SMS", operationId: "send-sms" }) + async send(@Request() req, @Body() dto: SmsDto): Promise { + // Only admins are allowed to send SMS messages. + if (!new AuthContext(req.user as User).user.roles?.isAdmin) { + throw new HttpException("Only administrators can send SMS messages.", HttpStatus.FORBIDDEN) + } + return await this.smsService.send(dto) + } +} diff --git a/backend/core/src/sms/dto/sms.dto.ts b/backend/core/src/sms/dto/sms.dto.ts new file mode 100644 index 0000000000..111b5d2ac5 --- /dev/null +++ b/backend/core/src/sms/dto/sms.dto.ts @@ -0,0 +1,13 @@ +import { Expose } from "class-transformer" +import { IsPhoneNumber, IsString } from "class-validator" +import { ValidationsGroupsEnum } from "../../shared/types/validations-groups-enum" + +export class SmsDto { + @Expose() + @IsString() + body: string + + @Expose() + @IsPhoneNumber("US", { groups: [ValidationsGroupsEnum.default] }) + phoneNumber: string +} diff --git a/backend/core/src/sms/services/sms.service.spec.ts b/backend/core/src/sms/services/sms.service.spec.ts new file mode 100644 index 0000000000..55cbd738fd --- /dev/null +++ b/backend/core/src/sms/services/sms.service.spec.ts @@ -0,0 +1,123 @@ +import { HttpException } from "@nestjs/common" +import { Test, TestingModule } from "@nestjs/testing" +import { Listing } from "../../listings/entities/listing.entity" +import { UserService } from "../../auth/services/user.service" +import { SmsService } from "./sms.service" +import { TwilioService } from "./twilio.service" +import { ListingMarketingTypeEnum } from "../../listings/types/listing-marketing-type-enum" + +// Cypress brings in Chai types for the global expect, but we want to use jest +// expect here so we need to re-declare it. +// see: https://github.com/cypress-io/cypress/issues/1319#issuecomment-593500345 +declare const expect: jest.Expect + +const mockedTwilioService = { send: jest.fn() } +const mockedUserService = { listAllUsers: jest.fn() } + +const mockListing: Listing = { + name: "Mock Listing", + id: "", + createdAt: null, + updatedAt: null, + referralApplication: null, + property: null, + applications: [], + showWaitlist: false, + unitSummaries: null, + unitGroups: null, + applicationMethods: [], + applicationDropOffAddress: null, + applicationMailingAddress: null, + events: [], + jurisdiction: null, + assets: [], + status: null, + displayWaitlistSize: false, + hasId: null, + marketingType: ListingMarketingTypeEnum.Marketing, + listingPreferences: [], + save: jest.fn(), + remove: jest.fn(), + softRemove: jest.fn(), + recover: jest.fn(), + reload: jest.fn(), +} + +describe("SmsService", () => { + let service: SmsService + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + SmsService, + { + provide: TwilioService, + useValue: mockedTwilioService, + }, + { + provide: UserService, + useValue: mockedUserService, + }, + ], + }).compile() + + mockedTwilioService.send = jest.fn().mockResolvedValue({ errorCode: 0 }) + service = await module.resolve(SmsService) + }) + + it("should be defined", () => { + expect(service).toBeDefined() + }) + + describe("listing notifications", () => { + it("sends a new-listing notification to opted-in users (who have phone numbers)", async () => { + mockedUserService.listAllUsers.mockResolvedValue([ + // User with a phone number, who is opted in for SMS notifications + { phoneNumber: "+12222222222", preferences: { sendSmsNotifications: true } }, + // User with a phone number, who is opted out of SMS notifications + { phoneNumber: "+13333333333", preferences: { sendSmsNotifications: false } }, + // User who is opted in for SMS notifications, but has no phone number + { preferences: { sendSmsNotifications: true } }, + ]) + + await service.sendNewListingNotification(mockListing) + + expect(mockedTwilioService.send).toHaveBeenCalledTimes(1) + expect(mockedTwilioService.send).toHaveBeenCalledWith( + "A new listing was recently added to Detroit Home Connect: Mock Listing.", + "+12222222222" + ) + }) + + it("throws an exception if the Twilio call returns an error", async () => { + mockedUserService.listAllUsers.mockResolvedValue([ + { phoneNumber: "+12222222222", preferences: { sendSmsNotifications: true } }, + ]) + mockedTwilioService.send.mockResolvedValue({ + errorCode: 1, + errorMessage: "test error message", + }) + + await expect(service.sendNewListingNotification(mockListing)).rejects.toThrow(HttpException) + }) + }) + + describe("send", () => { + it("sends to Twilio", async () => { + await service.send({ phoneNumber: "+15555555555", body: "test body" }) + + expect(mockedTwilioService.send).toHaveBeenCalledWith("test body", "+15555555555") + }) + + it("throws an exception when Twilio errors out", async () => { + mockedTwilioService.send.mockResolvedValue({ + errorCode: 1, + errorMessage: "test error message", + }) + + await expect( + service.send({ phoneNumber: "+15555555555", body: "test body" }) + ).rejects.toThrow(HttpException) + }) + }) +}) diff --git a/backend/core/src/sms/services/sms.service.ts b/backend/core/src/sms/services/sms.service.ts new file mode 100644 index 0000000000..28a5246263 --- /dev/null +++ b/backend/core/src/sms/services/sms.service.ts @@ -0,0 +1,41 @@ +import { HttpException, HttpStatus, Injectable, Scope } from "@nestjs/common" +import { User } from "../../auth/entities/user.entity" +import { UserService } from "../../auth/services/user.service" +import { Listing } from "../../listings/entities/listing.entity" +import { StatusDto } from "../../shared/dto/status.dto" +import { mapTo } from "../../shared/mapTo" +import { SmsDto } from "../dto/sms.dto" +import { TwilioService } from "./twilio.service" + +@Injectable({ scope: Scope.REQUEST }) +export class SmsService { + constructor(private readonly twilio: TwilioService, private readonly userService: UserService) {} + + async sendNewListingNotification(listing: Listing): Promise { + // TODO(https://github.com/CityOfDetroit/bloom/issues/705): when Detroit Home Connect has a + // URL, update this message so that it includes a link to the new listing. + // TODO(https://github.com/CityOfDetroit/bloom/issues/705): translate this string. + const notificationBody = `A new listing was recently added to Detroit Home Connect: ${listing.name}.` + + // TODO(https://github.com/CityOfDetroit/bloom/issues/705): handle filtering in the DB query + // instead of here. + const users: User[] = await this.userService.listAllUsers() + for (const user of users) { + if (user.preferences.sendSmsNotifications && user.phoneNumber) { + const smsDto: SmsDto = { body: notificationBody, phoneNumber: user.phoneNumber } + await this.send(smsDto) + } + } + + return { status: "ok" } + } + + async send(dto: SmsDto): Promise { + const messageInstance = await this.twilio.send(dto.body, dto.phoneNumber) + if (messageInstance.errorCode) { + console.error("Error sending SMS: " + messageInstance.errorMessage) + throw new HttpException(messageInstance.errorMessage, HttpStatus.INTERNAL_SERVER_ERROR) + } + return mapTo(StatusDto, { status: "ok" }) + } +} diff --git a/backend/core/src/sms/services/twilio.service.ts b/backend/core/src/sms/services/twilio.service.ts new file mode 100644 index 0000000000..51e6c073eb --- /dev/null +++ b/backend/core/src/sms/services/twilio.service.ts @@ -0,0 +1,25 @@ +import { Injectable } from "@nestjs/common" +import { + MessageInstance, + MessageListInstanceCreateOptions, +} from "twilio/lib/rest/api/v2010/account/message" +import TwilioClient = require("twilio/lib/rest/Twilio") +import twilio = require("twilio") + +@Injectable() +export class TwilioService { + client: TwilioClient + + constructor() { + // this.client = twilio(process.env.TWILIO_ACCOUNT_SID, process.env.TWILIO_AUTH_TOKEN) + } + + async send(message: string, recipientPhoneNumber: string): Promise { + const messageOptions: MessageListInstanceCreateOptions = { + body: message, + from: process.env.TWILIO_FROM_NUMBER, + to: recipientPhoneNumber, + } + return this.client.messages.create(messageOptions) + } +} diff --git a/backend/core/src/sms/sms.module.ts b/backend/core/src/sms/sms.module.ts new file mode 100644 index 0000000000..41886ce071 --- /dev/null +++ b/backend/core/src/sms/sms.module.ts @@ -0,0 +1,14 @@ +import { Module } from "@nestjs/common" +import { AuthModule } from "../auth/auth.module" +import { AuthzService } from "../auth/services/authz.service" +import { SmsService } from "./services/sms.service" +import { SmsController } from "./controllers/sms.controller" +import { TwilioService } from "./services/twilio.service" + +@Module({ + imports: [AuthModule], + providers: [AuthzService, SmsService, TwilioService], + exports: [SmsService], + controllers: [SmsController], +}) +export class SmsModule {} diff --git a/backend/core/src/translations/dto/translation.dto.ts b/backend/core/src/translations/dto/translation.dto.ts new file mode 100644 index 0000000000..f7c724895a --- /dev/null +++ b/backend/core/src/translations/dto/translation.dto.ts @@ -0,0 +1,41 @@ +import { OmitType } from "@nestjs/swagger" +import { Expose, Type } from "class-transformer" +import { IsDate, IsOptional, IsUUID } from "class-validator" +import { IdDto } from "../../shared/dto/id.dto" +import { ValidationsGroupsEnum } from "../../shared/types/validations-groups-enum" +import { Translation } from "../entities/translation.entity" + +export class TranslationDto extends OmitType(Translation, ["jurisdiction"] as const) { + @Expose() + @Type(() => IdDto) + jurisdiction: IdDto +} + +export class TranslationCreateDto extends OmitType(TranslationDto, [ + "id", + "createdAt", + "updatedAt", +] as const) {} + +export class TranslationUpdateDto extends OmitType(TranslationDto, [ + "id", + "createdAt", + "updatedAt", +] as const) { + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsUUID(4, { groups: [ValidationsGroupsEnum.default] }) + id?: string + + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsDate({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => Date) + createdAt?: Date + + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsDate({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => Date) + updatedAt?: Date +} diff --git a/backend/core/src/translations/entities/generated-listing-translation.entity.ts b/backend/core/src/translations/entities/generated-listing-translation.entity.ts new file mode 100644 index 0000000000..df098ff98e --- /dev/null +++ b/backend/core/src/translations/entities/generated-listing-translation.entity.ts @@ -0,0 +1,27 @@ +import { Column, Entity } from "typeorm" +import { AbstractEntity } from "../../shared/entities/abstract.entity" +import { Expose, Type } from "class-transformer" +import { Language } from "../../shared/types/language-enum" + +@Entity({ name: "generated_listing_translations" }) +export class GeneratedListingTranslation extends AbstractEntity { + @Column() + @Expose() + listingId: string + + @Column() + @Expose() + jurisdictionId: string + + @Column({ enum: Language }) + @Expose() + language: Language + + @Column({ type: "jsonb" }) + @Expose() + translations: any + + @Column() + @Type(() => Date) + timestamp: Date +} diff --git a/backend/core/src/translations/entities/translation.entity.ts b/backend/core/src/translations/entities/translation.entity.ts new file mode 100644 index 0000000000..0d3ee0564a --- /dev/null +++ b/backend/core/src/translations/entities/translation.entity.ts @@ -0,0 +1,31 @@ +import { Column, Entity, Index, ManyToOne } from "typeorm" +import { AbstractEntity } from "../../shared/entities/abstract.entity" +import { Expose } from "class-transformer" +import { IsEnum, IsJSON } from "class-validator" +import { ValidationsGroupsEnum } from "../../shared/types/validations-groups-enum" +import { ApiProperty } from "@nestjs/swagger" +import { Language } from "../../shared/types/language-enum" +import { TranslationsType } from "../types/translations-type" +import { Jurisdiction } from "../../jurisdictions/entities/jurisdiction.entity" + +@Entity({ name: "translations" }) +@Index(["jurisdiction", "language"], { unique: true }) +export class Translation extends AbstractEntity { + @ManyToOne(() => Jurisdiction, { + onDelete: "NO ACTION", + onUpdate: "NO ACTION", + eager: true, + }) + jurisdiction: Jurisdiction + + @Column({ enum: Language }) + @Expose() + @IsEnum(Language, { groups: [ValidationsGroupsEnum.default] }) + @ApiProperty({ enum: Language, enumName: "Language" }) + language: Language + + @Column({ type: "jsonb" }) + @Expose() + @IsJSON({ groups: [ValidationsGroupsEnum.default] }) + translations: TranslationsType +} diff --git a/backend/core/src/translations/services/google-translate.service.ts b/backend/core/src/translations/services/google-translate.service.ts new file mode 100644 index 0000000000..f6694996a9 --- /dev/null +++ b/backend/core/src/translations/services/google-translate.service.ts @@ -0,0 +1,27 @@ +import { Injectable } from "@nestjs/common" +import { Language } from "../../shared/types/language-enum" +import { Translate } from "@google-cloud/translate/build/src/v2" + +@Injectable() +export class GoogleTranslateService { + public isConfigured(): boolean { + const { GOOGLE_API_ID, GOOGLE_API_EMAIL, GOOGLE_API_KEY } = process.env + return !!GOOGLE_API_KEY && !!GOOGLE_API_EMAIL && !!GOOGLE_API_ID + } + public async fetch(values: string[], language: Language) { + return await GoogleTranslateService.makeTranslateService().translate(values, { + from: Language.en, + to: language, + }) + } + + private static makeTranslateService() { + return new Translate({ + credentials: { + private_key: process.env.GOOGLE_API_KEY.replace(/\\n/gm, "\n"), + client_email: process.env.GOOGLE_API_EMAIL, + }, + projectId: process.env.GOOGLE_API_ID, + }) + } +} diff --git a/backend/core/src/translations/services/translations.service.spec.ts b/backend/core/src/translations/services/translations.service.spec.ts new file mode 100644 index 0000000000..00126cc2a3 --- /dev/null +++ b/backend/core/src/translations/services/translations.service.spec.ts @@ -0,0 +1,212 @@ +import { Test, TestingModule } from "@nestjs/testing" +import { getRepositoryToken } from "@nestjs/typeorm" +import { nanoid } from "nanoid" +import { TranslationsService } from "./translations.service" +import { Translation } from "../entities/translation.entity" +import { Language } from "../../shared/types/language-enum" +import { GeneratedListingTranslation } from "../entities/generated-listing-translation.entity" +import { GoogleTranslateService } from "./google-translate.service" +import { Listing } from "../../listings/entities/listing.entity" +import { BaseEntity } from "typeorm" +import { ApplicationMethodDto } from "../../application-methods/dto/application-method.dto" +import { Jurisdiction } from "../../jurisdictions/entities/jurisdiction.entity" +import { ListingMarketingTypeEnum } from "../../listings/types/listing-marketing-type-enum" + +// Cypress brings in Chai types for the global expect, but we want to use jest +// expect here so we need to re-declare it. +// see: https://github.com/cypress-io/cypress/issues/1319#issuecomment-593500345 +declare const expect: jest.Expect + +const getMockListing = () => { + const mockListingTemplate: Omit = { + additionalApplicationSubmissionNotes: undefined, + applicationConfig: undefined, + applicationDropOffAddress: undefined, + applicationDropOffAddressOfficeHours: undefined, + applicationDropOffAddressType: undefined, + applicationDueDate: undefined, + applicationFee: undefined, + applicationMailingAddress: undefined, + applicationMethods: [], + applicationOpenDate: undefined, + applicationOrganization: undefined, + applicationPickUpAddress: undefined, + applicationPickUpAddressOfficeHours: undefined, + applicationPickUpAddressType: undefined, + applicationMailingAddressType: undefined, + applications: [], + assets: [], + buildingSelectionCriteria: undefined, + buildingSelectionCriteriaFile: undefined, + commonDigitalApplication: false, + costsNotIncluded: "costs not included", + createdAt: undefined, + creditHistory: undefined, + criminalBackground: undefined, + customMapPin: undefined, + depositHelperText: undefined, + depositMax: undefined, + depositMin: undefined, + digitalApplication: false, + disableUnitsAccordion: undefined, + displayWaitlistSize: false, + events: [], + id: "", + images: [], + isWaitlistOpen: undefined, + jurisdiction: { id: "abcd" } as Jurisdiction, + leasingAgentAddress: undefined, + leasingAgentEmail: undefined, + leasingAgentName: undefined, + leasingAgentOfficeHours: undefined, + leasingAgentPhone: undefined, + leasingAgentTitle: undefined, + leasingAgents: undefined, + listingPreferences: [], + listingPrograms: [], + name: "", + paperApplication: false, + postmarkedApplicationsReceivedByDate: undefined, + programRules: undefined, + property: undefined, + referralOpportunity: false, + rentalAssistance: undefined, + rentalHistory: undefined, + requiredDocuments: undefined, + reservedCommunityDescription: undefined, + reservedCommunityMinAge: undefined, + reservedCommunityType: undefined, + result: undefined, + resultLink: undefined, + reviewOrderType: undefined, + specialNotes: undefined, + status: undefined, + unitSummaries: undefined, + unitGroups: [], + updatedAt: new Date(), + waitlistCurrentSize: undefined, + waitlistMaxSize: undefined, + waitlistOpenSpots: undefined, + whatToExpect: undefined, + marketingType: ListingMarketingTypeEnum.Marketing, + get referralApplication(): ApplicationMethodDto | undefined { + return undefined + }, + get showWaitlist(): boolean { + return false + }, + } + return JSON.parse(JSON.stringify(mockListingTemplate)) +} + +describe("TranslationsService", () => { + let service: TranslationsService + const translationRepositoryFindOneMock = { + findOne: jest.fn(), + } + const generatedListingTranslationRepositoryMock = { + findOne: jest.fn(), + save: jest.fn(), + } + const googleTranslateServiceMock = { + isConfigured: () => true, + fetch: jest.fn(), + } + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + TranslationsService, + { + provide: getRepositoryToken(Translation), + useValue: translationRepositoryFindOneMock, + }, + { + provide: getRepositoryToken(GeneratedListingTranslation), + useValue: generatedListingTranslationRepositoryMock, + }, + { + provide: GoogleTranslateService, + useValue: googleTranslateServiceMock, + }, + ], + }).compile() + service = module.get(TranslationsService) + }) + + describe("Translations queries", () => { + it("Should return translations for given county code and language", async () => { + translationRepositoryFindOneMock.findOne.mockReturnValueOnce({}) + const jurisdictionId = nanoid() + const result = await service.getTranslationByLanguageAndJurisdictionOrDefaultEn( + Language.en, + jurisdictionId + ) + expect(result).toStrictEqual({}) + expect(translationRepositoryFindOneMock.findOne.mock.calls[0][0].where.language).toEqual( + Language.en + ) + expect( + translationRepositoryFindOneMock.findOne.mock.calls[0][0].where.jurisdiction.id + ).toEqual(jurisdictionId) + }) + + it("should fetch translations if none are persisted", async () => { + const mockListing = getMockListing() + generatedListingTranslationRepositoryMock.findOne.mockResolvedValueOnce(undefined) + generatedListingTranslationRepositoryMock.save.mockResolvedValueOnce({ translations: [{}] }) + googleTranslateServiceMock.fetch.mockResolvedValueOnce([]) + + await service.translateListing(mockListing as Listing, Language.es) + expect(generatedListingTranslationRepositoryMock.findOne).toHaveBeenCalledTimes(1) + expect(googleTranslateServiceMock.fetch).toHaveBeenCalledTimes(1) + expect(generatedListingTranslationRepositoryMock.save).toHaveBeenCalledTimes(1) + }) + + it("should not fetch translations if any are persisted", async () => { + const mockListing = getMockListing() + const translations = [["costs not included ES translation"]] + const persistedTranslation = { + timestamp: mockListing.updatedAt, + translations, + } + generatedListingTranslationRepositoryMock.findOne.mockResolvedValueOnce(persistedTranslation) + + const result = await service.translateListing(mockListing as Listing, Language.es) + expect(generatedListingTranslationRepositoryMock.findOne).toHaveBeenCalledTimes(1) + expect(googleTranslateServiceMock.fetch).toHaveBeenCalledTimes(0) + expect(generatedListingTranslationRepositoryMock.save).toHaveBeenCalledTimes(0) + expect(result.costsNotIncluded).toBe(translations[0][0]) + }) + + it("should fetch translations if timestamp is older than listing updatedAt", async () => { + const mockListing = getMockListing() + mockListing.updatedAt = new Date() + + const translations = [["costs not included ES translation"]] + const newTranslations = [["costs not included ES translation 2"]] + const persistedTranslation = { + timestamp: new Date(mockListing.updatedAt.getTime() - 1000), + translations, + } + const newPersistedTranslation = { + timestamp: mockListing.updatedAt, + translations: newTranslations, + } + + generatedListingTranslationRepositoryMock.findOne.mockResolvedValueOnce(persistedTranslation) + generatedListingTranslationRepositoryMock.save.mockResolvedValueOnce(newPersistedTranslation) + googleTranslateServiceMock.fetch.mockResolvedValueOnce(newTranslations) + + const result = await service.translateListing(mockListing as Listing, Language.es) + expect(generatedListingTranslationRepositoryMock.findOne).toHaveBeenCalledTimes(1) + expect(googleTranslateServiceMock.fetch).toHaveBeenCalledTimes(1) + expect(generatedListingTranslationRepositoryMock.save).toHaveBeenCalledTimes(1) + expect(result.costsNotIncluded).toBe(newTranslations[0][0]) + }) + }) + + afterEach(() => { + jest.clearAllMocks() + }) +}) diff --git a/backend/core/src/translations/services/translations.service.ts b/backend/core/src/translations/services/translations.service.ts new file mode 100644 index 0000000000..9a61323ddb --- /dev/null +++ b/backend/core/src/translations/services/translations.service.ts @@ -0,0 +1,163 @@ +import { Injectable, NotFoundException } from "@nestjs/common" +import { AbstractServiceFactory } from "../../shared/services/abstract-service" +import { Translation } from "../entities/translation.entity" +import { TranslationCreateDto, TranslationUpdateDto } from "../dto/translation.dto" +import { InjectRepository } from "@nestjs/typeorm" +import { Repository } from "typeorm" +import { Language } from "../../shared/types/language-enum" +import { Listing } from "../../listings/entities/listing.entity" +import { GoogleTranslateService } from "./google-translate.service" +import { GeneratedListingTranslation } from "../entities/generated-listing-translation.entity" +import * as lodash from "lodash" + +@Injectable() +export class TranslationsService extends AbstractServiceFactory< + Translation, + TranslationCreateDto, + TranslationUpdateDto +>(Translation) { + constructor( + @InjectRepository(GeneratedListingTranslation) + private readonly generatedListingTranslationRepository: Repository, + private readonly googleTranslateService: GoogleTranslateService + ) { + super() + } + + public async getTranslationByLanguageAndJurisdictionOrDefaultEn( + language: Language, + jurisdictionId: string | null + ) { + try { + return await this.findOne({ + where: { + language, + ...(jurisdictionId && { + jurisdiction: { + id: jurisdictionId, + }, + }), + ...(!jurisdictionId && { + jurisdiction: null, + }), + }, + }) + } catch (e) { + if (e instanceof NotFoundException && language != Language.en) { + console.warn(`Fetching translations for ${language} failed, defaulting to english.`) + return this.findOne({ + where: { + language: Language.en, + ...(jurisdictionId && { + jurisdiction: { + id: jurisdictionId, + }, + }), + ...(!jurisdictionId && { + jurisdiction: null, + }), + }, + }) + } else { + throw e + } + } + } + + public async translateListing(listing: Listing, language: Language) { + if (!this.googleTranslateService.isConfigured()) { + console.warn("listing translation requested, but google translate service is not configured") + return + } + + const pathsToFilter = [ + "applicationPickUpAddressOfficeHours", + "costsNotIncluded", + "creditHistory", + "criminalBackground", + "programRules", + "rentalAssistance", + "rentalHistory", + "requiredDocuments", + "specialNotes", + "whatToExpect", + "property.accessibility", + "property.amenities", + "property.neighborhood", + "property.petPolicy", + "property.servicesOffered", + "property.smokingPolicy", + "property.unitAmenities", + ] + + for (let i = 0; i < listing.events.length; i++) { + pathsToFilter.push(`events[${i}].note`) + pathsToFilter.push(`events[${i}].label`) + } + + for (let i = 0; i < listing.listingPreferences.length; i++) { + pathsToFilter.push(`listingPreferences[${i}].preference.title`) + pathsToFilter.push(`listingPreferences[${i}].preference.description`) + pathsToFilter.push(`listingPreferences[${i}].preference.subtitle`) + } + + const listingPathsAndValues: { [key: string]: any } = {} + for (const path of pathsToFilter) { + const value = lodash.get(listing, path) + if (value) { + listingPathsAndValues[path] = lodash.get(listing, path) + } + } + + // Caching + let persistedTranslatedValues + persistedTranslatedValues = await this.getPersistedTranslatedValues(listing, language) + + if (!persistedTranslatedValues || persistedTranslatedValues.timestamp < listing.updatedAt) { + const newTranslations = await this.googleTranslateService.fetch( + Object.values(listingPathsAndValues), + language + ) + persistedTranslatedValues = await this.persistTranslatedValues( + persistedTranslatedValues?.id, + listing, + language, + newTranslations + ) + } + + for (const [index, path] of Object.keys(listingPathsAndValues).entries()) { + // accessing 0th index here because google translate service response returns multiple + // possible arrays with results, we are interested in first + lodash.set(listing, path, persistedTranslatedValues.translations[0][index]) + } + + return listing + } + + private async persistTranslatedValues( + id: string | undefined, + listing: Listing, + language: Language, + translatedValues: any + ) { + return await this.generatedListingTranslationRepository.save({ + id, + listingId: listing.id, + jurisdictionId: listing.jurisdiction.id, + language, + translations: translatedValues, + timestamp: listing.updatedAt, + }) + } + + private async getPersistedTranslatedValues(listing: Listing, language: Language) { + return this.generatedListingTranslationRepository.findOne({ + where: { + jurisdictionId: listing.jurisdiction.id, + language, + listingId: listing.id, + }, + }) + } +} diff --git a/backend/core/src/translations/translations.controller.ts b/backend/core/src/translations/translations.controller.ts new file mode 100644 index 0000000000..df7f38f1a3 --- /dev/null +++ b/backend/core/src/translations/translations.controller.ts @@ -0,0 +1,63 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + Post, + Put, + UseGuards, + UsePipes, + ValidationPipe, +} from "@nestjs/common" +import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger" +import { AuthzGuard } from "../auth/guards/authz.guard" +import { ResourceType } from "../auth/decorators/resource-type.decorator" +import { mapTo } from "../shared/mapTo" +import { defaultValidationPipeOptions } from "../shared/default-validation-pipe-options" +import { TranslationCreateDto, TranslationDto, TranslationUpdateDto } from "./dto/translation.dto" +import { OptionalAuthGuard } from "../auth/guards/optional-auth.guard" +import { TranslationsService } from "./services/translations.service" + +@Controller("/translations") +@ApiTags("translations") +@ApiBearerAuth() +@ResourceType("translation") +@UseGuards(OptionalAuthGuard, AuthzGuard) +@UsePipes(new ValidationPipe(defaultValidationPipeOptions)) +export class TranslationsController { + constructor(private readonly translationsService: TranslationsService) {} + + @Get() + @ApiOperation({ summary: "List translations", operationId: "list" }) + async list(): Promise { + return mapTo(TranslationDto, await this.translationsService.list()) + } + + @Post() + @ApiOperation({ summary: "Create translation", operationId: "create" }) + async create(@Body() translation: TranslationCreateDto): Promise { + return mapTo(TranslationDto, await this.translationsService.create(translation)) + } + + @Put(`:translationId`) + @ApiOperation({ summary: "Update translation", operationId: "update" }) + async update(@Body() translation: TranslationUpdateDto): Promise { + return mapTo(TranslationDto, await this.translationsService.update(translation)) + } + + @Get(`:translationId`) + @ApiOperation({ summary: "Get translation by id", operationId: "retrieve" }) + async retrieve(@Param("translationId") translationId: string): Promise { + return mapTo( + TranslationDto, + await this.translationsService.findOne({ where: { id: translationId } }) + ) + } + + @Delete(`:translationId`) + @ApiOperation({ summary: "Delete translation by id", operationId: "delete" }) + async delete(@Param("translationId") translationId: string): Promise { + return await this.translationsService.delete(translationId) + } +} diff --git a/backend/core/src/translations/translations.module.ts b/backend/core/src/translations/translations.module.ts new file mode 100644 index 0000000000..7d14caaa91 --- /dev/null +++ b/backend/core/src/translations/translations.module.ts @@ -0,0 +1,19 @@ +import { forwardRef, Module } from "@nestjs/common" +import { TypeOrmModule } from "@nestjs/typeorm" +import { Translation } from "./entities/translation.entity" +import { TranslationsController } from "./translations.controller" +import { AuthModule } from "../auth/auth.module" +import { TranslationsService } from "./services/translations.service" +import { GoogleTranslateService } from "./services/google-translate.service" +import { GeneratedListingTranslation } from "./entities/generated-listing-translation.entity" + +@Module({ + imports: [ + TypeOrmModule.forFeature([Translation, GeneratedListingTranslation]), + forwardRef(() => AuthModule), + ], + controllers: [TranslationsController], + providers: [TranslationsService, GoogleTranslateService], + exports: [TranslationsService], +}) +export class TranslationsModule {} diff --git a/backend/core/src/translations/types/translations-type.ts b/backend/core/src/translations/types/translations-type.ts new file mode 100644 index 0000000000..5f87f5b518 --- /dev/null +++ b/backend/core/src/translations/types/translations-type.ts @@ -0,0 +1,3 @@ +export type TranslationsType = { + [key: string]: string | TranslationsType +} diff --git a/backend/core/src/unit-accessbility-priority-types/dto/unit-accessibility-priority-type.dto.ts b/backend/core/src/unit-accessbility-priority-types/dto/unit-accessibility-priority-type.dto.ts new file mode 100644 index 0000000000..16b18830d1 --- /dev/null +++ b/backend/core/src/unit-accessbility-priority-types/dto/unit-accessibility-priority-type.dto.ts @@ -0,0 +1,22 @@ +import { Expose } from "class-transformer" +import { IsString, IsUUID } from "class-validator" +import { OmitType } from "@nestjs/swagger" +import { ValidationsGroupsEnum } from "../../shared/types/validations-groups-enum" +import { UnitAccessibilityPriorityType } from "../entities/unit-accessibility-priority-type.entity" + +export class UnitAccessibilityPriorityTypeDto extends OmitType( + UnitAccessibilityPriorityType, + [] as const +) {} + +export class UnitAccessibilityPriorityTypeCreateDto extends OmitType( + UnitAccessibilityPriorityTypeDto, + ["id", "createdAt", "updatedAt"] as const +) {} + +export class UnitAccessibilityPriorityTypeUpdateDto extends UnitAccessibilityPriorityTypeCreateDto { + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @IsUUID() + id: string +} diff --git a/backend/core/src/unit-accessbility-priority-types/entities/unit-accessibility-priority-type.entity.ts b/backend/core/src/unit-accessbility-priority-types/entities/unit-accessibility-priority-type.entity.ts new file mode 100644 index 0000000000..1340dc48e7 --- /dev/null +++ b/backend/core/src/unit-accessbility-priority-types/entities/unit-accessibility-priority-type.entity.ts @@ -0,0 +1,14 @@ +import { Column, Entity } from "typeorm" +import { Expose } from "class-transformer" +import { IsString, MaxLength } from "class-validator" +import { ValidationsGroupsEnum } from "../../shared/types/validations-groups-enum" +import { AbstractEntity } from "../../shared/entities/abstract.entity" + +@Entity({ name: "unit_accessibility_priority_types" }) +export class UnitAccessibilityPriorityType extends AbstractEntity { + @Column({ type: "text" }) + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @MaxLength(256, { groups: [ValidationsGroupsEnum.default] }) + name: string +} diff --git a/backend/core/src/unit-accessbility-priority-types/unit-accessibility-priority-types.controller.ts b/backend/core/src/unit-accessbility-priority-types/unit-accessibility-priority-types.controller.ts new file mode 100644 index 0000000000..00fdd02251 --- /dev/null +++ b/backend/core/src/unit-accessbility-priority-types/unit-accessibility-priority-types.controller.ts @@ -0,0 +1,88 @@ +import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger" +import { DefaultAuthGuard } from "../auth/guards/default.guard" +import { AuthzGuard } from "../auth/guards/authz.guard" +import { ResourceType } from "../auth/decorators/resource-type.decorator" +import { mapTo } from "../shared/mapTo" +import { + Body, + Controller, + Delete, + Get, + Param, + Post, + Put, + UseGuards, + UsePipes, + ValidationPipe, +} from "@nestjs/common" +import { defaultValidationPipeOptions } from "../shared/default-validation-pipe-options" +import { UnitAccessibilityPriorityTypesService } from "./unit-accessibility-priority-types.service" +import { + UnitAccessibilityPriorityTypeCreateDto, + UnitAccessibilityPriorityTypeDto, + UnitAccessibilityPriorityTypeUpdateDto, +} from "./dto/unit-accessibility-priority-type.dto" + +@Controller("unitAccessibilityPriorityTypes") +@ApiTags("unitAccessibilityPriorityTypes") +@ApiBearerAuth() +@ResourceType("unitAccessibilityPriorityType") +@UseGuards(DefaultAuthGuard, AuthzGuard) +@UsePipes(new ValidationPipe(defaultValidationPipeOptions)) +export class UnitAccessibilityPriorityTypesController { + constructor( + private readonly unitAccessibilityPriorityTypesService: UnitAccessibilityPriorityTypesService + ) {} + + @Get() + @ApiOperation({ summary: "List unitAccessibilityPriorityTypes", operationId: "list" }) + async list(): Promise { + return mapTo( + UnitAccessibilityPriorityTypeDto, + await this.unitAccessibilityPriorityTypesService.list() + ) + } + + @Post() + @ApiOperation({ summary: "Create unitAccessibilityPriorityType", operationId: "create" }) + async create( + @Body() unitAccessibilityPriorityType: UnitAccessibilityPriorityTypeCreateDto + ): Promise { + return mapTo( + UnitAccessibilityPriorityTypeDto, + await this.unitAccessibilityPriorityTypesService.create(unitAccessibilityPriorityType) + ) + } + + @Put(`:unitAccessibilityPriorityTypeId`) + @ApiOperation({ summary: "Update unitAccessibilityPriorityType", operationId: "update" }) + async update( + @Body() unitAccessibilityPriorityType: UnitAccessibilityPriorityTypeUpdateDto + ): Promise { + return mapTo( + UnitAccessibilityPriorityTypeDto, + await this.unitAccessibilityPriorityTypesService.update(unitAccessibilityPriorityType) + ) + } + + @Get(`:unitAccessibilityPriorityTypeId`) + @ApiOperation({ summary: "Get unitAccessibilityPriorityType by id", operationId: "retrieve" }) + async retrieve( + @Param("unitAccessibilityPriorityTypeId") unitAccessibilityPriorityTypeId: string + ): Promise { + return mapTo( + UnitAccessibilityPriorityTypeDto, + await this.unitAccessibilityPriorityTypesService.findOne({ + where: { id: unitAccessibilityPriorityTypeId }, + }) + ) + } + + @Delete(`:unitAccessibilityPriorityTypeId`) + @ApiOperation({ summary: "Delete unitAccessibilityPriorityType by id", operationId: "delete" }) + async delete( + @Param("unitAccessibilityPriorityTypeId") unitAccessibilityPriorityTypeId: string + ): Promise { + return await this.unitAccessibilityPriorityTypesService.delete(unitAccessibilityPriorityTypeId) + } +} diff --git a/backend/core/src/unit-accessbility-priority-types/unit-accessibility-priority-types.module.ts b/backend/core/src/unit-accessbility-priority-types/unit-accessibility-priority-types.module.ts new file mode 100644 index 0000000000..cf35dab956 --- /dev/null +++ b/backend/core/src/unit-accessbility-priority-types/unit-accessibility-priority-types.module.ts @@ -0,0 +1,13 @@ +import { Module } from "@nestjs/common" +import { TypeOrmModule } from "@nestjs/typeorm" +import { AuthModule } from "../auth/auth.module" +import { UnitAccessibilityPriorityTypesService } from "./unit-accessibility-priority-types.service" +import { UnitAccessibilityPriorityTypesController } from "./unit-accessibility-priority-types.controller" +import { UnitAccessibilityPriorityType } from "./entities/unit-accessibility-priority-type.entity" + +@Module({ + imports: [TypeOrmModule.forFeature([UnitAccessibilityPriorityType]), AuthModule], + controllers: [UnitAccessibilityPriorityTypesController], + providers: [UnitAccessibilityPriorityTypesService], +}) +export class UnitAccessibilityPriorityTypesModule {} diff --git a/backend/core/src/unit-accessbility-priority-types/unit-accessibility-priority-types.service.ts b/backend/core/src/unit-accessbility-priority-types/unit-accessibility-priority-types.service.ts new file mode 100644 index 0000000000..51eb263382 --- /dev/null +++ b/backend/core/src/unit-accessbility-priority-types/unit-accessibility-priority-types.service.ts @@ -0,0 +1,14 @@ +import { AbstractServiceFactory } from "../shared/services/abstract-service" +import { Injectable } from "@nestjs/common" +import { + UnitAccessibilityPriorityTypeCreateDto, + UnitAccessibilityPriorityTypeUpdateDto, +} from "./dto/unit-accessibility-priority-type.dto" +import { UnitAccessibilityPriorityType } from "./entities/unit-accessibility-priority-type.entity" + +@Injectable() +export class UnitAccessibilityPriorityTypesService extends AbstractServiceFactory< + UnitAccessibilityPriorityType, + UnitAccessibilityPriorityTypeCreateDto, + UnitAccessibilityPriorityTypeUpdateDto +>(UnitAccessibilityPriorityType) {} diff --git a/backend/core/src/unit-rent-types/dto/unit-rent-type.dto.ts b/backend/core/src/unit-rent-types/dto/unit-rent-type.dto.ts new file mode 100644 index 0000000000..24fb3de46d --- /dev/null +++ b/backend/core/src/unit-rent-types/dto/unit-rent-type.dto.ts @@ -0,0 +1,20 @@ +import { Expose } from "class-transformer" +import { IsString, IsUUID } from "class-validator" +import { OmitType } from "@nestjs/swagger" +import { ValidationsGroupsEnum } from "../../shared/types/validations-groups-enum" +import { UnitRentType } from "../entities/unit-rent-type.entity" + +export class UnitRentTypeDto extends OmitType(UnitRentType, [] as const) {} + +export class UnitRentTypeCreateDto extends OmitType(UnitRentTypeDto, [ + "id", + "createdAt", + "updatedAt", +] as const) {} + +export class UnitRentTypeUpdateDto extends UnitRentTypeCreateDto { + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @IsUUID() + id: string +} diff --git a/backend/core/src/unit-rent-types/entities/unit-rent-type.entity.ts b/backend/core/src/unit-rent-types/entities/unit-rent-type.entity.ts new file mode 100644 index 0000000000..275e73f1f6 --- /dev/null +++ b/backend/core/src/unit-rent-types/entities/unit-rent-type.entity.ts @@ -0,0 +1,14 @@ +import { Column, Entity } from "typeorm" +import { Expose } from "class-transformer" +import { IsString, MaxLength } from "class-validator" +import { ValidationsGroupsEnum } from "../../shared/types/validations-groups-enum" +import { AbstractEntity } from "../../shared/entities/abstract.entity" + +@Entity({ name: "unit_rent_types" }) +export class UnitRentType extends AbstractEntity { + @Column({ type: "text" }) + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @MaxLength(256, { groups: [ValidationsGroupsEnum.default] }) + name: string +} diff --git a/backend/core/src/unit-rent-types/unit-rent-types.controller.ts b/backend/core/src/unit-rent-types/unit-rent-types.controller.ts new file mode 100644 index 0000000000..485e53dd42 --- /dev/null +++ b/backend/core/src/unit-rent-types/unit-rent-types.controller.ts @@ -0,0 +1,67 @@ +import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger" +import { DefaultAuthGuard } from "../auth/guards/default.guard" +import { AuthzGuard } from "../auth/guards/authz.guard" +import { ResourceType } from "../auth/decorators/resource-type.decorator" +import { mapTo } from "../shared/mapTo" +import { + Body, + Controller, + Delete, + Get, + Param, + Post, + Put, + UseGuards, + UsePipes, + ValidationPipe, +} from "@nestjs/common" +import { defaultValidationPipeOptions } from "../shared/default-validation-pipe-options" +import { + UnitRentTypeCreateDto, + UnitRentTypeDto, + UnitRentTypeUpdateDto, +} from "./dto/unit-rent-type.dto" +import { UnitRentTypesService } from "./unit-rent-types.service" + +@Controller("unitRentTypes") +@ApiTags("unitRentTypes") +@ApiBearerAuth() +@ResourceType("unitRentType") +@UseGuards(DefaultAuthGuard, AuthzGuard) +@UsePipes(new ValidationPipe(defaultValidationPipeOptions)) +export class UnitRentTypesController { + constructor(private readonly unitRentTypesService: UnitRentTypesService) {} + + @Get() + @ApiOperation({ summary: "List unitRentTypes", operationId: "list" }) + async list(): Promise { + return mapTo(UnitRentTypeDto, await this.unitRentTypesService.list()) + } + + @Post() + @ApiOperation({ summary: "Create unitRentType", operationId: "create" }) + async create(@Body() unitRentType: UnitRentTypeCreateDto): Promise { + return mapTo(UnitRentTypeDto, await this.unitRentTypesService.create(unitRentType)) + } + + @Put(`:unitRentTypeId`) + @ApiOperation({ summary: "Update unitRentType", operationId: "update" }) + async update(@Body() unitRentType: UnitRentTypeUpdateDto): Promise { + return mapTo(UnitRentTypeDto, await this.unitRentTypesService.update(unitRentType)) + } + + @Get(`:unitRentTypeId`) + @ApiOperation({ summary: "Get unitRentType by id", operationId: "retrieve" }) + async retrieve(@Param("unitRentTypeId") unitRentTypeId: string): Promise { + return mapTo( + UnitRentTypeDto, + await this.unitRentTypesService.findOne({ where: { id: unitRentTypeId } }) + ) + } + + @Delete(`:unitRentTypeId`) + @ApiOperation({ summary: "Delete unitRentType by id", operationId: "delete" }) + async delete(@Param("unitRentTypeId") unitRentTypeId: string): Promise { + return await this.unitRentTypesService.delete(unitRentTypeId) + } +} diff --git a/backend/core/src/unit-rent-types/unit-rent-types.module.ts b/backend/core/src/unit-rent-types/unit-rent-types.module.ts new file mode 100644 index 0000000000..42c0cef8b4 --- /dev/null +++ b/backend/core/src/unit-rent-types/unit-rent-types.module.ts @@ -0,0 +1,13 @@ +import { Module } from "@nestjs/common" +import { TypeOrmModule } from "@nestjs/typeorm" +import { AuthModule } from "../auth/auth.module" +import { UnitRentType } from "./entities/unit-rent-type.entity" +import { UnitRentTypesService } from "./unit-rent-types.service" +import { UnitRentTypesController } from "./unit-rent-types.controller" + +@Module({ + imports: [TypeOrmModule.forFeature([UnitRentType]), AuthModule], + controllers: [UnitRentTypesController], + providers: [UnitRentTypesService], +}) +export class UnitRentTypesModule {} diff --git a/backend/core/src/unit-rent-types/unit-rent-types.service.ts b/backend/core/src/unit-rent-types/unit-rent-types.service.ts new file mode 100644 index 0000000000..a9f5c22d85 --- /dev/null +++ b/backend/core/src/unit-rent-types/unit-rent-types.service.ts @@ -0,0 +1,11 @@ +import { AbstractServiceFactory } from "../shared/services/abstract-service" +import { Injectable } from "@nestjs/common" +import { UnitRentType } from "./entities/unit-rent-type.entity" +import { UnitRentTypeCreateDto, UnitRentTypeUpdateDto } from "./dto/unit-rent-type.dto" + +@Injectable() +export class UnitRentTypesService extends AbstractServiceFactory< + UnitRentType, + UnitRentTypeCreateDto, + UnitRentTypeUpdateDto +>(UnitRentType) {} diff --git a/backend/core/src/unit-types/dto/unit-type.dto.ts b/backend/core/src/unit-types/dto/unit-type.dto.ts new file mode 100644 index 0000000000..2c2cdb5076 --- /dev/null +++ b/backend/core/src/unit-types/dto/unit-type.dto.ts @@ -0,0 +1,20 @@ +import { Expose } from "class-transformer" +import { IsString, IsUUID } from "class-validator" +import { OmitType } from "@nestjs/swagger" +import { ValidationsGroupsEnum } from "../../shared/types/validations-groups-enum" +import { UnitType } from "../entities/unit-type.entity" + +export class UnitTypeDto extends OmitType(UnitType, [] as const) {} + +export class UnitTypeCreateDto extends OmitType(UnitTypeDto, [ + "id", + "createdAt", + "updatedAt", +] as const) {} + +export class UnitTypeUpdateDto extends UnitTypeCreateDto { + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @IsUUID() + id: string +} diff --git a/backend/core/src/unit-types/entities/unit-type.entity.ts b/backend/core/src/unit-types/entities/unit-type.entity.ts new file mode 100644 index 0000000000..8a4af6ba77 --- /dev/null +++ b/backend/core/src/unit-types/entities/unit-type.entity.ts @@ -0,0 +1,19 @@ +import { Column, Entity } from "typeorm" +import { Expose } from "class-transformer" +import { IsNumber, IsString, MaxLength } from "class-validator" +import { ValidationsGroupsEnum } from "../../shared/types/validations-groups-enum" +import { AbstractEntity } from "../../shared/entities/abstract.entity" + +@Entity({ name: "unit_types" }) +export class UnitType extends AbstractEntity { + @Column({ type: "text" }) + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @MaxLength(256, { groups: [ValidationsGroupsEnum.default] }) + name: string + + @Column({ type: "integer" }) + @Expose() + @IsNumber({}, { groups: [ValidationsGroupsEnum.default] }) + numBedrooms: number +} diff --git a/backend/core/src/unit-types/unit-types.controller.ts b/backend/core/src/unit-types/unit-types.controller.ts new file mode 100644 index 0000000000..a1c3483b63 --- /dev/null +++ b/backend/core/src/unit-types/unit-types.controller.ts @@ -0,0 +1,60 @@ +import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger" +import { DefaultAuthGuard } from "../auth/guards/default.guard" +import { AuthzGuard } from "../auth/guards/authz.guard" +import { ResourceType } from "../auth/decorators/resource-type.decorator" +import { mapTo } from "../shared/mapTo" +import { + Body, + Controller, + Delete, + Get, + Param, + Post, + Put, + UseGuards, + UsePipes, + ValidationPipe, +} from "@nestjs/common" +import { defaultValidationPipeOptions } from "../shared/default-validation-pipe-options" +import { UnitTypesService } from "./unit-types.service" +import { UnitTypeCreateDto, UnitTypeDto, UnitTypeUpdateDto } from "./dto/unit-type.dto" + +@Controller("unitTypes") +@ApiTags("unitTypes") +@ApiBearerAuth() +@ResourceType("unitType") +@UseGuards(DefaultAuthGuard, AuthzGuard) +@UsePipes(new ValidationPipe(defaultValidationPipeOptions)) +export class UnitTypesController { + constructor(private readonly unitTypesService: UnitTypesService) {} + + @Get() + @ApiOperation({ summary: "List unitTypes", operationId: "list" }) + async list(): Promise { + return mapTo(UnitTypeDto, await this.unitTypesService.list()) + } + + @Post() + @ApiOperation({ summary: "Create unitType", operationId: "create" }) + async create(@Body() unitType: UnitTypeCreateDto): Promise { + return mapTo(UnitTypeDto, await this.unitTypesService.create(unitType)) + } + + @Put(`:unitTypeId`) + @ApiOperation({ summary: "Update unitType", operationId: "update" }) + async update(@Body() unitType: UnitTypeUpdateDto): Promise { + return mapTo(UnitTypeDto, await this.unitTypesService.update(unitType)) + } + + @Get(`:unitTypeId`) + @ApiOperation({ summary: "Get unitType by id", operationId: "retrieve" }) + async retrieve(@Param("unitTypeId") unitTypeId: string): Promise { + return mapTo(UnitTypeDto, await this.unitTypesService.findOne({ where: { id: unitTypeId } })) + } + + @Delete(`:unitTypeId`) + @ApiOperation({ summary: "Delete unitType by id", operationId: "delete" }) + async delete(@Param("unitTypeId") unitTypeId: string): Promise { + return await this.unitTypesService.delete(unitTypeId) + } +} diff --git a/backend/core/src/unit-types/unit-types.module.ts b/backend/core/src/unit-types/unit-types.module.ts new file mode 100644 index 0000000000..de95a537a1 --- /dev/null +++ b/backend/core/src/unit-types/unit-types.module.ts @@ -0,0 +1,13 @@ +import { Module } from "@nestjs/common" +import { TypeOrmModule } from "@nestjs/typeorm" +import { AuthModule } from "../auth/auth.module" +import { UnitTypesService } from "./unit-types.service" +import { UnitType } from "./entities/unit-type.entity" +import { UnitTypesController } from "./unit-types.controller" + +@Module({ + imports: [TypeOrmModule.forFeature([UnitType]), AuthModule], + controllers: [UnitTypesController], + providers: [UnitTypesService], +}) +export class UnitTypesModule {} diff --git a/backend/core/src/unit-types/unit-types.service.ts b/backend/core/src/unit-types/unit-types.service.ts new file mode 100644 index 0000000000..38003481dd --- /dev/null +++ b/backend/core/src/unit-types/unit-types.service.ts @@ -0,0 +1,11 @@ +import { AbstractServiceFactory } from "../shared/services/abstract-service" +import { Injectable } from "@nestjs/common" +import { UnitType } from "./entities/unit-type.entity" +import { UnitTypeCreateDto, UnitTypeUpdateDto } from "./dto/unit-type.dto" + +@Injectable() +export class UnitTypesService extends AbstractServiceFactory< + UnitType, + UnitTypeCreateDto, + UnitTypeUpdateDto +>(UnitType) {} diff --git a/backend/core/src/units-summary/dto/unit-group-ami-level.dto.ts b/backend/core/src/units-summary/dto/unit-group-ami-level.dto.ts new file mode 100644 index 0000000000..fdf3fd0482 --- /dev/null +++ b/backend/core/src/units-summary/dto/unit-group-ami-level.dto.ts @@ -0,0 +1,49 @@ +import { OmitType } from "@nestjs/swagger" +import { Expose, plainToClass, Transform, Type } from "class-transformer" +import { IsOptional, IsUUID, ValidateNested } from "class-validator" +import { IdDto } from "../../shared/dto/id.dto" +import { ValidationsGroupsEnum } from "../../shared/types/validations-groups-enum" +import { UnitGroupAmiLevel } from "../entities/unit-group-ami-level.entity" + +export class UnitGroupAmiLevelDto extends OmitType(UnitGroupAmiLevel, [ + "unitGroup", + "amiChart", +] as const) { + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => IdDto) + @Transform( + (value, obj) => { + return obj.amiChartId ? plainToClass(IdDto, { id: obj.amiChartId }) : undefined + }, + { toClassOnly: true } + ) + amiChart?: IdDto +} + +export class UnitGroupAmiLevelCreateDto extends OmitType(UnitGroupAmiLevelDto, [ + "id", + "amiChart", +] as const) { + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => IdDto) + amiChart?: IdDto +} + +export class UnitGroupAmiLevelUpdateDto extends OmitType(UnitGroupAmiLevelCreateDto, [ + "amiChart", +] as const) { + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsUUID() + id?: string + + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => IdDto) + amiChart?: IdDto +} diff --git a/backend/core/src/units-summary/dto/unit-group.dto.ts b/backend/core/src/units-summary/dto/unit-group.dto.ts new file mode 100644 index 0000000000..e3940778d1 --- /dev/null +++ b/backend/core/src/units-summary/dto/unit-group.dto.ts @@ -0,0 +1,60 @@ +import { OmitType } from "@nestjs/swagger" +import { Expose, Type } from "class-transformer" +import { IsDefined, IsOptional, IsUUID, ValidateNested } from "class-validator" +import { UnitTypeDto } from "../../unit-types/dto/unit-type.dto" +import { ValidationsGroupsEnum } from "../../shared/types/validations-groups-enum" +import { UnitGroup } from "../entities/unit-group.entity" +import { + UnitGroupAmiLevelCreateDto, + UnitGroupAmiLevelDto, + UnitGroupAmiLevelUpdateDto, +} from "./unit-group-ami-level.dto" +import { IdDto } from "../../shared/dto/id.dto" + +export class UnitGroupDto extends OmitType(UnitGroup, [ + "listing", + "unitType", + "amiLevels", +] as const) { + @Expose() + @ValidateNested({ groups: [ValidationsGroupsEnum.default], each: true }) + @Type(() => UnitTypeDto) + unitType: UnitTypeDto[] + + @Expose() + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default], each: true }) + @Type(() => UnitGroupAmiLevelDto) + amiLevels: UnitGroupAmiLevelDto[] +} + +export class UnitGroupCreateDto extends OmitType(UnitGroupDto, [ + "id", + "unitType", + "amiLevels", + "listingId", +] as const) { + @Expose() + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default], each: true }) + @Type(() => IdDto) + unitType: IdDto[] + + @Expose() + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default], each: true }) + @Type(() => UnitGroupAmiLevelCreateDto) + amiLevels: UnitGroupAmiLevelCreateDto[] +} +export class UnitGroupUpdateDto extends OmitType(UnitGroupCreateDto, ["amiLevels"] as const) { + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsUUID() + id?: string + + @Expose() + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default], each: true }) + @Type(() => UnitGroupAmiLevelUpdateDto) + amiLevels: UnitGroupAmiLevelUpdateDto[] +} 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 new file mode 100644 index 0000000000..1096eded5e --- /dev/null +++ b/backend/core/src/units-summary/entities/unit-group-ami-level.entity.ts @@ -0,0 +1,55 @@ +import { Column, Entity, ManyToOne, PrimaryGeneratedColumn, RelationId } from "typeorm" +import { Expose } from "class-transformer" +import { IsEnum, IsNumber, IsOptional, IsString, IsUUID } from "class-validator" +import { ValidationsGroupsEnum } from "../../shared/types/validations-groups-enum" +import { MonthlyRentDeterminationType } from "../types/monthly-rent-determination.enum" +import { AmiChart } from "../../ami-charts/entities/ami-chart.entity" +import { UnitGroup } from "./unit-group.entity" +import { ApiProperty } from "@nestjs/swagger" + +@Entity({ name: "unit_group_ami_levels" }) +export class UnitGroupAmiLevel { + @PrimaryGeneratedColumn("uuid") + @Expose() + @IsUUID(4, { groups: [ValidationsGroupsEnum.default] }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + id: string + + @ManyToOne(() => AmiChart, { eager: false, nullable: true }) + amiChart?: AmiChart | null + + @RelationId( + (unitsSummaryAmiLevelEntity: UnitGroupAmiLevel) => unitsSummaryAmiLevelEntity.amiChart + ) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsUUID(4, { groups: [ValidationsGroupsEnum.default] }) + amiChartId?: string | null + + @ManyToOne(() => UnitGroup, (unitGroup: UnitGroup) => unitGroup.amiLevels) + unitGroup: UnitGroup + + @Column({ type: "integer", nullable: true }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsNumber({}, { groups: [ValidationsGroupsEnum.default] }) + amiPercentage: number | null + + @Column({ type: "enum", enum: MonthlyRentDeterminationType, nullable: false }) + @Expose() + @IsEnum(MonthlyRentDeterminationType, { groups: [ValidationsGroupsEnum.default] }) + @ApiProperty({ enum: MonthlyRentDeterminationType, enumName: "MonthlyRentDeterminationType" }) + monthlyRentDeterminationType: MonthlyRentDeterminationType + + @Column({ type: "numeric", nullable: true }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsNumber({}, { groups: [ValidationsGroupsEnum.default] }) + flatRentValue?: number | null + + @Column({ type: "numeric", nullable: true }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsNumber({}, { groups: [ValidationsGroupsEnum.default] }) + percentageOfIncomeValue?: number | null +} diff --git a/backend/core/src/units-summary/entities/unit-group.entity.ts b/backend/core/src/units-summary/entities/unit-group.entity.ts new file mode 100644 index 0000000000..ef2568ab1e --- /dev/null +++ b/backend/core/src/units-summary/entities/unit-group.entity.ts @@ -0,0 +1,132 @@ +import { + Column, + Entity, + JoinTable, + ManyToMany, + ManyToOne, + OneToMany, + PrimaryGeneratedColumn, + RelationId, +} from "typeorm" +import { + IsBoolean, + IsDefined, + IsNumber, + IsOptional, + IsString, + IsUUID, + ValidateNested, +} from "class-validator" +import { Expose, Type } from "class-transformer" +import { ValidationsGroupsEnum } from "../../shared/types/validations-groups-enum" +import { UnitType } from "../../unit-types/entities/unit-type.entity" +import { UnitAccessibilityPriorityType } from "../../unit-accessbility-priority-types/entities/unit-accessibility-priority-type.entity" +import { Listing } from "../../listings/entities/listing.entity" +import { UnitGroupAmiLevel } from "./unit-group-ami-level.entity" + +@Entity({ name: "unit_group" }) +export class UnitGroup { + @PrimaryGeneratedColumn("uuid") + @Expose() + @IsUUID(4, { groups: [ValidationsGroupsEnum.default] }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + id: string + + @ManyToMany(() => UnitType, { eager: true }) + @JoinTable() + @Expose() + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default], each: true }) + @Type(() => UnitType) + unitType: UnitType[] + + @ManyToOne(() => Listing, (listing) => listing.unitGroups, {}) + listing: Listing + + @RelationId((unitGroupEntity: UnitGroup) => unitGroupEntity.listing) + @Expose() + @IsUUID(4, { groups: [ValidationsGroupsEnum.default] }) + listingId: string + + @OneToMany(() => UnitGroupAmiLevel, (UnitGroupAmiLevel) => UnitGroupAmiLevel.unitGroup, { + eager: true, + cascade: true, + }) + @Expose() + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default], each: true }) + @Type(() => UnitGroupAmiLevel) + amiLevels: UnitGroupAmiLevel[] + + @Column({ nullable: true, type: "integer" }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsNumber({}, { groups: [ValidationsGroupsEnum.default] }) + maxOccupancy?: number | null + + @Column({ nullable: true, type: "integer" }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsNumber({}, { groups: [ValidationsGroupsEnum.default] }) + minOccupancy?: number | null + + @Column({ nullable: true, type: "integer" }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsNumber({}, { groups: [ValidationsGroupsEnum.default] }) + floorMin?: number | null + + @Column({ nullable: true, type: "integer" }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsNumber({}, { groups: [ValidationsGroupsEnum.default] }) + floorMax?: number | null + + @Column({ nullable: true, type: "numeric" }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsNumber({}, { groups: [ValidationsGroupsEnum.default] }) + sqFeetMin?: number | null + + @Column({ nullable: true, type: "numeric" }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsNumber({}, { groups: [ValidationsGroupsEnum.default] }) + sqFeetMax?: number | null + + @ManyToOne(() => UnitAccessibilityPriorityType, { eager: true, nullable: true }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => UnitAccessibilityPriorityType) + priorityType?: UnitAccessibilityPriorityType | null + + @Column({ nullable: true, type: "integer" }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsNumber({}, { groups: [ValidationsGroupsEnum.default] }) + totalCount?: number | null + + @Column({ nullable: true, type: "integer" }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsNumber({}, { groups: [ValidationsGroupsEnum.default] }) + totalAvailable?: number | null + + @Column({ nullable: true, type: "numeric" }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsNumber({}, { groups: [ValidationsGroupsEnum.default] }) + bathroomMin?: number | null + + @Column({ nullable: true, type: "numeric" }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsNumber({}, { groups: [ValidationsGroupsEnum.default] }) + bathroomMax?: number | null + + @Column({ type: "boolean", nullable: false, default: true }) + @Expose() + @IsBoolean({ groups: [ValidationsGroupsEnum.default] }) + openWaitlist: boolean +} diff --git a/backend/core/src/units-summary/types/monthly-rent-determination.enum.ts b/backend/core/src/units-summary/types/monthly-rent-determination.enum.ts new file mode 100644 index 0000000000..516ce5e1d8 --- /dev/null +++ b/backend/core/src/units-summary/types/monthly-rent-determination.enum.ts @@ -0,0 +1,4 @@ +export enum MonthlyRentDeterminationType { + flatRent = "flatRent", + percentageOfIncome = "percentageOfIncome", +} diff --git a/backend/core/src/units/dto/unit-ami-chart-override-create.dto.ts b/backend/core/src/units/dto/unit-ami-chart-override-create.dto.ts new file mode 100644 index 0000000000..d0b82dfd5c --- /dev/null +++ b/backend/core/src/units/dto/unit-ami-chart-override-create.dto.ts @@ -0,0 +1,8 @@ +import { OmitType } from "@nestjs/swagger" +import { UnitAmiChartOverrideDto } from "./unit-ami-chart-override.dto" + +export class UnitAmiChartOverrideCreateDto extends OmitType(UnitAmiChartOverrideDto, [ + "id", + "createdAt", + "updatedAt", +] as const) {} diff --git a/backend/core/src/units/dto/unit-ami-chart-override-update.dto.ts b/backend/core/src/units/dto/unit-ami-chart-override-update.dto.ts new file mode 100644 index 0000000000..919c605296 --- /dev/null +++ b/backend/core/src/units/dto/unit-ami-chart-override-update.dto.ts @@ -0,0 +1,28 @@ +import { OmitType } from "@nestjs/swagger" +import { Expose, Type } from "class-transformer" +import { IsDate, IsOptional, IsUUID } from "class-validator" +import { ValidationsGroupsEnum } from "../../shared/types/validations-groups-enum" +import { UnitAmiChartOverrideDto } from "./unit-ami-chart-override.dto" + +export class UnitAmiChartOverrideUpdateDto extends OmitType(UnitAmiChartOverrideDto, [ + "id", + "createdAt", + "updatedAt", +] as const) { + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsUUID(4, { groups: [ValidationsGroupsEnum.default] }) + id?: string + + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsDate({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => Date) + createdAt?: Date + + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsDate({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => Date) + updatedAt?: Date +} diff --git a/backend/core/src/units/dto/unit-ami-chart-override.dto.ts b/backend/core/src/units/dto/unit-ami-chart-override.dto.ts new file mode 100644 index 0000000000..51d7ab1916 --- /dev/null +++ b/backend/core/src/units/dto/unit-ami-chart-override.dto.ts @@ -0,0 +1,4 @@ +import { OmitType } from "@nestjs/swagger" +import { UnitAmiChartOverride } from "../entities/unit-ami-chart-override.entity" + +export class UnitAmiChartOverrideDto extends OmitType(UnitAmiChartOverride, [] as const) {} diff --git a/backend/core/src/units/dto/unit-create.dto.ts b/backend/core/src/units/dto/unit-create.dto.ts new file mode 100644 index 0000000000..08474c8bb3 --- /dev/null +++ b/backend/core/src/units/dto/unit-create.dto.ts @@ -0,0 +1,51 @@ +import { OmitType } from "@nestjs/swagger" +import { Expose, Type } from "class-transformer" +import { IsDefined, IsOptional, ValidateNested } from "class-validator" +import { ValidationsGroupsEnum } from "../../shared/types/validations-groups-enum" +import { IdDto } from "../../shared/dto/id.dto" +import { UnitDto } from "./unit.dto" +import { UnitAmiChartOverrideCreateDto } from "./unit-ami-chart-override-create.dto" + +export class UnitCreateDto extends OmitType(UnitDto, [ + "id", + "createdAt", + "updatedAt", + "amiChart", + "unitType", + "unitRentType", + "priorityType", + "amiChartOverride", +] as const) { + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => IdDto) + amiChart?: IdDto + + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => IdDto) + unitType?: IdDto + + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => IdDto) + unitRentType?: IdDto + + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => IdDto) + priorityType?: IdDto + + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => UnitAmiChartOverrideCreateDto) + amiChartOverride?: UnitAmiChartOverrideCreateDto +} diff --git a/backend/core/src/units/dto/unit-update.dto.ts b/backend/core/src/units/dto/unit-update.dto.ts new file mode 100644 index 0000000000..3f81bb2f4b --- /dev/null +++ b/backend/core/src/units/dto/unit-update.dto.ts @@ -0,0 +1,70 @@ +import { OmitType } from "@nestjs/swagger" +import { Expose, Type } from "class-transformer" +import { IsDate, IsDefined, IsOptional, IsUUID, ValidateNested } from "class-validator" +import { ValidationsGroupsEnum } from "../../shared/types/validations-groups-enum" +import { IdDto } from "../../shared/dto/id.dto" +import { UnitRentTypeUpdateDto } from "../../unit-rent-types/dto/unit-rent-type.dto" +import { UnitAccessibilityPriorityTypeUpdateDto } from "../../unit-accessbility-priority-types/dto/unit-accessibility-priority-type.dto" +import { UnitDto } from "./unit.dto" +import { UnitAmiChartOverrideUpdateDto } from "./unit-ami-chart-override-update.dto" + +export class UnitUpdateDto extends OmitType(UnitDto, [ + "id", + "createdAt", + "updatedAt", + "amiChart", + "unitType", + "unitRentType", + "priorityType", + "amiChartOverride", +] as const) { + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsUUID(4, { groups: [ValidationsGroupsEnum.default] }) + id?: string + + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsDate({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => Date) + createdAt?: Date + + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsDate({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => Date) + updatedAt?: Date + + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => IdDto) + amiChart?: IdDto + + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => IdDto) + unitType?: IdDto + + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => UnitRentTypeUpdateDto) + unitRentType?: IdDto + + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => UnitAccessibilityPriorityTypeUpdateDto) + priorityType?: IdDto + + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => UnitAmiChartOverrideUpdateDto) + amiChartOverride?: UnitAmiChartOverrideUpdateDto +} diff --git a/backend/core/src/units/dto/unit.dto.ts b/backend/core/src/units/dto/unit.dto.ts new file mode 100644 index 0000000000..accb53aef2 --- /dev/null +++ b/backend/core/src/units/dto/unit.dto.ts @@ -0,0 +1,63 @@ +import { ApiHideProperty, OmitType } from "@nestjs/swagger" +import { Unit } from "../entities/unit.entity" +import { Exclude, Expose, plainToClass, Transform, Type } from "class-transformer" +import { IsDefined, IsOptional, ValidateNested } from "class-validator" +import { ValidationsGroupsEnum } from "../../shared/types/validations-groups-enum" +import { IdDto } from "../../shared/dto/id.dto" +import { UnitTypeDto } from "../../unit-types/dto/unit-type.dto" +import { UnitRentTypeDto } from "../../unit-rent-types/dto/unit-rent-type.dto" +import { UnitAccessibilityPriorityTypeDto } from "../../unit-accessbility-priority-types/dto/unit-accessibility-priority-type.dto" +import { UnitAmiChartOverrideDto } from "./unit-ami-chart-override.dto" + +export class UnitDto extends OmitType(Unit, [ + "property", + "amiChart", + "amiChartId", + "unitType", + "unitRentType", + "priorityType", + "amiChartOverride", +] as const) { + @Exclude() + @ApiHideProperty() + property + + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => IdDto) + @Transform( + (value, obj) => { + return obj.amiChartId ? plainToClass(IdDto, { id: obj.amiChartId }) : undefined + }, + { toClassOnly: true } + ) + amiChart?: IdDto + + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => UnitTypeDto) + unitType?: UnitTypeDto + + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => UnitRentTypeDto) + unitRentType?: UnitRentTypeDto + + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => UnitAccessibilityPriorityTypeDto) + priorityType?: UnitAccessibilityPriorityTypeDto + + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => UnitAmiChartOverrideDto) + amiChartOverride?: UnitAmiChartOverrideDto +} diff --git a/backend/core/src/units/entities/unit-ami-chart-override.entity.ts b/backend/core/src/units/entities/unit-ami-chart-override.entity.ts new file mode 100644 index 0000000000..7d06a06084 --- /dev/null +++ b/backend/core/src/units/entities/unit-ami-chart-override.entity.ts @@ -0,0 +1,16 @@ +import { AbstractEntity } from "../../shared/entities/abstract.entity" +import { Column, Entity } from "typeorm" +import { Expose, Type } from "class-transformer" +import { IsDefined, ValidateNested } from "class-validator" +import { ValidationsGroupsEnum } from "../../shared/types/validations-groups-enum" +import { AmiChartItem } from "../../ami-charts/entities/ami-chart-item.entity" + +@Entity({ name: "unit_ami_chart_overrides" }) +export class UnitAmiChartOverride extends AbstractEntity { + @Column("jsonb") + @Expose() + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default], each: true }) + @Type(() => AmiChartItem) + items: AmiChartItem[] +} diff --git a/backend/core/src/units/entities/unit.entity.ts b/backend/core/src/units/entities/unit.entity.ts new file mode 100644 index 0000000000..27a56de72e --- /dev/null +++ b/backend/core/src/units/entities/unit.entity.ts @@ -0,0 +1,191 @@ +import { + Column, + CreateDateColumn, + Entity, + JoinColumn, + ManyToOne, + OneToOne, + PrimaryGeneratedColumn, + RelationId, + UpdateDateColumn, +} from "typeorm" +import { + IsBoolean, + IsDate, + IsEnum, + IsNumber, + IsNumberString, + IsOptional, + IsString, + IsUUID, + ValidateNested, +} from "class-validator" +import { Expose, Type } from "class-transformer" +import { Property } from "../../property/entities/property.entity" +import { AmiChart } from "../../ami-charts/entities/ami-chart.entity" +import { ValidationsGroupsEnum } from "../../shared/types/validations-groups-enum" +import { UnitStatus } from "../types/unit-status-enum" +import { ApiProperty } from "@nestjs/swagger" +import { UnitType } from "../../unit-types/entities/unit-type.entity" +import { UnitRentType } from "../../unit-rent-types/entities/unit-rent-type.entity" +import { UnitAccessibilityPriorityType } from "../../unit-accessbility-priority-types/entities/unit-accessibility-priority-type.entity" +import { UnitAmiChartOverride } from "../../units/entities/unit-ami-chart-override.entity" + +@Entity({ name: "units" }) +class Unit { + @PrimaryGeneratedColumn("uuid") + @Expose() + @IsUUID(4, { groups: [ValidationsGroupsEnum.default] }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + id: string + + @CreateDateColumn() + @Expose() + @IsDate({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => Date) + createdAt: Date + + @UpdateDateColumn() + @Expose() + @IsDate({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => Date) + updatedAt: Date + + @ManyToOne(() => AmiChart, { eager: false, nullable: true }) + amiChart?: AmiChart | null + + @RelationId((unit: Unit) => unit.amiChart) + @Expose() + amiChartId?: string + + @Column({ nullable: true, type: "text" }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + amiPercentage?: string | null + + @Column({ nullable: true, type: "text" }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsNumberString({}, { groups: [ValidationsGroupsEnum.default] }) + annualIncomeMin?: string | null + + @Column({ nullable: true, type: "text" }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsNumberString({}, { groups: [ValidationsGroupsEnum.default] }) + monthlyIncomeMin?: string | null + + @Column({ nullable: true, type: "integer" }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsNumber({}, { groups: [ValidationsGroupsEnum.default] }) + floor?: number | null + + @Column({ nullable: true, type: "text" }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + annualIncomeMax?: string | null + + @Column({ nullable: true, type: "integer" }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsNumber({}, { groups: [ValidationsGroupsEnum.default] }) + maxOccupancy?: number | null + + @Column({ nullable: true, type: "integer" }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsNumber({}, { groups: [ValidationsGroupsEnum.default] }) + minOccupancy?: number | null + + @Column({ nullable: true, type: "text" }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsNumberString({}, { groups: [ValidationsGroupsEnum.default] }) + monthlyRent?: string | null + + @Column({ nullable: true, type: "integer" }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsNumber({}, { groups: [ValidationsGroupsEnum.default] }) + numBathrooms?: number | null + + @Column({ nullable: true, type: "integer" }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsNumber({}, { groups: [ValidationsGroupsEnum.default] }) + numBedrooms?: number | null + + @Column({ nullable: true, type: "text" }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + number?: string | null + + @Column({ nullable: true, type: "numeric", precision: 8, scale: 2 }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + sqFeet?: string | null + + @Column({ + type: "enum", + enum: UnitStatus, + default: UnitStatus.unknown, + }) + @Expose() + @IsEnum(UnitStatus, { groups: [ValidationsGroupsEnum.default] }) + @ApiProperty({ enum: UnitStatus, enumName: "UnitStatus" }) + status: UnitStatus + + @Column({ nullable: true, type: "numeric", precision: 8, scale: 2 }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + monthlyRentAsPercentOfIncome?: string | null + + @ManyToOne(() => Property, (property) => property.units, { + onDelete: "CASCADE", + onUpdate: "CASCADE", + }) + property: Property + + @Column({ type: "boolean", nullable: true }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsBoolean({ groups: [ValidationsGroupsEnum.default] }) + bmrProgramChart?: boolean | null + + @ManyToOne(() => UnitType, { eager: true, nullable: true }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => UnitType) + unitType?: UnitType | null + + @ManyToOne(() => UnitRentType, { eager: true, nullable: true }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => UnitRentType) + unitRentType?: UnitRentType | null + + @ManyToOne(() => UnitAccessibilityPriorityType, { eager: true, nullable: true }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => UnitAccessibilityPriorityType) + priorityType?: UnitAccessibilityPriorityType | null + + @OneToOne(() => UnitAmiChartOverride, { eager: true, cascade: true }) + @JoinColumn() + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => UnitAmiChartOverride) + amiChartOverride?: UnitAmiChartOverride +} + +export { Unit as default, Unit } diff --git a/backend/core/src/units/types/household-max-income-columns.ts b/backend/core/src/units/types/household-max-income-columns.ts new file mode 100644 index 0000000000..a30782ad92 --- /dev/null +++ b/backend/core/src/units/types/household-max-income-columns.ts @@ -0,0 +1,92 @@ +import { Expose } from "class-transformer" +import { IsDefined, IsOptional, IsString } from "class-validator" +import { ValidationsGroupsEnum } from "../../shared/types/validations-groups-enum" +import { ApiProperty } from "@nestjs/swagger" + +export class HMIColumns { + @Expose() + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @IsString() + @ApiProperty() + householdSize: string + + @Expose() + @IsOptional() + @ApiProperty({ required: false }) + 20?: number + + @Expose() + @IsOptional() + @ApiProperty({ required: false }) + 25?: number + + @Expose() + @IsOptional() + @ApiProperty({ required: false }) + 30?: number + + @Expose() + @IsOptional() + @ApiProperty({ required: false }) + 35?: number + + @Expose() + @IsOptional() + @ApiProperty({ required: false }) + 40?: number + + @Expose() + @IsOptional() + @ApiProperty({ required: false }) + 45?: number + + @Expose() + @IsOptional() + @ApiProperty({ required: false }) + 50?: number + + @Expose() + @IsOptional() + @ApiProperty({ required: false }) + 55?: number + + @Expose() + @IsOptional() + @ApiProperty({ required: false }) + 60?: number + + @Expose() + @IsOptional() + @ApiProperty({ required: false }) + 70?: number + + @Expose() + @IsOptional() + @ApiProperty({ required: false }) + 80?: number + + @Expose() + @IsOptional() + @ApiProperty({ required: false }) + 100?: number + + @Expose() + @IsOptional() + @ApiProperty({ required: false }) + 120?: number + + @Expose() + @IsOptional() + @ApiProperty({ required: false }) + 125?: number + + @Expose() + @IsOptional() + @ApiProperty({ required: false }) + 140?: number + + @Expose() + @IsOptional() + @ApiProperty({ required: false }) + 150?: number +} diff --git a/backend/core/src/units/types/household-max-income-summary.ts b/backend/core/src/units/types/household-max-income-summary.ts new file mode 100644 index 0000000000..ef86c6ef34 --- /dev/null +++ b/backend/core/src/units/types/household-max-income-summary.ts @@ -0,0 +1,10 @@ +import { ApiProperty } from "@nestjs/swagger" +import { HMIColumns } from "./household-max-income-columns" + +export class HouseholdMaxIncomeSummary { + @ApiProperty() + columns: HMIColumns + + @ApiProperty({ type: [HMIColumns] }) + rows: HMIColumns[] +} diff --git a/backend/core/src/units/types/min-max-currency.ts b/backend/core/src/units/types/min-max-currency.ts new file mode 100644 index 0000000000..387899e8ce --- /dev/null +++ b/backend/core/src/units/types/min-max-currency.ts @@ -0,0 +1,18 @@ +import { Expose } from "class-transformer" +import { IsDefined, IsString } from "class-validator" +import { ValidationsGroupsEnum } from "../../shared/types/validations-groups-enum" +import { ApiProperty } from "@nestjs/swagger" + +export class MinMaxCurrency { + @Expose() + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @ApiProperty() + min: string + + @Expose() + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @ApiProperty() + max: string +} diff --git a/backend/core/src/units/types/min-max-string.ts b/backend/core/src/units/types/min-max-string.ts new file mode 100644 index 0000000000..dfd9636266 --- /dev/null +++ b/backend/core/src/units/types/min-max-string.ts @@ -0,0 +1,18 @@ +import { Expose } from "class-transformer" +import { IsDefined, IsString } from "class-validator" +import { ValidationsGroupsEnum } from "../../shared/types/validations-groups-enum" +import { ApiProperty } from "@nestjs/swagger" + +export class MinMaxString { + @Expose() + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @IsString() + @ApiProperty() + min: string + + @Expose() + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @IsString() + @ApiProperty() + max: string +} diff --git a/backend/core/src/units/types/min-max.ts b/backend/core/src/units/types/min-max.ts new file mode 100644 index 0000000000..aeed101ef7 --- /dev/null +++ b/backend/core/src/units/types/min-max.ts @@ -0,0 +1,18 @@ +import { Expose } from "class-transformer" +import { IsDefined, IsNumber } from "class-validator" +import { ValidationsGroupsEnum } from "../../shared/types/validations-groups-enum" +import { ApiProperty } from "@nestjs/swagger" + +export class MinMax { + @Expose() + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @IsNumber({}, { groups: [ValidationsGroupsEnum.default] }) + @ApiProperty() + min: number + + @Expose() + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @IsNumber({}, { groups: [ValidationsGroupsEnum.default] }) + @ApiProperty() + max: number +} diff --git a/backend/core/src/units/types/unit-group-summary.ts b/backend/core/src/units/types/unit-group-summary.ts new file mode 100644 index 0000000000..9b022443b3 --- /dev/null +++ b/backend/core/src/units/types/unit-group-summary.ts @@ -0,0 +1,66 @@ +import { Expose, Type } from "class-transformer" +import { IsDefined, IsNumber, IsString, ValidateNested, IsBoolean } from "class-validator" +import { ApiProperty } from "@nestjs/swagger" +import { ValidationsGroupsEnum } from "../../shared/types/validations-groups-enum" +import { MinMaxCurrency } from "./min-max-currency" +import { MinMax } from "./min-max" + +export class UnitGroupSummary { + @Expose() + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @ApiProperty({ required: false }) + unitTypes?: string[] | null + + @Expose() + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => MinMax) + @ApiProperty({ required: false }) + rentAsPercentIncomeRange?: MinMax + + @Expose() + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => MinMaxCurrency) + @ApiProperty({ required: false }) + rentRange?: MinMaxCurrency + + @Expose() + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @IsNumber({}, { groups: [ValidationsGroupsEnum.default] }) + @ApiProperty() + amiPercentageRange: MinMax + + @Expose() + @IsBoolean({ groups: [ValidationsGroupsEnum.default] }) + @ApiProperty() + openWaitlist: boolean + + @Expose() + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @IsNumber({}, { groups: [ValidationsGroupsEnum.default] }) + @ApiProperty() + unitVacancies: number + + @Expose() + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => MinMax) + @ApiProperty({ required: false }) + floorRange?: MinMax + + @Expose() + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => MinMax) + @ApiProperty({ required: false }) + sqFeetRange?: MinMax + + @Expose() + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => MinMax) + @ApiProperty({ required: false }) + bathroomRange?: MinMax +} diff --git a/backend/core/src/units/types/unit-status-enum.ts b/backend/core/src/units/types/unit-status-enum.ts new file mode 100644 index 0000000000..c509ef13c5 --- /dev/null +++ b/backend/core/src/units/types/unit-status-enum.ts @@ -0,0 +1,6 @@ +export enum UnitStatus { + unknown = "unknown", + available = "available", + occupied = "occupied", + unavailable = "unavailable", +} diff --git a/backend/core/src/units/types/unit-summaries.ts b/backend/core/src/units/types/unit-summaries.ts new file mode 100644 index 0000000000..bddb935fbe --- /dev/null +++ b/backend/core/src/units/types/unit-summaries.ts @@ -0,0 +1,19 @@ +import { Expose, Type } from "class-transformer" +import { IsDefined, ValidateNested } from "class-validator" +import { ValidationsGroupsEnum } from "../../shared/types/validations-groups-enum" +import { UnitGroupSummary } from "./unit-group-summary" +import { HouseholdMaxIncomeSummary } from "./household-max-income-summary" +import { ApiProperty } from "@nestjs/swagger" + +export class UnitSummaries { + @Expose() + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default], each: true }) + @Type(() => UnitGroupSummary) + @ApiProperty({ type: [UnitGroupSummary] }) + unitGroupSummary: UnitGroupSummary[] + + @Expose() + @ApiProperty({ type: HouseholdMaxIncomeSummary }) + householdMaxIncomeSummary: HouseholdMaxIncomeSummary +} diff --git a/backend/core/src/units/types/unit-type-summary.ts b/backend/core/src/units/types/unit-type-summary.ts new file mode 100644 index 0000000000..321d87495e --- /dev/null +++ b/backend/core/src/units/types/unit-type-summary.ts @@ -0,0 +1,42 @@ +import { Expose, Type } from "class-transformer" +import { IsDefined, IsOptional, IsString, ValidateNested } from "class-validator" +import { ValidationsGroupsEnum } from "../../shared/types/validations-groups-enum" +import { MinMax } from "./min-max" +import { ApiProperty } from "@nestjs/swagger" +import { UnitTypeDto } from "../../unit-types/dto/unit-type.dto" + +export class UnitTypeSummary { + @Expose() + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @ApiProperty() + unitTypes?: UnitTypeDto[] | null + + @Expose() + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => MinMax) + @ApiProperty() + occupancyRange: MinMax + + @Expose() + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => MinMax) + @ApiProperty() + areaRange?: MinMax + + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => MinMax) + @ApiProperty({ type: MinMax, required: false }) + floorRange?: MinMax + + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => MinMax) + @ApiProperty({ type: MinMax, required: false }) + bathroomRange?: MinMax +} diff --git a/backend/core/src/units/units.controller.ts b/backend/core/src/units/units.controller.ts new file mode 100644 index 0000000000..fa0675b6ae --- /dev/null +++ b/backend/core/src/units/units.controller.ts @@ -0,0 +1,62 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + Post, + Put, + UseGuards, + UsePipes, + ValidationPipe, +} from "@nestjs/common" +import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger" +import { UnitsService } from "./units.service" +import { UnitDto } from "./dto/unit.dto" +import { AuthzGuard } from "../auth/guards/authz.guard" +import { ResourceType } from "../auth/decorators/resource-type.decorator" +import { mapTo } from "../shared/mapTo" +import { OptionalAuthGuard } from "../auth/guards/optional-auth.guard" +import { defaultValidationPipeOptions } from "../shared/default-validation-pipe-options" +import { UnitCreateDto } from "./dto/unit-create.dto" +import { UnitUpdateDto } from "./dto/unit-update.dto" + +@Controller("/units") +@ApiTags("units") +@ApiBearerAuth() +@ResourceType("unit") +@UseGuards(OptionalAuthGuard, AuthzGuard) +@UsePipes(new ValidationPipe(defaultValidationPipeOptions)) +export class UnitsController { + constructor(private readonly unitsService: UnitsService) {} + + @Get() + @ApiOperation({ summary: "List units", operationId: "list" }) + async list(): Promise { + return mapTo(UnitDto, await this.unitsService.list()) + } + + @Post() + @ApiOperation({ summary: "Create unit", operationId: "create" }) + async create(@Body() unit: UnitCreateDto): Promise { + return mapTo(UnitDto, await this.unitsService.create(unit)) + } + + @Put(`:unitId`) + @ApiOperation({ summary: "Update unit", operationId: "update" }) + async update(@Body() unit: UnitUpdateDto): Promise { + return mapTo(UnitDto, await this.unitsService.update(unit)) + } + + @Get(`:unitId`) + @ApiOperation({ summary: "Get unit by id", operationId: "retrieve" }) + async retrieve(@Param("unitId") unitId: string): Promise { + return mapTo(UnitDto, await this.unitsService.findOne({ where: { id: unitId } })) + } + + @Delete(`:unitId`) + @ApiOperation({ summary: "Delete unit by id", operationId: "delete" }) + async delete(@Param("unitId") unitId: string): Promise { + return await this.unitsService.delete(unitId) + } +} diff --git a/backend/core/src/units/units.module.ts b/backend/core/src/units/units.module.ts new file mode 100644 index 0000000000..5cb88e2959 --- /dev/null +++ b/backend/core/src/units/units.module.ts @@ -0,0 +1,21 @@ +import { Module } from "@nestjs/common" +import { TypeOrmModule } from "@nestjs/typeorm" +import { UnitsService } from "./units.service" +import { UnitsController } from "./units.controller" +import { AuthzService } from "../auth/services/authz.service" +import { AuthModule } from "../auth/auth.module" +import { Unit } from "./entities/unit.entity" +import { UnitType } from "../unit-types/entities/unit-type.entity" +import { UnitRentType } from "../unit-rent-types/entities/unit-rent-type.entity" +import { UnitAccessibilityPriorityType } from "../unit-accessbility-priority-types/entities/unit-accessibility-priority-type.entity" + +@Module({ + imports: [ + TypeOrmModule.forFeature([Unit, UnitType, UnitRentType, UnitAccessibilityPriorityType]), + AuthModule, + ], + providers: [UnitsService, AuthzService], + exports: [UnitsService], + controllers: [UnitsController], +}) +export class UnitsModule {} diff --git a/backend/core/src/units/units.service.ts b/backend/core/src/units/units.service.ts new file mode 100644 index 0000000000..a11aebc682 --- /dev/null +++ b/backend/core/src/units/units.service.ts @@ -0,0 +1,8 @@ +import { Unit } from "./entities/unit.entity" +import { AbstractServiceFactory } from "../shared/services/abstract-service" +import { UnitCreateDto } from "./dto/unit-create.dto" +import { UnitUpdateDto } from "./dto/unit-update.dto" + +export class UnitsService extends AbstractServiceFactory( + Unit +) {} diff --git a/backend/core/src/views/base.view.ts b/backend/core/src/views/base.view.ts new file mode 100644 index 0000000000..94df565346 --- /dev/null +++ b/backend/core/src/views/base.view.ts @@ -0,0 +1,39 @@ +import { SelectQueryBuilder } from "typeorm" + +export interface View { + select?: string[] + leftJoins?: { + join: string + alias: string + }[] + leftJoinAndSelect?: [string, string][] +} + +export class BaseView { + qb: SelectQueryBuilder + view: View + constructor(qb: SelectQueryBuilder) { + this.qb = qb + this.view = undefined + } + + getViewQb(): SelectQueryBuilder { + this.qb.select(this.view.select) + + this.view.leftJoins.forEach((join) => { + this.qb.leftJoin(join.join, join.alias) + }) + + return this.qb + } +} + +export const getBaseAddressSelect = (schemas: string[]): string[] => { + const fields = ["city", "state", "street", "street2", "zipCode", "latitude", "longitude"] + + let select: string[] = [] + schemas.forEach((schema) => { + select = select.concat(fields.map((field) => `${schema}.${field}`)) + }) + return select +} diff --git a/backend/core/src/views/change-email.hbs b/backend/core/src/views/change-email.hbs new file mode 100644 index 0000000000..d1d321efb9 --- /dev/null +++ b/backend/core/src/views/change-email.hbs @@ -0,0 +1,11 @@ +

{{t "t.hello"}} {{> user-name }},

+

+ {{t "changeEmail.message" appOptions}} +

+

+ {{t "changeEmail.onChangeEmailMessage"}} +

+

+ {{t "changeEmail.changeMyEmail"}} +

+{{> footer }} diff --git a/backend/core/src/views/new-listing.hbs b/backend/core/src/views/new-listing.hbs new file mode 100644 index 0000000000..f556f41616 --- /dev/null +++ b/backend/core/src/views/new-listing.hbs @@ -0,0 +1,438 @@ + + + + + + Rental Opportunity + + + + + + + + + + + + + + + + + +   + + + + diff --git a/backend/core/src/views/update-listing.hbs b/backend/core/src/views/update-listing.hbs new file mode 100644 index 0000000000..5c4a295046 --- /dev/null +++ b/backend/core/src/views/update-listing.hbs @@ -0,0 +1,446 @@ + + + + + + Rental Opportunity + + + + + + + + + + + + + + + + + +   + + + + diff --git a/backend/core/test/activity-logs/activity-log.e2e-spec.ts b/backend/core/test/activity-logs/activity-log.e2e-spec.ts new file mode 100644 index 0000000000..fe5ce43762 --- /dev/null +++ b/backend/core/test/activity-logs/activity-log.e2e-spec.ts @@ -0,0 +1,143 @@ +import { Test } from "@nestjs/testing" +import { INestApplication } from "@nestjs/common" +import { getRepositoryToken, TypeOrmModule } from "@nestjs/typeorm" +// Use require because of the CommonJS/AMD style export. +// See https://www.typescriptlang.org/docs/handbook/modules.html#export--and-import--require +import dbOptions = require("../../ormconfig.test") +import supertest from "supertest" +import { applicationSetup } from "../../src/app.module" +import { AuthModule } from "../../src/auth/auth.module" +import { setAuthorization } from "../utils/set-authorization-helper" +import { ActivityLogModule } from "../../src/activity-log/activity-log.module" +import { ActivityLog } from "../../src/activity-log/entities/activity-log.entity" +import { Repository } from "typeorm" +import { authzActions } from "../../src/auth/enum/authz-actions.enum" +import { Application } from "../../src/applications/entities/application.entity" +import { ApplicationsModule } from "../../src/applications/applications.module" +import { ThrottlerModule } from "@nestjs/throttler" +import { User } from "../../src/auth/entities/user.entity" +import { getTestAppBody } from "../lib/get-test-app-body" +import { ListingsModule } from "../../src/listings/listings.module" +import { Listing } from "../../src/listings/entities/listing.entity" + +// Cypress brings in Chai types for the global expect, but we want to use jest +// expect here so we need to re-declare it. +// see: https://github.com/cypress-io/cypress/issues/1319#issuecomment-593500345 +declare const expect: jest.Expect +jest.setTimeout(30000) + +describe.skip("Programs", () => { + let app: INestApplication + let adminId: string + let adminAccessToken: string + let activityLogsRepository: Repository + let applicationsRepository: Repository + let listingsRepository: Repository+ + beforeAll(async () => { + /* eslint-enable @typescript-eslint/no-empty-function */ + const moduleRef = await Test.createTestingModule({ + imports: [ + TypeOrmModule.forRoot(dbOptions), + AuthModule, + ActivityLogModule, + ApplicationsModule, + ThrottlerModule.forRoot({ + ttl: 60, + limit: 2, + ignoreUserAgents: [/^node-superagent.*$/], + }), + ListingsModule, + ], + }).compile() + app = moduleRef.createNestApplication() + app = applicationSetup(app) + await app.init() + const res = await supertest(app.getHttpServer()) + .post("/auth/login") + .send({ email: "admin@example.com", password: "abcdef" }) + .expect(201) + adminAccessToken = res.body.accessToken + const userRepository = app.get>(getRepositoryToken(User)) + adminId = (await userRepository.findOne({ email: "admin@example.com" })).id + activityLogsRepository = app.get>(getRepositoryToken(ActivityLog)) + applicationsRepository = app.get>(getRepositoryToken(Application)) + listingsRepository = app.get>(getRepositoryToken(Listing)) + + const listingsRes = await supertest(app.getHttpServer()) + .get("/listings?limit=all&view=full") + .expect(200) + const appBody = getTestAppBody(listingsRes.body.items[0].id) + await supertest(app.getHttpServer()) + .post(`/applications/submit`) + .send(appBody) + .set("jurisdictionName", "Alameda") + .set(...setAuthorization(adminAccessToken)) + }) + + beforeEach(async () => { + await activityLogsRepository.createQueryBuilder().delete().execute() + }) + + it(`should capture application edit`, async () => { + const testApplication = ( + await applicationsRepository.find({ take: 1, relations: ["listing"] }) + )[0] + await supertest(app.getHttpServer()) + .put(`/applications/${testApplication.id}`) + .set(...setAuthorization(adminAccessToken)) + .send(testApplication) + .expect(200) + const activityLogs = await activityLogsRepository.find({ relations: ["user"] }) + expect(activityLogs.length).toBe(1) + expect(activityLogs[0].recordId).toBe(testApplication.id) + expect(activityLogs[0].user.id).toBe(adminId) + expect(activityLogs[0].action).toBe(authzActions.update) + expect(activityLogs[0].module).toBe("application") + expect(activityLogs[0].metadata).toBe(null) + }) + + it(`should not capture application edit that failed`, async () => { + const testApplication = ( + await applicationsRepository.find({ take: 1, relations: ["listing"] }) + )[0] + await supertest(app.getHttpServer()) + .put(`/applications/${testApplication.id}`) + .send({ + ...testApplication, + listing: null, + }) + .set(...setAuthorization(adminAccessToken)) + .expect(400) + const activityLogs = await activityLogsRepository.find({ relations: ["user"] }) + expect(activityLogs.length).toBe(0) + }) + + it(`should capture listing status as activity log metadata`, async () => { + const testListing = (await listingsRepository.find({ take: 1 }))[0] + + const listingGetRes = await supertest(app.getHttpServer()) + .get(`/listings/${testListing.id}`) + .expect(200) + + await supertest(app.getHttpServer()) + .put(`/listings/${testListing.id}`) + .send({ + ...listingGetRes.body, + }) + .set(...setAuthorization(adminAccessToken)) + .expect(200) + + const activityLogs = await activityLogsRepository.find({ relations: ["user"] }) + expect(activityLogs.length).toBe(1) + expect(activityLogs[0].recordId).toBe(testListing.id) + expect(activityLogs[0].user.id).toBe(adminId) + expect(activityLogs[0].action).toBe(authzActions.update) + expect(activityLogs[0].module).toBe("listing") + expect(activityLogs[0].metadata).toStrictEqual({ status: listingGetRes.body.status }) + }) + + afterAll(async () => { + await app.close() + }) +}) diff --git a/backend/core/test/afs/afs.e2e-spec.ts b/backend/core/test/afs/afs.e2e-spec.ts new file mode 100644 index 0000000000..52c86a538d --- /dev/null +++ b/backend/core/test/afs/afs.e2e-spec.ts @@ -0,0 +1,392 @@ +import { Test } from "@nestjs/testing" +import { INestApplication } from "@nestjs/common" +import { getRepositoryToken, TypeOrmModule } from "@nestjs/typeorm" +import supertest from "supertest" +import { applicationSetup } from "../../src/app.module" +import { AuthModule } from "../../src/auth/auth.module" +import { ApplicationsModule } from "../../src/applications/applications.module" +import { ListingsModule } from "../../src/listings/listings.module" +import { getUserAccessToken } from "../utils/get-user-access-token" +import { setAuthorization } from "../utils/set-authorization-helper" +import { Repository } from "typeorm" +import { Application } from "../../src/applications/entities/application.entity" +import { HouseholdMember } from "../../src/applications/entities/household-member.entity" +import { ThrottlerModule } from "@nestjs/throttler" +import { ApplicationFlaggedSet } from "../../src/application-flagged-sets/entities/application-flagged-set.entity" +import { getTestAppBody } from "../lib/get-test-app-body" +import { FlaggedSetStatus } from "../../src/application-flagged-sets/types/flagged-set-status-enum" +import { Rule } from "../../src/application-flagged-sets/types/rule-enum" +import { ApplicationDto } from "../../src/applications/dto/application.dto" +import { Listing } from "../../src/listings/entities/listing.entity" +import { ListingStatus } from "../../src/listings/types/listing-status-enum" +// Use require because of the CommonJS/AMD style export. +// See https://www.typescriptlang.org/docs/handbook/modules.html#export--and-import--require +import dbOptions = require("../../ormconfig.test") +import { EmailService } from "../../src/email/email.service" + +// Cypress brings in Chai types for the global expect, but we want to use jest +// expect here so we need to re-declare it. +// see: https://github.com/cypress-io/cypress/issues/1319#issuecomment-593500345 +declare const expect: jest.Expect +jest.setTimeout(30000) + +describe("ApplicationFlaggedSets", () => { + let app: INestApplication + let adminAccessToken: string + let applicationsRepository: Repository + let afsRepository: Repository + let householdMembersRepository: Repository + let listingsRepository: Repository+ let listing1Id: string + let updateApplication + let getApplication + let getAfsesForListingId + + const setupDb = async () => { + await householdMembersRepository.createQueryBuilder().delete().execute() + await applicationsRepository.createQueryBuilder().delete().execute() + await afsRepository.createQueryBuilder().delete().execute() + } + + beforeAll(async () => { + /* eslint-disable @typescript-eslint/no-empty-function */ + const testEmailService = { + confirmation: async () => {}, + } + /* eslint-enable @typescript-eslint/no-empty-function */ + const moduleRef = await Test.createTestingModule({ + imports: [ + TypeOrmModule.forRoot(dbOptions), + AuthModule, + ListingsModule, + ApplicationsModule, + TypeOrmModule.forFeature([ApplicationFlaggedSet, Application, HouseholdMember, Listing]), + ThrottlerModule.forRoot({ + ttl: 60, + limit: 5, + ignoreUserAgents: [/^node-superagent.*$/], + }), + ], + }) + .overrideProvider(EmailService) + .useValue(testEmailService) + .compile() + app = moduleRef.createNestApplication() + app = applicationSetup(app) + await app.init() + applicationsRepository = app.get>(getRepositoryToken(Application)) + afsRepository = app.get>( + getRepositoryToken(ApplicationFlaggedSet) + ) + householdMembersRepository = app.get>( + getRepositoryToken(HouseholdMember) + ) + listingsRepository = app.get>(getRepositoryToken(Listing)) + const listing = (await listingsRepository.find({ take: 1 }))[0] + await listingsRepository.save({ + ...listing, + status: ListingStatus.closed, + }) + + adminAccessToken = await getUserAccessToken(app, "admin@example.com", "abcdef") + listing1Id = listing.id + await setupDb() + + updateApplication = async (application: ApplicationDto) => { + return ( + await supertest(app.getHttpServer()) + .put(`/applications/${application.id}`) + .send(application) + .set(...setAuthorization(adminAccessToken)) + .expect(200) + ).body + } + + getApplication = async (id: string) => { + return ( + await supertest(app.getHttpServer()) + .get(`/applications/${id}`) + .set(...setAuthorization(adminAccessToken)) + .expect(200) + ).body + } + + getAfsesForListingId = async (listingId) => { + return ( + await supertest(app.getHttpServer()) + .get(`/applicationFlaggedSets?listingId=${listingId}`) + .set(...setAuthorization(adminAccessToken)) + .expect(200) + ).body + } + }) + + it(`should mark two similar application as flagged`, async () => { + function checkAppsInAfsForRule(afsResponse, apps, rule) { + const afsesForRule = afsResponse.body.items.filter((item) => item.rule === rule) + expect(afsesForRule.length).toBe(1) + expect(afsesForRule[0].status).toBe(FlaggedSetStatus.flagged) + for (const appId of apps.map((app) => app.body.id)) { + expect(afsesForRule[0].applications.map((app) => app.id).includes(appId)).toBe(true) + } + expect(afsesForRule[0].applications.length).toBe(apps.length) + } + + const appContent = getTestAppBody(listing1Id) + const apps = [] + await Promise.all( + [appContent, appContent].map(async (payload) => { + const appRes = await supertest(app.getHttpServer()) + .post("/applications/submit") + .send(payload) + .expect(201) + apps.push(appRes) + }) + ) + + let afses = await supertest(app.getHttpServer()) + .get(`/applicationFlaggedSets?listingId=${listing1Id}`) + .set(...setAuthorization(adminAccessToken)) + + expect(Array.isArray(afses.body.items)).toBe(true) + expect(afses.body.items.length).toBe(2) + + checkAppsInAfsForRule(afses, apps, Rule.nameAndDOB) + checkAppsInAfsForRule(afses, apps, Rule.email) + + const app3 = await supertest(app.getHttpServer()) + .post("/applications/submit") + .send(appContent) + .expect(201) + + apps.push(app3) + + afses = await supertest(app.getHttpServer()) + .get(`/applicationFlaggedSets?listingId=${listing1Id}`) + .set(...setAuthorization(adminAccessToken)) + + expect(Array.isArray(afses.body.items)).toBe(true) + expect(afses.body.items.length).toBe(2) + + checkAppsInAfsForRule(afses, apps, Rule.nameAndDOB) + checkAppsInAfsForRule(afses, apps, Rule.email) + }) + + it(`should resolve an application flagged set`, async () => { + const appContent1 = getTestAppBody(listing1Id) + const appContent2 = getTestAppBody(listing1Id) + + appContent2.applicant.emailAddress = "another@email.com" + const apps = [] + + await Promise.all( + [appContent1, appContent2].map(async (payload) => { + const appRes = await supertest(app.getHttpServer()) + .post("/applications/submit") + .send(payload) + .expect(201) + apps.push(appRes) + }) + ) + + let afses = await supertest(app.getHttpServer()) + .get(`/applicationFlaggedSets?listingId=${listing1Id}`) + .set(...setAuthorization(adminAccessToken)) + + expect(afses.body.meta.totalFlagged).toBe(1) + + let resolveRes = await supertest(app.getHttpServer()) + .post(`/applicationFlaggedSets/resolve`) + .send({ afsId: afses.body.items[0].id, applications: [{ id: apps[0].body.id }] }) + .set(...setAuthorization(adminAccessToken)) + .expect(201) + + afses = await supertest(app.getHttpServer()) + .get(`/applicationFlaggedSets?listingId=${listing1Id}`) + .set(...setAuthorization(adminAccessToken)) + + expect(afses.body.meta.totalFlagged).toBe(0) + + let resolvedAfs = resolveRes.body + expect(resolvedAfs.status).toBe(FlaggedSetStatus.resolved) + expect(resolvedAfs.applications.filter((app) => app.markedAsDuplicate === true).length).toBe(1) + expect(resolvedAfs.applications.filter((app) => app.markedAsDuplicate === false).length).toBe(1) + + resolveRes = await supertest(app.getHttpServer()) + .post(`/applicationFlaggedSets/resolve`) + .send({ afsId: afses.body.items[0].id, applications: [{ id: apps[1].body.id }] }) + .set(...setAuthorization(adminAccessToken)) + .expect(201) + + resolvedAfs = resolveRes.body + expect(resolvedAfs.status).toBe(FlaggedSetStatus.resolved) + expect(resolvedAfs.applications.filter((app) => app.markedAsDuplicate === true).length).toBe(1) + expect(resolvedAfs.applications.filter((app) => app.markedAsDuplicate === false).length).toBe(1) + }) + + it(`should take application edits into account (application toggles between conflicting and non conflicting in an AFS of 2 apps)`, async () => { + const app1Seed = getTestAppBody(listing1Id) + const app2Seed = getTestAppBody(listing1Id) + + // Two applications do not conflict by any rule at this point + app2Seed.applicant.emailAddress = "another@email.com" + app2Seed.applicant.firstName = "AnotherFirstName" + const apps = [] + + for (const payload of [app1Seed, app2Seed]) { + const appRes = await supertest(app.getHttpServer()) + .post("/applications/submit") + .send(payload) + .expect(201) + apps.push(appRes.body) + } + + let afses = await getAfsesForListingId(listing1Id) + + expect(afses.meta.totalFlagged).toBe(0) + expect(afses.items.length).toBe(0) + + const [app1, app2] = apps + + // Applications conflict by email rule + app2.applicant.emailAddress = app1Seed.applicant.emailAddress + await updateApplication(app2) + + afses = await getAfsesForListingId(listing1Id) + + expect(afses.meta.totalFlagged).toBe(1) + expect(afses.items[0].applications.map((app) => app.id).includes(app1.id)).toBe(true) + expect(afses.items[0].applications.map((app) => app.id).includes(app2.id)).toBe(true) + + // Applications do not conflict by any rule + app2.applicant.emailAddress = app2Seed.applicant.emailAddress + await updateApplication(app2) + + afses = await getAfsesForListingId(listing1Id) + + expect(afses.meta.totalFlagged).toBe(0) + expect(afses.items.length).toBe(0) + }) + + it(`should take application edits into account (application toggles between conflicting and non conflicting in an AFS of 3 apps)`, async () => { + const app1Seed = getTestAppBody(listing1Id) + const app2Seed = getTestAppBody(listing1Id) + const app3Seed = getTestAppBody(listing1Id) + + // Three applications do not conflict by any rule at this point + app2Seed.applicant.emailAddress = "another@email.com" + app2Seed.applicant.firstName = "AnotherFirstName" + app3Seed.applicant.emailAddress = "third@email.com" + app3Seed.applicant.firstName = "ThirdFirstName" + const apps = [] + + for (const payload of [app1Seed, app2Seed, app3Seed]) { + const appRes = await supertest(app.getHttpServer()) + .post("/applications/submit") + .send(payload) + .expect(201) + apps.push(appRes.body) + } + + let afses = await getAfsesForListingId(listing1Id) + + expect(afses.meta.totalFlagged).toBe(0) + expect(afses.items.length).toBe(0) + + // eslint-disable-next-line + let [app1, app2, app3] = apps + + // Applications conflict by email rule + app2.applicant.emailAddress = app1Seed.applicant.emailAddress + app3.applicant.emailAddress = app1Seed.applicant.emailAddress + await updateApplication(app2) + app3 = await updateApplication(app3) + + afses = await getAfsesForListingId(listing1Id) + + expect(afses.meta.totalFlagged).toBe(1) + expect(afses.items[0].applications.map((app) => app.id).includes(app1.id)).toBe(true) + expect(afses.items[0].applications.map((app) => app.id).includes(app2.id)).toBe(true) + expect(afses.items[0].applications.map((app) => app.id).includes(app3.id)).toBe(true) + + // Application 3 do not conflict with others now + app3.applicant.emailAddress = app3Seed.applicant.emailAddress + app3 = await updateApplication(app3) + + afses = await getAfsesForListingId(listing1Id) + + expect(afses.meta.totalFlagged).toBe(1) + expect(afses.items.length).toBe(1) + expect(afses.items[0].applications.map((app) => app.id).includes(app3.id)).toBe(false) + }) + + it(`should take application edits into account (application toggles between conflicting and non conflicting in an AFS of 3 apps, AFS already resolved)`, async () => { + const app1Seed = getTestAppBody(listing1Id) + const app2Seed = getTestAppBody(listing1Id) + const app3Seed = getTestAppBody(listing1Id) + + // Three applications conflict by email rule + app2Seed.applicant.firstName = "AnotherFirstName" + app3Seed.applicant.firstName = "ThirdFirstName" + const apps = [] + + for (const payload of [app1Seed, app2Seed, app3Seed]) { + const appRes = await supertest(app.getHttpServer()) + .post("/applications/submit") + .send(payload) + .expect(201) + apps.push(appRes.body) + } + + // eslint-disable-next-line + let [app1, app2, app3] = apps + expect(app3.markedAsDuplicate).toBe(false) + + const afses = await getAfsesForListingId(listing1Id) + const afsToBeResolved = afses.items[0] + + const resolveRes = await supertest(app.getHttpServer()) + .post(`/applicationFlaggedSets/resolve`) + .send({ afsId: afsToBeResolved.id, applications: [{ id: app3.id }] }) + .set(...setAuthorization(adminAccessToken)) + .expect(201) + expect(resolveRes.body.resolvedTime).not.toBe(null) + expect(resolveRes.body.resolvingUser).not.toBe(null) + expect(resolveRes.body.status).toBe(FlaggedSetStatus.resolved) + + app3 = await getApplication(app3.id) + expect(app3.markedAsDuplicate).toBe(true) + + // App3 now does not conflict with any other applications + app3.applicant.emailAddress = "third@email.com" + app3 = await updateApplication(app3) + expect(app3.markedAsDuplicate).toBe(false) + + const previouslyResolvedAfs = ( + await supertest(app.getHttpServer()) + .get(`/applicationFlaggedSets/${afsToBeResolved.id}`) + .set(...setAuthorization(adminAccessToken)) + .expect(200) + ).body + expect(previouslyResolvedAfs.resolvedTime).toBe(null) + expect(previouslyResolvedAfs.resolvingUser).toBe(null) + expect(previouslyResolvedAfs.status).toBe(FlaggedSetStatus.flagged) + expect(previouslyResolvedAfs.applications.map((app) => app.id).includes(app1.id)).toBe(true) + expect(previouslyResolvedAfs.applications.map((app) => app.id).includes(app2.id)).toBe(true) + expect(previouslyResolvedAfs.applications.map((app) => app.id).includes(app3.id)).toBe(false) + }) + + afterEach(async () => { + await setupDb() + jest.clearAllMocks() + }) + + afterAll(async () => { + const modifiedListing = await listingsRepository.findOne({ id: listing1Id }) + await listingsRepository.save({ + ...modifiedListing, + status: ListingStatus.active, + }) + await app.close() + }) +}) diff --git a/backend/core/test/ami-charts/ami-charts.e2e-spec.ts b/backend/core/test/ami-charts/ami-charts.e2e-spec.ts new file mode 100644 index 0000000000..574124c666 --- /dev/null +++ b/backend/core/test/ami-charts/ami-charts.e2e-spec.ts @@ -0,0 +1,99 @@ +import { Test } from "@nestjs/testing" +import { INestApplication } from "@nestjs/common" +import { getRepositoryToken, TypeOrmModule } from "@nestjs/typeorm" +import supertest from "supertest" +import { applicationSetup } from "../../src/app.module" +import { AuthModule } from "../../src/auth/auth.module" +import { getUserAccessToken } from "../utils/get-user-access-token" +import { setAuthorization } from "../utils/set-authorization-helper" +// Use require because of the CommonJS/AMD style export. +// See https://www.typescriptlang.org/docs/handbook/modules.html#export--and-import--require +import dbOptions = require("../../ormconfig.test") +import { AmiChartsModule } from "../../src/ami-charts/ami-charts.module" +import { AmiChartCreateDto } from "../../src/ami-charts/dto/ami-chart.dto" +import { Jurisdiction } from "../../src/jurisdictions/entities/jurisdiction.entity" +import { Repository } from "typeorm" +import { EmailService } from "../../src/email/email.service" + +// Cypress brings in Chai types for the global expect, but we want to use jest +// expect here so we need to re-declare it. +// see: https://github.com/cypress-io/cypress/issues/1319#issuecomment-593500345 +declare const expect: jest.Expect +jest.setTimeout(30000) + +describe("AmiCharts", () => { + let app: INestApplication + let adminAccesstoken: string + let jurisdictionRepository: Repository + beforeAll(async () => { + /* eslint-disable @typescript-eslint/no-empty-function */ + const testEmailService = { confirmation: async () => {} } + /* eslint-enable @typescript-eslint/no-empty-function */ + const moduleRef = await Test.createTestingModule({ + imports: [ + TypeOrmModule.forRoot(dbOptions), + AuthModule, + AmiChartsModule, + TypeOrmModule.forFeature([Jurisdiction]), + ], + }) + .overrideProvider(EmailService) + .useValue(testEmailService) + .compile() + app = moduleRef.createNestApplication() + app = applicationSetup(app) + jurisdictionRepository = app.get>(getRepositoryToken(Jurisdiction)) + await app.init() + adminAccesstoken = await getUserAccessToken(app, "admin@example.com", "abcdef") + }) + + it(`should return amiCharts`, async () => { + const res = await supertest(app.getHttpServer()) + .get(`/amiCharts`) + .set(...setAuthorization(adminAccesstoken)) + .expect(200) + expect(Array.isArray(res.body)).toBe(true) + expect(Array.isArray(res.body[0].items)).toBe(true) + expect(res.body[0].items.length).toBeGreaterThan(0) + }) + + it(`should create and return a new ami chart`, async () => { + const jurisdiction = (await jurisdictionRepository.find({ name: "Alameda" }))[0] + const amiChartCreateDto: AmiChartCreateDto = { + items: [ + { + percentOfAmi: 50, + householdSize: 5, + income: 5000, + }, + ], + name: "testAmiChart", + jurisdiction, + } + const res = await supertest(app.getHttpServer()) + .post(`/amiCharts`) + .set(...setAuthorization(adminAccesstoken)) + .send(amiChartCreateDto) + .expect(201) + expect(res.body).toHaveProperty("id") + expect(res.body).toHaveProperty("createdAt") + expect(res.body).toHaveProperty("updatedAt") + expect(res.body.name).toBe(amiChartCreateDto.name) + + const getById = await supertest(app.getHttpServer()) + .get(`/amiCharts/${res.body.id}`) + .set(...setAuthorization(adminAccesstoken)) + .expect(200) + expect(getById.body.name).toBe(amiChartCreateDto.name) + expect(res.body.items.length).toBe(1) + expect(res.body.items[0].percentOfAmi).toBe(amiChartCreateDto.items[0].percentOfAmi) + }) + + afterEach(() => { + jest.clearAllMocks() + }) + + afterAll(async () => { + await app.close() + }) +}) diff --git a/backend/core/test/application-methods/application-methods.e2e-spec.ts b/backend/core/test/application-methods/application-methods.e2e-spec.ts new file mode 100644 index 0000000000..cc65202681 --- /dev/null +++ b/backend/core/test/application-methods/application-methods.e2e-spec.ts @@ -0,0 +1,76 @@ +import { Test } from "@nestjs/testing" +import { INestApplication } from "@nestjs/common" +import { TypeOrmModule } from "@nestjs/typeorm" +import supertest from "supertest" +import { applicationSetup } from "../../src/app.module" +import { AuthModule } from "../../src/auth/auth.module" +import { getUserAccessToken } from "../utils/get-user-access-token" +import { setAuthorization } from "../utils/set-authorization-helper" +// Use require because of the CommonJS/AMD style export. +// See https://www.typescriptlang.org/docs/handbook/modules.html#export--and-import--require +import dbOptions = require("../../ormconfig.test") +import { ApplicationMethodsModule } from "../../src/application-methods/applications-methods.module" +import { ApplicationMethodType } from "../../src/application-methods/types/application-method-type-enum" +import { EmailService } from "../../src/email/email.service" + +// Cypress brings in Chai types for the global expect, but we want to use jest +// expect here so we need to re-declare it. +// see: https://github.com/cypress-io/cypress/issues/1319#issuecomment-593500345 +declare const expect: jest.Expect +jest.setTimeout(30000) + +describe("ApplicationMethods", () => { + let app: INestApplication + let adminAccesstoken: string + beforeAll(async () => { + /* eslint-disable @typescript-eslint/no-empty-function */ + const testEmailService = { confirmation: async () => {} } + /* eslint-enable @typescript-eslint/no-empty-function */ + const moduleRef = await Test.createTestingModule({ + imports: [TypeOrmModule.forRoot(dbOptions), AuthModule, ApplicationMethodsModule], + }) + .overrideProvider(EmailService) + .useValue(testEmailService) + .compile() + app = moduleRef.createNestApplication() + app = applicationSetup(app) + await app.init() + adminAccesstoken = await getUserAccessToken(app, "admin@example.com", "abcdef") + }) + + it(`should return applicationMethods`, async () => { + const res = await supertest(app.getHttpServer()) + .get(`/applicationMethods`) + .set(...setAuthorization(adminAccesstoken)) + .expect(200) + expect(Array.isArray(res.body)).toBe(true) + }) + + it(`should create and return a new application method`, async () => { + const res = await supertest(app.getHttpServer()) + .post(`/applicationMethods`) + .set(...setAuthorization(adminAccesstoken)) + .send({ type: ApplicationMethodType.ExternalLink }) + .expect(201) + + expect(res.body).toHaveProperty("id") + expect(res.body).toHaveProperty("createdAt") + expect(res.body).toHaveProperty("updatedAt") + expect(res.body).toHaveProperty("type") + expect(res.body.type).toBe(ApplicationMethodType.ExternalLink) + + const getById = await supertest(app.getHttpServer()) + .get(`/applicationMethods/${res.body.id}`) + .set(...setAuthorization(adminAccesstoken)) + .expect(200) + expect(getById.body.type).toBe(ApplicationMethodType.ExternalLink) + }) + + afterEach(() => { + jest.clearAllMocks() + }) + + afterAll(async () => { + await app.close() + }) +}) diff --git a/backend/core/test/applications/applications.e2e-spec.ts b/backend/core/test/applications/applications.e2e-spec.ts new file mode 100644 index 0000000000..2a7c67d849 --- /dev/null +++ b/backend/core/test/applications/applications.e2e-spec.ts @@ -0,0 +1,688 @@ +import { Test } from "@nestjs/testing" +import { INestApplication } from "@nestjs/common" +import { getRepositoryToken, TypeOrmModule } from "@nestjs/typeorm" +import supertest from "supertest" +import { applicationSetup } from "../../src/app.module" +import { AuthModule } from "../../src/auth/auth.module" +import { ApplicationsModule } from "../../src/applications/applications.module" +import { ListingsModule } from "../../src/listings/listings.module" +import { getUserAccessToken } from "../utils/get-user-access-token" +import { setAuthorization } from "../utils/set-authorization-helper" +// Use require because of the CommonJS/AMD style export. +// See https://www.typescriptlang.org/docs/handbook/modules.html#export--and-import--require +import dbOptions = require("../../ormconfig.test") +import { InputType } from "../../src/shared/types/input-type" +import { Repository } from "typeorm" +import { Application } from "../../src/applications/entities/application.entity" +import { ListingDto } from "../../src/listings/dto/listing.dto" +import { HouseholdMember } from "../../src/applications/entities/household-member.entity" +import { ThrottlerModule } from "@nestjs/throttler" +import { getTestAppBody } from "../lib/get-test-app-body" +import { UserDto } from "../../src/auth/dto/user.dto" +import { UserService } from "../../src/auth/services/user.service" +import { UserCreateDto } from "../../src/auth/dto/user-create.dto" +import { Listing } from "../../src/listings/entities/listing.entity" +import { EmailService } from "../../src/email/email.service" + +// Cypress brings in Chai types for the global expect, but we want to use jest +// expect here so we need to re-declare it. +// see: https://github.com/cypress-io/cypress/issues/1319#issuecomment-593500345 +declare const expect: jest.Expect +jest.setTimeout(30000) + +describe("Applications", () => { + let app: INestApplication + let user1AccessToken: string + let user2AccessToken: string + let adminAccessToken: string + let leasingAgent1AccessToken: string + let leasingAgent1Profile: UserDto + let leasingAgent2AccessToken: string + let leasingAgent2Profile: UserDto + let applicationsRepository: Repository + let householdMembersRepository: Repository + let listing1Id: string + let listing2Id: string + + beforeAll(async () => { + /* eslint-disable @typescript-eslint/no-empty-function */ + const testEmailService = { confirmation: async () => {} } + /* eslint-enable @typescript-eslint/no-empty-function */ + const moduleRef = await Test.createTestingModule({ + imports: [ + TypeOrmModule.forRoot(dbOptions), + AuthModule, + ListingsModule, + ApplicationsModule, + TypeOrmModule.forFeature([Application, HouseholdMember, Listing]), + ThrottlerModule.forRoot({ + ttl: 60, + limit: 2, + ignoreUserAgents: [/^node-superagent.*$/], + }), + ], + }) + .overrideProvider(EmailService) + .useValue(testEmailService) + .compile() + app = moduleRef.createNestApplication() + app = applicationSetup(app) + await app.init() + applicationsRepository = app.get>(getRepositoryToken(Application)) + householdMembersRepository = app.get>( + getRepositoryToken(HouseholdMember) + ) + + user1AccessToken = await getUserAccessToken(app, "test@example.com", "abcdef") + + user2AccessToken = await getUserAccessToken(app, "test2@example.com", "ghijkl") + + adminAccessToken = await getUserAccessToken(app, "admin@example.com", "abcdef") + + leasingAgent1AccessToken = await getUserAccessToken( + app, + "leasing-agent-1@example.com", + "abcdef" + ) + + leasingAgent2AccessToken = await getUserAccessToken( + app, + "leasing-agent-2@example.com", + "abcdef" + ) + + leasingAgent1Profile = ( + await supertest(app.getHttpServer()) + .get(`/user`) + .set(...setAuthorization(leasingAgent1AccessToken)) + .expect(200) + ).body + + leasingAgent2Profile = ( + await supertest(app.getHttpServer()) + .get(`/user`) + .set(...setAuthorization(leasingAgent2AccessToken)) + .expect(200) + ).body + + const res = await supertest(app.getHttpServer()) + .get("/listings?limit=all&view=full") + .expect(200) + // Finding listings corresponding to leasing agents (permission wise) + listing1Id = res.body.items.filter((listing: ListingDto) => { + const leasingAgentsIds = listing.leasingAgents.map((agent) => agent.id) + return leasingAgentsIds.indexOf(leasingAgent1Profile.id) !== -1 + })[0].id + listing2Id = res.body.items.filter((listing: ListingDto) => { + const leasingAgentsIds = listing.leasingAgents.map((agent) => agent.id) + return leasingAgentsIds.indexOf(leasingAgent2Profile.id) !== -1 + })[0].id + + await householdMembersRepository.createQueryBuilder().delete().execute() + await applicationsRepository.createQueryBuilder().delete().execute() + }) + + it(`should allow a user to create and read their own application `, async () => { + const body = getTestAppBody(listing1Id) + body.preferences = [ + { + key: "liveWork", + claimed: true, + options: [ + { + key: "live", + checked: true, + extraData: [], + }, + { + key: "work", + checked: false, + extraData: [], + }, + ], + }, + { + key: "displacedTenant", + claimed: true, + options: [ + { + key: "general", + checked: true, + extraData: [ + { + key: "name", + type: InputType.text, + value: "Roger Thornhill", + }, + { + key: "address", + type: InputType.address, + value: { + street: "", + street2: "", + city: "", + state: "", + zipCode: "", + county: "", + latitude: null, + longitude: null, + }, + }, + ], + }, + { + key: "missionCorridor", + checked: false, + extraData: [], + }, + ], + }, + ] + + let res = await supertest(app.getHttpServer()) + .post(`/applications/submit`) + .send(body) + .set(...setAuthorization(user1AccessToken)) + .expect(201) + expect(res.body).toMatchObject(body) + expect(res.body).toHaveProperty("createdAt") + expect(res.body).toHaveProperty("updatedAt") + expect(res.body).toHaveProperty("id") + expect(res.body).toHaveProperty("confirmationCode") + expect(res.body.confirmationCode.length).toBe(8) + res = await supertest(app.getHttpServer()) + .get(`/applications/${res.body.id}`) + .set(...setAuthorization(user1AccessToken)) + .expect(200) + expect(res.body).toMatchObject(body) + res = await supertest(app.getHttpServer()) + .get(`/applications`) + .set(...setAuthorization(user1AccessToken)) + .expect(200) + expect(Array.isArray(res.body.items)).toBe(true) + expect(res.body.items.length).toBe(1) + expect(res.body.items[0]).toMatchObject(body) + }) + + it(`should not allow leasing agents to list all applications, but should allow to list owned`, async () => { + const listing1Application = getTestAppBody(listing1Id) + const app1 = await supertest(app.getHttpServer()) + .post(`/applications/submit`) + .send(listing1Application) + .set(...setAuthorization(user1AccessToken)) + .expect(201) + const listing2Application = getTestAppBody(listing2Id) + const app2 = await supertest(app.getHttpServer()) + .post(`/applications/submit`) + .send(listing2Application) + .set(...setAuthorization(user2AccessToken)) + .expect(201) + + await supertest(app.getHttpServer()) + .get(`/applications`) + .set(...setAuthorization(leasingAgent1AccessToken)) + .expect(403) + + const appsForListing1 = await supertest(app.getHttpServer()) + .get(`/applications?listingId=${listing1Id}`) + .set(...setAuthorization(leasingAgent1AccessToken)) + .expect(200) + expect(Array.isArray(appsForListing1.body.items)).toBe(true) + expect(appsForListing1.body.items.length).toBe(1) + expect(appsForListing1.body.items[0].id).toBe(app1.body.id) + + const appsForListing2 = await supertest(app.getHttpServer()) + .get(`/applications?listingId=${listing2Id}`) + .set(...setAuthorization(leasingAgent2AccessToken)) + .expect(200) + expect(Array.isArray(appsForListing2.body.items)).toBe(true) + expect(appsForListing2.body.items.length).toBe(1) + expect(appsForListing2.body.items[0].id).toBe(app2.body.id) + + await supertest(app.getHttpServer()) + .get(`/applications?listingId=${listing2Id}`) + .set(...setAuthorization(leasingAgent1AccessToken)) + .expect(403) + }) + + it(`should only allow leasing agents to POST/PUT /applications for listings they are assigned to`, async () => { + const listing1Application = getTestAppBody(listing1Id) + const listing2Application = getTestAppBody(listing2Id) + const res = await supertest(app.getHttpServer()) + .post(`/applications`) + .send(listing1Application) + .set(...setAuthorization(leasingAgent1AccessToken)) + .expect(201) + await supertest(app.getHttpServer()) + .post(`/applications`) + .send(listing2Application) + .set(...setAuthorization(leasingAgent1AccessToken)) + .expect(403) + await supertest(app.getHttpServer()) + .put(`/applications/${res.body.id}`) + .send(res.body) + .set(...setAuthorization(leasingAgent1AccessToken)) + .expect(200) + await supertest(app.getHttpServer()) + .put(`/applications/${res.body.id}`) + .send(res.body) + .set(...setAuthorization(leasingAgent2AccessToken)) + .expect(403) + }) + + it(`should allow admin to list all`, async () => { + const app1Body = getTestAppBody(listing1Id) + await supertest(app.getHttpServer()) + .post(`/applications/submit`) + .send(app1Body) + .set(...setAuthorization(user1AccessToken)) + .expect(201) + const app2Body = getTestAppBody(listing2Id) + await supertest(app.getHttpServer()) + .post(`/applications/submit`) + .send(app2Body) + .set(...setAuthorization(user2AccessToken)) + .expect(201) + const res = await supertest(app.getHttpServer()) + .get(`/applications`) + .set(...setAuthorization(adminAccessToken)) + .expect(200) + expect(Array.isArray(res.body.items)).toBe(true) + expect(res.body.items.length).toBe(2) + }) + + it(`should allow a leasing agent to list all for specific list`, async () => { + const body = getTestAppBody(listing1Id) + await supertest(app.getHttpServer()) + .post(`/applications/submit`) + .send(body) + .set(...setAuthorization(user1AccessToken)) + .expect(201) + await supertest(app.getHttpServer()) + .post(`/applications/submit`) + .send(body) + .set(...setAuthorization(user2AccessToken)) + .expect(201) + const res = await supertest(app.getHttpServer()) + .get(`/applications?listingId=${listing1Id}`) + .set(...setAuthorization(leasingAgent1AccessToken)) + .expect(200) + expect(Array.isArray(res.body.items)).toBe(true) + expect(res.body.items.length).toBe(2) + }) + + it(`should allow a user to create and retrieve by ID their own application`, async () => { + const body = getTestAppBody(listing1Id) + const createRes = await supertest(app.getHttpServer()) + .post(`/applications/submit`) + .send(body) + .set(...setAuthorization(user1AccessToken)) + .expect(201) + expect(createRes.body).toMatchObject(body) + expect(createRes.body).toHaveProperty("createdAt") + expect(createRes.body).toHaveProperty("updatedAt") + expect(createRes.body).toHaveProperty("id") + const res = await supertest(app.getHttpServer()) + .get(`/applications/${createRes.body.id}`) + .set(...setAuthorization(user1AccessToken)) + .expect(200) + expect(res.body.id === createRes.body.id) + }) + + it(`should allow unauthenticated user to create an application, but not allow to retrieve it by id`, async () => { + const body = getTestAppBody(listing1Id) + const res = await supertest(app.getHttpServer()) + .post(`/applications/submit`) + .send(body) + .expect(201) + expect(res.body).toMatchObject(body) + expect(res.body).toHaveProperty("createdAt") + expect(res.body).toHaveProperty("updatedAt") + expect(res.body).toHaveProperty("id") + await supertest(app.getHttpServer()).get(`/applications/${res.body.id}`).expect(403) + }) + + it(`should allow an admin to search for users application using search query param`, async () => { + const body = getTestAppBody(listing1Id) + body.applicant.firstName = "MyName" + const createRes = await supertest(app.getHttpServer()) + .post(`/applications/submit`) + .send(body) + .expect(201) + expect(createRes.body).toMatchObject(body) + expect(createRes.body).toHaveProperty("createdAt") + expect(createRes.body).toHaveProperty("updatedAt") + expect(createRes.body).toHaveProperty("id") + const res = await supertest(app.getHttpServer()) + .get(`/applications/?search=MyName`) + .set(...setAuthorization(adminAccessToken)) + .expect(200) + 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) + }) + + it(`should allow an admin to search for users application using search query param with partial textquery`, async () => { + const body = getTestAppBody(listing1Id) + body.applicant.firstName = "John" + const createRes = await supertest(app.getHttpServer()) + .post(`/applications/submit`) + .send(body) + .expect(201) + expect(createRes.body).toMatchObject(body) + expect(createRes.body).toHaveProperty("createdAt") + expect(createRes.body).toHaveProperty("updatedAt") + expect(createRes.body).toHaveProperty("id") + + const res = await supertest(app.getHttpServer()) + .get(`/applications/?search=joh`) + .set(...setAuthorization(adminAccessToken)) + .expect(200) + 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) + }) + + it(`should allow an admin to search for users application using email as textquery`, async () => { + const body = getTestAppBody(listing1Id) + body.applicant.firstName = "John" + body.applicant.emailAddress = "john-doe@contact.com" + const createRes = await supertest(app.getHttpServer()) + .post(`/applications/submit`) + .send(body) + .expect(201) + expect(createRes.body).toMatchObject(body) + expect(createRes.body).toHaveProperty("createdAt") + expect(createRes.body).toHaveProperty("updatedAt") + expect(createRes.body).toHaveProperty("id") + + const res = await supertest(app.getHttpServer()) + .get(`/applications/?search=john-doe@contact.com`) + .set(...setAuthorization(adminAccessToken)) + .expect(200) + 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) + }) + + it(`should allow exporting applications as CSV`, async () => { + const body = getTestAppBody(listing1Id) + const createRes = await supertest(app.getHttpServer()) + .post(`/applications/submit`) + .send(body) + .expect(201) + expect(createRes.body).toMatchObject(body) + expect(createRes.body).toHaveProperty("createdAt") + expect(createRes.body).toHaveProperty("updatedAt") + expect(createRes.body).toHaveProperty("id") + const res = await supertest(app.getHttpServer()) + .get(`/applications/csv/?listingId=${listing1Id}`) + .set(...setAuthorization(adminAccessToken)) + .expect(200) + expect(typeof res.text === "string") + expect(new RegExp(/Flagged/).test(res.text)).toEqual(true) + }) + + it(`should allow an admin to delete user's applications`, async () => { + const body = getTestAppBody(listing1Id) + const createRes = await supertest(app.getHttpServer()) + .post(`/applications/submit`) + .send(body) + .set(...setAuthorization(user1AccessToken)) + .expect(201) + await supertest(app.getHttpServer()) + .delete(`/applications/${createRes.body.id}`) + .set(...setAuthorization(adminAccessToken)) + .expect(200) + await supertest(app.getHttpServer()) + .get(`/applications/${createRes.body.id}`) + .set(...setAuthorization(user1AccessToken)) + .expect(404) + }) + + it(`should disallow users to delete their own applications`, async () => { + const body = getTestAppBody(listing1Id) + const createRes = await supertest(app.getHttpServer()) + .post(`/applications/submit`) + .send(body) + .set(...setAuthorization(user1AccessToken)) + .expect(201) + await supertest(app.getHttpServer()) + .delete(`/applications/${createRes.body.id}`) + .set(...setAuthorization(user1AccessToken)) + .expect(403) + }) + + it(`should disallow users to edit their own applications`, async () => { + const body = getTestAppBody(listing1Id) + const createRes = await supertest(app.getHttpServer()) + .post(`/applications/submit`) + .send(body) + .set(...setAuthorization(user1AccessToken)) + .expect(201) + await supertest(app.getHttpServer()) + .put(`/applications/${createRes.body.id}`) + .send(createRes.body) + .set(...setAuthorization(user1AccessToken)) + .expect(403) + }) + + it(`should allow an admin to edit user's application`, async () => { + const body = getTestAppBody(listing1Id) + const createRes = await supertest(app.getHttpServer()) + .post(`/applications/submit`) + .send(body) + .set(...setAuthorization(user1AccessToken)) + .expect(201) + expect(createRes.body).toMatchObject(body) + const newBody = getTestAppBody(listing1Id) as Application + newBody.id = createRes.body.id + // Because submission date is applied server side + newBody.submissionDate = createRes.body.submissionDate + const putRes = await supertest(app.getHttpServer()) + .put(`/applications/${createRes.body.id}`) + .send(newBody) + .set(...setAuthorization(adminAccessToken)) + .expect(200) + expect(putRes.body).toMatchObject(newBody) + }) + + it(`should disallow users editing applications of other users`, async () => { + const body = getTestAppBody(listing1Id) + const createRes = await supertest(app.getHttpServer()) + .post(`/applications/submit`) + .send(body) + .set(...setAuthorization(user1AccessToken)) + .expect(201) + expect(createRes.body).toMatchObject(body) + const newBody = getTestAppBody(listing1Id) as Application + newBody.id = createRes.body.id + await supertest(app.getHttpServer()) + .put(`/applications/${createRes.body.id}`) + .send(newBody) + .set(...setAuthorization(user2AccessToken)) + .expect(403) + }) + + it(`should allow an admin to order users application using orderBy and order query params`, async () => { + const firstNames = ["A FirstName", "B FirstName", "C FirstName"] + const responses = [] + const body = getTestAppBody(listing1Id) + + for (const firstName of firstNames) { + const initialBody = Object.assign({}, body) + initialBody.applicant.firstName = firstName + + const createRes = await supertest(app.getHttpServer()) + .post(`/applications/submit`) + .send(body) + .expect(201) + expect(createRes.body).toMatchObject(initialBody) + expect(createRes.body).toHaveProperty("createdAt") + expect(createRes.body).toHaveProperty("updatedAt") + expect(createRes.body).toHaveProperty("id") + + responses.push({ initialBody, createRes, firstName }) + } + + const res = await supertest(app.getHttpServer()) + .get(`/applications/?orderBy=firstName&order=ASC`) + .set(...setAuthorization(adminAccessToken)) + .expect(200) + + expect(Array.isArray(res.body.items)).toBe(true) + expect(res.body.items.length).toBe(3) + + for (const index in res.body.items) { + const item = res.body.items[index] + const { createRes, firstName } = responses[index] + + expect(item.id === createRes.body.id) + expect(item).toMatchObject(createRes.body) + expect(item.applicant).toMatchObject(createRes.body.applicant) + expect(item.applicant.firstName === firstName) + } + }) + + it(`should disallow an admin to order users application using bad orderBy query param`, async () => { + await supertest(app.getHttpServer()) + .get(`/applications/?orderBy=XYZ`) + .set(...setAuthorization(adminAccessToken)) + .expect(400) + }) + + it(`should disallow an admin to order users application using bad order query param`, async () => { + await supertest(app.getHttpServer()) + .get(`/applications/?order=XYZ`) + .set(...setAuthorization(adminAccessToken)) + .expect(400) + }) + + it(`should disallow a user to send too much application submits`, async () => { + const body = getTestAppBody(listing1Id) + const failAfter = 2 + + for (let i = 0; i < failAfter + 1; i++) { + const expect = i < failAfter ? 201 : 429 + await supertest(app.getHttpServer()) + .post(`/applications/submit`) + .set("User-Agent", "faked") + .send(body) + .set(...setAuthorization(user1AccessToken)) + .expect(expect) + } + }) + + it(`should disallow a user to create an application post submission due date`, async () => { + const body = getTestAppBody(listing1Id) + await supertest(app.getHttpServer()) + .post(`/applications/submit`) + .send(body) + .set(...setAuthorization(user1AccessToken)) + .expect(201) + + const getListingRes = await supertest(app.getHttpServer()).get(`/listings/${listing1Id}`) + const listing: Listing = getListingRes.body + + const oldApplicationDueDate = listing.applicationDueDate + listing.applicationDueDate = new Date() + await supertest(app.getHttpServer()) + .put(`/listings/${listing.id}`) + .send(listing) + .set(...setAuthorization(adminAccessToken)) + .expect(200) + + const res = await supertest(app.getHttpServer()) + .post(`/applications/submit`) + .send(body) + .set(...setAuthorization(user1AccessToken)) + .expect(400) + expect(res.body.message).toBe("Listing is not open for application submission.") + + listing.applicationDueDate = oldApplicationDueDate + await supertest(app.getHttpServer()) + .put(`/listings/${listing.id}`) + .send(listing) + .set(...setAuthorization(adminAccessToken)) + .expect(200) + }) + + it(`should connect existing applications to new user account based on applicant.email`, async () => { + const body = getTestAppBody(listing1Id) + body.applicant.emailAddress = "mytestemail@example.com" + const appSubmisionRes = await supertest(app.getHttpServer()) + .post(`/applications/submit`) + .send(body) + .expect(201) + + const userCreateDto: UserCreateDto = { + password: "Abcdef1!", + passwordConfirmation: "Abcdef1!", + email: "mytestemail@example.com", + emailConfirmation: "mytestemail@example.com", + firstName: "A", + middleName: "A", + lastName: "A", + dob: new Date(), + } + + const newUser = await supertest(app.getHttpServer()) + .post(`/user/?noWelcomeEmail=true`) + .set("jurisdictionName", "Detroit") + .send(userCreateDto) + .expect(201) + + const userService = await app.resolve(UserService) + const user = await userService.findByEmail(userCreateDto.email) + + await supertest(app.getHttpServer()) + .put(`/user/confirm/`) + .send({ token: user.confirmationToken }) + .expect(200) + const userAccessToken = await getUserAccessToken( + app, + userCreateDto.email, + userCreateDto.password + ) + + const listApplicationsRes = await supertest(app.getHttpServer()) + .get(`/applications/?userId=${newUser.body.id}`) + .set(...setAuthorization(userAccessToken)) + .expect(200) + + expect(listApplicationsRes.body.items.length).toBe(1) + expect(listApplicationsRes.body.items[0].id).toBe(appSubmisionRes.body.id) + }) + + it(`should not assign a user relation when partner submits an application`, async () => { + const body = getTestAppBody(listing1Id) + let appSubmisionRes = await supertest(app.getHttpServer()) + .post(`/applications`) + .set(...setAuthorization(adminAccessToken)) + .send(body) + .expect(201) + + expect(appSubmisionRes.body.user).toBeFalsy() + + appSubmisionRes = await supertest(app.getHttpServer()) + .post(`/applications/submit`) + .set(...setAuthorization(user1AccessToken)) + .send(body) + .expect(201) + + expect(appSubmisionRes.body.user).toBeTruthy() + }) + + afterEach(async () => { + await householdMembersRepository.createQueryBuilder().delete().execute() + await applicationsRepository.createQueryBuilder().delete().execute() + jest.clearAllMocks() + }) + + afterAll(async () => { + await app.close() + }) +}) diff --git a/backend/core/test/assets/assets.e2e-spec.ts b/backend/core/test/assets/assets.e2e-spec.ts new file mode 100644 index 0000000000..64a809d9db --- /dev/null +++ b/backend/core/test/assets/assets.e2e-spec.ts @@ -0,0 +1,103 @@ +import { Test } from "@nestjs/testing" +import { AssetsController } from "../../src/assets/assets.controller" +import { TypeOrmModule } from "@nestjs/typeorm" +import dbOptions = require("../../ormconfig.test") +import { Asset } from "../../src/assets/entities/asset.entity" +import { UploadService } from "../../src/assets/services/upload.service" +import { SharedModule } from "../../src/shared/shared.module" +import supertest from "supertest" +import { setAuthorization } from "../utils/set-authorization-helper" +import { applicationSetup } from "../../src/app.module" +import { INestApplication } from "@nestjs/common" +import { getUserAccessToken } from "../utils/get-user-access-token" +import { AssetsModule } from "../../src/assets/assets.module" + +class FakeUploadService implements UploadService { + createPresignedUploadMetadata(): { signature: string } { + return { signature: "fake" } + } +} + +describe("AssetsController", () => { + let app: INestApplication + let assetsController: AssetsController + let adminAccessToken: string + + beforeEach(async () => { + const moduleRef = await Test.createTestingModule({ + imports: [ + SharedModule, + TypeOrmModule.forRoot({ ...dbOptions, keepConnectionAlive: true }), + TypeOrmModule.forFeature([Asset]), + AssetsModule, + ], + }) + .overrideProvider(UploadService) + .useClass(FakeUploadService) + .compile() + + app = moduleRef.createNestApplication() + app = applicationSetup(app) + await app.init() + assetsController = moduleRef.get(AssetsController) + adminAccessToken = await getUserAccessToken(app, "admin@example.com", "abcdef") + }) + + describe("create", () => { + it("should create an asset", async () => { + const assetInput = { + fileId: "fileId", + label: "label", + } + const asset = await assetsController.create(assetInput) + expect(asset).toMatchObject(assetInput) + expect(asset).toHaveProperty("id") + expect(asset).toHaveProperty("createdAt") + expect(asset).toHaveProperty("updatedAt") + }) + + it("should create a presigned url for upload", async () => { + const publicId = "publicId" + const eager = "eager" + const createPresignedUploadMetadataResponseDto = await assetsController.createPresignedUploadMetadata( + { parametersToSign: { publicId, eager } } + ) + expect(createPresignedUploadMetadataResponseDto).toHaveProperty("signature") + expect(createPresignedUploadMetadataResponseDto.signature).toBe("fake") + }) + }) + + describe("retrieve", () => { + it("should return a paginated assets list", async () => { + const res = await supertest(app.getHttpServer()) + .get(`/assets/`) + .set(...setAuthorization(adminAccessToken)) + .expect(200) + const assets = res.body + expect(assets).toHaveProperty("meta") + expect(assets).toHaveProperty("items") + expect(assets.items.length).toBeGreaterThan(0) + expect(assets.items[0]).toHaveProperty("fileId") + expect(assets.items[0]).toHaveProperty("label") + }) + + it("should return an asset by Id", async () => { + const res = await supertest(app.getHttpServer()) + .get(`/assets/`) + .set(...setAuthorization(adminAccessToken)) + .expect(200) + expect(res.body).toHaveProperty("meta") + expect(res.body).toHaveProperty("items") + expect(res.body.items.length).toBeGreaterThan(0) + const assetId = res.body.items[0].id + const getByIdRes = await supertest(app.getHttpServer()) + .get(`/assets/${assetId}`) + .set(...setAuthorization(adminAccessToken)) + .expect(200) + const asset: Asset = getByIdRes.body + expect(asset).toBeDefined() + expect(asset).toHaveProperty("fileId") + expect(asset).toHaveProperty("label") + }) + }) +}) diff --git a/backend/core/test/authz/authz.e2e-spec.ts b/backend/core/test/authz/authz.e2e-spec.ts new file mode 100644 index 0000000000..10f41bdd1e --- /dev/null +++ b/backend/core/test/authz/authz.e2e-spec.ts @@ -0,0 +1,275 @@ +import { Test } from "@nestjs/testing" +import { INestApplication } from "@nestjs/common" +// Use require because of the CommonJS/AMD style export. +// See https://www.typescriptlang.org/docs/handbook/modules.html#export--and-import--require +import dbOptions = require("../../ormconfig.test") + +import supertest from "supertest" +import { applicationSetup, AppModule } from "../../src/app.module" +import { getUserAccessToken } from "../utils/get-user-access-token" +import { setAuthorization } from "../utils/set-authorization-helper" +import { UserDto } from "../../src/auth/dto/user.dto" +import { v4 as uuidv4 } from "uuid" +import { Repository } from "typeorm" +import { Application } from "../../src/applications/entities/application.entity" +import { getRepositoryToken } from "@nestjs/typeorm" +import { getTestAppBody } from "../lib/get-test-app-body" +import { Listing } from "../../src/listings/entities/listing.entity" +import { ApplicationDto } from "../../src/applications/dto/application.dto" + +jest.setTimeout(30000) + +describe("Authz", () => { + let app: INestApplication + let userAccessToken: string + let userProfile: UserDto + const adminOnlyEndpoints = ["/preferences", "/units", "/translations"] + const applicationsEndpoint = "/applications" + const listingsEndpoint = "/listings" + const userEndpoint = "/user" + let applicationsRepository: Repository + let listingsRepository: Repository+ let app1: ApplicationDto + + beforeAll(async () => { + const moduleRef = await Test.createTestingModule({ + imports: [AppModule.register(dbOptions)], + }).compile() + + app = moduleRef.createNestApplication() + app = applicationSetup(app) + await app.init() + + userAccessToken = await getUserAccessToken(app, "test@example.com", "abcdef") + userProfile = ( + await supertest(app.getHttpServer()) + .get("/user") + .set(...setAuthorization(userAccessToken)) + ).body + applicationsRepository = app.get>(getRepositoryToken(Application)) + listingsRepository = app.get>(getRepositoryToken(Listing)) + const listings = await listingsRepository.find({ take: 1 }) + const listing1Application = getTestAppBody(listings[0].id) + app1 = ( + await supertest(app.getHttpServer()) + .post(`/applications/submit`) + .send(listing1Application) + .set("jurisdictionName", "Detroit") + .set(...setAuthorization(userAccessToken)) + ).body + }) + + describe("admin endpoints", () => { + it(`should not allow normal/anonymous user to GET to admin only endpoints`, async () => { + for (const endpoint of adminOnlyEndpoints) { + // anonymous + await supertest(app.getHttpServer()).get(endpoint).expect(403) + // logged in normal user + await supertest(app.getHttpServer()) + .get(endpoint) + .set(...setAuthorization(userAccessToken)) + .expect(403) + } + }) + it(`should not allow normal/anonymous user to GET/:id to admin only endpoints`, async () => { + for (const endpoint of adminOnlyEndpoints) { + // anonymous + await supertest(app.getHttpServer()) + .get(endpoint + `/${uuidv4()}`) + .expect(403) + // logged in normal user + await supertest(app.getHttpServer()) + .get(endpoint + `/${uuidv4()}`) + .set(...setAuthorization(userAccessToken)) + .expect(403) + } + }) + it(`should not allow normal/anonymous user to POST to admin only endpoints`, async () => { + for (const endpoint of adminOnlyEndpoints) { + // anonymous + await supertest(app.getHttpServer()).post(endpoint).send({}).expect(403) + // logged in normal user + await supertest(app.getHttpServer()) + .post(endpoint) + .send({}) + .set(...setAuthorization(userAccessToken)) + .expect(403) + } + }) + it(`should not allow normal/anonymous user to PUT to admin only endpoints`, async () => { + for (const endpoint of adminOnlyEndpoints) { + // anonymous + await supertest(app.getHttpServer()) + .put(endpoint + `/${uuidv4()}`) + .send({}) + .expect(403) + // logged in normal user + await supertest(app.getHttpServer()) + .put(endpoint + `/${uuidv4()}`) + .send({}) + .set(...setAuthorization(userAccessToken)) + .expect(403) + } + }) + it(`should not allow normal/anonymous user to DELETE to admin only endpoints`, async () => { + for (const endpoint of adminOnlyEndpoints) { + // anonymous + await supertest(app.getHttpServer()) + .delete(endpoint + `/${uuidv4()}`) + .expect(403) + // logged in normal user + await supertest(app.getHttpServer()) + .delete(endpoint + `/${uuidv4()}`) + .set(...setAuthorization(userAccessToken)) + .expect(403) + } + }) + }) + + describe("user", () => { + it(`should not allow anonymous user to GET to get any user profile`, async () => { + await supertest(app.getHttpServer()).get(userEndpoint).expect(401) + }) + + it(`should allow a logged in user to GET to get any user profile`, async () => { + await supertest(app.getHttpServer()) + .get(userEndpoint) + .set(...setAuthorization(userAccessToken)) + .expect(200) + }) + + it(`should allow anonymous user to CREATE a user`, async () => { + await supertest(app.getHttpServer()).post(userEndpoint).expect(400) + }) + }) + + describe("applications", () => { + it("should not allow anonymous user to GET applications", async () => { + await supertest(app.getHttpServer()).get(applicationsEndpoint).expect(403) + }) + it("should allow logged in user to GET applications", async () => { + await supertest(app.getHttpServer()) + .get(applicationsEndpoint + `?userId=${userProfile.id}`) + .set(...setAuthorization(userAccessToken)) + .expect(200) + }) + it("should not allow anonymous user to GET CSV applications", async () => { + await supertest(app.getHttpServer()) + .get(applicationsEndpoint + "/csv") + .expect(403) + }) + it("should not allow anonymous user to GET applications by ID", async () => { + const applications = await applicationsRepository.find({ take: 1 }) + await supertest(app.getHttpServer()) + .get(applicationsEndpoint + `/${applications[0].id}`) + .expect(403) + }) + it(`should not allow normal/anonymous user to DELETE applications`, async () => { + // anonymous + const applications = await applicationsRepository.find({ take: 1 }) + await supertest(app.getHttpServer()) + .delete(applicationsEndpoint + `/${applications[0].id}`) + .expect(403) + // logged in normal user + await supertest(app.getHttpServer()) + .delete(applicationsEndpoint + `/${applications[0].id}`) + .set(...setAuthorization(userAccessToken)) + .expect(403) + }) + it(`should not allow normal/anonymous user to PUT applications`, async () => { + // anonymous + await supertest(app.getHttpServer()) + .put(applicationsEndpoint + `/${app1.id}`) + .send(app1) + .expect(403) + // logged in normal user + await supertest(app.getHttpServer()) + .put(applicationsEndpoint + `/${app1.id}`) + .set(...setAuthorization(userAccessToken)) + .send(app1) + .expect(403) + }) + it.skip(`should allow normal/anonymous user to POST applications`, async () => { + // anonymous + await supertest(app.getHttpServer()) + .post(applicationsEndpoint + "/submit") + .expect(400) + // logged in normal user + await supertest(app.getHttpServer()) + .post(applicationsEndpoint + "/submit") + .set(...setAuthorization(userAccessToken)) + .expect(400) + }) + }) + describe("listings", () => { + it("should allow anonymous user to GET listings", async () => { + await supertest(app.getHttpServer()).get(listingsEndpoint).expect(200) + }) + it("should allow anonymous user to GET listings by ID", async () => { + const res = await supertest(app.getHttpServer()).get(listingsEndpoint).expect(200) + await supertest(app.getHttpServer()) + .get(`${listingsEndpoint}/${res.body.items[0].id}`) + .expect(200) + }) + it(`should not allow normal/anonymous user to DELETE listings`, async () => { + // anonymous + await supertest(app.getHttpServer()) + .delete(listingsEndpoint + `/${uuidv4()}`) + .expect(403) + // logged in normal user + await supertest(app.getHttpServer()) + .delete(listingsEndpoint + `/${uuidv4()}`) + .set(...setAuthorization(userAccessToken)) + .expect(403) + }) + it(`should not allow normal/anonymous user to PUT listings`, async () => { + // anonymous + await supertest(app.getHttpServer()) + .put(listingsEndpoint + `/${uuidv4()}`) + .expect(403) + // logged in normal user + await supertest(app.getHttpServer()) + .put(listingsEndpoint + `/${uuidv4()}`) + .set(...setAuthorization(userAccessToken)) + .expect(403) + }) + it(`should not allow normal/anonymous user to POST listings`, async () => { + // anonymous + await supertest(app.getHttpServer()).post(listingsEndpoint).expect(403) + // logged in normal user + await supertest(app.getHttpServer()) + .post(listingsEndpoint) + .set(...setAuthorization(userAccessToken)) + .expect(403) + }) + + it(`should not allow normal user to change it's role`, async () => { + let profileRes = await supertest(app.getHttpServer()) + .get("/user") + .set(...setAuthorization(userAccessToken)) + .expect(200) + + expect(profileRes.body.roles).toBe(null) + await supertest(app.getHttpServer()) + .put(`/userProfile/${profileRes.body.id}`) + .send({ ...profileRes.body, roles: { isAdmin: true, isPartner: false } }) + .set(...setAuthorization(userAccessToken)) + .expect(200) + + profileRes = await supertest(app.getHttpServer()) + .get("/user") + .set(...setAuthorization(userAccessToken)) + .expect(200) + + expect(profileRes.body.roles).toBe(null) + }) + }) + + afterEach(() => { + jest.clearAllMocks() + }) + + afterAll(async () => { + await app.close() + }) +}) diff --git a/backend/core/test/factories/index.ts b/backend/core/test/factories/index.ts new file mode 100644 index 0000000000..04655b833a --- /dev/null +++ b/backend/core/test/factories/index.ts @@ -0,0 +1,8 @@ +import { register } from "fishery" +import listing from "./listing" +import unit from "./unit" + +export const factories = register({ + listing, + unit, +}) diff --git a/backend/core/test/factories/listing.ts b/backend/core/test/factories/listing.ts new file mode 100644 index 0000000000..c47a2a949f --- /dev/null +++ b/backend/core/test/factories/listing.ts @@ -0,0 +1,69 @@ +import { Factory } from "fishery" +import { nanoid } from "nanoid" +import { Listing } from "../../src/listings/entities/listing.entity" + +export default Factory.define(({ sequence, factories }) => ({ + id: nanoid(), + preferences: [], + units: [factories.unit.build()], + attachments: [], + acceptingApplicationsAtLeasingAgent: true, + acceptingOnlineApplications: false, + acceptsPostmarkedApplications: true, + accessibility: "There is a total of 5 ADA units in the complex", + amenities: "Community Room, Laundry Room", + applicationDueDate: "2019-12-31T15:22:57.000-07:00", + applicationOpenDate: null, + applicationFee: "30.0", + applicationOrganization: `Triton-${sequence}`, + blankPaperApplicationCanBePickedUp: true, + buildingAddress: { + city: `Foster City-${sequence}`, + street: "55 Triton Park Lane", + zipCode: "94404", + state: "CA", + county: "San Jose", + latitude: 37.55695, + longitude: -122.27521, + }, + buildingTotalUnits: 48, + buildingSelectionCriteria: null, + costsNotIncluded: + "Residents responsible for PG&E and Internet. Residents encourage to obtain renter’s insurance but this is not a requirement. Rent is due by the 5rd of each month. Late fee is $20.00. Owner pays for water, trash, and sewage. Resident to pay $25 for each returned check or rejected electronic payment.", + creditHistory: + "A poor credit history may be grounds to deem an applicant ineligible for housing.", + criminalBackground: "A check will be made of criminal conviction records", + depositMin: "1140.0", + depositMax: null, + developer: "Charities Housing", + disableUnitsAccordion: true, + imageUrl: + "https://regional-dahlia-staging.s3-us-west-1.amazonaws.com/listings/archer/archer-studios.jpg", + leasingAgentAddress: { + city: `Foster City-${sequence}`, + street: "55 Triton Park Lane", + zipCode: "94404", + state: "CA", + county: "San Jose", + latitude: 37.55695, + longitude: -122.27521, + }, + leasingAgentEmail: "thetriton@legacypartners.com", + leasingAgentName: "Francis Santos", + leasingAgentOfficeHours: "Monday - Friday, 9:00 am - 5:00 pm", + leasingAgentPhone: "650-437-2039", + leasingAgentTitle: "Business Manager", + name: "Gish Apartments", + neighborhood: "San Jose", + petPolicy: "No Pets Allowed", + postmarkedApplicationsReceivedByDate: "2019-12-05", + programRules: null, + rentalHistory: "Two years of rental history will be verified", + requiredDocuments: "Due at interview - Paystubs", + smokingPolicy: "Non-Smoking", + unitsAvailable: 2, + unitAmenities: "Dishwasher", + waitlistCurrentSize: 20, + waitlistMaxSize: 20, + yearBuilt: 2019, +})) diff --git a/backend/core/test/factories/unit.ts b/backend/core/test/factories/unit.ts new file mode 100644 index 0000000000..a3084a1554 --- /dev/null +++ b/backend/core/test/factories/unit.ts @@ -0,0 +1,40 @@ +import { Factory } from "fishery" +import { nanoid } from "nanoid" +import { Unit } from "../../src/units/entities/unit.entity" +import { BaseEntity } from "typeorm" +import { UnitStatus } from "../../src/units/types/unit-status-enum" + +type NonDbUnit = Omit + +export default Factory.define(() => ({ + id: nanoid(), + amiPercentage: "80.0", + annualIncomeMin: "58152.0", + monthlyIncomeMin: "4858.0", + floor: 2, + annualIncomeMax: "103350.0", + maxOccupancy: 2, + minOccupancy: 1, + monthlyRent: "2429.0", + numBathrooms: 1, + numBedrooms: null, + number: "265", + priorityType: null, + reservedType: null, + sqFeet: "750", + amiChart: null, + property: null, + status: UnitStatus.available, + unitType: { + id: nanoid(), + createdAt: new Date(), + updatedAt: new Date(), + name: "oneBdrm", + numBedrooms: 1, + }, + createdAt: new Date(), + updatedAt: new Date(), + amiChartId: 1, + monthlyRentAsPercentOfIncome: null, + listing: null, +})) diff --git a/backend/core/test/jurisdictions/jurisdictions.e2e-spec.ts b/backend/core/test/jurisdictions/jurisdictions.e2e-spec.ts new file mode 100644 index 0000000000..0d8e5ee5c9 --- /dev/null +++ b/backend/core/test/jurisdictions/jurisdictions.e2e-spec.ts @@ -0,0 +1,160 @@ +import { Test } from "@nestjs/testing" +import { INestApplication } from "@nestjs/common" +import { getRepositoryToken, TypeOrmModule } from "@nestjs/typeorm" +// Use require because of the CommonJS/AMD style export. +// See https://www.typescriptlang.org/docs/handbook/modules.html#export--and-import--require +import dbOptions = require("../../ormconfig.test") +import supertest from "supertest" +import { applicationSetup } from "../../src/app.module" +import { AuthModule } from "../../src/auth/auth.module" +import { getUserAccessToken } from "../utils/get-user-access-token" +import { setAuthorization } from "../utils/set-authorization-helper" +import { JurisdictionsModule } from "../../src/jurisdictions/jurisdictions.module" +import { Repository } from "typeorm" +import { Program } from "../../src/program/entities/program.entity" +import { Language } from "../../src/shared/types/language-enum" +import { Preference } from "../../src/preferences/entities/preference.entity" +import { EmailService } from "../../src/email/email.service" + +// Cypress brings in Chai types for the global expect, but we want to use jest +// expect here so we need to re-declare it. +// see: https://github.com/cypress-io/cypress/issues/1319#issuecomment-593500345 +declare const expect: jest.Expect +jest.setTimeout(30000) + +describe("Jurisdictions", () => { + let app: INestApplication + let adminAccesstoken: string + let preferencesRepository: Repository + let programsRepository: Repository + + beforeAll(async () => { + /* eslint-disable @typescript-eslint/no-empty-function */ + const testEmailService = { + confirmation: async () => {}, + } + /* eslint-enable @typescript-eslint/no-empty-function */ + const moduleRef = await Test.createTestingModule({ + imports: [ + TypeOrmModule.forRoot(dbOptions), + AuthModule, + JurisdictionsModule, + TypeOrmModule.forFeature([Preference, Program]), + ], + }) + .overrideProvider(EmailService) + .useValue(testEmailService) + .compile() + app = moduleRef.createNestApplication() + app = applicationSetup(app) + await app.init() + adminAccesstoken = await getUserAccessToken(app, "admin@example.com", "abcdef") + preferencesRepository = app.get>(getRepositoryToken(Preference)) + programsRepository = app.get>(getRepositoryToken(Program)) + }) + + it(`should return jurisdictions`, async () => { + const res = await supertest(app.getHttpServer()) + .get(`/jurisdictions`) + .set(...setAuthorization(adminAccesstoken)) + .expect(200) + expect(Array.isArray(res.body)).toBe(true) + }) + + it(`should create and return a new jurisdiction with a preference`, async () => { + const newPreference = await preferencesRepository.save({ + title: "TestTitle", + subtitle: "TestSubtitle", + description: "TestDescription", + links: [], + }) + const newProgram = await programsRepository.save({ + question: "TestQuestion", + subtitle: "TestSubtitle", + description: "TestDescription", + subdescription: "TestDescription", + }) + const res = await supertest(app.getHttpServer()) + .post(`/jurisdictions`) + .set(...setAuthorization(adminAccesstoken)) + .send({ + name: "test", + languages: [Language.en], + preferences: [newPreference], + programs: [newProgram], + publicUrl: "", + emailFromAddress: "", + }) + .expect(201) + + expect(res.body).toHaveProperty("id") + expect(res.body).toHaveProperty("createdAt") + expect(res.body).toHaveProperty("updatedAt") + expect(res.body).toHaveProperty("name") + expect(res.body).toHaveProperty("preferences") + expect(res.body.name).toBe("test") + expect(Array.isArray(res.body.preferences)).toBe(true) + expect(res.body.preferences.length).toBe(1) + expect(res.body.preferences[0].id).toBe(newPreference.id) + expect(res.body).toHaveProperty("programs") + expect(Array.isArray(res.body.programs)).toBe(true) + expect(res.body.programs.length).toBe(1) + expect(res.body.programs[0].id).toBe(newProgram.id) + + const getById = await supertest(app.getHttpServer()) + .get(`/jurisdictions/${res.body.id}`) + .expect(200) + expect(getById.body.name).toBe("test") + expect(getById.body.preferences[0].id).toBe(newPreference.id) + expect(getById.body.programs[0].id).toBe(newProgram.id) + }) + + it(`should create and return a new jurisdiction by name`, async () => { + const res = await supertest(app.getHttpServer()) + .post(`/jurisdictions`) + .set(...setAuthorization(adminAccesstoken)) + .send({ + name: "test2", + languages: [Language.en], + preferences: [], + publicUrl: "", + emailFromAddress: "", + }) + .send({ + name: "test2", + programs: [], + languages: [Language.en], + preferences: [], + publicUrl: "", + emailFromAddress: "", + }) + .expect(201) + expect(res.body).toHaveProperty("id") + expect(res.body).toHaveProperty("createdAt") + expect(res.body).toHaveProperty("updatedAt") + expect(res.body).toHaveProperty("name") + expect(res.body.name).toBe("test2") + + const getByName = await supertest(app.getHttpServer()) + .get(`/jurisdictions/byName/${res.body.name}`) + .expect(200) + expect(getByName.body.name).toBe("test2") + expect(getByName.body.languages[0]).toBe(Language.en) + }) + + it(`should not allow to create a jurisdiction with unsupported language`, async () => { + await supertest(app.getHttpServer()) + .post(`/jurisdictions`) + .set(...setAuthorization(adminAccesstoken)) + .send({ name: "test2", languages: ["non_existent_language"], emailFromAddress: "" }) + .expect(400) + }) + + afterEach(() => { + jest.clearAllMocks() + }) + + afterAll(async () => { + await app.close() + }) +}) diff --git a/backend/core/test/lib/get-test-app-body.ts b/backend/core/test/lib/get-test-app-body.ts new file mode 100644 index 0000000000..f0598d8e6d --- /dev/null +++ b/backend/core/test/lib/get-test-app-body.ts @@ -0,0 +1,110 @@ +import { + ApplicationStatus, + ApplicationSubmissionType, + ApplicationUpdate, + IncomePeriod, + Language, +} from "../../types" + +export const getTestAppBody: (listingId?: string) => ApplicationUpdate = (listingId?: string) => { + return { + appUrl: "", + listing: { + id: listingId, + }, + language: Language.en, + status: ApplicationStatus.submitted, + submissionType: ApplicationSubmissionType.electronical, + acceptedTerms: false, + applicant: { + firstName: "Applicant", + middleName: "Middlename", + lastName: "lastName", + birthMonth: "", + birthDay: "", + birthYear: "", + emailAddress: "test@email.com", + noEmail: false, + phoneNumber: "", + phoneNumberType: "", + noPhone: false, + workInRegion: null, + address: { + street: "", + street2: "", + city: "", + state: "", + zipCode: "", + county: "", + latitude: null, + longitude: null, + }, + workAddress: { + street: "", + street2: "", + city: "", + state: "", + zipCode: "", + county: "", + latitude: null, + longitude: null, + }, + }, + additionalPhone: true, + additionalPhoneNumber: "12345", + additionalPhoneNumberType: "cell", + contactPreferences: ["a", "b"], + householdSize: 1, + housingStatus: "status", + sendMailToMailingAddress: true, + mailingAddress: { + street: "", + street2: "", + city: "", + state: "", + zipCode: "", + }, + alternateAddress: { + street: "", + street2: "", + city: "", + state: "", + zipCode: "", + }, + alternateContact: { + type: "", + otherType: "", + firstName: "", + lastName: "", + agency: "", + phoneNumber: "", + emailAddress: "alternate@contact.com", + mailingAddress: { + street: "", + city: "", + state: "", + zipCode: "", + }, + }, + accessibility: { + mobility: null, + vision: null, + hearing: null, + }, + demographics: { + ethnicity: "", + race: [], + gender: "", + sexualOrientation: "", + howDidYouHear: [], + }, + incomeVouchers: true, + income: "100.00", + incomePeriod: IncomePeriod.perMonth, + householdStudent: false, + householdExpectingChanges: false, + householdMembers: [], + preferredUnit: [], + preferences: [], + } +} diff --git a/backend/core/test/lib/url_helper.spec.ts b/backend/core/test/lib/url_helper.spec.ts new file mode 100644 index 0000000000..0c8b69802f --- /dev/null +++ b/backend/core/test/lib/url_helper.spec.ts @@ -0,0 +1,44 @@ +import { formatUrlSlug, listingUrlSlug } from "../../src/shared/url-helper" + +import { ArcherListing, Listing } from "../../types" + +// Cypress brings in Chai types for the global expect, but we want to use jest +// expect here so we need to re-declare it. +// see: https://github.com/cypress-io/cypress/issues/1319#issuecomment-593500345 +declare const expect: jest.Expect + +describe("formatUrlSlug", () => { + test("reformats strings properly", () => { + expect(formatUrlSlug("snake_case")).toEqual("snake_case") + expect(formatUrlSlug("SnakeCase")).toEqual("snake_case") + expect(formatUrlSlug("Mix of spaces_and-hyphens")).toEqual("mix_of_spaces_and_hyphens") + expect(formatUrlSlug("Lots@of&weird spaces&^&!@^*&AND OTHER_CHARS")).toEqual( + "lots_of_weird_spaces_and_other_chars" + ) + }) + + test("with an empty string", () => { + expect(formatUrlSlug("")).toEqual("") + }) +}) + +describe("listingUrlSlug", () => { + // Force cast to listing - should we add a dependency to `listingsLoader` instead? + const listing: Listing = ArcherListing + + test("Generates a URL slug for a Listing", () => { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + const slug = listingUrlSlug({ + ...listing, + // TODO: refactor this tests, creating property here is required after moving property model + // to the listing model. + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + property: { + buildingAddress: listing.buildingAddress, + }, + }) + expect(slug).toEqual("archer_studios_98_archer_street_san_jose_ca") + }) +}) diff --git a/backend/core/test/listings/listings.e2e-spec.ts b/backend/core/test/listings/listings.e2e-spec.ts new file mode 100644 index 0000000000..0e08cd770d --- /dev/null +++ b/backend/core/test/listings/listings.e2e-spec.ts @@ -0,0 +1,577 @@ +import { Test } from "@nestjs/testing" +import { getRepositoryToken, TypeOrmModule } from "@nestjs/typeorm" +import supertest from "supertest" +import { ListingsModule } from "../../src/listings/listings.module" +import { applicationSetup } from "../../src/app.module" +import { ListingDto } from "../../src/listings/dto/listing.dto" +import { getUserAccessToken } from "../utils/get-user-access-token" +import { setAuthorization } from "../utils/set-authorization-helper" +import { AssetCreateDto } from "../../src/assets/dto/asset.dto" +import { ApplicationMethodCreateDto } from "../../src/application-methods/dto/application-method.dto" +import { ApplicationMethodType } from "../../src/application-methods/types/application-method-type-enum" +import { Language } from "../../types" +import { AssetsModule } from "../../src/assets/assets.module" +import { ApplicationMethodsModule } from "../../src/application-methods/applications-methods.module" +import { PaperApplicationsModule } from "../../src/paper-applications/paper-applications.module" +import { ListingEventCreateDto } from "../../src/listings/dto/listing-event.dto" +import { ListingEventType } from "../../src/listings/types/listing-event-type-enum" +import { Listing } from "../../src/listings/entities/listing.entity" +import qs from "qs" +import { ListingUpdateDto } from "../../src/listings/dto/listing-update.dto" +import { Program } from "../../src/program/entities/program.entity" +import { Repository } from "typeorm" +import { INestApplication } from "@nestjs/common" +import { Jurisdiction } from "../../src/jurisdictions/entities/jurisdiction.entity" + +// eslint-disable-next-line @typescript-eslint/no-var-requires +const dbOptions = require("../../ormconfig.test") + +// Cypress brings in Chai types for the global expect, but we want to use jest +// expect here so we need to re-declare it. +// see: https://github.com/cypress-io/cypress/issues/1319#issuecomment-593500345 +declare const expect: jest.Expect +jest.setTimeout(30000) + +describe("Listings", () => { + let app: INestApplication + let programsRepository: Repository + let adminAccessToken: string + let jurisdictionsRepository: Repository + + beforeAll(async () => { + const moduleRef = await Test.createTestingModule({ + imports: [ + TypeOrmModule.forRoot(dbOptions), + TypeOrmModule.forFeature([Jurisdiction]), + ListingsModule, + AssetsModule, + ApplicationMethodsModule, + PaperApplicationsModule, + TypeOrmModule.forFeature([Program]), + ], + }).compile() + app = moduleRef.createNestApplication() + app = applicationSetup(app) + await app.init() + programsRepository = app.get>(getRepositoryToken(Program)) + adminAccessToken = await getUserAccessToken(app, "admin@example.com", "abcdef") + jurisdictionsRepository = moduleRef.get>( + getRepositoryToken(Jurisdiction) + ) + }) + + it("should return all listings", async () => { + const res = await supertest(app.getHttpServer()).get("/listings").expect(200) + expect(res.body.items.map((listing) => listing.id).length).toBeGreaterThan(0) + }) + + it("should return the first page of paginated listings", async () => { + // Make the limit 1 less than the full number of listings, so that the first page contains all + // but the last listing. + const page = "1" + // This is the number of listings in ../../src/seed.ts minus 1 + const limit = 12 + const params = "/?page=" + page + "&limit=" + limit.toString() + const res = await supertest(app.getHttpServer()) + .get("/listings" + params) + .expect(200) + expect(res.body.items.length).toEqual(limit) + }) + + it("should return the last page of paginated listings", async () => { + // Make the limit 1 less than the full number of listings, so that the second page contains + // only one listing. + const queryParams = { + limit: 12, + page: 3, + view: "base", + } + const query = qs.stringify(queryParams) + const res = await supertest(app.getHttpServer()).get(`/listings?${query}`).expect(200) + expect(res.body.items.length).toEqual(1) + }) + + it("should return listings with matching zipcodes", async () => { + const queryParams = { + limit: "all", + filter: [ + { + $comparison: "IN", + zipcode: "94621,94404", + }, + ], + view: "base", + } + const query = qs.stringify(queryParams) + await supertest(app.getHttpServer()).get(`/listings?${query}`).expect(200) + }) + + it("should return listings with matching Alameda jurisdiction", async () => { + const jurisdictions = await jurisdictionsRepository.find() + const alameda = jurisdictions.find((jurisdiction) => jurisdiction.name === "Alameda") + const queryParams = { + limit: "all", + filter: [ + { + $comparison: "=", + jurisdiction: alameda.id, + }, + ], + view: "base", + } + const query = qs.stringify(queryParams) + const res = await supertest(app.getHttpServer()).get(`/listings?${query}`).expect(200) + expect(res.body.items.length).toBe(20) + }) + + it("should modify property related fields of a listing and return a modified value", async () => { + const res = await supertest(app.getHttpServer()) + .get("/listings?orderBy=applicationDates") + .expect(200) + + const listing: ListingDto = { ...res.body.items[0] } + + const amenitiesValue = "Random amenities value" + expect(listing.amenities).not.toBe(amenitiesValue) + listing.amenities = amenitiesValue + + const oldOccupancy = Number(listing.units[0].maxOccupancy) + listing.units[0].maxOccupancy = oldOccupancy + 1 + + const putResponse = await supertest(app.getHttpServer()) + .put(`/listings/${listing.id}`) + .send(listing) + .set(...setAuthorization(adminAccessToken)) + .expect(200) + const modifiedListing: ListingDto = putResponse.body + expect(modifiedListing.amenities).toBe(amenitiesValue) + expect(modifiedListing.units[0].maxOccupancy).toBe(oldOccupancy + 1) + }) + + it("should add/overwrite image in existing listing", async () => { + const res = await supertest(app.getHttpServer()) + .get("/listings?orderBy=applicationDates") + .expect(200) + + const listing: ListingUpdateDto = { ...res.body.items[0] } + + const fileId = "fileId" + const label = "label" + const image: AssetCreateDto = { + fileId: fileId, + label: label, + } + + const assetCreateResponse = await supertest(app.getHttpServer()) + .post(`/assets`) + .send(image) + .set(...setAuthorization(adminAccessToken)) + .expect(201) + listing.images = [{ image: assetCreateResponse.body, ordinal: 1 }] + + const putResponse = await supertest(app.getHttpServer()) + .put(`/listings/${listing.id}`) + .send(listing) + .set(...setAuthorization(adminAccessToken)) + .expect(200) + const modifiedListing: ListingDto = putResponse.body + + expect(modifiedListing.images[0].image.id).toBe(assetCreateResponse.body.id) + }) + + it("should add/overwrite application methods in existing listing", async () => { + const res = await supertest(app.getHttpServer()).get("/listings").expect(200) + + const listing: Listing = { ...res.body.items[0] } + + const assetCreateDto: AssetCreateDto = { + fileId: "testFileId2", + label: "testLabel2", + } + + const file = await supertest(app.getHttpServer()) + .post(`/assets`) + .send(assetCreateDto) + .set(...setAuthorization(adminAccessToken)) + .expect(201) + + const am: ApplicationMethodCreateDto = { + type: ApplicationMethodType.FileDownload, + paperApplications: [{ file: file.body, language: Language.en }], + listing, + } + + const applicationMethod = await supertest(app.getHttpServer()) + .post(`/applicationMethods`) + .send(am) + .set(...setAuthorization(adminAccessToken)) + .expect(201) + + listing.applicationMethods = [applicationMethod.body] + + const putResponse = await supertest(app.getHttpServer()) + .put(`/listings/${listing.id}`) + .send(listing) + .set(...setAuthorization(adminAccessToken)) + .expect(200) + const modifiedListing: ListingDto = putResponse.body + + expect(modifiedListing.applicationMethods[0]).toHaveProperty("id") + }) + + it("should add/overwrite listing events in existing listing", async () => { + const res = await supertest(app.getHttpServer()) + .get("/listings?orderBy=applicationDates") + .expect(200) + + const listing: ListingUpdateDto = { ...res.body.items[0] } + + const listingEvent: ListingEventCreateDto = { + type: ListingEventType.openHouse, + startTime: new Date(), + endTime: new Date(), + url: "testurl", + note: "testnote", + label: "testlabel", + file: { + fileId: "testid", + label: "testlabel", + }, + } + listing.events = [listingEvent] + + const putResponse = await supertest(app.getHttpServer()) + .put(`/listings/${listing.id}`) + .send(listing) + .set(...setAuthorization(adminAccessToken)) + .expect(200) + + const modifiedListing: ListingDto = putResponse.body + + expect(modifiedListing.events.length).toBe(1) + expect(modifiedListing.events[0].url).toBe(listingEvent.url) + expect(modifiedListing.events[0].note).toBe(listingEvent.note) + expect(modifiedListing.events[0].label).toBe(listingEvent.label) + expect(modifiedListing.events[0].file.id).toBeDefined() + expect(modifiedListing.events[0].file.fileId).toBe(listingEvent.file.fileId) + expect(modifiedListing.events[0].file.label).toBe(listingEvent.file.label) + }) + + it("should add/overwrite and remove listing programs in existing listing", async () => { + const res = await supertest(app.getHttpServer()) + .get("/listings?orderBy=applicationDates") + .expect(200) + const listing: ListingUpdateDto = { ...res.body.items[0] } + const newProgram = await programsRepository.save({ + title: "TestTitle", + subtitle: "TestSubtitle", + description: "TestDescription", + }) + listing.listingPrograms = [{ program: newProgram, ordinal: 1 }] + + const putResponse = await supertest(app.getHttpServer()) + .put(`/listings/${listing.id}`) + .send(listing) + .set(...setAuthorization(adminAccessToken)) + .expect(200) + + const listingResponse = await supertest(app.getHttpServer()) + .get(`/listings/${putResponse.body.id}`) + .expect(200) + + expect(listingResponse.body.listingPrograms[0].program.id).toBe(newProgram.id) + expect(listingResponse.body.listingPrograms[0].program.title).toBe(newProgram.title) + expect(listingResponse.body.listingPrograms[0].ordinal).toBe(1) + + await supertest(app.getHttpServer()) + .put(`/listings/${listing.id}`) + .send({ + ...putResponse.body, + listingPrograms: [], + }) + .set(...setAuthorization(adminAccessToken)) + .expect(200) + + const listingResponse2 = await supertest(app.getHttpServer()) + .get(`/listings/${putResponse.body.id}`) + .expect(200) + expect(listingResponse2.body.listingPrograms.length).toBe(0) + }) + + describe.skip("AMI Filter", () => { + it("should return listings with AMI >= the filter value", async () => { + const paramsWithEqualAmi = { + view: "base", + limit: "all", + filter: [ + { + $comparison: "NA", + minAmiPercentage: "60", + }, + ], + } + const res = await supertest(app.getHttpServer()) + .get("/listings?" + qs.stringify(paramsWithEqualAmi)) + .expect(200) + expect(res.body.items).toEqual( + expect.arrayContaining([ + expect.objectContaining({ name: "Test: Default, Summary With 30 and 60 Ami Percentage" }), + ]) + ) + + const paramsWithLessAmi = { + view: "base", + limit: "all", + filter: [ + { + $comparison: "NA", + minAmiPercentage: "59", + }, + ], + } + const res2 = await supertest(app.getHttpServer()) + .get("/listings?" + qs.stringify(paramsWithLessAmi)) + .expect(200) + expect(res2.body.items).toEqual( + expect.arrayContaining([ + expect.objectContaining({ name: "Test: Default, Summary With 30 and 60 Ami Percentage" }), + ]) + ) + }) + + it("should not return listings with AMI < the filter value", async () => { + const params = { + view: "base", + limit: "all", + filter: [ + { + $comparison: "NA", + minAmiPercentage: "61", + }, + ], + } + const res = await supertest(app.getHttpServer()) + .get("/listings?" + qs.stringify(params)) + .expect(200) + expect(res.body.items).not.toEqual( + expect.arrayContaining([ + expect.objectContaining({ name: "Test: Default, Summary With 30 and 60 Ami Percentage" }), + ]) + ) + }) + + it("should return listings with matching AMI in Units Summary, even if Listings.amiPercentageMax field does not match", async () => { + const params = { + view: "base", + limit: "all", + filter: [ + { + $comparison: "NA", + minAmiPercentage: "30", + }, + ], + } + const res = await supertest(app.getHttpServer()) + .get("/listings?" + qs.stringify(params)) + .expect(200) + expect(res.body.items).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: "Test: Default, Summary With 30 Listing With 10 Ami Percentage", + }), + ]) + ) + }) + + it("should not return listings with matching AMI in Listings.amiPercentageMax field, if Unit Summary field does not match", async () => { + const params = { + view: "base", + limit: "all", + filter: [ + { + $comparison: "NA", + minAmiPercentage: "30", + }, + ], + } + const res = await supertest(app.getHttpServer()) + .get("/listings?" + qs.stringify(params)) + .expect(200) + expect(res.body.items).not.toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: "Test: Default, Summary With 10 Listing With 30 Ami Percentage", + }), + ]) + ) + }) + + it("should return listings with matching AMI in the Listings.amiPercentageMax field, if the Unit Summary field is empty", async () => { + const params = { + view: "base", + limit: "all", + filter: [ + { + $comparison: "NA", + minAmiPercentage: "19", + }, + ], + } + const res = await supertest(app.getHttpServer()) + .get("/listings?" + qs.stringify(params)) + .expect(200) + expect(res.body.items).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: "Test: Default, Summary Without And Listing With 20 Ami Percentage", + }), + ]) + ) + }) + }) + + describe.skip("Unit size filtering", () => { + it("should return listings with >= 1 bedroom", async () => { + const params = { + view: "base", + limit: "all", + filter: [ + { + $comparison: ">=", + bedrooms: "1", + }, + ], + } + const res = await supertest(app.getHttpServer()) + .get("/listings?" + qs.stringify(params)) + .expect(200) + + const listings: Listing[] = res.body.items + expect(listings.length).toBeGreaterThan(0) + // expect that all listings have at least one unit with >= 1 bedroom + /* expect( + listings.map((listing) => { + listing.unitsSummary.find((unit) => { + unit.unitType.some((unitType) => unitType.numBedrooms >= 1) + }) !== undefined + }) + ).not.toContain(false) */ + }) + + it("should return listings with exactly 1 bedroom", async () => { + const params = { + view: "base", + limit: "all", + filter: [ + { + $comparison: "=", + bedrooms: "1", + }, + ], + } + const res = await supertest(app.getHttpServer()) + .get("/listings?" + qs.stringify(params)) + .expect(200) + + const listings: Listing[] = res.body.items + expect(listings.length).toBeGreaterThan(0) + // expect that all listings have at least one unit with exactly 1 bedroom + /* expect( + listings.map((listing) => { + listing.unitsSummary.find((unit) => { + unit.unitType.some((unitType) => unitType.numBedrooms >= 1) + }) !== undefined + }) + ).not.toContain(false) */ + }) + }) + + it("defaults to sorting listings by name", async () => { + const res = await supertest(app.getHttpServer()).get(`/listings?limit=all`).expect(200) + const listings = res.body.items + + // The Coliseum seed has the soonest applicationDueDate (1 day in the future) + expect(listings[0].name).toBe("Medical Center Village") + + // Triton and "Default, No Preferences" share the next-soonest applicationDueDate + // (5 days in the future). Between the two, Triton 1 appears first because it has + // the closer applicationOpenDate. + const secondListing = listings[1] + expect(secondListing.name).toBe("Melrose Square Homes") + const thirdListing = listings[2] + expect(thirdListing.name).toBe("New Center Commons") + const fourthListing = listings[3] + expect(fourthListing.name).toBe("New Center Pavilion") + + const secondListingAppDueDate = new Date(secondListing.applicationDueDate) + const thirdListingAppDueDate = new Date(thirdListing.applicationDueDate) + expect(secondListingAppDueDate.getDate()).toEqual(thirdListingAppDueDate.getDate()) + + const secondListingAppOpenDate = new Date(secondListing.applicationOpenDate) + const thirdListingAppOpenDate = new Date(thirdListing.applicationOpenDate) + expect(secondListingAppOpenDate.getTime()).toBeGreaterThanOrEqual( + thirdListingAppOpenDate.getTime() + ) + + // Verify that listings with null applicationDueDate's appear at the end. + const lastListing = listings[listings.length - 1] + expect(lastListing.applicationDueDate).toBeNull() + }) + + it("sorts listings by most recently updated when that orderBy param is set", async () => { + const res = await supertest(app.getHttpServer()) + .get(`/listings?orderBy=mostRecentlyUpdated&limit=all`) + .expect(200) + for (let i = 0; i < res.body.items.length - 1; ++i) { + const currentUpdatedAt = new Date(res.body.items[i].updatedAt) + const nextUpdatedAt = new Date(res.body.items[i + 1].updatedAt) + + // Verify that each listing's updatedAt timestamp is more recent than the next listing's. + expect(currentUpdatedAt.getTime()).toBeGreaterThan(nextUpdatedAt.getTime()) + } + }) + + it("fails if orderBy param doesn't conform to one of the enum values", async () => { + await supertest(app.getHttpServer()).get(`/listings?orderBy=notAValidOrderByParam`).expect(400) + }) + + it("sorts results within a page, and across sequential pages", async () => { + // Get the first page of 5 results. + const firstPage = await supertest(app.getHttpServer()) + .get(`/listings?orderBy=mostRecentlyUpdated&limit=5&page=1`) + .expect(200) + + // Verify that listings on the first page are ordered from most to least recently updated. + for (let i = 0; i < 4; ++i) { + const currentUpdatedAt = new Date(firstPage.body.items[i].updatedAt) + const nextUpdatedAt = new Date(firstPage.body.items[i + 1].updatedAt) + + // Verify that each listing's updatedAt timestamp is more recent than the next listing's. + expect(currentUpdatedAt.getTime()).toBeGreaterThan(nextUpdatedAt.getTime()) + } + + const lastListingOnFirstPageUpdateTimestamp = new Date(firstPage.body.items[4].updatedAt) + + // Get the second page of 5 results + const secondPage = await supertest(app.getHttpServer()) + .get(`/listings?orderBy=mostRecentlyUpdated&limit=5&page=2`) + .expect(200) + + // Verify that each of the listings on the second page was less recently updated than the last + // first-page listing. + for (const secondPageListing of secondPage.body.items) { + const secondPageListingUpdateTimestamp = new Date(secondPageListing.updatedAt) + expect(lastListingOnFirstPageUpdateTimestamp.getTime()).toBeGreaterThan( + secondPageListingUpdateTimestamp.getTime() + ) + } + }) + + afterEach(() => { + jest.clearAllMocks() + }) + + afterAll(async () => { + await app.close() + }) +}) diff --git a/backend/core/test/paper-applications/paper-applications.e2e-spec.ts b/backend/core/test/paper-applications/paper-applications.e2e-spec.ts new file mode 100644 index 0000000000..609a00e08b --- /dev/null +++ b/backend/core/test/paper-applications/paper-applications.e2e-spec.ts @@ -0,0 +1,89 @@ +import { Test } from "@nestjs/testing" +import { INestApplication } from "@nestjs/common" +import { TypeOrmModule } from "@nestjs/typeorm" +import supertest from "supertest" +import { applicationSetup } from "../../src/app.module" +import { AuthModule } from "../../src/auth/auth.module" +import { getUserAccessToken } from "../utils/get-user-access-token" +import { setAuthorization } from "../utils/set-authorization-helper" +// Use require because of the CommonJS/AMD style export. +// See https://www.typescriptlang.org/docs/handbook/modules.html#export--and-import--require +import dbOptions = require("../../ormconfig.test") +import { Language } from "../../src/shared/types/language-enum" +import { PaperApplicationsModule } from "../../src/paper-applications/paper-applications.module" +import { AssetsModule } from "../../src/assets/assets.module" +import { EmailService } from "../../src/email/email.service" + +// Cypress brings in Chai types for the global expect, but we want to use jest +// expect here so we need to re-declare it. +// see: https://github.com/cypress-io/cypress/issues/1319#issuecomment-593500345 +declare const expect: jest.Expect +jest.setTimeout(30000) + +describe("PaperApplications", () => { + let app: INestApplication + let adminAccesstoken: string + beforeAll(async () => { + /* eslint-disable @typescript-eslint/no-empty-function */ + const testEmailService = { confirmation: async () => {} } + /* eslint-enable @typescript-eslint/no-empty-function */ + const moduleRef = await Test.createTestingModule({ + imports: [ + TypeOrmModule.forRoot(dbOptions), + AuthModule, + PaperApplicationsModule, + AssetsModule, + ], + }) + .overrideProvider(EmailService) + .useValue(testEmailService) + .compile() + app = moduleRef.createNestApplication() + app = applicationSetup(app) + await app.init() + adminAccesstoken = await getUserAccessToken(app, "admin@example.com", "abcdef") + }) + + it(`should return paperApplications`, async () => { + const res = await supertest(app.getHttpServer()) + .get(`/paperApplications`) + .set(...setAuthorization(adminAccesstoken)) + .expect(200) + expect(Array.isArray(res.body)).toBe(true) + }) + // TODO: paper applications are only creatable through a listing + it.skip(`should create and return a new paper application`, async () => { + const asset = await supertest(app.getHttpServer()) + .post(`/assets`) + .set(...setAuthorization(adminAccesstoken)) + .send({ fileId: "testFileId", label: "testLabel" }) + .expect(201) + + const res = await supertest(app.getHttpServer()) + .post(`/paperApplications`) + .set(...setAuthorization(adminAccesstoken)) + .send({ language: Language.en, file: { id: asset.body.id } }) + .expect(201) + expect(res.body).toHaveProperty("id") + expect(res.body).toHaveProperty("createdAt") + expect(res.body).toHaveProperty("updatedAt") + expect(res.body).toHaveProperty("language") + expect(res.body.language).toBe(Language.en) + + const getById = await supertest(app.getHttpServer()) + .get(`/paperApplications/${res.body.id}`) + .set(...setAuthorization(adminAccesstoken)) + .expect(200) + expect(getById.body.language).toBe(Language.en) + expect(getById.body.file.id).toBe(asset.body.id) + expect(getById.body.file.fileId).toBe("testFileId") + }) + + afterEach(() => { + jest.clearAllMocks() + }) + + afterAll(async () => { + await app.close() + }) +}) diff --git a/backend/core/test/programs/programs.e2e-spec.ts b/backend/core/test/programs/programs.e2e-spec.ts new file mode 100644 index 0000000000..813c5117c8 --- /dev/null +++ b/backend/core/test/programs/programs.e2e-spec.ts @@ -0,0 +1,79 @@ +import { Test } from "@nestjs/testing" +import { INestApplication } from "@nestjs/common" +import { TypeOrmModule } from "@nestjs/typeorm" +// Use require because of the CommonJS/AMD style export. +// See https://www.typescriptlang.org/docs/handbook/modules.html#export--and-import--require +import dbOptions = require("../../ormconfig.test") +import supertest from "supertest" +import { applicationSetup } from "../../src/app.module" +import { AuthModule } from "../../src/auth/auth.module" +import { getUserAccessToken } from "../utils/get-user-access-token" +import { setAuthorization } from "../utils/set-authorization-helper" +import { ProgramsModule } from "../../src/program/programs.module" +import { ProgramCreateDto } from "../../src/program/dto/program-create.dto" +import { EmailService } from "../../src/email/email.service" + +// Cypress brings in Chai types for the global expect, but we want to use jest +// expect here so we need to re-declare it. +// see: https://github.com/cypress-io/cypress/issues/1319#issuecomment-593500345 +declare const expect: jest.Expect +jest.setTimeout(30000) + +describe("Programs", () => { + let app: INestApplication + let adminAccessToken: string + + beforeAll(async () => { + /* eslint-disable @typescript-eslint/no-empty-function */ + const testEmailService = { confirmation: async () => {} } + /* eslint-enable @typescript-eslint/no-empty-function */ + const moduleRef = await Test.createTestingModule({ + imports: [TypeOrmModule.forRoot(dbOptions), AuthModule, ProgramsModule], + }) + .overrideProvider(EmailService) + .useValue(testEmailService) + .compile() + app = moduleRef.createNestApplication() + app = applicationSetup(app) + await app.init() + adminAccessToken = await getUserAccessToken(app, "admin@example.com", "abcdef") + }) + + it(`should return programs`, async () => { + const res = await supertest(app.getHttpServer()) + .get(`/programs`) + .set(...setAuthorization(adminAccessToken)) + .expect(200) + expect(Array.isArray(res.body)).toBe(true) + }) + + it(`should create and return a new program`, async () => { + const newProgram: ProgramCreateDto = { + title: "title", + description: "description", + subtitle: "subtitle", + } + const res = await supertest(app.getHttpServer()) + .post(`/programs`) + .set(...setAuthorization(adminAccessToken)) + .send(newProgram) + .expect(201) + expect(res.body).toHaveProperty("id") + expect(res.body).toHaveProperty("createdAt") + expect(res.body).toHaveProperty("updatedAt") + expect(res.body.id).toBe(res.body.id) + expect(res.body.title).toBe(newProgram.title) + + const getById = await supertest(app.getHttpServer()).get(`/programs/${res.body.id}`).expect(200) + expect(getById.body.id).toBe(res.body.id) + expect(getById.body.title).toBe(newProgram.title) + }) + + afterEach(() => { + jest.clearAllMocks() + }) + + afterAll(async () => { + await app.close() + }) +}) diff --git a/backend/core/test/properties/properties.e2e-spec.ts b/backend/core/test/properties/properties.e2e-spec.ts new file mode 100644 index 0000000000..7c42104f9d --- /dev/null +++ b/backend/core/test/properties/properties.e2e-spec.ts @@ -0,0 +1,55 @@ +import { Test } from "@nestjs/testing" +import { INestApplication } from "@nestjs/common" +import { TypeOrmModule } from "@nestjs/typeorm" +// Use require because of the CommonJS/AMD style export. +// See https://www.typescriptlang.org/docs/handbook/modules.html#export--and-import--require +import dbOptions = require("../../ormconfig.test") +import supertest from "supertest" +import { applicationSetup } from "../../src/app.module" +import { AuthModule } from "../../src/auth/auth.module" +import { getUserAccessToken } from "../utils/get-user-access-token" +import { setAuthorization } from "../utils/set-authorization-helper" +import { PropertiesModule } from "../../src/property/properties.module" +import { EmailService } from "../../src/email/email.service" + +// Cypress brings in Chai types for the global expect, but we want to use jest +// expect here so we need to re-declare it. +// see: https://github.com/cypress-io/cypress/issues/1319#issuecomment-593500345 +declare const expect: jest.Expect +jest.setTimeout(30000) + +describe("Properties", () => { + let app: INestApplication + let adminAccesstoken: string + beforeAll(async () => { + /* eslint-disable @typescript-eslint/no-empty-function */ + const testEmailService = { confirmation: async () => {} } + /* eslint-enable @typescript-eslint/no-empty-function */ + const moduleRef = await Test.createTestingModule({ + imports: [TypeOrmModule.forRoot(dbOptions), AuthModule, PropertiesModule], + }) + .overrideProvider(EmailService) + .useValue(testEmailService) + .compile() + app = moduleRef.createNestApplication() + app = applicationSetup(app) + await app.init() + adminAccesstoken = await getUserAccessToken(app, "admin@example.com", "abcdef") + }) + + it(`/GET `, async () => { + const res = await supertest(app.getHttpServer()) + .get(`/properties`) + .set(...setAuthorization(adminAccesstoken)) + .expect(200) + expect(Array.isArray(res.body)).toBe(true) + }) + + afterEach(() => { + jest.clearAllMocks() + }) + + afterAll(async () => { + await app.close() + }) +}) diff --git a/backend/core/test/reserved-community-types/reserved-community-types.e2e-spec.ts b/backend/core/test/reserved-community-types/reserved-community-types.e2e-spec.ts new file mode 100644 index 0000000000..03d34d26ee --- /dev/null +++ b/backend/core/test/reserved-community-types/reserved-community-types.e2e-spec.ts @@ -0,0 +1,89 @@ +import { Test } from "@nestjs/testing" +import { INestApplication } from "@nestjs/common" +import { getRepositoryToken, TypeOrmModule } from "@nestjs/typeorm" +// Use require because of the CommonJS/AMD style export. +// See https://www.typescriptlang.org/docs/handbook/modules.html#export--and-import--require +import dbOptions = require("../../ormconfig.test") +import supertest from "supertest" +import { applicationSetup } from "../../src/app.module" +import { AuthModule } from "../../src/auth/auth.module" +import { getUserAccessToken } from "../utils/get-user-access-token" +import { setAuthorization } from "../utils/set-authorization-helper" +import { ReservedCommunityTypesModule } from "../../src/reserved-community-type/reserved-community-types.module" +import { JurisdictionsModule } from "../../src/jurisdictions/jurisdictions.module" +import { Jurisdiction } from "../../src/jurisdictions/entities/jurisdiction.entity" +import { Repository } from "typeorm" +import { EmailService } from "../../src/email/email.service" + +// Cypress brings in Chai types for the global expect, but we want to use jest +// expect here so we need to re-declare it. +// see: https://github.com/cypress-io/cypress/issues/1319#issuecomment-593500345 +declare const expect: jest.Expect +jest.setTimeout(30000) + +describe("ReservedCommunityTypes", () => { + let app: INestApplication + let adminAccesstoken: string + let jurisdictionsRepository: Repository + beforeAll(async () => { + /* eslint-disable @typescript-eslint/no-empty-function */ + const testEmailService = { confirmation: async () => {} } + /* eslint-enable @typescript-eslint/no-empty-function */ + const moduleRef = await Test.createTestingModule({ + imports: [ + TypeOrmModule.forRoot(dbOptions), + AuthModule, + ReservedCommunityTypesModule, + JurisdictionsModule, + ], + }) + .overrideProvider(EmailService) + .useValue(testEmailService) + .compile() + app = moduleRef.createNestApplication() + app = applicationSetup(app) + await app.init() + adminAccesstoken = await getUserAccessToken(app, "admin@example.com", "abcdef") + jurisdictionsRepository = app.get>(getRepositoryToken(Jurisdiction)) + }) + + it(`should return reservedCommunityTypes`, async () => { + const res = await supertest(app.getHttpServer()) + .get(`/reservedCommunityTypes`) + .set(...setAuthorization(adminAccesstoken)) + .expect(200) + expect(Array.isArray(res.body)).toBe(true) + }) + + it(`should create and return a new reserved community type`, async () => { + const jurisdiction = (await jurisdictionsRepository.find())[0] + const res = await supertest(app.getHttpServer()) + .post(`/reservedCommunityTypes`) + .set(...setAuthorization(adminAccesstoken)) + .send({ name: "test", description: "description", jurisdiction }) + .expect(201) + expect(res.body).toHaveProperty("id") + expect(res.body).toHaveProperty("createdAt") + expect(res.body).toHaveProperty("updatedAt") + expect(res.body).toHaveProperty("name") + expect(res.body).toHaveProperty("description") + expect(res.body.name).toBe("test") + expect(res.body.description).toBe("description") + expect(res.body.jurisdiction.id).toBe(jurisdiction.id) + + const getById = await supertest(app.getHttpServer()) + .get(`/reservedCommunityTypes/${res.body.id}`) + .set(...setAuthorization(adminAccesstoken)) + .expect(200) + expect(getById.body.name).toBe("test") + expect(getById.body.description).toBe("description") + }) + + afterEach(() => { + jest.clearAllMocks() + }) + + afterAll(async () => { + await app.close() + }) +}) diff --git a/backend/core/test/unit-accessibility-priority-types/unit-accessibility-priority-types.e2e-spec.ts b/backend/core/test/unit-accessibility-priority-types/unit-accessibility-priority-types.e2e-spec.ts new file mode 100644 index 0000000000..7499111290 --- /dev/null +++ b/backend/core/test/unit-accessibility-priority-types/unit-accessibility-priority-types.e2e-spec.ts @@ -0,0 +1,90 @@ +import { Test } from "@nestjs/testing" +import { INestApplication } from "@nestjs/common" +import { TypeOrmModule } from "@nestjs/typeorm" +import supertest from "supertest" +import { applicationSetup } from "../../src/app.module" +import { AuthModule } from "../../src/auth/auth.module" +import { getUserAccessToken } from "../utils/get-user-access-token" +import { setAuthorization } from "../utils/set-authorization-helper" +// Use require because of the CommonJS/AMD style export. +// See https://www.typescriptlang.org/docs/handbook/modules.html#export--and-import--require +import dbOptions = require("../../ormconfig.test") +import { UnitAccessibilityPriorityTypesModule } from "../../src/unit-accessbility-priority-types/unit-accessibility-priority-types.module" +import { EmailService } from "../../src/email/email.service" + +// Cypress brings in Chai types for the global expect, but we want to use jest +// expect here so we need to re-declare it. +// see: https://github.com/cypress-io/cypress/issues/1319#issuecomment-593500345 +declare const expect: jest.Expect +jest.setTimeout(30000) + +describe("UnitAccessibilityPriorityTypes", () => { + let app: INestApplication + let adminAccesstoken: string + beforeAll(async () => { + /* eslint-disable @typescript-eslint/no-empty-function */ + const testEmailService = { confirmation: async () => {} } + /* eslint-enable @typescript-eslint/no-empty-function */ + const moduleRef = await Test.createTestingModule({ + imports: [TypeOrmModule.forRoot(dbOptions), AuthModule, UnitAccessibilityPriorityTypesModule], + }) + .overrideProvider(EmailService) + .useValue(testEmailService) + .compile() + app = moduleRef.createNestApplication() + app = applicationSetup(app) + await app.init() + adminAccesstoken = await getUserAccessToken(app, "admin@example.com", "abcdef") + }) + + it(`should return unitAccessibilityPriorityTypes`, async () => { + const res = await supertest(app.getHttpServer()) + .get(`/unitAccessibilityPriorityTypes`) + .set(...setAuthorization(adminAccesstoken)) + .expect(200) + expect(Array.isArray(res.body)).toBe(true) + + const unitAccessibilityPriorityTypes = res.body.map( + (unitAccessibilityPriorityTypes) => unitAccessibilityPriorityTypes.name + ) + for (const predefinedUnitAccessibilityPriorityTypes of [ + "Mobility", + "Hearing", + "Visual", + "Hearing and Visual", + "Mobility and Hearing", + "Mobility and Visual", + "Mobility, Hearing and Visual", + ]) { + expect(unitAccessibilityPriorityTypes).toContain(predefinedUnitAccessibilityPriorityTypes) + } + }) + + it(`should create and return a new unit accessibility priority type`, async () => { + const unitAccessibilityPriorityTypesName = "new unit accessibility priority type" + const res = await supertest(app.getHttpServer()) + .post(`/unitAccessibilityPriorityTypes`) + .set(...setAuthorization(adminAccesstoken)) + .send({ name: unitAccessibilityPriorityTypesName }) + .expect(201) + expect(res.body).toHaveProperty("id") + expect(res.body).toHaveProperty("createdAt") + expect(res.body).toHaveProperty("updatedAt") + expect(res.body).toHaveProperty("name") + expect(res.body.name).toBe(unitAccessibilityPriorityTypesName) + + const getById = await supertest(app.getHttpServer()) + .get(`/unitAccessibilityPriorityTypes/${res.body.id}`) + .set(...setAuthorization(adminAccesstoken)) + .expect(200) + expect(getById.body.name).toBe(unitAccessibilityPriorityTypesName) + }) + + afterEach(() => { + jest.clearAllMocks() + }) + + afterAll(async () => { + await app.close() + }) +}) diff --git a/backend/core/test/unit-rent-types/unit-rent-types.e2e-spec.ts b/backend/core/test/unit-rent-types/unit-rent-types.e2e-spec.ts new file mode 100644 index 0000000000..542a43d0b7 --- /dev/null +++ b/backend/core/test/unit-rent-types/unit-rent-types.e2e-spec.ts @@ -0,0 +1,80 @@ +import { Test } from "@nestjs/testing" +import { INestApplication } from "@nestjs/common" +import { TypeOrmModule } from "@nestjs/typeorm" +import supertest from "supertest" +import { applicationSetup } from "../../src/app.module" +import { AuthModule } from "../../src/auth/auth.module" +import { getUserAccessToken } from "../utils/get-user-access-token" +import { setAuthorization } from "../utils/set-authorization-helper" +// Use require because of the CommonJS/AMD style export. +// See https://www.typescriptlang.org/docs/handbook/modules.html#export--and-import--require +import dbOptions = require("../../ormconfig.test") +import { UnitRentTypesModule } from "../../src/unit-rent-types/unit-rent-types.module" +import { EmailService } from "../../src/email/email.service" + +// Cypress brings in Chai types for the global expect, but we want to use jest +// expect here so we need to re-declare it. +// see: https://github.com/cypress-io/cypress/issues/1319#issuecomment-593500345 +declare const expect: jest.Expect +jest.setTimeout(30000) + +describe("UnitRentTypes", () => { + let app: INestApplication + let adminAccesstoken: string + beforeAll(async () => { + /* eslint-disable @typescript-eslint/no-empty-function */ + const testEmailService = { confirmation: async () => {} } + /* eslint-enable @typescript-eslint/no-empty-function */ + const moduleRef = await Test.createTestingModule({ + imports: [TypeOrmModule.forRoot(dbOptions), AuthModule, UnitRentTypesModule], + }) + .overrideProvider(EmailService) + .useValue(testEmailService) + .compile() + app = moduleRef.createNestApplication() + app = applicationSetup(app) + await app.init() + adminAccesstoken = await getUserAccessToken(app, "admin@example.com", "abcdef") + }) + + it(`should return unitRentTypes`, async () => { + const res = await supertest(app.getHttpServer()) + .get(`/unitRentTypes`) + .set(...setAuthorization(adminAccesstoken)) + .expect(200) + expect(Array.isArray(res.body)).toBe(true) + + const unitRentTypes = res.body.map((unitRentTypes) => unitRentTypes.name) + for (const predefinedUnitRentType of ["fixed", "percentageOfIncome"]) { + expect(unitRentTypes).toContain(predefinedUnitRentType) + } + }) + + it(`should create and return a new unit rent type`, async () => { + const unitRentTypesName = "new unit rent type" + const res = await supertest(app.getHttpServer()) + .post(`/unitRentTypes`) + .set(...setAuthorization(adminAccesstoken)) + .send({ name: unitRentTypesName }) + .expect(201) + expect(res.body).toHaveProperty("id") + expect(res.body).toHaveProperty("createdAt") + expect(res.body).toHaveProperty("updatedAt") + expect(res.body).toHaveProperty("name") + expect(res.body.name).toBe(unitRentTypesName) + + const getById = await supertest(app.getHttpServer()) + .get(`/unitRentTypes/${res.body.id}`) + .set(...setAuthorization(adminAccesstoken)) + .expect(200) + expect(getById.body.name).toBe(unitRentTypesName) + }) + + afterEach(() => { + jest.clearAllMocks() + }) + + afterAll(async () => { + await app.close() + }) +}) diff --git a/backend/core/test/unit-types/unit-types.e2e-spec.ts b/backend/core/test/unit-types/unit-types.e2e-spec.ts new file mode 100644 index 0000000000..ceef075136 --- /dev/null +++ b/backend/core/test/unit-types/unit-types.e2e-spec.ts @@ -0,0 +1,90 @@ +import { Test } from "@nestjs/testing" +import { INestApplication } from "@nestjs/common" +import { TypeOrmModule } from "@nestjs/typeorm" +import supertest from "supertest" +import { applicationSetup } from "../../src/app.module" +import { AuthModule } from "../../src/auth/auth.module" +import { getUserAccessToken } from "../utils/get-user-access-token" +import { setAuthorization } from "../utils/set-authorization-helper" +import { UnitTypesModule } from "../../src/unit-types/unit-types.module" +// Use require because of the CommonJS/AMD style export. +// See https://www.typescriptlang.org/docs/handbook/modules.html#export--and-import--require +import dbOptions = require("../../ormconfig.test") +import { EmailService } from "../../src/email/email.service" + +// Cypress brings in Chai types for the global expect, but we want to use jest +// expect here so we need to re-declare it. +// see: https://github.com/cypress-io/cypress/issues/1319#issuecomment-593500345 +declare const expect: jest.Expect +jest.setTimeout(30000) + +describe("UnitTypes", () => { + let app: INestApplication + let adminAccesstoken: string + beforeAll(async () => { + /* eslint-disable @typescript-eslint/no-empty-function */ + const testEmailService = { confirmation: async () => {} } + /* eslint-enable @typescript-eslint/no-empty-function */ + const moduleRef = await Test.createTestingModule({ + imports: [TypeOrmModule.forRoot(dbOptions), AuthModule, UnitTypesModule], + }) + .overrideProvider(EmailService) + .useValue(testEmailService) + .compile() + app = moduleRef.createNestApplication() + app = applicationSetup(app) + await app.init() + adminAccesstoken = await getUserAccessToken(app, "admin@example.com", "abcdef") + }) + + it(`should return unitTypes`, async () => { + const res = await supertest(app.getHttpServer()) + .get(`/unitTypes`) + .set(...setAuthorization(adminAccesstoken)) + .expect(200) + expect(Array.isArray(res.body)).toBe(true) + + const unitTypes = res.body.map((unitType) => unitType.name) + for (const predefinedUnitType of [ + "studio", + "oneBdrm", + "twoBdrm", + "threeBdrm", + "fourBdrm", + "SRO", + ]) { + expect(unitTypes).toContain(predefinedUnitType) + } + }) + + it(`should create and return a new unit type`, async () => { + const unitTypeName = "new unit type" + const numBedrooms = 7 + const res = await supertest(app.getHttpServer()) + .post(`/unitTypes`) + .set(...setAuthorization(adminAccesstoken)) + .send({ name: unitTypeName, numBedrooms: numBedrooms }) + .expect(201) + expect(res.body).toHaveProperty("id") + expect(res.body).toHaveProperty("createdAt") + expect(res.body).toHaveProperty("updatedAt") + expect(res.body).toHaveProperty("name") + expect(res.body.name).toBe(unitTypeName) + expect(res.body).toHaveProperty("numBedrooms") + expect(res.body.numBedrooms).toBe(numBedrooms) + + const getById = await supertest(app.getHttpServer()) + .get(`/unitTypes/${res.body.id}`) + .set(...setAuthorization(adminAccesstoken)) + .expect(200) + expect(getById.body.name).toBe(unitTypeName) + }) + + afterEach(() => { + jest.clearAllMocks() + }) + + afterAll(async () => { + await app.close() + }) +}) diff --git a/backend/core/test/user/user-preferences.e2e-spec.ts b/backend/core/test/user/user-preferences.e2e-spec.ts new file mode 100644 index 0000000000..8286326aed --- /dev/null +++ b/backend/core/test/user/user-preferences.e2e-spec.ts @@ -0,0 +1,156 @@ +import { Test } from "@nestjs/testing" +import { INestApplication } from "@nestjs/common" +import { TypeOrmModule } from "@nestjs/typeorm" +import { applicationSetup } from "../../src/app.module" +import { AuthModule } from "../../src/auth/auth.module" +import { getUserAccessToken } from "../utils/get-user-access-token" + +// Use require because of the CommonJS/AMD style export. +// See https://www.typescriptlang.org/docs/handbook/modules.html#export--and-import--require +import dbOptions = require("../../ormconfig.test") +import supertest from "supertest" +import { setAuthorization } from "../utils/set-authorization-helper" +import { UserService } from "../../src/auth/services/user.service" +import { UserCreateDto } from "../../src/auth/dto/user-create.dto" +import { Listing } from "../../src/listings/entities/listing.entity" +import { Jurisdiction } from "../../src/jurisdictions/entities/jurisdiction.entity" +import { UserPreferencesDto } from "../../src/auth/dto/user-preferences.dto" +import { Language } from "../../src/shared/types/language-enum" +import { User } from "../../src/auth/entities/user.entity" +import { Application } from "../../src/applications/entities/application.entity" +import { EmailService } from "../../src/email/email.service" +import { UserPreferences } from "../../src/auth/entities/user-preferences.entity" + +// Cypress brings in Chai types for the global expect, but we want to use jest +// expect here so we need to re-declare it. +// see: https://github.com/cypress-io/cypress/issues/1319#issuecomment-593500345 +declare const expect: jest.Expect +jest.setTimeout(30000) + +describe("Users", () => { + let app: INestApplication + let userService: UserService + let adminAccessToken: string + + const testEmailService = { + /* eslint-disable @typescript-eslint/no-empty-function */ + confirmation: async () => {}, + welcome: async () => {}, + invite: async () => {}, + changeEmail: async () => {}, + forgotPassword: async () => {}, + /* eslint-enable @typescript-eslint/no-empty-function */ + } + + beforeEach(() => { + jest.clearAllMocks() + }) + + beforeAll(async () => { + const moduleRef = await Test.createTestingModule({ + imports: [ + TypeOrmModule.forRoot(dbOptions), + TypeOrmModule.forFeature([Listing, Jurisdiction, User, Application, UserPreferences]), + AuthModule, + ], + }) + .overrideProvider(EmailService) + .useValue(testEmailService) + .compile() + app = moduleRef.createNestApplication() + app = applicationSetup(app) + await app.init() + userService = await moduleRef.resolve(UserService) + adminAccessToken = await getUserAccessToken(app, "admin@example.com", "abcdef") + }) + + it("should disallow preference changes across users", async () => { + const createAndConfirmUser = async (createDto: UserCreateDto) => { + const userCreateResponse = await supertest(app.getHttpServer()) + .post(`/user/`) + .set("jurisdictionName", "Detroit") + .send(createDto) + .expect(201) + + const userService = await app.resolve(UserService) + const user = await userService.findByEmail(createDto.email) + + await supertest(app.getHttpServer()) + .put(`/user/confirm/`) + .send({ token: user.confirmationToken }) + .expect(200) + + const accessToken = await getUserAccessToken(app, createDto.email, createDto.password) + return { accessToken, userId: userCreateResponse.body.id } + } + + const user1CreateDto: UserCreateDto = { + password: "Abcdef1!", + passwordConfirmation: "Abcdef1!", + email: "user-1@example.com", + emailConfirmation: "user-1@example.com", + firstName: "First", + middleName: "Mid", + lastName: "Last", + dob: new Date(), + language: Language.en, + } + + const user2CreateDto: UserCreateDto = { + password: "Abcdef1!", + passwordConfirmation: "Abcdef1!", + email: "user-2@example.com", + emailConfirmation: "user-2@example.com", + firstName: "First", + middleName: "Mid", + lastName: "Last", + dob: new Date(), + language: Language.en, + } + + const { userId: user1Id, accessToken: user1AccessToken } = await createAndConfirmUser( + user1CreateDto + ) + const { accessToken: user2AccessToken } = await createAndConfirmUser(user2CreateDto) + + const user1ProfileUpdateDto: UserPreferencesDto = { + sendEmailNotifications: false, + sendSmsNotifications: false, + favoriteIds: ["example listing id"], + } + + const user2ProfileUpdateDto: UserPreferencesDto = { + sendEmailNotifications: true, + sendSmsNotifications: true, + favoriteIds: ["example of second listing id"], + } + + const userService = await app.resolve(UserService) + + // let user 1 edit their preferences + await supertest(app.getHttpServer()) + .put(`/userPreferences/${user1Id}`) + .send(user1ProfileUpdateDto) + .set(...setAuthorization(user1AccessToken)) + .expect(200) + + // verify the listing was added as a favorite to user 1 + let user = await userService.findByEmail(user1CreateDto.email) + expect(user.preferences.sendEmailNotifications === false) + expect(user.preferences.sendSmsNotifications === false) + expect(user.preferences.favoriteIds).toEqual(["example listing id"]) + + // Restrict user 2 editing user 1's preferences + await supertest(app.getHttpServer()) + .put(`/userPreferences/${user1Id}`) + .send(user2ProfileUpdateDto) + .set(...setAuthorization(user2AccessToken)) + .expect(403) + + // verify the listing was not added as a favorite user 1 + user = await userService.findByEmail(user1CreateDto.email) + expect(user.preferences.sendEmailNotifications === false) + expect(user.preferences.sendSmsNotifications === false) + expect(user.preferences.favoriteIds).toEqual(["example listing id"]) + }) +}) diff --git a/backend/core/test/user/user.e2e-spec.ts b/backend/core/test/user/user.e2e-spec.ts new file mode 100644 index 0000000000..91ace2bb0d --- /dev/null +++ b/backend/core/test/user/user.e2e-spec.ts @@ -0,0 +1,955 @@ +import { Test } from "@nestjs/testing" +import { INestApplication } from "@nestjs/common" +import { getRepositoryToken, TypeOrmModule } from "@nestjs/typeorm" +import { applicationSetup } from "../../src/app.module" +import { AuthModule } from "../../src/auth/auth.module" +import { getUserAccessToken } from "../utils/get-user-access-token" +import qs from "qs" + +// Use require because of the CommonJS/AMD style export. +// See https://www.typescriptlang.org/docs/handbook/modules.html#export--and-import--require +import dbOptions = require("../../ormconfig.test") +import supertest from "supertest" +import { setAuthorization } from "../utils/set-authorization-helper" +import { UserDto } from "../../src/auth/dto/user.dto" +import { UserService } from "../../src/auth/services/user.service" +import { UserCreateDto } from "../../src/auth/dto/user-create.dto" +import { UserUpdateDto } from "../../src/auth/dto/user-update.dto" +import { UserInviteDto } from "../../src/auth/dto/user-invite.dto" +import { Listing } from "../../src/listings/entities/listing.entity" +import { Repository } from "typeorm" +import { Jurisdiction } from "../../src/jurisdictions/entities/jurisdiction.entity" +import { UserProfileUpdateDto } from "../../src/auth/dto/user-profile.dto" +import { Language } from "../../src/shared/types/language-enum" +import { User } from "../../src/auth/entities/user.entity" +import { EnumUserFilterParamsComparison } from "../../types" +import { getTestAppBody } from "../lib/get-test-app-body" +import { Application } from "../../src/applications/entities/application.entity" +import { UserRoles } from "../../src/auth/entities/user-roles.entity" +import { EmailService } from "../../src/email/email.service" +import { MfaType } from "../../src/auth/types/mfa-type" + +// Cypress brings in Chai types for the global expect, but we want to use jest +// expect here so we need to re-declare it. +// see: https://github.com/cypress-io/cypress/issues/1319#issuecomment-593500345 +declare const expect: jest.Expect +jest.setTimeout(30000) + +describe("Users", () => { + let app: INestApplication + let user1AccessToken: string + let user2AccessToken: string + let user2Profile: UserDto + let listingRepository: Repository+ let applicationsRepository: Repository + let userService: UserService + let jurisdictionsRepository: Repository + let usersRepository: Repository + let adminAccessToken: string + let userAccessToken: string + + const testEmailService = { + /* eslint-disable @typescript-eslint/no-empty-function */ + confirmation: async () => {}, + welcome: async () => {}, + invite: async () => {}, + changeEmail: async () => {}, + forgotPassword: async () => {}, + sendMfaCode: jest.fn(), + /* eslint-enable @typescript-eslint/no-empty-function */ + } + + beforeEach(() => { + jest.resetAllMocks() + }) + + beforeAll(async () => { + const moduleRef = await Test.createTestingModule({ + imports: [ + TypeOrmModule.forRoot(dbOptions), + TypeOrmModule.forFeature([Listing, Jurisdiction, User, Application]), + AuthModule, + ], + }) + .overrideProvider(EmailService) + .useValue(testEmailService) + .compile() + app = moduleRef.createNestApplication() + app = applicationSetup(app) + await app.init() + + user1AccessToken = await getUserAccessToken(app, "test@example.com", "abcdef") + user2AccessToken = await getUserAccessToken(app, "test2@example.com", "ghijkl") + + user2Profile = ( + await supertest(app.getHttpServer()) + .get("/user") + .set(...setAuthorization(user2AccessToken)) + ).body + listingRepository = moduleRef.get>(getRepositoryToken(Listing)) + applicationsRepository = moduleRef.get>(getRepositoryToken(Application)) + jurisdictionsRepository = moduleRef.get>( + getRepositoryToken(Jurisdiction) + ) + usersRepository = moduleRef.get>(getRepositoryToken(User)) + userService = await moduleRef.resolve(UserService) + adminAccessToken = await getUserAccessToken(app, "admin@example.com", "abcdef") + userAccessToken = await getUserAccessToken(app, "test@example.com", "abcdef") + }) + + it("should not allow user to create an account with weak password", async () => { + const userCreateDto: UserCreateDto = { + password: "abcdef", + passwordConfirmation: "abcdef", + email: "abc@b.com", + emailConfirmation: "abc@b.com", + firstName: "First", + middleName: "Mid", + lastName: "Last", + dob: new Date(), + } + await supertest(app.getHttpServer()).post(`/user/`).send(userCreateDto).expect(400) + }) + + it("should not allow user to create an account which is already confirmed nor confirm it using PUT", async () => { + const userCreateDto: UserCreateDto = { + password: "Abcdef1!", + passwordConfirmation: "Abcdef1!", + email: "abc@b.com", + emailConfirmation: "abc@b.com", + firstName: "First", + middleName: "Mid", + lastName: "Last", + dob: new Date(), + confirmedAt: new Date(), + } + await supertest(app.getHttpServer()) + .post(`/user/`) + .set("jurisdictionName", "Alameda") + .send(userCreateDto) + .expect(403) + + delete userCreateDto.confirmedAt + const userCreateResponse = await supertest(app.getHttpServer()) + .post(`/user/`) + .set("jurisdictionName", "Alameda") + .send(userCreateDto) + .expect(201) + + expect(userCreateResponse.body.confirmedAt).toBe(null) + + // Not confirmed user should not be able to log in + await supertest(app.getHttpServer()) + .post("/auth/login") + .send({ email: userCreateDto.email, password: userCreateDto.password }) + .expect(401) + + const userModifyResponse = await supertest(app.getHttpServer()) + .put(`/user/${userCreateResponse.body.id}`) + .set(...setAuthorization(adminAccessToken)) + .send({ + ...userCreateResponse.body, + confirmedAt: new Date(), + }) + .expect(200) + + expect(userModifyResponse.body.confirmedAt).toBeDefined() + + const userLoginResponse = await supertest(app.getHttpServer()) + .post("/auth/login") + .send({ email: userCreateDto.email, password: userCreateDto.password }) + .expect(201) + + await supertest(app.getHttpServer()) + .put(`/user/${userCreateResponse.body.id}`) + .send({ + ...userCreateResponse.body, + confirmedAt: new Date(), + }) + .set(...setAuthorization(userLoginResponse.body.accessToken)) + .expect(403) + }) + + it("should allow anonymous user to create an account", async () => { + const userCreateDto: UserCreateDto = { + password: "Abcdef1!", + passwordConfirmation: "Abcdef1!", + email: "a@b.com", + emailConfirmation: "a@b.com", + firstName: "First", + middleName: "Mid", + lastName: "Last", + dob: new Date(), + } + const mockWelcome = jest.spyOn(testEmailService, "welcome") + const res = await supertest(app.getHttpServer()) + .post(`/user`) + .set("jurisdictionName", "Alameda") + .send(userCreateDto) + expect(mockWelcome.mock.calls.length).toBe(1) + expect(res.body).toHaveProperty("id") + expect(res.body).not.toHaveProperty("passwordHash") + expect(res.body).toHaveProperty("passwordUpdatedAt") + expect(res.body).toHaveProperty("passwordValidForDays") + expect(res.body.passwordValidForDays).toBe(180) + }) + + it("should not allow user to sign in before confirming the account", async () => { + const userCreateDto: UserCreateDto = { + password: "Abcdef1!", + passwordConfirmation: "Abcdef1!", + email: "a1@b.com", + emailConfirmation: "a1@b.com", + firstName: "First", + middleName: "Mid", + lastName: "Last", + dob: new Date(), + } + await supertest(app.getHttpServer()) + .post(`/user/`) + .set("jurisdictionName", "Alameda") + .send(userCreateDto) + .expect(201) + await supertest(app.getHttpServer()) + .post("/auth/login") + .send({ email: userCreateDto.email, password: userCreateDto.password }) + .expect(401) + + const userService = await app.resolve(UserService) + const user = await userService.findByEmail(userCreateDto.email) + + await supertest(app.getHttpServer()) + .put(`/user/confirm/`) + .send({ token: user.confirmationToken }) + .expect(200) + await getUserAccessToken(app, userCreateDto.email, userCreateDto.password) + }) + + it("should not allow user to create an account without matching confirmation", async () => { + const userCreateDto: UserCreateDto = { + password: "Abcdef1!", + passwordConfirmation: "abcdef2", + email: "a2@b.com", + emailConfirmation: "a2@b.com", + firstName: "First", + middleName: "Mid", + lastName: "Last", + dob: new Date(), + } + await supertest(app.getHttpServer()).post(`/user/`).send(userCreateDto).expect(400) + userCreateDto.passwordConfirmation = "Abcdef1!" + userCreateDto.emailConfirmation = "a1@b.com" + await supertest(app.getHttpServer()).post(`/user/`).send(userCreateDto).expect(400) + userCreateDto.emailConfirmation = "a2@b.com" + await supertest(app.getHttpServer()) + .post(`/user/`) + .set("jurisdictionName", "Alameda") + .send(userCreateDto) + .expect(201) + }) + + it("should not allow to create a new account with duplicate email", async () => { + const userCreateDto: UserCreateDto = { + password: "Abcdef1!", + passwordConfirmation: "Abcdef1!", + email: "b@c.com", + emailConfirmation: "b@c.com", + firstName: "First", + middleName: "Mid", + lastName: "Last", + dob: new Date(), + } + const res = await supertest(app.getHttpServer()) + .post(`/user`) + .set("jurisdictionName", "Alameda") + .send(userCreateDto) + .expect(201) + expect(res.body).toHaveProperty("id") + await supertest(app.getHttpServer()) + .post(`/user/`) + .set("jurisdictionName", "Alameda") + .send(userCreateDto) + expect(res.body).toHaveProperty("id") + }) + + it("should not allow user/anonymous to modify other existing user's data", async () => { + const user2UpdateDto: UserUpdateDto = { + id: user2Profile.id, + dob: new Date(), + firstName: "First", + lastName: "Last", + email: "test2@example.com", + jurisdictions: user2Profile.jurisdictions.map((jurisdiction) => ({ + id: jurisdiction.id, + })), + } + await supertest(app.getHttpServer()) + .put(`/user/${user2UpdateDto.id}`) + .send(user2UpdateDto) + .set(...setAuthorization(user1AccessToken)) + .expect(403) + await supertest(app.getHttpServer()) + .put(`/user/${user2UpdateDto.id}`) + .send(user2UpdateDto) + .expect(401) + }) + + it("should allow user to resend confirmation", async () => { + const userCreateDto: UserCreateDto = { + password: "Abcdef1!", + passwordConfirmation: "Abcdef1!", + email: "b1@b.com", + emailConfirmation: "b1@b.com", + firstName: "First", + middleName: "Mid", + lastName: "Last", + dob: new Date(), + } + await supertest(app.getHttpServer()) + .post(`/user/`) + .set("jurisdictionName", "Alameda") + .send(userCreateDto) + .expect(201) + await supertest(app.getHttpServer()) + .post("/user/resend-confirmation") + .send({ email: userCreateDto.email }) + .expect(201) + }) + + it("should not allow user to resend confirmation if account is confirmed", async () => { + const userCreateDto: UserCreateDto = { + password: "Abcdef1!", + passwordConfirmation: "Abcdef1!", + email: "b2@b.com", + emailConfirmation: "b2@b.com", + firstName: "First", + middleName: "Mid", + lastName: "Last", + dob: new Date(), + } + await supertest(app.getHttpServer()) + .post(`/user/`) + .set("jurisdictionName", "Alameda") + .send(userCreateDto) + .expect(201) + const userService = await app.resolve(UserService) + const user = await userService.findByEmail(userCreateDto.email) + + await supertest(app.getHttpServer()) + .put(`/user/confirm/`) + .send({ token: user.confirmationToken }) + .expect(200) + await supertest(app.getHttpServer()) + .post("/user/resend-confirmation") + .send({ email: userCreateDto.email }) + .expect(406) + }) + + it("should return 404 if there is no user to resend confirmation to", async () => { + await supertest(app.getHttpServer()) + .post("/user/resend-confirmation") + .send({ email: "unknown@email.com" }) + .expect(404) + }) + + it("should not send confirmation email when noConfirmationEmail query param is specified", async () => { + const userCreateDto: UserCreateDto = { + password: "Abcdef1!", + passwordConfirmation: "Abcdef1!", + email: "b3@b.com", + emailConfirmation: "b3@b.com", + firstName: "First", + middleName: "Mid", + lastName: "Last", + dob: new Date(), + } + const mockWelcome = jest.spyOn(testEmailService, "welcome") + await supertest(app.getHttpServer()) + .post(`/user?noWelcomeEmail=true`) + .set("jurisdictionName", "Alameda") + .send(userCreateDto) + .expect(201) + expect(mockWelcome.mock.calls.length).toBe(0) + }) + + it("should invite a user to the partners portal", async () => { + const listing = (await listingRepository.find({ take: 1 }))[0] + const jurisdiction = (await jurisdictionsRepository.find({ take: 1 }))[0] + const userInviteDto: UserInviteDto = { + email: "b4@b.com", + firstName: "First", + middleName: "Partner", + lastName: "Partner", + dob: new Date(), + leasingAgentInListings: [{ id: listing.id }], + roles: { isPartner: true }, + jurisdictions: [{ id: jurisdiction.id }], + } + + const mockInvite = jest.spyOn(testEmailService, "invite") + + await supertest(app.getHttpServer()) + .post(`/user/invite`) + .set(...setAuthorization(userAccessToken)) + .send(userInviteDto) + .expect(403) + + const response = await supertest(app.getHttpServer()) + .post(`/user/invite`) + .send(userInviteDto) + .set(...setAuthorization(adminAccessToken)) + .expect(201) + + const newUser = response.body + + expect(newUser.roles.isPartner).toBe(true) + expect(newUser.roles.isAdmin).toBe(false) + expect(newUser.leasingAgentInListings.length).toBe(1) + expect(newUser.leasingAgentInListings[0].id).toBe(listing.id) + expect(mockInvite.mock.calls.length).toBe(1) + + const userService = await app.resolve(UserService) + const user = await userService.findByEmail(newUser.email) + user.mfaEnabled = false + await usersRepository.save(user) + + const password = "Abcdef1!" + await supertest(app.getHttpServer()) + .put(`/user/confirm/`) + .send({ token: user.confirmationToken, password }) + .expect(200) + const token = await getUserAccessToken(app, newUser.email, password) + expect(token).toBeDefined() + }) + + it("should allow user to update user profile through PUT /userProfile/:id endpoint", async () => { + const userCreateDto: UserCreateDto = { + password: "Abcdef1!", + passwordConfirmation: "Abcdef1!", + email: "userProfile@b.com", + emailConfirmation: "userProfile@b.com", + firstName: "First", + middleName: "Mid", + lastName: "Last", + dob: new Date(), + language: Language.en, + } + + const userCreateResponse = await supertest(app.getHttpServer()) + .post(`/user/`) + .set("jurisdictionName", "Alameda") + .send(userCreateDto) + .expect(201) + + const userService = await app.resolve(UserService) + const user = await userService.findByEmail(userCreateDto.email) + + await supertest(app.getHttpServer()) + .put(`/user/confirm/`) + .send({ token: user.confirmationToken }) + .expect(200) + + const userAccessToken = await getUserAccessToken( + app, + userCreateDto.email, + userCreateDto.password + ) + + const userProfileUpdateDto: UserProfileUpdateDto = { + id: userCreateResponse.body.id, + createdAt: userCreateResponse.body.createdAt, + updatedAt: userCreateResponse.body.updatedAt, + jurisdictions: userCreateResponse.body.jurisdictions, + ...userCreateDto, + currentPassword: userCreateDto.password, + firstName: "NewFirstName", + phoneNumber: "+12025550194", + } + + await supertest(app.getHttpServer()) + .put(`/userProfile/${userCreateResponse.body.id}`) + .send(userProfileUpdateDto) + .expect(401) + + const userProfileUpdateResponse = await supertest(app.getHttpServer()) + .put(`/userProfile/${userCreateResponse.body.id}`) + .send(userProfileUpdateDto) + .set(...setAuthorization(userAccessToken)) + .expect(200) + expect(userProfileUpdateResponse.body.firstName).toBe(userProfileUpdateDto.firstName) + }) + + it("should not allow user A to edit user B profile (with /userProfile)", async () => { + const createAndConfirmUser = async (createDto: UserCreateDto) => { + const userCreateResponse = await supertest(app.getHttpServer()) + .post(`/user/`) + .set("jurisdictionName", "Alameda") + .send(createDto) + .expect(201) + + const userService = await app.resolve(UserService) + const user = await userService.findByEmail(createDto.email) + + await supertest(app.getHttpServer()) + .put(`/user/confirm/`) + .send({ token: user.confirmationToken }) + .expect(200) + + const accessToken = await getUserAccessToken(app, createDto.email, createDto.password) + return { accessToken, userId: userCreateResponse.body.id } + } + + const userACreateDto: UserCreateDto = { + password: "Abcdef1!", + passwordConfirmation: "Abcdef1!", + email: "user-a@example.com", + emailConfirmation: "user-a@example.com", + firstName: "First", + middleName: "Mid", + lastName: "Last", + dob: new Date(), + language: Language.en, + } + + const userBCreateDto: UserCreateDto = { + password: "Abcdef1!", + passwordConfirmation: "Abcdef1!", + email: "user-b@example.com", + emailConfirmation: "user-b@example.com", + firstName: "First", + middleName: "Mid", + lastName: "Last", + dob: new Date(), + language: Language.en, + } + + const { userId: userAId } = await createAndConfirmUser(userACreateDto) + const { accessToken: userBAccessToken } = await createAndConfirmUser(userBCreateDto) + + const userAProfileUpdateDto: UserProfileUpdateDto = { + id: userAId, + createdAt: new Date(), + updatedAt: new Date(), + ...userACreateDto, + password: undefined, + jurisdictions: [], + } + + // Restrict user B editing user A's profile + await supertest(app.getHttpServer()) + .put(`/userProfile/${userAId}`) + .send(userAProfileUpdateDto) + .set(...setAuthorization(userBAccessToken)) + .expect(403) + + // Allow admin to edit userA + await supertest(app.getHttpServer()) + .put(`/userProfile/${userAId}`) + .send(userAProfileUpdateDto) + .set(...setAuthorization(adminAccessToken)) + .expect(200) + }) + + it("should allow filtering by isPartner user role", async () => { + const user = await userService._createUser( + { + dob: new Date(), + email: "michalp@airnauts.com", + firstName: "Michal", + jurisdictions: [], + language: Language.en, + lastName: "", + middleName: "", + roles: { isPartner: true, isAdmin: false }, + updatedAt: undefined, + passwordHash: "abcd", + mfaEnabled: false, + }, + null + ) + + const filters = [ + { + isPartner: true, + $comparison: EnumUserFilterParamsComparison["="], + }, + ] + + const res = await supertest(app.getHttpServer()) + .get(`/user/list/?${qs.stringify({ filter: filters })}`) + .set(...setAuthorization(adminAccessToken)) + .expect(200) + + expect(res.body.items.map((user) => user.id).includes(user.id)).toBe(true) + expect(res.body.items.map((user) => user.roles.isPartner).some((isPartner) => !isPartner)).toBe( + false + ) + expect(res.body.items.map((user) => user.roles.isPartner).every((isPartner) => isPartner)).toBe( + true + ) + }) + + it("should get and delete a user by ID", async () => { + const user = await userService._createUser( + { + dob: new Date(), + email: "test+1@test.com", + firstName: "test", + jurisdictions: [], + language: Language.en, + lastName: "", + middleName: "", + roles: { isPartner: true, isAdmin: false }, + updatedAt: undefined, + passwordHash: "abcd", + mfaEnabled: false, + }, + null + ) + + const res = await supertest(app.getHttpServer()) + .get(`/user/${user.id}`) + .set(...setAuthorization(adminAccessToken)) + .expect(200) + expect(res.body.id).toBe(user.id) + expect(res.body.email).toBe(user.email) + + await supertest(app.getHttpServer()) + .delete(`/user/${user.id}`) + .set(...setAuthorization(adminAccessToken)) + .expect(200) + + await supertest(app.getHttpServer()) + .get(`/user/${user.id}`) + .set(...setAuthorization(adminAccessToken)) + .expect(404) + }) + + it("should create and delete a user with existing application by ID", async () => { + const listing = (await listingRepository.find({ take: 1 }))[0] + const user = await userService._createUser( + { + dob: new Date(), + email: "test+1@test.com", + firstName: "test", + jurisdictions: [], + language: Language.en, + lastName: "", + middleName: "", + roles: { isPartner: true, isAdmin: false }, + updatedAt: undefined, + passwordHash: "abcd", + mfaEnabled: false, + }, + null + ) + const applicationUpdate = getTestAppBody(listing.id) + const newApp = await applicationsRepository.save({ + ...applicationUpdate, + user, + confirmationCode: "abcdefgh", + }) + + await supertest(app.getHttpServer()) + .delete(`/user/${user.id}`) + .set(...setAuthorization(adminAccessToken)) + .expect(200) + + const application = await applicationsRepository.findOneOrFail({ + where: { id: (newApp as Application).id }, + relations: ["user"], + }) + + expect(application.user).toBe(null) + }) + + it("should lower case email of new user", async () => { + const userCreateDto: UserCreateDto = { + password: "Abcdef1!", + passwordConfirmation: "Abcdef1!", + email: "TestingLowerCasing@LowerCasing.com", + emailConfirmation: "TestingLowerCasing@LowerCasing.com", + firstName: "First", + middleName: "Mid", + lastName: "Last", + dob: new Date(), + } + const res = await supertest(app.getHttpServer()) + .post(`/user`) + .set("jurisdictionName", "Alameda") + .send(userCreateDto) + expect(res.body).toHaveProperty("id") + expect(res.body).not.toHaveProperty("passwordHash") + expect(res.body).toHaveProperty("email") + expect(res.body.email).toBe("testinglowercasing@lowercasing.com") + + const confirmation = await supertest(app.getHttpServer()) + .put(`/user/${res.body.id}`) + .set(...setAuthorization(adminAccessToken)) + .send({ + ...res.body, + confirmedAt: new Date(), + }) + .expect(200) + + expect(confirmation.body.confirmedAt).toBeDefined() + + await supertest(app.getHttpServer()) + .post("/auth/login") + .send({ email: userCreateDto.email.toLowerCase(), password: userCreateDto.password }) + .expect(201) + }) + + it("should change an email with confirmation flow", async () => { + const userCreateDto: UserCreateDto = { + password: "Abcdef1!", + passwordConfirmation: "Abcdef1!", + email: "confirm@confirm.com", + emailConfirmation: "confirm@confirm.com", + firstName: "First", + middleName: "Mid", + lastName: "Last", + dob: new Date(), + } + + const res = await supertest(app.getHttpServer()) + .post(`/user/`) + .set("jurisdictionName", "Alameda") + .send(userCreateDto) + .expect(201) + + const userService = await app.resolve(UserService) + let user = await userService.findByEmail(userCreateDto.email) + + await supertest(app.getHttpServer()) + .put(`/user/confirm/`) + .send({ token: user.confirmationToken }) + .expect(200) + const userAccessToken = await getUserAccessToken( + app, + userCreateDto.email, + userCreateDto.password + ) + + const newEmail = "test+confirm@example.com" + await supertest(app.getHttpServer()) + .put(`/userProfile/${user.id}`) + .send({ ...res.body, newEmail, appUrl: "http://localhost" }) + .set(...setAuthorization(userAccessToken)) + .expect(200) + + // User should still be able to log in with the old email + await getUserAccessToken(app, userCreateDto.email, userCreateDto.password) + + user = await userService.findByEmail(userCreateDto.email) + await supertest(app.getHttpServer()) + .put(`/user/confirm/`) + .send({ token: user.confirmationToken }) + .expect(200) + + await getUserAccessToken(app, newEmail, userCreateDto.password) + }) + + it("should allow filtering by isPortalUser", async () => { + const usersRepository = app.get>(getRepositoryToken(User)) + + const totalUsersCount = await usersRepository.count() + + const allUsersListRes = await supertest(app.getHttpServer()) + .get(`/user/list`) + .set(...setAuthorization(adminAccessToken)) + .expect(200) + expect(allUsersListRes.body.meta.totalItems).toBe(totalUsersCount) + + const portalUsersFilter = [ + { + isPortalUser: true, + $comparison: EnumUserFilterParamsComparison["NA"], + }, + ] + const portalUsersListRes = await supertest(app.getHttpServer()) + .get(`/user/list?${qs.stringify({ filter: portalUsersFilter })}&limit=200`) + .set(...setAuthorization(adminAccessToken)) + .expect(200) + expect( + portalUsersListRes.body.items.every( + (user: UserDto) => user.roles.isAdmin || user.roles.isPartner + ) + ) + expect(portalUsersListRes.body.meta.totalItems).toBeLessThan(totalUsersCount) + + const nonPortalUsersFilter = [ + { + isPortalUser: false, + $comparison: EnumUserFilterParamsComparison["NA"], + }, + ] + const nonPortalUsersListRes = await supertest(app.getHttpServer()) + .get(`/user/list?${qs.stringify({ filter: nonPortalUsersFilter })}&limit=200`) + .set(...setAuthorization(adminAccessToken)) + .expect(200) + expect( + nonPortalUsersListRes.body.items.every( + (user: UserDto) => !!user.roles?.isAdmin && !!user.roles?.isPartner + ) + ) + expect(nonPortalUsersListRes.body.meta.totalItems).toBeLessThan(totalUsersCount) + }) + + it("should require mfa code for users with mfa enabled", async () => { + const userCreateDto: UserCreateDto = { + password: "Abcdef1!", + passwordConfirmation: "Abcdef1!", + email: "mfa@b.com", + emailConfirmation: "mfa@b.com", + firstName: "First", + middleName: "Mid", + lastName: "Last", + dob: new Date(), + } + + await supertest(app.getHttpServer()) + .post(`/user/`) + .set("jurisdictionName", "Alameda") + .send(userCreateDto) + .expect(201) + + let user = await usersRepository.findOne({ email: userCreateDto.email }) + user.mfaEnabled = true + user = await usersRepository.save(user) + + await supertest(app.getHttpServer()) + .put(`/user/confirm/`) + .send({ token: user.confirmationToken }) + .expect(200) + + testEmailService.sendMfaCode = jest.fn() + + let getMfaInfoResponse = await supertest(app.getHttpServer()) + .post(`/auth/mfa-info`) + .send({ + email: userCreateDto.email, + password: userCreateDto.password, + }) + .expect(201) + + expect(getMfaInfoResponse.body.maskedPhoneNumber).toBeUndefined() + expect(getMfaInfoResponse.body.email).toBe(userCreateDto.email) + expect(getMfaInfoResponse.body.isMfaEnabled).toBe(true) + expect(getMfaInfoResponse.body.mfaUsedInThePast).toBe(false) + + await supertest(app.getHttpServer()) + .post(`/auth/request-mfa-code`) + .send({ + email: userCreateDto.email, + password: userCreateDto.password, + mfaType: MfaType.email, + }) + .expect(201) + + user = await usersRepository.findOne({ email: userCreateDto.email }) + expect(typeof user.mfaCode).toBe("string") + expect(user.mfaCodeUpdatedAt).toBeDefined() + expect(testEmailService.sendMfaCode).toBeCalled() + expect(testEmailService.sendMfaCode.mock.calls[0][2]).toBe(user.mfaCode) + + await supertest(app.getHttpServer()) + .post(`/auth/login`) + .send({ + email: userCreateDto.email, + password: userCreateDto.password, + mfaCode: user.mfaCode, + }) + .expect(201) + + getMfaInfoResponse = await supertest(app.getHttpServer()) + .post(`/auth/mfa-info`) + .send({ + email: userCreateDto.email, + password: userCreateDto.password, + }) + .expect(201) + expect(getMfaInfoResponse.body.mfaUsedInThePast).toBe(true) + }) + + it("should prevent user access if password is outdated", async () => { + const userCreateDto: UserCreateDto = { + password: "Abcdef1!", + passwordConfirmation: "Abcdef1!", + email: "password-outdated@b.com", + emailConfirmation: "password-outdated@b.com", + firstName: "First", + middleName: "Mid", + lastName: "Last", + dob: new Date(), + } + + await supertest(app.getHttpServer()) + .post(`/user/`) + .set("jurisdictionName", "Alameda") + .send(userCreateDto) + .expect(201) + await supertest(app.getHttpServer()) + .post("/auth/login") + .send({ email: userCreateDto.email, password: userCreateDto.password }) + .expect(401) + + const userService = await app.resolve(UserService) + let user = await userService.findByEmail(userCreateDto.email) + + await supertest(app.getHttpServer()) + .put(`/user/confirm/`) + .send({ token: user.confirmationToken }) + .expect(200) + + const userAccessToken = await getUserAccessToken( + app, + userCreateDto.email, + userCreateDto.password + ) + + // User should be able to fetch it's own profile now + await supertest(app.getHttpServer()) + .get(`/user/${user.id}`) + .set(...setAuthorization(userAccessToken)) + .expect(200) + + // Put password updated at date 190 days in the past + user = await userService.findByEmail(userCreateDto.email) + user.roles = { isAdmin: true, isPartner: false } as UserRoles + user.passwordUpdatedAt = new Date(user.passwordUpdatedAt.getTime() - 190 * 24 * 60 * 60 * 1000) + + await usersRepository.save(user) + + // Confirm that both login and using existing access tokens stopped authenticating + await supertest(app.getHttpServer()) + .get(`/user/${user.id}`) + .set(...setAuthorization(userAccessToken)) + .expect(401) + + await supertest(app.getHttpServer()) + .post("/auth/login") + .send({ email: userCreateDto.email, password: userCreateDto.password }) + .expect(401) + + // Start password reset flow + await supertest(app.getHttpServer()) + .put(`/user/forgot-password`) + .send({ email: user.email }) + .expect(200) + + user = await usersRepository.findOne({ email: user.email }) + + const newPassword = "Abcefghjijk90!" + await supertest(app.getHttpServer()) + .put(`/user/update-password`) + .send({ token: user.resetToken, password: newPassword, passwordConfirmation: newPassword }) + .expect(200) + + // Confirm that login works again (passwordUpdateAt timestamp has been refreshed) + await supertest(app.getHttpServer()) + .post("/auth/login") + .send({ email: userCreateDto.email, password: newPassword }) + .expect(201) + }) +}) diff --git a/backend/core/test/utils/clearDb.ts b/backend/core/test/utils/clearDb.ts new file mode 100644 index 0000000000..e45f64629b --- /dev/null +++ b/backend/core/test/utils/clearDb.ts @@ -0,0 +1,8 @@ +export const clearDb = async (connection) => { + const entities = connection.entityMetadatas + + for (const entity of entities) { + const repository = await connection.getRepository(entity.name) + await repository.query(`DELETE FROM "${entity.tableName}";`) + } +} diff --git a/backend/core/test/utils/get-user-access-token.ts b/backend/core/test/utils/get-user-access-token.ts new file mode 100644 index 0000000000..607c8e39ba --- /dev/null +++ b/backend/core/test/utils/get-user-access-token.ts @@ -0,0 +1,9 @@ +import supertest from "supertest" + +export const getUserAccessToken = async (app, email, password): Promise => { + const res = await supertest(app.getHttpServer()) + .post("/auth/login") + .send({ email, password }) + .expect(201) + return res.body.accessToken +} diff --git a/backend/core/test/utils/set-authorization-helper.ts b/backend/core/test/utils/set-authorization-helper.ts new file mode 100644 index 0000000000..b96e8d0d79 --- /dev/null +++ b/backend/core/test/utils/set-authorization-helper.ts @@ -0,0 +1,3 @@ +export const setAuthorization = (accessToken: string): [string, string] => { + return ["Authorization", `Bearer ${accessToken}`] +} diff --git a/backend/core/tsconfig.build.json b/backend/core/tsconfig.build.json new file mode 100644 index 0000000000..765d0bfc14 --- /dev/null +++ b/backend/core/tsconfig.build.json @@ -0,0 +1,11 @@ +{ + "extends": "./tsconfig.json", + "exclude": [ + "node_modules", + "test", + "__tests__", + "dist", + "**/*spec.ts", + "generate-axios-client.ts" + ] +} diff --git a/backend/core/tsconfig.json b/backend/core/tsconfig.json new file mode 100644 index 0000000000..d065734dc6 --- /dev/null +++ b/backend/core/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "module": "commonjs", + "declaration": true, + "removeComments": true, + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "target": "es2017", + "sourceMap": true, + "outDir": "./dist", + "baseUrl": "./", + "incremental": true, + "esModuleInterop": true, + "resolveJsonModule": true, + "isolatedModules": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true, + "strict": false + }, + "exclude": ["node_modules", "dist"] +} diff --git a/backend/core/types/index.ts b/backend/core/types/index.ts new file mode 100644 index 0000000000..dda58a886b --- /dev/null +++ b/backend/core/types/index.ts @@ -0,0 +1,5 @@ +export * from "./src/HousingCounselors" +export * from "./src/backend-swagger" +export * from "./src/archer-listing" +export * from "./src/Member" +export * from "./src/filter-keys" diff --git a/backend/core/types/src/HousingCounselors.ts b/backend/core/types/src/HousingCounselors.ts new file mode 100644 index 0000000000..e681438b99 --- /dev/null +++ b/backend/core/types/src/HousingCounselors.ts @@ -0,0 +1,8 @@ +export interface HousingCounselor { + name: string + languages: string[] + address?: string + citystate?: string + phone?: string + website?: string +} diff --git a/backend/core/types/src/Member.ts b/backend/core/types/src/Member.ts new file mode 100644 index 0000000000..b12d9b9c1c --- /dev/null +++ b/backend/core/types/src/Member.ts @@ -0,0 +1,46 @@ +import { HouseholdMemberUpdate } from "./backend-swagger" + +export class Member implements HouseholdMemberUpdate { + id: string + orderId = undefined as number | undefined + firstName = "" + middleName = "" + lastName = "" + birthMonth = undefined + birthDay = undefined + birthYear = undefined + emailAddress = "" + noEmail = undefined + phoneNumber = "" + phoneNumberType = "" + noPhone = undefined + + constructor(orderId: number) { + this.orderId = orderId + } + address = { + placeName: undefined, + city: "", + county: "", + state: "", + street: "", + street2: "", + zipCode: "", + latitude: undefined, + longitude: undefined, + } + workAddress = { + placeName: undefined, + city: "", + county: "", + state: "", + street: "", + street2: "", + zipCode: "", + latitude: undefined, + longitude: undefined, + } + sameAddress?: string + relationship?: string + workInRegion?: string +} diff --git a/backend/core/types/src/archer-listing.ts b/backend/core/types/src/archer-listing.ts new file mode 100644 index 0000000000..6660e08c9b --- /dev/null +++ b/backend/core/types/src/archer-listing.ts @@ -0,0 +1,1645 @@ +import { + AmiChart, + EnumJurisdictionLanguages, + Listing, + ListingStatus, + UnitStatus, +} from "./backend-swagger" + +const amiChart: AmiChart = { + id: "somerandomid", + createdAt: new Date(), + updatedAt: new Date(), + name: "San Jose TCAC 2019", + jurisdiction: { + id: "jurisdiction_id", + createdAt: new Date(), + updatedAt: new Date(), + name: "Alameda", + emailFromAddress: "Alameda Housing Email", + programs: [], + languages: [EnumJurisdictionLanguages.en], + preferences: [], + publicUrl: "", + }, + items: [ + { + percentOfAmi: 120, + householdSize: 1, + income: 110400, + }, + { + percentOfAmi: 120, + householdSize: 2, + income: 126150, + }, + { + percentOfAmi: 120, + householdSize: 3, + income: 141950, + }, + { + percentOfAmi: 120, + householdSize: 4, + income: 157700, + }, + { + percentOfAmi: 120, + householdSize: 5, + income: 170300, + }, + { + percentOfAmi: 120, + householdSize: 6, + income: 182950, + }, + { + percentOfAmi: 120, + householdSize: 7, + income: 195550, + }, + { + percentOfAmi: 120, + householdSize: 8, + income: 208150, + }, + { + percentOfAmi: 110, + householdSize: 1, + income: 101200, + }, + { + percentOfAmi: 110, + householdSize: 2, + income: 115610, + }, + { + percentOfAmi: 110, + householdSize: 3, + income: 130075, + }, + { + percentOfAmi: 110, + householdSize: 4, + income: 144540, + }, + { + percentOfAmi: 110, + householdSize: 5, + income: 156090, + }, + { + percentOfAmi: 110, + householdSize: 6, + income: 167640, + }, + { + percentOfAmi: 110, + householdSize: 7, + income: 179245, + }, + { + percentOfAmi: 110, + householdSize: 8, + income: 190795, + }, + { + percentOfAmi: 100, + householdSize: 1, + income: 92000, + }, + { + percentOfAmi: 100, + householdSize: 2, + income: 105100, + }, + { + percentOfAmi: 100, + householdSize: 3, + income: 118250, + }, + { + percentOfAmi: 100, + householdSize: 4, + income: 131400, + }, + { + percentOfAmi: 100, + householdSize: 5, + income: 141900, + }, + { + percentOfAmi: 100, + householdSize: 6, + income: 152400, + }, + { + percentOfAmi: 100, + householdSize: 7, + income: 162950, + }, + { + percentOfAmi: 100, + householdSize: 8, + income: 173450, + }, + { + percentOfAmi: 80, + householdSize: 1, + income: 72750, + }, + { + percentOfAmi: 80, + householdSize: 2, + income: 83150, + }, + { + percentOfAmi: 80, + householdSize: 3, + income: 93550, + }, + { + percentOfAmi: 80, + householdSize: 4, + income: 103900, + }, + { + percentOfAmi: 80, + householdSize: 5, + income: 112250, + }, + { + percentOfAmi: 80, + householdSize: 6, + income: 120550, + }, + { + percentOfAmi: 80, + householdSize: 7, + income: 128850, + }, + { + percentOfAmi: 80, + householdSize: 8, + income: 137150, + }, + { + percentOfAmi: 60, + householdSize: 1, + income: 61500, + }, + { + percentOfAmi: 60, + householdSize: 2, + income: 70260, + }, + { + percentOfAmi: 60, + householdSize: 3, + income: 79020, + }, + { + percentOfAmi: 60, + householdSize: 4, + income: 87780, + }, + { + percentOfAmi: 60, + householdSize: 5, + income: 94860, + }, + { + percentOfAmi: 60, + householdSize: 6, + income: 101880, + }, + { + percentOfAmi: 60, + householdSize: 7, + income: 108900, + }, + { + percentOfAmi: 60, + householdSize: 8, + income: 115920, + }, + { + percentOfAmi: 55, + householdSize: 1, + income: 56375, + }, + { + percentOfAmi: 55, + householdSize: 2, + income: 64405, + }, + { + percentOfAmi: 55, + householdSize: 3, + income: 72435, + }, + { + percentOfAmi: 55, + householdSize: 4, + income: 80465, + }, + { + percentOfAmi: 55, + householdSize: 5, + income: 86955, + }, + { + percentOfAmi: 55, + householdSize: 6, + income: 93390, + }, + { + percentOfAmi: 55, + householdSize: 7, + income: 99825, + }, + { + percentOfAmi: 55, + householdSize: 8, + income: 106260, + }, + { + percentOfAmi: 50, + householdSize: 1, + income: 51250, + }, + { + percentOfAmi: 50, + householdSize: 2, + income: 58550, + }, + { + percentOfAmi: 50, + householdSize: 3, + income: 65850, + }, + { + percentOfAmi: 50, + householdSize: 4, + income: 73150, + }, + { + percentOfAmi: 50, + householdSize: 5, + income: 79050, + }, + { + percentOfAmi: 50, + householdSize: 6, + income: 84900, + }, + { + percentOfAmi: 50, + householdSize: 7, + income: 90750, + }, + { + percentOfAmi: 50, + householdSize: 8, + income: 96600, + }, + { + percentOfAmi: 45, + householdSize: 1, + income: 46125, + }, + { + percentOfAmi: 45, + householdSize: 2, + income: 52695, + }, + { + percentOfAmi: 45, + householdSize: 3, + income: 59265, + }, + { + percentOfAmi: 45, + householdSize: 4, + income: 65835, + }, + { + percentOfAmi: 45, + householdSize: 5, + income: 71145, + }, + { + percentOfAmi: 45, + householdSize: 6, + income: 76410, + }, + { + percentOfAmi: 45, + householdSize: 7, + income: 81675, + }, + { + percentOfAmi: 40, + householdSize: 1, + income: 41000, + }, + { + percentOfAmi: 40, + householdSize: 2, + income: 46840, + }, + { + percentOfAmi: 40, + householdSize: 3, + income: 52680, + }, + { + percentOfAmi: 40, + householdSize: 4, + income: 58520, + }, + { + percentOfAmi: 40, + householdSize: 5, + income: 63240, + }, + { + percentOfAmi: 40, + householdSize: 6, + income: 67920, + }, + { + percentOfAmi: 40, + householdSize: 7, + income: 72600, + }, + { + percentOfAmi: 40, + householdSize: 8, + income: 77280, + }, + { + percentOfAmi: 35, + householdSize: 1, + income: 35875, + }, + { + percentOfAmi: 35, + householdSize: 2, + income: 40985, + }, + { + percentOfAmi: 35, + householdSize: 3, + income: 46095, + }, + { + percentOfAmi: 35, + householdSize: 4, + income: 51205, + }, + { + percentOfAmi: 35, + householdSize: 5, + income: 55335, + }, + { + percentOfAmi: 35, + householdSize: 6, + income: 59430, + }, + { + percentOfAmi: 35, + householdSize: 7, + income: 63525, + }, + { + percentOfAmi: 35, + householdSize: 8, + income: 67620, + }, + { + percentOfAmi: 30, + householdSize: 1, + income: 30750, + }, + { + percentOfAmi: 30, + householdSize: 2, + income: 35130, + }, + { + percentOfAmi: 30, + householdSize: 3, + income: 39510, + }, + { + percentOfAmi: 30, + householdSize: 4, + income: 43890, + }, + { + percentOfAmi: 30, + householdSize: 5, + income: 47430, + }, + { + percentOfAmi: 30, + householdSize: 6, + income: 50940, + }, + { + percentOfAmi: 30, + householdSize: 7, + income: 54450, + }, + { + percentOfAmi: 25, + householdSize: 1, + income: 25625, + }, + { + percentOfAmi: 25, + householdSize: 2, + income: 29275, + }, + { + percentOfAmi: 25, + householdSize: 3, + income: 32925, + }, + { + percentOfAmi: 25, + householdSize: 4, + income: 36575, + }, + { + percentOfAmi: 25, + householdSize: 5, + income: 39525, + }, + { + percentOfAmi: 25, + householdSize: 6, + income: 42450, + }, + { + percentOfAmi: 25, + householdSize: 7, + income: 45375, + }, + { + percentOfAmi: 25, + householdSize: 8, + income: 48300, + }, + { + percentOfAmi: 20, + householdSize: 1, + income: 20500, + }, + { + percentOfAmi: 20, + householdSize: 2, + income: 23420, + }, + { + percentOfAmi: 20, + householdSize: 3, + income: 26340, + }, + { + percentOfAmi: 20, + householdSize: 4, + income: 29260, + }, + { + percentOfAmi: 20, + householdSize: 5, + income: 31620, + }, + { + percentOfAmi: 20, + householdSize: 6, + income: 33960, + }, + { + percentOfAmi: 20, + householdSize: 7, + income: 36300, + }, + { + percentOfAmi: 20, + householdSize: 8, + income: 38640, + }, + { + percentOfAmi: 15, + householdSize: 1, + income: 15375, + }, + { + percentOfAmi: 15, + householdSize: 2, + income: 17565, + }, + { + percentOfAmi: 15, + householdSize: 3, + income: 19755, + }, + { + percentOfAmi: 15, + householdSize: 4, + income: 21945, + }, + { + percentOfAmi: 15, + householdSize: 5, + income: 23715, + }, + { + percentOfAmi: 15, + householdSize: 6, + income: 25470, + }, + { + percentOfAmi: 15, + householdSize: 7, + income: 27225, + }, + { + percentOfAmi: 15, + householdSize: 8, + income: 28980, + }, + ], +} + +export const ArcherListing: Listing = { + id: "Uvbk5qurpB2WI9V6WnNdH", + applicationConfig: undefined, + applicationOpenDate: new Date("2019-12-31T15:22:57.000-07:00"), + applicationPickUpAddress: { + id: "id", + createdAt: new Date(), + updatedAt: new Date(), + city: "San Jose", + street: "98 Archer Street", + zipCode: "95112", + state: "CA", + latitude: 37.36537, + longitude: -121.91071, + }, + applicationPickUpAddressOfficeHours: "", + depositMax: "", + disableUnitsAccordion: false, + jurisdiction: { + id: "id", + name: "Alameda", + publicUrl: "", + }, + events: [], + urlSlug: "listing-slug-abcdef", + status: ListingStatus.active, + applicationDueDate: new Date("2019-12-31T15:22:57.000-07:00"), + applicationMethods: [], + applicationOrganization: "98 Archer Street", + // TODO confirm not used anywhere + // applicationPhone: "(408) 217-8562", + assets: [ + { + label: "building", + fileId: + "https://regional-dahlia-staging.s3-us-west-1.amazonaws.com/listings/archer/archer-studios.jpg", + }, + ], + buildingSelectionCriteria: + "Tenant Selection Criteria will be available to all applicants upon request.", + costsNotIncluded: + "Resident responsible for PG&E, internet and phone. Owner pays for water, trash, and sewage. Residents encouraged to obtain renter's insurance but this is not a requirement. Rent is due by the 5th of each month. Late fee $35 and returned check fee is $35 additional.", + creditHistory: + "Applications will be rated on a score system for housing. An applicant's score may be impacted by negative tenant peformance information provided to the credit reporting agency. All applicants are expected have a passing acore of 70 points out of 100 to be considered for housing. Applicants with no credit history will receive a maximum of 80 points to fairly outweigh positive and/or negative trades as would an applicant with established credit history. Refer to Tenant Selection Criteria or Qualification Criteria for details related to the qualification process. ", + depositMin: "1140.0", + programRules: + "Applicants must adhere to minimum & maximum income limits. Tenant Selection Criteria applies.", + // TODO confirm not used anywhere + // externalId: null, + waitlistMaxSize: 300, + name: "Archer Studios", + waitlistCurrentSize: 300, + // TODO confirm not used anywhere + // prioritiesDescriptor: null, + requiredDocuments: "Completed application and government issued IDs", + // TODO confirm not used anywhere + // reservedCommunityMaximumAge: null, + // TODO confirm not used anywhere + // reservedCommunityMinimumAge: null, + // TODO confirm not used anywhere + // reservedDescriptor: null, + createdAt: new Date("2019-07-08T15:37:19.565-07:00"), + updatedAt: new Date("2019-07-09T14:35:11.142-07:00"), + // TODO confirm not used anywhere + // groupId: 1, + // TODO confirm not used anywhere + // hideUnitFeatures: false, + applicationFee: "30.0", + criminalBackground: + "A criminal background investigation will be obtained on each applicant. As criminal background checks are done county by county and will be ran for all counties in which the applicant lived, Applicants will be disqualified for tenancy if they have been convicted of a felony or misdemeanor. Refer to Tenant Selection Criteria or Qualification Criteria for details related to the qualification process. ", + leasingAgentAddress: { + id: "id", + createdAt: new Date(), + updatedAt: new Date(), + city: "San Jose", + street: "98 Archer Street", + zipCode: "95112", + state: "CA", + latitude: 37.36537, + longitude: -121.91071, + }, + leasingAgentEmail: "mbaca@charitieshousing.org", + leasingAgentName: "Marisela Baca", + leasingAgentOfficeHours: "Monday, Tuesday & Friday, 9:00AM - 5:00PM", + leasingAgentPhone: "(408) 217-8562", + leasingAgentTitle: "", + listingPrograms: [], + rentalAssistance: "Custom rental assistance", + rentalHistory: + "Two years of rental history will be verified with all applicable landlords. Household family members and/or personal friends are not acceptable landlord references. Two professional character references may be used in lieu of rental history for applicants with no prior rental history. An unlawful detainer report will be processed thourhg the U.D. Registry, Inc. Applicants will be disqualified if they have any evictions filing within the last 7 years. Refer to Tenant Selection Criteria or Qualification Criteria for details related to the qualification process.", + householdSizeMin: 2, + householdSizeMax: 3, + smokingPolicy: "Non-smoking building", + unitsAvailable: 0, + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + unitSummaries: {}, + unitAmenities: "Dishwasher", + developer: "Charities Housing ", + yearBuilt: 2012, + accessibility: + "There is a total of 5 ADA units in the complex, all others are adaptable. Exterior Wheelchair ramp (front entry)", + amenities: + "Community Room, Laundry Room, Assigned Parking, Bike Storage, Roof Top Garden, Part-time Resident Service Coordinator", + buildingTotalUnits: 35, + buildingAddress: { + id: "buildingId", + createdAt: new Date(), + updatedAt: new Date(), + city: "San Jose", + street: "98 Archer Street", + zipCode: "95112", + state: "CA", + latitude: 37.36537, + longitude: -121.91071, + }, + neighborhood: "Rosemary Gardens Park", + petPolicy: + "No pets allowed. Accommodation animals may be granted to persons with disabilities via a reasonable accommodation request.", + units: [ + { + id: "sQ19KuyILEo0uuNqti2fl", + amiPercentage: "45.0", + annualIncomeMin: "26496.0", + monthlyIncomeMin: "2208.0", + floor: 2, + annualIncomeMax: "46125.0", + maxOccupancy: 2, + minOccupancy: 1, + monthlyRent: "1104.0", + sqFeet: "285", + status: UnitStatus.occupied, + unitType: { + id: "random_id_35edf", + createdAt: new Date(), + updatedAt: new Date(), + name: "studio", + numBedrooms: 0, + }, + createdAt: new Date("2019-07-09T21:20:05.783Z"), + updatedAt: new Date("2019-08-14T23:05:43.913Z"), + amiChart, + }, + { + id: "Cq870hwYXcPxCYT4_uW_3", + amiPercentage: "45.0", + annualIncomeMin: "26496.0", + monthlyIncomeMin: "2208.0", + floor: 3, + annualIncomeMax: "46125.0", + maxOccupancy: 2, + minOccupancy: 1, + monthlyRent: "1104.0", + sqFeet: "285", + status: UnitStatus.occupied, + unitType: { + id: "random_id_35edf", + createdAt: new Date(), + updatedAt: new Date(), + name: "studio", + numBedrooms: 0, + }, + createdAt: new Date("2019-08-14T22:53:09.982Z"), + updatedAt: new Date("2019-08-14T23:06:59.015Z"), + amiChart, + }, + { + id: "9XQrfuAPOn8wtD7HlhCTR", + amiPercentage: "45.0", + annualIncomeMin: "26496.0", + monthlyIncomeMin: "2208.0", + floor: 2, + annualIncomeMax: "46125.0", + maxOccupancy: 2, + minOccupancy: 1, + monthlyRent: "1104.0", + sqFeet: "285", + status: UnitStatus.occupied, + unitType: { + id: "random_id_35edf", + createdAt: new Date(), + updatedAt: new Date(), + name: "studio", + numBedrooms: 0, + }, + createdAt: new Date("2019-08-14T22:52:08.758Z"), + updatedAt: new Date("2019-08-14T23:06:59.023Z"), + amiChart, + }, + { + id: "bamrJpZA9JmnLSMEbTlI4", + amiPercentage: "45.0", + annualIncomeMin: "26496.0", + monthlyIncomeMin: "2208.0", + floor: 2, + annualIncomeMax: "46125.0", + maxOccupancy: 2, + minOccupancy: 1, + monthlyRent: "1104.0", + sqFeet: "285", + status: UnitStatus.occupied, + unitType: { + id: "random_id_35edf", + createdAt: new Date(), + updatedAt: new Date(), + name: "studio", + numBedrooms: 0, + }, + createdAt: new Date("2019-08-14T22:52:08.766Z"), + updatedAt: new Date("2019-08-14T23:06:59.031Z"), + amiChart, + }, + { + id: "BCwOFAHJDpyPbKcVBjIUM", + amiPercentage: "45.0", + annualIncomeMin: "26496.0", + monthlyIncomeMin: "2208.0", + floor: 2, + annualIncomeMax: "46125.0", + maxOccupancy: 2, + minOccupancy: 1, + monthlyRent: "1104.0", + sqFeet: "285", + status: UnitStatus.occupied, + unitType: { + id: "random_id_35edf", + createdAt: new Date(), + updatedAt: new Date(), + name: "studio", + numBedrooms: 0, + }, + createdAt: new Date("2019-08-14T22:52:08.771Z"), + updatedAt: new Date("2019-08-14T23:06:59.039Z"), + amiChart, + }, + { + id: "5t56gXJdJLZiksBuX8BtL", + amiPercentage: "45.0", + annualIncomeMin: "26496.0", + monthlyIncomeMin: "2208.0", + floor: 2, + annualIncomeMax: "46125.0", + maxOccupancy: 2, + minOccupancy: 1, + monthlyRent: "1104.0", + sqFeet: "285", + status: UnitStatus.occupied, + unitType: { + id: "random_id_35edf", + createdAt: new Date(), + updatedAt: new Date(), + name: "studio", + numBedrooms: 0, + }, + createdAt: new Date("2019-08-14T22:52:08.777Z"), + updatedAt: new Date("2019-08-14T23:06:59.046Z"), + amiChart, + }, + { + id: "7mmAuJ0x7l_2VxJLoSzX5", + amiPercentage: "45.0", + annualIncomeMin: "26496.0", + monthlyIncomeMin: "2208.0", + floor: 2, + annualIncomeMax: "46125.0", + maxOccupancy: 2, + minOccupancy: 1, + monthlyRent: "1104.0", + sqFeet: "285", + status: UnitStatus.occupied, + unitType: { + id: "random_id_35edf", + createdAt: new Date(), + updatedAt: new Date(), + name: "studio", + numBedrooms: 0, + }, + createdAt: new Date("2019-08-14T22:52:08.783Z"), + updatedAt: new Date("2019-08-14T23:06:59.053Z"), + amiChart, + }, + { + id: "LVsJ-_PYy8x2rn5V8Deo9", + amiPercentage: "45.0", + annualIncomeMin: "26496.0", + monthlyIncomeMin: "2208.0", + floor: 3, + annualIncomeMax: "46125.0", + maxOccupancy: 2, + minOccupancy: 1, + monthlyRent: "1104.0", + sqFeet: "285", + status: UnitStatus.occupied, + unitType: { + id: "random_id_35edf", + createdAt: new Date(), + updatedAt: new Date(), + name: "studio", + numBedrooms: 0, + }, + createdAt: new Date("2019-08-14T22:53:09.976Z"), + updatedAt: new Date("2019-08-14T23:06:59.161Z"), + amiChart, + }, + { + id: "neDXHUzJkL2YZ2CQOZx1i", + amiPercentage: "45.0", + annualIncomeMin: "26496.0", + monthlyIncomeMin: "2208.0", + floor: 3, + annualIncomeMax: "46125.0", + maxOccupancy: 2, + minOccupancy: 1, + monthlyRent: "1104.0", + sqFeet: "285", + status: UnitStatus.occupied, + unitType: { + id: "random_id_35edf", + createdAt: new Date(), + updatedAt: new Date(), + name: "studio", + numBedrooms: 0, + }, + createdAt: new Date("2019-08-14T22:53:09.976Z"), + updatedAt: new Date("2019-08-14T23:06:59.167Z"), + amiChart, + }, + { + id: "3_cr3dd76rGY7tDYlvfEO", + amiPercentage: "30.0", + annualIncomeMin: "17256.0", + monthlyIncomeMin: "1438.0", + floor: 2, + annualIncomeMax: "30750.0", + maxOccupancy: 2, + minOccupancy: 1, + monthlyRent: "719.0", + sqFeet: "285", + status: UnitStatus.occupied, + unitType: { + id: "random_id_35edf", + createdAt: new Date(), + updatedAt: new Date(), + name: "studio", + numBedrooms: 0, + }, + createdAt: new Date("2019-07-09T21:24:14.122Z"), + updatedAt: new Date("2019-08-14T23:06:59.173Z"), + amiChart, + }, + { + id: "_38QsH2XMgHEzn_Sn4b2r", + amiPercentage: "30.0", + annualIncomeMin: "17256.0", + monthlyIncomeMin: "1438.0", + floor: 2, + annualIncomeMax: "30750.0", + maxOccupancy: 2, + minOccupancy: 1, + monthlyRent: "719.0", + sqFeet: "285", + status: UnitStatus.occupied, + unitType: { + id: "random_id_35edf", + createdAt: new Date(), + updatedAt: new Date(), + name: "studio", + numBedrooms: 0, + }, + createdAt: new Date("2019-08-14T22:55:22.950Z"), + updatedAt: new Date("2019-08-14T23:06:59.179Z"), + amiChart, + }, + { + id: "gTHXtJ37uP8R8zkOp7wOt", + amiPercentage: "30.0", + annualIncomeMin: "17256.0", + monthlyIncomeMin: "1438.0", + floor: 2, + annualIncomeMax: "30750.0", + maxOccupancy: 2, + minOccupancy: 1, + monthlyRent: "719.0", + sqFeet: "285", + status: UnitStatus.occupied, + unitType: { + id: "random_id_35edf", + createdAt: new Date(), + updatedAt: new Date(), + name: "studio", + numBedrooms: 0, + }, + createdAt: new Date("2019-08-14T22:55:22.956Z"), + updatedAt: new Date("2019-08-14T23:06:59.186Z"), + amiChart, + }, + { + id: "me-MRbUEn6ox-OYpzosO1", + amiPercentage: "30.0", + annualIncomeMin: "17256.0", + monthlyIncomeMin: "1438.0", + floor: 2, + annualIncomeMax: "30750.0", + maxOccupancy: 2, + minOccupancy: 1, + monthlyRent: "719.0", + sqFeet: "285", + status: UnitStatus.occupied, + unitType: { + id: "random_id_35edf", + createdAt: new Date(), + updatedAt: new Date(), + name: "studio", + numBedrooms: 0, + }, + createdAt: new Date("2019-08-14T22:55:22.961Z"), + updatedAt: new Date("2019-08-14T23:06:59.192Z"), + amiChart, + }, + { + id: "ZOtuFSb79LX7p6CVW3H4w", + amiPercentage: "30.0", + annualIncomeMin: "17256.0", + monthlyIncomeMin: "1438.0", + floor: 2, + annualIncomeMax: "30750.0", + maxOccupancy: 2, + minOccupancy: 1, + monthlyRent: "719.0", + sqFeet: "285", + status: UnitStatus.occupied, + unitType: { + id: "random_id_35edf", + createdAt: new Date(), + updatedAt: new Date(), + name: "studio", + numBedrooms: 0, + }, + createdAt: new Date("2019-08-14T22:55:22.967Z"), + updatedAt: new Date("2019-08-14T23:06:59.198Z"), + amiChart, + }, + { + id: "nISGOCiWoCzQXkMZGV5bV", + amiPercentage: "30.0", + annualIncomeMin: "17256.0", + monthlyIncomeMin: "1438.0", + floor: 2, + annualIncomeMax: "30750.0", + maxOccupancy: 2, + minOccupancy: 1, + monthlyRent: "719.0", + sqFeet: "285", + status: UnitStatus.occupied, + unitType: { + id: "random_id_35edf", + createdAt: new Date(), + updatedAt: new Date(), + name: "studio", + numBedrooms: 0, + }, + createdAt: new Date("2019-08-14T22:55:22.972Z"), + updatedAt: new Date("2019-08-14T23:06:59.204Z"), + amiChart, + }, + { + id: "Ppne-7ChrEht1HxwfO0gc", + amiPercentage: "30.0", + annualIncomeMin: "17256.0", + monthlyIncomeMin: "1438.0", + floor: 2, + annualIncomeMax: "30750.0", + maxOccupancy: 2, + minOccupancy: 1, + monthlyRent: "719.0", + sqFeet: "285", + status: UnitStatus.occupied, + unitType: { + id: "random_id_35edf", + createdAt: new Date(), + updatedAt: new Date(), + name: "studio", + numBedrooms: 0, + }, + createdAt: new Date("2019-08-14T22:55:22.978Z"), + updatedAt: new Date("2019-08-14T23:06:59.210Z"), + amiChart, + }, + { + id: "78hBgnEoHw3aW5r4Mn2Jf", + amiPercentage: "30.0", + annualIncomeMin: "17256.0", + monthlyIncomeMin: "1438.0", + floor: 2, + annualIncomeMax: "30750.0", + maxOccupancy: 2, + minOccupancy: 1, + monthlyRent: "719.0", + sqFeet: "285", + status: UnitStatus.available, + unitType: { + id: "random_id_35edf", + createdAt: new Date(), + updatedAt: new Date(), + name: "studio", + numBedrooms: 0, + }, + createdAt: new Date("2019-08-14T22:55:22.984Z"), + updatedAt: new Date("2019-08-14T23:06:59.216Z"), + amiChart, + }, + { + id: "0RtHf-Iogw3x643r46y-a", + amiPercentage: "30.0", + annualIncomeMin: "17256.0", + monthlyIncomeMin: "1438.0", + floor: 3, + annualIncomeMax: "30750.0", + maxOccupancy: 2, + minOccupancy: 1, + monthlyRent: "719.0", + sqFeet: "285", + status: UnitStatus.occupied, + unitType: { + id: "random_id_35edf", + createdAt: new Date(), + updatedAt: new Date(), + name: "studio", + numBedrooms: 0, + }, + createdAt: new Date("2019-08-14T22:56:06.563Z"), + updatedAt: new Date("2019-08-14T23:06:59.222Z"), + amiChart, + }, + { + id: "ENMVc3sX0kmD3G4762naM", + amiPercentage: "30.0", + annualIncomeMin: "17256.0", + monthlyIncomeMin: "1438.0", + floor: 3, + annualIncomeMax: "30750.0", + maxOccupancy: 2, + minOccupancy: 1, + monthlyRent: "719.0", + sqFeet: "285", + status: UnitStatus.occupied, + unitType: { + id: "random_id_35edf", + createdAt: new Date(), + updatedAt: new Date(), + name: "studio", + numBedrooms: 0, + }, + createdAt: new Date("2019-08-14T22:56:06.570Z"), + updatedAt: new Date("2019-08-14T23:06:59.229Z"), + amiChart, + }, + { + id: "O9OSAiIFTSA5SimFlCbd7", + amiPercentage: "30.0", + annualIncomeMin: "17256.0", + monthlyIncomeMin: "1438.0", + floor: 3, + annualIncomeMax: "30750.0", + maxOccupancy: 2, + minOccupancy: 1, + monthlyRent: "719.0", + sqFeet: "285", + status: UnitStatus.occupied, + unitType: { + id: "random_id_35edf", + createdAt: new Date(), + updatedAt: new Date(), + name: "studio", + numBedrooms: 0, + }, + createdAt: new Date("2019-08-14T22:56:06.575Z"), + updatedAt: new Date("2019-08-14T23:06:59.235Z"), + amiChart, + }, + { + id: "d_7SUFpxe1rZZ5dIgMgTG", + amiPercentage: "30.0", + annualIncomeMin: "17256.0", + monthlyIncomeMin: "1438.0", + floor: 3, + annualIncomeMax: "30750.0", + maxOccupancy: 2, + minOccupancy: 1, + monthlyRent: "719.0", + sqFeet: "285", + status: UnitStatus.occupied, + unitType: { + id: "random_id_35edf", + createdAt: new Date(), + updatedAt: new Date(), + name: "studio", + numBedrooms: 0, + }, + createdAt: new Date("2019-08-14T22:56:06.580Z"), + updatedAt: new Date("2019-08-14T23:06:59.241Z"), + amiChart, + }, + { + id: "bR17hir7729c22LyVbQ3m", + amiPercentage: "30.0", + annualIncomeMin: "17256.0", + monthlyIncomeMin: "1438.0", + floor: 3, + annualIncomeMax: "30750.0", + maxOccupancy: 2, + minOccupancy: 1, + monthlyRent: "719.0", + sqFeet: "285", + status: UnitStatus.occupied, + unitType: { + id: "random_id_35edf", + createdAt: new Date(), + updatedAt: new Date(), + name: "studio", + numBedrooms: 0, + }, + createdAt: new Date("2019-08-14T22:56:06.587Z"), + updatedAt: new Date("2019-08-14T23:06:59.247Z"), + amiChart, + }, + { + id: "B62kKSz7qwAA7aM6tzwtB", + amiPercentage: "30.0", + annualIncomeMin: "17256.0", + monthlyIncomeMin: "1438.0", + floor: 3, + annualIncomeMax: "30750.0", + maxOccupancy: 2, + minOccupancy: 1, + monthlyRent: "719.0", + sqFeet: "285", + status: UnitStatus.occupied, + unitType: { + id: "random_id_35edf", + createdAt: new Date(), + updatedAt: new Date(), + name: "studio", + numBedrooms: 0, + }, + createdAt: new Date("2019-08-14T22:56:06.593Z"), + updatedAt: new Date("2019-08-14T23:06:59.254Z"), + amiChart, + }, + { + id: "C3YePWy05Or9fDeVuRPTF", + amiPercentage: "30.0", + annualIncomeMin: "17256.0", + monthlyIncomeMin: "1438.0", + floor: 3, + annualIncomeMax: "30750.0", + maxOccupancy: 2, + minOccupancy: 1, + monthlyRent: "719.0", + sqFeet: "285", + status: UnitStatus.occupied, + unitType: { + id: "random_id_35edf", + createdAt: new Date(), + updatedAt: new Date(), + name: "studio", + numBedrooms: 0, + }, + createdAt: new Date("2019-08-14T22:56:06.606Z"), + updatedAt: new Date("2019-08-14T23:06:59.260Z"), + amiChart, + }, + { + id: "Logk3eY0iXtf3oCOctxqT", + amiPercentage: "30.0", + annualIncomeMin: "17256.0", + monthlyIncomeMin: "1438.0", + floor: 3, + annualIncomeMax: "30750.0", + maxOccupancy: 2, + minOccupancy: 1, + monthlyRent: "719.0", + sqFeet: "285", + status: UnitStatus.occupied, + unitType: { + id: "random_id_35edf", + createdAt: new Date(), + updatedAt: new Date(), + name: "studio", + numBedrooms: 0, + }, + createdAt: new Date("2019-08-14T22:56:06.612Z"), + updatedAt: new Date("2019-08-14T23:06:59.267Z"), + amiChart, + }, + { + id: "nIYojGurvtF7xelaeT0tN", + amiPercentage: "45.0", + annualIncomeMin: "26496.0", + monthlyIncomeMin: "2208.0", + floor: 2, + annualIncomeMax: "46125.0", + maxOccupancy: 2, + minOccupancy: 1, + monthlyRent: "1104.0", + sqFeet: "285", + status: UnitStatus.occupied, + unitType: { + id: "random_id_35edf", + createdAt: new Date(), + updatedAt: new Date(), + name: "studio", + numBedrooms: 0, + }, + createdAt: new Date("2019-08-14T22:52:08.790Z"), + updatedAt: new Date("2019-08-14T23:06:59.060Z"), + amiChart, + }, + { + id: "omzU7rRoirKXq8SQfaShf", + amiPercentage: "45.0", + annualIncomeMin: "26496.0", + monthlyIncomeMin: "2208.0", + floor: 2, + annualIncomeMax: "46125.0", + maxOccupancy: 2, + minOccupancy: 1, + monthlyRent: "1104.0", + sqFeet: "285", + status: UnitStatus.occupied, + unitType: { + id: "random_id_35edf", + createdAt: new Date(), + updatedAt: new Date(), + name: "studio", + numBedrooms: 0, + }, + createdAt: new Date("2019-08-14T22:52:08.796Z"), + updatedAt: new Date("2019-08-14T23:06:59.067Z"), + amiChart, + }, + { + id: "IzVtblU-KMTHf3wPGzx2g", + amiPercentage: "45.0", + annualIncomeMin: "26496.0", + monthlyIncomeMin: "2208.0", + floor: 2, + annualIncomeMax: "46125.0", + maxOccupancy: 2, + minOccupancy: 1, + monthlyRent: "1104.0", + sqFeet: "285", + status: UnitStatus.occupied, + unitType: { + id: "random_id_35edf", + createdAt: new Date(), + updatedAt: new Date(), + name: "studio", + numBedrooms: 0, + }, + createdAt: new Date("2019-08-14T22:52:08.802Z"), + updatedAt: new Date("2019-08-14T23:06:59.074Z"), + amiChart, + }, + { + id: "7g-6eFE_Q6Xi5K2xT2bE5", + amiPercentage: "45.0", + annualIncomeMin: "26496.0", + monthlyIncomeMin: "2208.0", + floor: 2, + annualIncomeMax: "46125.0", + maxOccupancy: 2, + minOccupancy: 1, + monthlyRent: "1104.0", + sqFeet: "285", + status: UnitStatus.occupied, + unitType: { + id: "random_id_35edf", + createdAt: new Date(), + updatedAt: new Date(), + name: "studio", + numBedrooms: 0, + }, + createdAt: new Date("2019-08-14T22:52:08.807Z"), + updatedAt: new Date("2019-08-14T23:06:59.080Z"), + amiChart, + }, + { + id: "4Br-28LII41R3pINIzLwe", + amiPercentage: "45.0", + annualIncomeMin: "26496.0", + monthlyIncomeMin: "2208.0", + floor: 2, + annualIncomeMax: "46125.0", + maxOccupancy: 2, + minOccupancy: 1, + monthlyRent: "1104.0", + sqFeet: "285", + status: UnitStatus.occupied, + unitType: { + id: "random_id_35edf", + createdAt: new Date(), + updatedAt: new Date(), + name: "studio", + numBedrooms: 0, + }, + createdAt: new Date("2019-08-14T22:52:08.813Z"), + updatedAt: new Date("2019-08-14T23:06:59.086Z"), + amiChart, + }, + { + id: "5bvjTW2ATEpxwsKppCh0l", + amiPercentage: "45.0", + annualIncomeMin: "26496.0", + monthlyIncomeMin: "2208.0", + floor: 2, + annualIncomeMax: "46125.0", + maxOccupancy: 2, + minOccupancy: 1, + monthlyRent: "1104.0", + sqFeet: "285", + status: UnitStatus.occupied, + unitType: { + id: "random_id_35edf", + createdAt: new Date(), + updatedAt: new Date(), + name: "studio", + numBedrooms: 0, + }, + createdAt: new Date("2019-08-14T22:52:08.819Z"), + updatedAt: new Date("2019-08-14T23:06:59.093Z"), + amiChart, + }, + { + id: "BZlMmnCXwT4bChrcaNUW3", + amiPercentage: "45.0", + annualIncomeMin: "26496.0", + monthlyIncomeMin: "2208.0", + floor: 3, + annualIncomeMax: "46125.0", + maxOccupancy: 2, + minOccupancy: 1, + monthlyRent: "1104.0", + sqFeet: "285", + status: UnitStatus.occupied, + unitType: { + id: "random_id_35edf", + createdAt: new Date(), + updatedAt: new Date(), + name: "studio", + numBedrooms: 0, + }, + createdAt: new Date("2019-08-14T22:53:09.921Z"), + updatedAt: new Date("2019-08-14T23:06:59.099Z"), + amiChart, + }, + { + id: "j2hU6Qv5ayOHMKPLQBolz", + amiPercentage: "45.0", + annualIncomeMin: "26496.0", + monthlyIncomeMin: "2208.0", + floor: 3, + annualIncomeMax: "46125.0", + maxOccupancy: 2, + minOccupancy: 1, + monthlyRent: "1104.0", + sqFeet: "285", + status: UnitStatus.occupied, + unitType: { + id: "random_id_35edf", + createdAt: new Date(), + updatedAt: new Date(), + name: "studio", + numBedrooms: 0, + }, + createdAt: new Date("2019-08-14T22:53:09.927Z"), + updatedAt: new Date("2019-08-14T23:06:59.105Z"), + amiChart, + }, + { + id: "w2-TtBySVELMWyL1cLTkA", + amiPercentage: "45.0", + annualIncomeMin: "26496.0", + monthlyIncomeMin: "2208.0", + floor: 3, + annualIncomeMax: "46125.0", + maxOccupancy: 2, + minOccupancy: 1, + monthlyRent: "1104.0", + sqFeet: "285", + status: UnitStatus.occupied, + unitType: { + id: "random_id_35edf", + createdAt: new Date(), + updatedAt: new Date(), + name: "studio", + numBedrooms: 0, + }, + createdAt: new Date("2019-08-14T22:53:09.933Z"), + updatedAt: new Date("2019-08-14T23:06:59.111Z"), + amiChart, + }, + { + id: "YhC6LoOIT6hxPfk4uKU3m", + amiPercentage: "45.0", + annualIncomeMin: "26496.0", + monthlyIncomeMin: "2208.0", + floor: 3, + annualIncomeMax: "46125.0", + maxOccupancy: 2, + minOccupancy: 1, + monthlyRent: "1104.0", + sqFeet: "285", + status: UnitStatus.occupied, + unitType: { + id: "random_id_35edf", + createdAt: new Date(), + updatedAt: new Date(), + name: "studio", + numBedrooms: 0, + }, + createdAt: new Date("2019-08-14T22:53:09.938Z"), + updatedAt: new Date("2019-08-14T23:06:59.118Z"), + amiChart, + }, + { + id: "5CuSFqgGgFX245JQsnG84", + amiPercentage: "45.0", + annualIncomeMin: "26496.0", + monthlyIncomeMin: "2208.0", + floor: 3, + annualIncomeMax: "46125.0", + maxOccupancy: 2, + minOccupancy: 1, + monthlyRent: "1104.0", + sqFeet: "285", + status: UnitStatus.occupied, + unitType: { + id: "random_id_35edf", + createdAt: new Date(), + updatedAt: new Date(), + name: "studio", + numBedrooms: 0, + }, + createdAt: new Date("2019-08-14T22:53:09.944Z"), + updatedAt: new Date("2019-08-14T23:06:59.124Z"), + amiChart, + }, + { + id: "WoD20A8q1CZm8NmGvLHUn", + amiPercentage: "45.0", + annualIncomeMin: "26496.0", + monthlyIncomeMin: "2208.0", + floor: 3, + annualIncomeMax: "46125.0", + maxOccupancy: 2, + minOccupancy: 1, + monthlyRent: "1104.0", + sqFeet: "285", + status: UnitStatus.occupied, + unitType: { + id: "random_id_35edf", + createdAt: new Date(), + updatedAt: new Date(), + name: "studio", + numBedrooms: 0, + }, + createdAt: new Date("2019-08-14T22:53:09.950Z"), + updatedAt: new Date("2019-08-14T23:06:59.130Z"), + amiChart, + }, + { + id: "srzDhzV5HQpqR5vuyHKlQ", + amiPercentage: "45.0", + annualIncomeMin: "26496.0", + monthlyIncomeMin: "2208.0", + floor: 3, + annualIncomeMax: "46125.0", + maxOccupancy: 2, + minOccupancy: 1, + monthlyRent: "1104.0", + sqFeet: "285", + status: UnitStatus.occupied, + unitType: { + id: "random_id_35edf", + createdAt: new Date(), + updatedAt: new Date(), + name: "studio", + numBedrooms: 0, + }, + createdAt: new Date("2019-08-14T22:53:09.955Z"), + updatedAt: new Date("2019-08-14T23:06:59.136Z"), + amiChart, + }, + { + id: "b9jo7kYEOQcATHWBjwJ6r", + amiPercentage: "45.0", + annualIncomeMin: "26496.0", + monthlyIncomeMin: "2208.0", + floor: 3, + annualIncomeMax: "46125.0", + maxOccupancy: 2, + minOccupancy: 1, + monthlyRent: "1104.0", + sqFeet: "285", + status: UnitStatus.occupied, + unitType: { + id: "random_id_35edf", + createdAt: new Date(), + updatedAt: new Date(), + name: "studio", + numBedrooms: 0, + }, + createdAt: new Date("2019-08-14T22:53:09.960Z"), + updatedAt: new Date("2019-08-14T23:06:59.142Z"), + amiChart, + }, + { + id: "i5tQbXCZRrU_X3ultDSii", + amiPercentage: "45.0", + annualIncomeMin: "26496.0", + monthlyIncomeMin: "2208.0", + floor: 3, + annualIncomeMax: "46125.0", + maxOccupancy: 2, + minOccupancy: 1, + monthlyRent: "1104.0", + sqFeet: "285", + status: UnitStatus.occupied, + unitType: { + id: "random_id_35edf", + createdAt: new Date(), + updatedAt: new Date(), + name: "studio", + numBedrooms: 0, + }, + createdAt: new Date("2019-08-14T22:53:09.965Z"), + updatedAt: new Date("2019-08-14T23:06:59.148Z"), + amiChart, + }, + { + id: "mrRtN0rArISKnE-PFomth", + amiPercentage: "45.0", + annualIncomeMin: "26496.0", + monthlyIncomeMin: "2208.0", + floor: 3, + annualIncomeMax: "46125.0", + maxOccupancy: 2, + minOccupancy: 1, + monthlyRent: "1104.0", + sqFeet: "285", + status: UnitStatus.occupied, + unitType: { + id: "random_id_35edf", + createdAt: new Date(), + updatedAt: new Date(), + name: "studio", + numBedrooms: 0, + }, + createdAt: new Date("2019-08-14T22:53:09.970Z"), + updatedAt: new Date("2019-08-14T23:06:59.155Z"), + amiChart, + }, + ], + // TODO confirm not used anywhere + // totalUnits: 2, +} diff --git a/backend/core/types/src/backend-swagger.ts b/backend/core/types/src/backend-swagger.ts new file mode 100644 index 0000000000..d9c7fd7ea4 --- /dev/null +++ b/backend/core/types/src/backend-swagger.ts @@ -0,0 +1,7110 @@ +/** Generate by swagger-axios-codegen */ +// tslint:disable +/* eslint-disable */ +import axiosStatic, { AxiosInstance } from "axios" + +export interface IRequestOptions { + headers?: any + baseURL?: string + responseType?: string +} + +export interface IRequestConfig { + method?: any + headers?: any + url?: any + data?: any + params?: any +} + +// Add options interface +export interface ServiceOptions { + axios?: AxiosInstance +} + +// Add default options +export const serviceOptions: ServiceOptions = {} + +// Instance selector +export function axios( + configs: IRequestConfig, + resolve: (p: any) => void, + reject: (p: any) => void +): Promise { + if (serviceOptions.axios) { + return serviceOptions.axios + .request(configs) + .then((res) => { + resolve(res.data) + }) + .catch((err) => { + reject(err) + }) + } else { + throw new Error("please inject yourself instance like axios ") + } +} + +export function getConfigs( + method: string, + contentType: string, + url: string, + options: any +): IRequestConfig { + const configs: IRequestConfig = { ...options, method, url } + configs.headers = { + ...options.headers, + "Content-Type": contentType, + } + return configs +} + +const basePath = "" + +export interface IList extends Array {} +export interface List extends Array {} +export interface IDictionary { + [key: string]: TValue +} +export interface Dictionary extends IDictionary {} + +export interface IListResult { + items?: T[] +} + +export class ListResult implements IListResult { + items?: T[] +} + +export interface IPagedResult extends IListResult { + totalCount?: number + items?: T[] +} + +export class PagedResult implements IPagedResult { + totalCount?: number + items?: T[] +} + +// customer definition +// empty + +export class AmiChartsService { + /** + * List amiCharts + */ + list( + params: { + /** */ + jurisdictionName?: string + /** */ + jurisdictionId?: string + } = {} as any, + options: IRequestOptions = {} + ): Promise { + return new Promise((resolve, reject) => { + let url = basePath + "/amiCharts" + + const configs: IRequestConfig = getConfigs("get", "application/json", url, options) + configs.params = { + jurisdictionName: params["jurisdictionName"], + jurisdictionId: params["jurisdictionId"], + } + let data = null + + configs.data = data + axios(configs, resolve, reject) + }) + } + /** + * Create amiChart + */ + create( + params: { + /** requestBody */ + body?: AmiChartCreate + } = {} as any, + options: IRequestOptions = {} + ): Promise { + return new Promise((resolve, reject) => { + let url = basePath + "/amiCharts" + + const configs: IRequestConfig = getConfigs("post", "application/json", url, options) + + let data = params.body + + configs.data = data + axios(configs, resolve, reject) + }) + } + /** + * Update amiChart + */ + update( + params: { + /** requestBody */ + body?: AmiChartUpdate + } = {} as any, + options: IRequestOptions = {} + ): Promise { + return new Promise((resolve, reject) => { + let url = basePath + "/amiCharts/{amiChartId}" + + const configs: IRequestConfig = getConfigs("put", "application/json", url, options) + + let data = params.body + + configs.data = data + axios(configs, resolve, reject) + }) + } + /** + * Get amiChart by id + */ + retrieve( + params: { + /** */ + amiChartId: string + } = {} as any, + options: IRequestOptions = {} + ): Promise { + return new Promise((resolve, reject) => { + let url = basePath + "/amiCharts/{amiChartId}" + url = url.replace("{amiChartId}", params["amiChartId"] + "") + + const configs: IRequestConfig = getConfigs("get", "application/json", url, options) + + let data = null + + configs.data = data + axios(configs, resolve, reject) + }) + } + /** + * Delete amiChart by id + */ + delete( + params: { + /** */ + amiChartId: string + } = {} as any, + options: IRequestOptions = {} + ): Promise { + return new Promise((resolve, reject) => { + let url = basePath + "/amiCharts/{amiChartId}" + url = url.replace("{amiChartId}", params["amiChartId"] + "") + + const configs: IRequestConfig = getConfigs("delete", "application/json", url, options) + + let data = null + + configs.data = data + axios(configs, resolve, reject) + }) + } +} + +export class ApplicationFlaggedSetsService { + /** + * List application flagged sets + */ + list( + params: { + /** */ + page?: number + /** */ + limit?: number + /** */ + listingId: string + } = {} as any, + options: IRequestOptions = {} + ): Promise { + return new Promise((resolve, reject) => { + let url = basePath + "/applicationFlaggedSets" + + const configs: IRequestConfig = getConfigs("get", "application/json", url, options) + configs.params = { + page: params["page"], + limit: params["limit"], + listingId: params["listingId"], + } + let data = null + + configs.data = data + axios(configs, resolve, reject) + }) + } + /** + * Retrieve application flagged set by id + */ + retrieve( + params: { + /** */ + afsId: string + } = {} as any, + options: IRequestOptions = {} + ): Promise { + return new Promise((resolve, reject) => { + let url = basePath + "/applicationFlaggedSets/{afsId}" + url = url.replace("{afsId}", params["afsId"] + "") + + const configs: IRequestConfig = getConfigs("get", "application/json", url, options) + + let data = null + + configs.data = data + axios(configs, resolve, reject) + }) + } + /** + * Resolve application flagged set + */ + resolve( + params: { + /** requestBody */ + body?: ApplicationFlaggedSetResolve + } = {} as any, + options: IRequestOptions = {} + ): Promise { + return new Promise((resolve, reject) => { + let url = basePath + "/applicationFlaggedSets/resolve" + + const configs: IRequestConfig = getConfigs("post", "application/json", url, options) + + let data = params.body + + configs.data = data + axios(configs, resolve, reject) + }) + } +} + +export class ApplicationMethodsService { + /** + * List applicationMethods + */ + list(options: IRequestOptions = {}): Promise { + return new Promise((resolve, reject) => { + let url = basePath + "/applicationMethods" + + const configs: IRequestConfig = getConfigs("get", "application/json", url, options) + + let data = null + + configs.data = data + axios(configs, resolve, reject) + }) + } + /** + * Create applicationMethod + */ + create( + params: { + /** requestBody */ + body?: ApplicationMethodCreate + } = {} as any, + options: IRequestOptions = {} + ): Promise { + return new Promise((resolve, reject) => { + let url = basePath + "/applicationMethods" + + const configs: IRequestConfig = getConfigs("post", "application/json", url, options) + + let data = params.body + + configs.data = data + axios(configs, resolve, reject) + }) + } + /** + * Update applicationMethod + */ + update( + params: { + /** requestBody */ + body?: ApplicationMethodUpdate + } = {} as any, + options: IRequestOptions = {} + ): Promise { + return new Promise((resolve, reject) => { + let url = basePath + "/applicationMethods/{applicationMethodId}" + + const configs: IRequestConfig = getConfigs("put", "application/json", url, options) + + let data = params.body + + configs.data = data + axios(configs, resolve, reject) + }) + } + /** + * Get applicationMethod by id + */ + retrieve( + params: { + /** */ + applicationMethodId: string + } = {} as any, + options: IRequestOptions = {} + ): Promise { + return new Promise((resolve, reject) => { + let url = basePath + "/applicationMethods/{applicationMethodId}" + url = url.replace("{applicationMethodId}", params["applicationMethodId"] + "") + + const configs: IRequestConfig = getConfigs("get", "application/json", url, options) + + let data = null + + configs.data = data + axios(configs, resolve, reject) + }) + } + /** + * Delete applicationMethod by id + */ + delete( + params: { + /** */ + applicationMethodId: string + } = {} as any, + options: IRequestOptions = {} + ): Promise { + return new Promise((resolve, reject) => { + let url = basePath + "/applicationMethods/{applicationMethodId}" + url = url.replace("{applicationMethodId}", params["applicationMethodId"] + "") + + const configs: IRequestConfig = getConfigs("delete", "application/json", url, options) + + let data = null + + configs.data = data + axios(configs, resolve, reject) + }) + } +} + +export class ApplicationsService { + /** + * List applications + */ + list( + params: { + /** */ + page?: number + /** */ + limit?: number + /** */ + listingId?: string + /** */ + search?: string + /** */ + userId?: string + /** */ + orderBy?: string + /** */ + order?: string + /** */ + markedAsDuplicate?: boolean + } = {} as any, + options: IRequestOptions = {} + ): Promise { + return new Promise((resolve, reject) => { + let url = basePath + "/applications" + + const configs: IRequestConfig = getConfigs("get", "application/json", url, options) + configs.params = { + page: params["page"], + limit: params["limit"], + listingId: params["listingId"], + search: params["search"], + userId: params["userId"], + orderBy: params["orderBy"], + order: params["order"], + markedAsDuplicate: params["markedAsDuplicate"], + } + let data = null + + configs.data = data + axios(configs, resolve, reject) + }) + } + /** + * Create application + */ + create( + params: { + /** requestBody */ + body?: ApplicationCreate + } = {} as any, + options: IRequestOptions = {} + ): Promise { + return new Promise((resolve, reject) => { + let url = basePath + "/applications" + + const configs: IRequestConfig = getConfigs("post", "application/json", url, options) + + let data = params.body + + configs.data = data + axios(configs, resolve, reject) + }) + } + /** + * List applications as csv + */ + listAsCsv( + params: { + /** */ + page?: number + /** */ + limit?: number + /** */ + search?: string + /** */ + userId?: string + /** */ + orderBy?: string + /** */ + order?: string + /** */ + markedAsDuplicate?: boolean + /** */ + listingId: string + /** */ + includeDemographics?: boolean + } = {} as any, + options: IRequestOptions = {} + ): Promise { + return new Promise((resolve, reject) => { + let url = basePath + "/applications/csv" + + const configs: IRequestConfig = getConfigs("get", "application/json", url, options) + configs.params = { + page: params["page"], + limit: params["limit"], + search: params["search"], + userId: params["userId"], + orderBy: params["orderBy"], + order: params["order"], + markedAsDuplicate: params["markedAsDuplicate"], + listingId: params["listingId"], + includeDemographics: params["includeDemographics"], + } + let data = null + + configs.data = data + axios(configs, resolve, reject) + }) + } + /** + * Get application by id + */ + retrieve( + params: { + /** */ + id: string + } = {} as any, + options: IRequestOptions = {} + ): Promise { + return new Promise((resolve, reject) => { + let url = basePath + "/applications/{id}" + url = url.replace("{id}", params["id"] + "") + + const configs: IRequestConfig = getConfigs("get", "application/json", url, options) + + let data = null + + configs.data = data + axios(configs, resolve, reject) + }) + } + /** + * Update application by id + */ + update( + params: { + /** */ + id: string + /** requestBody */ + body?: ApplicationUpdate + } = {} as any, + options: IRequestOptions = {} + ): Promise { + return new Promise((resolve, reject) => { + let url = basePath + "/applications/{id}" + url = url.replace("{id}", params["id"] + "") + + const configs: IRequestConfig = getConfigs("put", "application/json", url, options) + + let data = params.body + + configs.data = data + axios(configs, resolve, reject) + }) + } + /** + * Delete application by id + */ + delete( + params: { + /** */ + id: string + } = {} as any, + options: IRequestOptions = {} + ): Promise { + return new Promise((resolve, reject) => { + let url = basePath + "/applications/{id}" + url = url.replace("{id}", params["id"] + "") + + const configs: IRequestConfig = getConfigs("delete", "application/json", url, options) + + let data = null + + configs.data = data + axios(configs, resolve, reject) + }) + } + /** + * Submit application + */ + submit( + params: { + /** requestBody */ + body?: ApplicationCreate + } = {} as any, + options: IRequestOptions = {} + ): Promise { + return new Promise((resolve, reject) => { + let url = basePath + "/applications/submit" + + const configs: IRequestConfig = getConfigs("post", "application/json", url, options) + + let data = params.body + + configs.data = data + axios(configs, resolve, reject) + }) + } +} + +export class AssetsService { + /** + * Create asset + */ + create( + params: { + /** requestBody */ + body?: AssetCreate + } = {} as any, + options: IRequestOptions = {} + ): Promise { + return new Promise((resolve, reject) => { + let url = basePath + "/assets" + + const configs: IRequestConfig = getConfigs("post", "application/json", url, options) + + let data = params.body + + configs.data = data + axios(configs, resolve, reject) + }) + } + /** + * List assets + */ + list( + params: { + /** */ + page?: number + /** */ + limit?: number + } = {} as any, + options: IRequestOptions = {} + ): Promise { + return new Promise((resolve, reject) => { + let url = basePath + "/assets" + + const configs: IRequestConfig = getConfigs("get", "application/json", url, options) + configs.params = { page: params["page"], limit: params["limit"] } + let data = null + + configs.data = data + axios(configs, resolve, reject) + }) + } + /** + * Create presigned upload metadata + */ + createPresignedUploadMetadata( + params: { + /** requestBody */ + body?: CreatePresignedUploadMetadata + } = {} as any, + options: IRequestOptions = {} + ): Promise { + return new Promise((resolve, reject) => { + let url = basePath + "/assets/presigned-upload-metadata" + + const configs: IRequestConfig = getConfigs("post", "application/json", url, options) + + let data = params.body + + configs.data = data + axios(configs, resolve, reject) + }) + } + /** + * Get asset by id + */ + retrieve( + params: { + /** */ + assetId: string + } = {} as any, + options: IRequestOptions = {} + ): Promise { + return new Promise((resolve, reject) => { + let url = basePath + "/assets/{assetId}" + url = url.replace("{assetId}", params["assetId"] + "") + + const configs: IRequestConfig = getConfigs("get", "application/json", url, options) + + let data = null + + configs.data = data + axios(configs, resolve, reject) + }) + } +} + +export class AuthService { + /** + * Login + */ + login( + params: { + /** requestBody */ + body?: Login + } = {} as any, + options: IRequestOptions = {} + ): Promise { + return new Promise((resolve, reject) => { + let url = basePath + "/auth/login" + + const configs: IRequestConfig = getConfigs("post", "application/json", url, options) + + let data = params.body + + configs.data = data + axios(configs, resolve, reject) + }) + } + /** + * Token + */ + token(options: IRequestOptions = {}): Promise { + return new Promise((resolve, reject) => { + let url = basePath + "/auth/token" + + const configs: IRequestConfig = getConfigs("post", "application/json", url, options) + + let data = null + + configs.data = data + axios(configs, resolve, reject) + }) + } + /** + * Request mfa code + */ + requestMfaCode( + params: { + /** requestBody */ + body?: RequestMfaCode + } = {} as any, + options: IRequestOptions = {} + ): Promise { + return new Promise((resolve, reject) => { + let url = basePath + "/auth/request-mfa-code" + + const configs: IRequestConfig = getConfigs("post", "application/json", url, options) + + let data = params.body + + configs.data = data + axios(configs, resolve, reject) + }) + } + /** + * Get mfa info + */ + getMfaInfo( + params: { + /** requestBody */ + body?: GetMfaInfo + } = {} as any, + options: IRequestOptions = {} + ): Promise { + return new Promise((resolve, reject) => { + let url = basePath + "/auth/mfa-info" + + const configs: IRequestConfig = getConfigs("post", "application/json", url, options) + + let data = params.body + + configs.data = data + axios(configs, resolve, reject) + }) + } +} + +export class UserService { + /** + * + */ + userControllerProfile(options: IRequestOptions = {}): Promise { + return new Promise((resolve, reject) => { + let url = basePath + "/user" + + const configs: IRequestConfig = getConfigs("get", "application/json", url, options) + + let data = null + + configs.data = data + axios(configs, resolve, reject) + }) + } + /** + * Create user + */ + create( + params: { + /** */ + noWelcomeEmail?: boolean + /** requestBody */ + body?: UserCreate + } = {} as any, + options: IRequestOptions = {} + ): Promise { + return new Promise((resolve, reject) => { + let url = basePath + "/user" + + const configs: IRequestConfig = getConfigs("post", "application/json", url, options) + configs.params = { noWelcomeEmail: params["noWelcomeEmail"] } + let data = params.body + + configs.data = data + axios(configs, resolve, reject) + }) + } + /** + * Verifies token is valid + */ + isUserConfirmationTokenValid( + params: { + /** requestBody */ + body?: Confirm + } = {} as any, + options: IRequestOptions = {} + ): Promise { + return new Promise((resolve, reject) => { + let url = basePath + "/user/is-confirmation-token-valid" + + const configs: IRequestConfig = getConfigs("post", "application/json", url, options) + + let data = params.body + + configs.data = data + axios(configs, resolve, reject) + }) + } + /** + * Resend confirmation + */ + resendConfirmation( + params: { + /** requestBody */ + body?: Email + } = {} as any, + options: IRequestOptions = {} + ): Promise { + return new Promise((resolve, reject) => { + let url = basePath + "/user/resend-confirmation" + + const configs: IRequestConfig = getConfigs("post", "application/json", url, options) + + let data = params.body + + configs.data = data + axios(configs, resolve, reject) + }) + } + /** + * Resend confirmation + */ + resendPartnerConfirmation( + params: { + /** requestBody */ + body?: Email + } = {} as any, + options: IRequestOptions = {} + ): Promise { + return new Promise((resolve, reject) => { + let url = basePath + "/user/resend-partner-confirmation" + + const configs: IRequestConfig = getConfigs("post", "application/json", url, options) + + let data = params.body + + configs.data = data + axios(configs, resolve, reject) + }) + } + /** + * Confirm email + */ + confirm( + params: { + /** requestBody */ + body?: Confirm + } = {} as any, + options: IRequestOptions = {} + ): Promise { + return new Promise((resolve, reject) => { + let url = basePath + "/user/confirm" + + const configs: IRequestConfig = getConfigs("put", "application/json", url, options) + + let data = params.body + + configs.data = data + axios(configs, resolve, reject) + }) + } + /** + * Forgot Password + */ + forgotPassword( + params: { + /** requestBody */ + body?: ForgotPassword + } = {} as any, + options: IRequestOptions = {} + ): Promise { + return new Promise((resolve, reject) => { + let url = basePath + "/user/forgot-password" + + const configs: IRequestConfig = getConfigs("put", "application/json", url, options) + + let data = params.body + + configs.data = data + axios(configs, resolve, reject) + }) + } + /** + * Update Password + */ + updatePassword( + params: { + /** requestBody */ + body?: UpdatePassword + } = {} as any, + options: IRequestOptions = {} + ): Promise { + return new Promise((resolve, reject) => { + let url = basePath + "/user/update-password" + + const configs: IRequestConfig = getConfigs("put", "application/json", url, options) + + let data = params.body + + configs.data = data + axios(configs, resolve, reject) + }) + } + /** + * Update user + */ + update( + params: { + /** requestBody */ + body?: UserUpdate + } = {} as any, + options: IRequestOptions = {} + ): Promise { + return new Promise((resolve, reject) => { + let url = basePath + "/user/{id}" + + const configs: IRequestConfig = getConfigs("put", "application/json", url, options) + + let data = params.body + + configs.data = data + axios(configs, resolve, reject) + }) + } + /** + * Get user by id + */ + retrieve( + params: { + /** */ + id: string + } = {} as any, + options: IRequestOptions = {} + ): Promise { + return new Promise((resolve, reject) => { + let url = basePath + "/user/{id}" + url = url.replace("{id}", params["id"] + "") + + const configs: IRequestConfig = getConfigs("get", "application/json", url, options) + + let data = null + + configs.data = data + axios(configs, resolve, reject) + }) + } + /** + * Delete user by id + */ + delete( + params: { + /** */ + id: string + } = {} as any, + options: IRequestOptions = {} + ): Promise { + return new Promise((resolve, reject) => { + let url = basePath + "/user/{id}" + url = url.replace("{id}", params["id"] + "") + + const configs: IRequestConfig = getConfigs("delete", "application/json", url, options) + + let data = null + + configs.data = data + axios(configs, resolve, reject) + }) + } + /** + * List users + */ + list( + params: { + /** */ + page?: number + /** */ + limit?: number | "all" + /** */ + filter?: UserFilterParams[] + } = {} as any, + options: IRequestOptions = {} + ): Promise { + return new Promise((resolve, reject) => { + let url = basePath + "/user/list" + + const configs: IRequestConfig = getConfigs("get", "application/json", url, options) + configs.params = { page: params["page"], limit: params["limit"], filter: params["filter"] } + let data = null + + configs.data = data + axios(configs, resolve, reject) + }) + } + /** + * Invite user + */ + invite( + params: { + /** requestBody */ + body?: UserInvite + } = {} as any, + options: IRequestOptions = {} + ): Promise { + return new Promise((resolve, reject) => { + let url = basePath + "/user/invite" + + const configs: IRequestConfig = getConfigs("post", "application/json", url, options) + + let data = params.body + + configs.data = data + axios(configs, resolve, reject) + }) + } +} + +export class UserProfileService { + /** + * Update profile user + */ + update( + params: { + /** requestBody */ + body?: UserProfileUpdate + } = {} as any, + options: IRequestOptions = {} + ): Promise { + return new Promise((resolve, reject) => { + let url = basePath + "/userProfile/{id}" + + const configs: IRequestConfig = getConfigs("put", "application/json", url, options) + + let data = params.body + + configs.data = data + axios(configs, resolve, reject) + }) + } +} + +export class UserPreferencesService { + /** + * Update user preferences + */ + update( + params: { + /** */ + id: string + /** requestBody */ + body?: UserPreferences + } = {} as any, + options: IRequestOptions = {} + ): Promise { + return new Promise((resolve, reject) => { + let url = basePath + "/userPreferences/{id}" + url = url.replace("{id}", params["id"] + "") + + const configs: IRequestConfig = getConfigs("put", "application/json", url, options) + + let data = params.body + + configs.data = data + axios(configs, resolve, reject) + }) + } +} + +export class JurisdictionsService { + /** + * List jurisdictions + */ + list(options: IRequestOptions = {}): Promise { + return new Promise((resolve, reject) => { + let url = basePath + "/jurisdictions" + + const configs: IRequestConfig = getConfigs("get", "application/json", url, options) + + let data = null + + configs.data = data + axios(configs, resolve, reject) + }) + } + /** + * Create jurisdiction + */ + create( + params: { + /** requestBody */ + body?: JurisdictionCreate + } = {} as any, + options: IRequestOptions = {} + ): Promise { + return new Promise((resolve, reject) => { + let url = basePath + "/jurisdictions" + + const configs: IRequestConfig = getConfigs("post", "application/json", url, options) + + let data = params.body + + configs.data = data + axios(configs, resolve, reject) + }) + } + /** + * Update jurisdiction + */ + update( + params: { + /** requestBody */ + body?: JurisdictionUpdate + } = {} as any, + options: IRequestOptions = {} + ): Promise { + return new Promise((resolve, reject) => { + let url = basePath + "/jurisdictions/{jurisdictionId}" + + const configs: IRequestConfig = getConfigs("put", "application/json", url, options) + + let data = params.body + + configs.data = data + axios(configs, resolve, reject) + }) + } + /** + * Get jurisdiction by id + */ + retrieve( + params: { + /** */ + jurisdictionId: string + } = {} as any, + options: IRequestOptions = {} + ): Promise { + return new Promise((resolve, reject) => { + let url = basePath + "/jurisdictions/{jurisdictionId}" + url = url.replace("{jurisdictionId}", params["jurisdictionId"] + "") + + const configs: IRequestConfig = getConfigs("get", "application/json", url, options) + + let data = null + + configs.data = data + axios(configs, resolve, reject) + }) + } + /** + * Delete jurisdiction by id + */ + delete( + params: { + /** */ + jurisdictionId: string + } = {} as any, + options: IRequestOptions = {} + ): Promise { + return new Promise((resolve, reject) => { + let url = basePath + "/jurisdictions/{jurisdictionId}" + url = url.replace("{jurisdictionId}", params["jurisdictionId"] + "") + + const configs: IRequestConfig = getConfigs("delete", "application/json", url, options) + + let data = null + + configs.data = data + axios(configs, resolve, reject) + }) + } + /** + * Get jurisdiction by name + */ + retrieveByName( + params: { + /** */ + jurisdictionName: string + } = {} as any, + options: IRequestOptions = {} + ): Promise { + return new Promise((resolve, reject) => { + let url = basePath + "/jurisdictions/byName/{jurisdictionName}" + url = url.replace("{jurisdictionName}", params["jurisdictionName"] + "") + + const configs: IRequestConfig = getConfigs("get", "application/json", url, options) + + let data = null + + configs.data = data + axios(configs, resolve, reject) + }) + } +} + +export class ListingsService { + /** + * List listings + */ + list( + params: { + /** */ + page?: number + /** */ + limit?: number | "all" + /** */ + filter?: ListingFilterParams[] + /** */ + view?: string + /** */ + orderBy?: OrderByFieldsEnum + } = {} as any, + options: IRequestOptions = {} + ): Promise { + return new Promise((resolve, reject) => { + let url = basePath + "/listings" + + const configs: IRequestConfig = getConfigs("get", "application/json", url, options) + configs.params = { + page: params["page"], + limit: params["limit"], + filter: params["filter"], + view: params["view"], + orderBy: params["orderBy"], + } + let data = null + + configs.data = data + axios(configs, resolve, reject) + }) + } + /** + * Create listing + */ + create( + params: { + /** requestBody */ + body?: ListingCreate + } = {} as any, + options: IRequestOptions = {} + ): Promise { + return new Promise((resolve, reject) => { + let url = basePath + "/listings" + + const configs: IRequestConfig = getConfigs("post", "application/json", url, options) + + let data = params.body + + configs.data = data + axios(configs, resolve, reject) + }) + } + /** + * Get listing by id + */ + retrieve( + params: { + /** */ + id: string + /** */ + view?: string + } = {} as any, + options: IRequestOptions = {} + ): Promise { + return new Promise((resolve, reject) => { + let url = basePath + "/listings/{id}" + url = url.replace("{id}", params["id"] + "") + + const configs: IRequestConfig = getConfigs("get", "application/json", url, options) + configs.params = { view: params["view"] } + let data = null + + configs.data = data + axios(configs, resolve, reject) + }) + } + /** + * Update listing by id + */ + update( + params: { + /** */ + id: string + /** requestBody */ + body?: ListingUpdate + } = {} as any, + options: IRequestOptions = {} + ): Promise { + return new Promise((resolve, reject) => { + let url = basePath + "/listings/{id}" + url = url.replace("{id}", params["id"] + "") + + const configs: IRequestConfig = getConfigs("put", "application/json", url, options) + + let data = params.body + + configs.data = data + axios(configs, resolve, reject) + }) + } + /** + * Delete listing by id + */ + delete( + params: { + /** */ + id: string + } = {} as any, + options: IRequestOptions = {} + ): Promise { + return new Promise((resolve, reject) => { + let url = basePath + "/listings/{id}" + url = url.replace("{id}", params["id"] + "") + + const configs: IRequestConfig = getConfigs("delete", "application/json", url, options) + + let data = null + + configs.data = data + axios(configs, resolve, reject) + }) + } +} + +export class PaperApplicationsService { + /** + * List paperApplications + */ + list(options: IRequestOptions = {}): Promise { + return new Promise((resolve, reject) => { + let url = basePath + "/paperApplications" + + const configs: IRequestConfig = getConfigs("get", "application/json", url, options) + + let data = null + + configs.data = data + axios(configs, resolve, reject) + }) + } + /** + * Create paperApplication + */ + create( + params: { + /** requestBody */ + body?: PaperApplicationCreate + } = {} as any, + options: IRequestOptions = {} + ): Promise { + return new Promise((resolve, reject) => { + let url = basePath + "/paperApplications" + + const configs: IRequestConfig = getConfigs("post", "application/json", url, options) + + let data = params.body + + configs.data = data + axios(configs, resolve, reject) + }) + } + /** + * Update paperApplication + */ + update( + params: { + /** requestBody */ + body?: PaperApplicationUpdate + } = {} as any, + options: IRequestOptions = {} + ): Promise { + return new Promise((resolve, reject) => { + let url = basePath + "/paperApplications/{paperApplicationId}" + + const configs: IRequestConfig = getConfigs("put", "application/json", url, options) + + let data = params.body + + configs.data = data + axios(configs, resolve, reject) + }) + } + /** + * Get paperApplication by id + */ + retrieve( + params: { + /** */ + paperApplicationId: string + } = {} as any, + options: IRequestOptions = {} + ): Promise { + return new Promise((resolve, reject) => { + let url = basePath + "/paperApplications/{paperApplicationId}" + url = url.replace("{paperApplicationId}", params["paperApplicationId"] + "") + + const configs: IRequestConfig = getConfigs("get", "application/json", url, options) + + let data = null + + configs.data = data + axios(configs, resolve, reject) + }) + } + /** + * Delete paperApplication by id + */ + delete( + params: { + /** */ + paperApplicationId: string + } = {} as any, + options: IRequestOptions = {} + ): Promise { + return new Promise((resolve, reject) => { + let url = basePath + "/paperApplications/{paperApplicationId}" + url = url.replace("{paperApplicationId}", params["paperApplicationId"] + "") + + const configs: IRequestConfig = getConfigs("delete", "application/json", url, options) + + let data = null + + configs.data = data + axios(configs, resolve, reject) + }) + } +} + +export class PreferencesService { + /** + * List preferences + */ + list( + params: { + /** */ + filter?: PreferencesFilterParams[] + } = {} as any, + options: IRequestOptions = {} + ): Promise { + return new Promise((resolve, reject) => { + let url = basePath + "/preferences" + + const configs: IRequestConfig = getConfigs("get", "application/json", url, options) + configs.params = { filter: params["filter"] } + let data = null + + configs.data = data + axios(configs, resolve, reject) + }) + } + /** + * Create preference + */ + create( + params: { + /** requestBody */ + body?: PreferenceCreate + } = {} as any, + options: IRequestOptions = {} + ): Promise { + return new Promise((resolve, reject) => { + let url = basePath + "/preferences" + + const configs: IRequestConfig = getConfigs("post", "application/json", url, options) + + let data = params.body + + configs.data = data + axios(configs, resolve, reject) + }) + } + /** + * Update preference + */ + update( + params: { + /** requestBody */ + body?: PreferenceUpdate + } = {} as any, + options: IRequestOptions = {} + ): Promise { + return new Promise((resolve, reject) => { + let url = basePath + "/preferences/{preferenceId}" + + const configs: IRequestConfig = getConfigs("put", "application/json", url, options) + + let data = params.body + + configs.data = data + axios(configs, resolve, reject) + }) + } + /** + * Get preference by id + */ + retrieve( + params: { + /** */ + preferenceId: string + } = {} as any, + options: IRequestOptions = {} + ): Promise { + return new Promise((resolve, reject) => { + let url = basePath + "/preferences/{preferenceId}" + url = url.replace("{preferenceId}", params["preferenceId"] + "") + + const configs: IRequestConfig = getConfigs("get", "application/json", url, options) + + let data = null + + configs.data = data + axios(configs, resolve, reject) + }) + } + /** + * Delete preference by id + */ + delete( + params: { + /** */ + preferenceId: string + } = {} as any, + options: IRequestOptions = {} + ): Promise { + return new Promise((resolve, reject) => { + let url = basePath + "/preferences/{preferenceId}" + url = url.replace("{preferenceId}", params["preferenceId"] + "") + + const configs: IRequestConfig = getConfigs("delete", "application/json", url, options) + + let data = null + + configs.data = data + axios(configs, resolve, reject) + }) + } +} + +export class ProgramsService { + /** + * List programs + */ + list( + params: { + /** */ + filter?: ProgramsFilterParams[] + } = {} as any, + options: IRequestOptions = {} + ): Promise { + return new Promise((resolve, reject) => { + let url = basePath + "/programs" + + const configs: IRequestConfig = getConfigs("get", "application/json", url, options) + configs.params = { filter: params["filter"] } + let data = null + + configs.data = data + axios(configs, resolve, reject) + }) + } + /** + * Create program + */ + create( + params: { + /** requestBody */ + body?: ProgramCreate + } = {} as any, + options: IRequestOptions = {} + ): Promise { + return new Promise((resolve, reject) => { + let url = basePath + "/programs" + + const configs: IRequestConfig = getConfigs("post", "application/json", url, options) + + let data = params.body + + configs.data = data + axios(configs, resolve, reject) + }) + } + /** + * Update program + */ + update( + params: { + /** requestBody */ + body?: ProgramUpdate + } = {} as any, + options: IRequestOptions = {} + ): Promise { + return new Promise((resolve, reject) => { + let url = basePath + "/programs/{programId}" + + const configs: IRequestConfig = getConfigs("put", "application/json", url, options) + + let data = params.body + + configs.data = data + axios(configs, resolve, reject) + }) + } + /** + * Get program by id + */ + retrieve( + params: { + /** */ + programId: string + } = {} as any, + options: IRequestOptions = {} + ): Promise { + return new Promise((resolve, reject) => { + let url = basePath + "/programs/{programId}" + url = url.replace("{programId}", params["programId"] + "") + + const configs: IRequestConfig = getConfigs("get", "application/json", url, options) + + let data = null + + configs.data = data + axios(configs, resolve, reject) + }) + } + /** + * Delete program by id + */ + delete( + params: { + /** */ + programId: string + } = {} as any, + options: IRequestOptions = {} + ): Promise { + return new Promise((resolve, reject) => { + let url = basePath + "/programs/{programId}" + url = url.replace("{programId}", params["programId"] + "") + + const configs: IRequestConfig = getConfigs("delete", "application/json", url, options) + + let data = null + + configs.data = data + axios(configs, resolve, reject) + }) + } +} + +export class PropertiesService { + /** + * List properties + */ + list(options: IRequestOptions = {}): Promise { + return new Promise((resolve, reject) => { + let url = basePath + "/properties" + + const configs: IRequestConfig = getConfigs("get", "application/json", url, options) + + let data = null + + configs.data = data + axios(configs, resolve, reject) + }) + } + /** + * Create property + */ + create( + params: { + /** requestBody */ + body?: PropertyCreate + } = {} as any, + options: IRequestOptions = {} + ): Promise { + return new Promise((resolve, reject) => { + let url = basePath + "/properties" + + const configs: IRequestConfig = getConfigs("post", "application/json", url, options) + + let data = params.body + + configs.data = data + axios(configs, resolve, reject) + }) + } + /** + * Update property + */ + update( + params: { + /** requestBody */ + body?: PropertyUpdate + } = {} as any, + options: IRequestOptions = {} + ): Promise { + return new Promise((resolve, reject) => { + let url = basePath + "/properties/{propertyId}" + + const configs: IRequestConfig = getConfigs("put", "application/json", url, options) + + let data = params.body + + configs.data = data + axios(configs, resolve, reject) + }) + } + /** + * Get property by id + */ + retrieve( + params: { + /** */ + propertyId: string + } = {} as any, + options: IRequestOptions = {} + ): Promise { + return new Promise((resolve, reject) => { + let url = basePath + "/properties/{propertyId}" + url = url.replace("{propertyId}", params["propertyId"] + "") + + const configs: IRequestConfig = getConfigs("get", "application/json", url, options) + + let data = null + + configs.data = data + axios(configs, resolve, reject) + }) + } + /** + * Delete property by id + */ + delete( + params: { + /** */ + propertyId: string + } = {} as any, + options: IRequestOptions = {} + ): Promise { + return new Promise((resolve, reject) => { + let url = basePath + "/properties/{propertyId}" + url = url.replace("{propertyId}", params["propertyId"] + "") + + const configs: IRequestConfig = getConfigs("delete", "application/json", url, options) + + let data = null + + configs.data = data + axios(configs, resolve, reject) + }) + } +} + +export class PropertyGroupsService { + /** + * List propertyGroups + */ + list(options: IRequestOptions = {}): Promise { + return new Promise((resolve, reject) => { + let url = basePath + "/propertyGroups" + + const configs: IRequestConfig = getConfigs("get", "application/json", url, options) + + let data = null + + configs.data = data + axios(configs, resolve, reject) + }) + } + /** + * Create propertyGroup + */ + create( + params: { + /** requestBody */ + body?: PropertyGroupCreate + } = {} as any, + options: IRequestOptions = {} + ): Promise { + return new Promise((resolve, reject) => { + let url = basePath + "/propertyGroups" + + const configs: IRequestConfig = getConfigs("post", "application/json", url, options) + + let data = params.body + + configs.data = data + axios(configs, resolve, reject) + }) + } + /** + * Update propertyGroup + */ + update( + params: { + /** requestBody */ + body?: PropertyGroupUpdate + } = {} as any, + options: IRequestOptions = {} + ): Promise { + return new Promise((resolve, reject) => { + let url = basePath + "/propertyGroups/{propertyGroupId}" + + const configs: IRequestConfig = getConfigs("put", "application/json", url, options) + + let data = params.body + + configs.data = data + axios(configs, resolve, reject) + }) + } + /** + * Get propertyGroup by id + */ + retrieve( + params: { + /** */ + propertyGroupId: string + } = {} as any, + options: IRequestOptions = {} + ): Promise { + return new Promise((resolve, reject) => { + let url = basePath + "/propertyGroups/{propertyGroupId}" + url = url.replace("{propertyGroupId}", params["propertyGroupId"] + "") + + const configs: IRequestConfig = getConfigs("get", "application/json", url, options) + + let data = null + + configs.data = data + axios(configs, resolve, reject) + }) + } + /** + * Delete propertyGroup by id + */ + delete( + params: { + /** */ + propertyGroupId: string + } = {} as any, + options: IRequestOptions = {} + ): Promise { + return new Promise((resolve, reject) => { + let url = basePath + "/propertyGroups/{propertyGroupId}" + url = url.replace("{propertyGroupId}", params["propertyGroupId"] + "") + + const configs: IRequestConfig = getConfigs("delete", "application/json", url, options) + + let data = null + + configs.data = data + axios(configs, resolve, reject) + }) + } +} + +export class ReservedCommunityTypesService { + /** + * List reservedCommunityTypes + */ + list( + params: { + /** */ + jurisdictionName?: string + } = {} as any, + options: IRequestOptions = {} + ): Promise { + return new Promise((resolve, reject) => { + let url = basePath + "/reservedCommunityTypes" + + const configs: IRequestConfig = getConfigs("get", "application/json", url, options) + configs.params = { jurisdictionName: params["jurisdictionName"] } + let data = null + + configs.data = data + axios(configs, resolve, reject) + }) + } + /** + * Create reservedCommunityType + */ + create( + params: { + /** requestBody */ + body?: ReservedCommunityTypeCreate + } = {} as any, + options: IRequestOptions = {} + ): Promise { + return new Promise((resolve, reject) => { + let url = basePath + "/reservedCommunityTypes" + + const configs: IRequestConfig = getConfigs("post", "application/json", url, options) + + let data = params.body + + configs.data = data + axios(configs, resolve, reject) + }) + } + /** + * Update reservedCommunityType + */ + update( + params: { + /** requestBody */ + body?: ReservedCommunityTypeUpdate + } = {} as any, + options: IRequestOptions = {} + ): Promise { + return new Promise((resolve, reject) => { + let url = basePath + "/reservedCommunityTypes/{reservedCommunityTypeId}" + + const configs: IRequestConfig = getConfigs("put", "application/json", url, options) + + let data = params.body + + configs.data = data + axios(configs, resolve, reject) + }) + } + /** + * Get reservedCommunityType by id + */ + retrieve( + params: { + /** */ + reservedCommunityTypeId: string + } = {} as any, + options: IRequestOptions = {} + ): Promise { + return new Promise((resolve, reject) => { + let url = basePath + "/reservedCommunityTypes/{reservedCommunityTypeId}" + url = url.replace("{reservedCommunityTypeId}", params["reservedCommunityTypeId"] + "") + + const configs: IRequestConfig = getConfigs("get", "application/json", url, options) + + let data = null + + configs.data = data + axios(configs, resolve, reject) + }) + } + /** + * Delete reservedCommunityType by id + */ + delete( + params: { + /** */ + reservedCommunityTypeId: string + } = {} as any, + options: IRequestOptions = {} + ): Promise { + return new Promise((resolve, reject) => { + let url = basePath + "/reservedCommunityTypes/{reservedCommunityTypeId}" + url = url.replace("{reservedCommunityTypeId}", params["reservedCommunityTypeId"] + "") + + const configs: IRequestConfig = getConfigs("delete", "application/json", url, options) + + let data = null + + configs.data = data + axios(configs, resolve, reject) + }) + } +} + +export class SmsService { + /** + * Send an SMS + */ + sendSms( + params: { + /** requestBody */ + body?: Sms + } = {} as any, + options: IRequestOptions = {} + ): Promise { + return new Promise((resolve, reject) => { + let url = basePath + "/sms" + + const configs: IRequestConfig = getConfigs("post", "application/json", url, options) + + let data = params.body + + configs.data = data + axios(configs, resolve, reject) + }) + } +} + +export class TranslationsService { + /** + * List translations + */ + list(options: IRequestOptions = {}): Promise { + return new Promise((resolve, reject) => { + let url = basePath + "/translations" + + const configs: IRequestConfig = getConfigs("get", "application/json", url, options) + + let data = null + + configs.data = data + axios(configs, resolve, reject) + }) + } + /** + * Create translation + */ + create( + params: { + /** requestBody */ + body?: TranslationCreate + } = {} as any, + options: IRequestOptions = {} + ): Promise { + return new Promise((resolve, reject) => { + let url = basePath + "/translations" + + const configs: IRequestConfig = getConfigs("post", "application/json", url, options) + + let data = params.body + + configs.data = data + axios(configs, resolve, reject) + }) + } + /** + * Update translation + */ + update( + params: { + /** requestBody */ + body?: TranslationUpdate + } = {} as any, + options: IRequestOptions = {} + ): Promise { + return new Promise((resolve, reject) => { + let url = basePath + "/translations/{translationId}" + + const configs: IRequestConfig = getConfigs("put", "application/json", url, options) + + let data = params.body + + configs.data = data + axios(configs, resolve, reject) + }) + } + /** + * Get translation by id + */ + retrieve( + params: { + /** */ + translationId: string + } = {} as any, + options: IRequestOptions = {} + ): Promise { + return new Promise((resolve, reject) => { + let url = basePath + "/translations/{translationId}" + url = url.replace("{translationId}", params["translationId"] + "") + + const configs: IRequestConfig = getConfigs("get", "application/json", url, options) + + let data = null + + configs.data = data + axios(configs, resolve, reject) + }) + } + /** + * Delete translation by id + */ + delete( + params: { + /** */ + translationId: string + } = {} as any, + options: IRequestOptions = {} + ): Promise { + return new Promise((resolve, reject) => { + let url = basePath + "/translations/{translationId}" + url = url.replace("{translationId}", params["translationId"] + "") + + const configs: IRequestConfig = getConfigs("delete", "application/json", url, options) + + let data = null + + configs.data = data + axios(configs, resolve, reject) + }) + } +} + +export class UnitsService { + /** + * List units + */ + list(options: IRequestOptions = {}): Promise { + return new Promise((resolve, reject) => { + let url = basePath + "/units" + + const configs: IRequestConfig = getConfigs("get", "application/json", url, options) + + let data = null + + configs.data = data + axios(configs, resolve, reject) + }) + } + /** + * Create unit + */ + create( + params: { + /** requestBody */ + body?: UnitCreate + } = {} as any, + options: IRequestOptions = {} + ): Promise { + return new Promise((resolve, reject) => { + let url = basePath + "/units" + + const configs: IRequestConfig = getConfigs("post", "application/json", url, options) + + let data = params.body + + configs.data = data + axios(configs, resolve, reject) + }) + } + /** + * Update unit + */ + update( + params: { + /** requestBody */ + body?: UnitUpdate + } = {} as any, + options: IRequestOptions = {} + ): Promise { + return new Promise((resolve, reject) => { + let url = basePath + "/units/{unitId}" + + const configs: IRequestConfig = getConfigs("put", "application/json", url, options) + + let data = params.body + + configs.data = data + axios(configs, resolve, reject) + }) + } + /** + * Get unit by id + */ + retrieve( + params: { + /** */ + unitId: string + } = {} as any, + options: IRequestOptions = {} + ): Promise { + return new Promise((resolve, reject) => { + let url = basePath + "/units/{unitId}" + url = url.replace("{unitId}", params["unitId"] + "") + + const configs: IRequestConfig = getConfigs("get", "application/json", url, options) + + let data = null + + configs.data = data + axios(configs, resolve, reject) + }) + } + /** + * Delete unit by id + */ + delete( + params: { + /** */ + unitId: string + } = {} as any, + options: IRequestOptions = {} + ): Promise { + return new Promise((resolve, reject) => { + let url = basePath + "/units/{unitId}" + url = url.replace("{unitId}", params["unitId"] + "") + + const configs: IRequestConfig = getConfigs("delete", "application/json", url, options) + + let data = null + + configs.data = data + axios(configs, resolve, reject) + }) + } +} + +export class UnitTypesService { + /** + * List unitTypes + */ + list(options: IRequestOptions = {}): Promise { + return new Promise((resolve, reject) => { + let url = basePath + "/unitTypes" + + const configs: IRequestConfig = getConfigs("get", "application/json", url, options) + + let data = null + + configs.data = data + axios(configs, resolve, reject) + }) + } + /** + * Create unitType + */ + create( + params: { + /** requestBody */ + body?: UnitTypeCreate + } = {} as any, + options: IRequestOptions = {} + ): Promise { + return new Promise((resolve, reject) => { + let url = basePath + "/unitTypes" + + const configs: IRequestConfig = getConfigs("post", "application/json", url, options) + + let data = params.body + + configs.data = data + axios(configs, resolve, reject) + }) + } + /** + * Update unitType + */ + update( + params: { + /** requestBody */ + body?: UnitTypeUpdate + } = {} as any, + options: IRequestOptions = {} + ): Promise { + return new Promise((resolve, reject) => { + let url = basePath + "/unitTypes/{unitTypeId}" + + const configs: IRequestConfig = getConfigs("put", "application/json", url, options) + + let data = params.body + + configs.data = data + axios(configs, resolve, reject) + }) + } + /** + * Get unitType by id + */ + retrieve( + params: { + /** */ + unitTypeId: string + } = {} as any, + options: IRequestOptions = {} + ): Promise { + return new Promise((resolve, reject) => { + let url = basePath + "/unitTypes/{unitTypeId}" + url = url.replace("{unitTypeId}", params["unitTypeId"] + "") + + const configs: IRequestConfig = getConfigs("get", "application/json", url, options) + + let data = null + + configs.data = data + axios(configs, resolve, reject) + }) + } + /** + * Delete unitType by id + */ + delete( + params: { + /** */ + unitTypeId: string + } = {} as any, + options: IRequestOptions = {} + ): Promise { + return new Promise((resolve, reject) => { + let url = basePath + "/unitTypes/{unitTypeId}" + url = url.replace("{unitTypeId}", params["unitTypeId"] + "") + + const configs: IRequestConfig = getConfigs("delete", "application/json", url, options) + + let data = null + + configs.data = data + axios(configs, resolve, reject) + }) + } +} + +export class UnitRentTypesService { + /** + * List unitRentTypes + */ + list(options: IRequestOptions = {}): Promise { + return new Promise((resolve, reject) => { + let url = basePath + "/unitRentTypes" + + const configs: IRequestConfig = getConfigs("get", "application/json", url, options) + + let data = null + + configs.data = data + axios(configs, resolve, reject) + }) + } + /** + * Create unitRentType + */ + create( + params: { + /** requestBody */ + body?: UnitRentTypeCreate + } = {} as any, + options: IRequestOptions = {} + ): Promise { + return new Promise((resolve, reject) => { + let url = basePath + "/unitRentTypes" + + const configs: IRequestConfig = getConfigs("post", "application/json", url, options) + + let data = params.body + + configs.data = data + axios(configs, resolve, reject) + }) + } + /** + * Update unitRentType + */ + update( + params: { + /** requestBody */ + body?: UnitRentTypeUpdate + } = {} as any, + options: IRequestOptions = {} + ): Promise { + return new Promise((resolve, reject) => { + let url = basePath + "/unitRentTypes/{unitRentTypeId}" + + const configs: IRequestConfig = getConfigs("put", "application/json", url, options) + + let data = params.body + + configs.data = data + axios(configs, resolve, reject) + }) + } + /** + * Get unitRentType by id + */ + retrieve( + params: { + /** */ + unitRentTypeId: string + } = {} as any, + options: IRequestOptions = {} + ): Promise { + return new Promise((resolve, reject) => { + let url = basePath + "/unitRentTypes/{unitRentTypeId}" + url = url.replace("{unitRentTypeId}", params["unitRentTypeId"] + "") + + const configs: IRequestConfig = getConfigs("get", "application/json", url, options) + + let data = null + + configs.data = data + axios(configs, resolve, reject) + }) + } + /** + * Delete unitRentType by id + */ + delete( + params: { + /** */ + unitRentTypeId: string + } = {} as any, + options: IRequestOptions = {} + ): Promise { + return new Promise((resolve, reject) => { + let url = basePath + "/unitRentTypes/{unitRentTypeId}" + url = url.replace("{unitRentTypeId}", params["unitRentTypeId"] + "") + + const configs: IRequestConfig = getConfigs("delete", "application/json", url, options) + + let data = null + + configs.data = data + axios(configs, resolve, reject) + }) + } +} + +export class UnitAccessibilityPriorityTypesService { + /** + * List unitAccessibilityPriorityTypes + */ + list(options: IRequestOptions = {}): Promise { + return new Promise((resolve, reject) => { + let url = basePath + "/unitAccessibilityPriorityTypes" + + const configs: IRequestConfig = getConfigs("get", "application/json", url, options) + + let data = null + + configs.data = data + axios(configs, resolve, reject) + }) + } + /** + * Create unitAccessibilityPriorityType + */ + create( + params: { + /** requestBody */ + body?: UnitAccessibilityPriorityTypeCreate + } = {} as any, + options: IRequestOptions = {} + ): Promise { + return new Promise((resolve, reject) => { + let url = basePath + "/unitAccessibilityPriorityTypes" + + const configs: IRequestConfig = getConfigs("post", "application/json", url, options) + + let data = params.body + + configs.data = data + axios(configs, resolve, reject) + }) + } + /** + * Update unitAccessibilityPriorityType + */ + update( + params: { + /** requestBody */ + body?: UnitAccessibilityPriorityTypeUpdate + } = {} as any, + options: IRequestOptions = {} + ): Promise { + return new Promise((resolve, reject) => { + let url = basePath + "/unitAccessibilityPriorityTypes/{unitAccessibilityPriorityTypeId}" + + const configs: IRequestConfig = getConfigs("put", "application/json", url, options) + + let data = params.body + + configs.data = data + axios(configs, resolve, reject) + }) + } + /** + * Get unitAccessibilityPriorityType by id + */ + retrieve( + params: { + /** */ + unitAccessibilityPriorityTypeId: string + } = {} as any, + options: IRequestOptions = {} + ): Promise { + return new Promise((resolve, reject) => { + let url = basePath + "/unitAccessibilityPriorityTypes/{unitAccessibilityPriorityTypeId}" + url = url.replace( + "{unitAccessibilityPriorityTypeId}", + params["unitAccessibilityPriorityTypeId"] + "" + ) + + const configs: IRequestConfig = getConfigs("get", "application/json", url, options) + + let data = null + + configs.data = data + axios(configs, resolve, reject) + }) + } + /** + * Delete unitAccessibilityPriorityType by id + */ + delete( + params: { + /** */ + unitAccessibilityPriorityTypeId: string + } = {} as any, + options: IRequestOptions = {} + ): Promise { + return new Promise((resolve, reject) => { + let url = basePath + "/unitAccessibilityPriorityTypes/{unitAccessibilityPriorityTypeId}" + url = url.replace( + "{unitAccessibilityPriorityTypeId}", + params["unitAccessibilityPriorityTypeId"] + "" + ) + + const configs: IRequestConfig = getConfigs("delete", "application/json", url, options) + + let data = null + + configs.data = data + axios(configs, resolve, reject) + }) + } +} + +export interface AmiChartItem { + /** */ + percentOfAmi: number + + /** */ + householdSize: number + + /** */ + income: number +} + +export interface Id { + /** */ + id: string +} + +export interface Jurisdiction { + /** */ + programs: Id[] + + /** */ + preferences: Id[] + + /** */ + id: string + + /** */ + createdAt: Date + + /** */ + updatedAt: Date + + /** */ + name: string + + /** */ + notificationsSignUpURL?: string + + /** */ + languages: EnumJurisdictionLanguages[] + + /** */ + partnerTerms?: string + + /** */ + publicUrl: string + + /** */ + emailFromAddress: string +} + +export interface AmiChart { + /** */ + items: AmiChartItem[] + + /** */ + jurisdiction: Jurisdiction + + /** */ + id: string + + /** */ + createdAt: Date + + /** */ + updatedAt: Date + + /** */ + name: string +} + +export interface AmiChartCreate { + /** */ + items: AmiChartItem[] + + /** */ + jurisdiction: Id + + /** */ + name: string +} + +export interface AmiChartUpdate { + /** */ + id?: string + + /** */ + createdAt?: Date + + /** */ + updatedAt?: Date + + /** */ + items: AmiChartItem[] + + /** */ + jurisdiction: Id + + /** */ + name: string +} + +export interface Address { + /** */ + id: string + + /** */ + createdAt: Date + + /** */ + updatedAt: Date + + /** */ + placeName?: string + + /** */ + city: string + + /** */ + county?: string + + /** */ + state: string + + /** */ + street: string + + /** */ + street2?: string + + /** */ + zipCode: string + + /** */ + latitude?: number + + /** */ + longitude?: number +} + +export interface Applicant { + /** */ + address: Address + + /** */ + workAddress: Address + + /** */ + id: string + + /** */ + createdAt: Date + + /** */ + updatedAt: Date + + /** */ + firstName?: string + + /** */ + middleName?: string + + /** */ + lastName?: string + + /** */ + birthMonth?: string + + /** */ + birthDay?: string + + /** */ + birthYear?: string + + /** */ + emailAddress?: string + + /** */ + noEmail?: boolean + + /** */ + phoneNumber?: string + + /** */ + phoneNumberType?: string + + /** */ + noPhone?: boolean + + /** */ + workInRegion?: string +} + +export interface AlternateContact { + /** */ + mailingAddress: Address + + /** */ + id: string + + /** */ + createdAt: Date + + /** */ + updatedAt: Date + + /** */ + type?: string + + /** */ + otherType?: string + + /** */ + firstName?: string + + /** */ + lastName?: string + + /** */ + agency?: string + + /** */ + phoneNumber?: string + + /** */ + emailAddress?: string +} + +export interface Accessibility { + /** */ + mobility?: boolean + + /** */ + vision?: boolean + + /** */ + hearing?: boolean + + /** */ + id: string + + /** */ + createdAt: Date + + /** */ + updatedAt: Date +} + +export interface Demographics { + /** */ + id: string + + /** */ + createdAt: Date + + /** */ + updatedAt: Date + + /** */ + ethnicity?: string + + /** */ + gender?: string + + /** */ + sexualOrientation?: string + + /** */ + howDidYouHear: string[] + + /** */ + race?: string[] +} + +export interface HouseholdMember { + /** */ + address: Address + + /** */ + workAddress: Address + + /** */ + id: string + + /** */ + createdAt: Date + + /** */ + updatedAt: Date + + /** */ + orderId?: number + + /** */ + firstName?: string + + /** */ + middleName?: string + + /** */ + lastName?: string + + /** */ + birthMonth?: string + + /** */ + birthDay?: string + + /** */ + birthYear?: string + + /** */ + emailAddress?: string + + /** */ + noEmail?: boolean + + /** */ + phoneNumber?: string + + /** */ + phoneNumberType?: string + + /** */ + noPhone?: boolean + + /** */ + sameAddress?: string + + /** */ + relationship?: string + + /** */ + workInRegion?: string +} + +export interface UnitType { + /** */ + id: string + + /** */ + createdAt: Date + + /** */ + updatedAt: Date + + /** */ + name: string + + /** */ + numBedrooms: number +} + +export interface ApplicationPreferenceOption { + /** */ + key: string + + /** */ + checked: boolean + + /** */ + extraData?: AllExtraDataTypes[] +} + +export interface ApplicationPreference { + /** */ + key: string + + /** */ + claimed: boolean + + /** */ + options: ApplicationPreferenceOption[] +} + +export interface ApplicationProgramOption { + /** */ + key: string + + /** */ + checked: boolean + + /** */ + extraData?: AllExtraDataTypes[] +} + +export interface ApplicationProgram { + /** */ + key: string + + /** */ + claimed: boolean + + /** */ + options: ApplicationProgramOption[] +} + +export interface Application { + /** */ + incomePeriod?: IncomePeriod + + /** */ + status: ApplicationStatus + + /** */ + language?: Language + + /** */ + submissionType: ApplicationSubmissionType + + /** */ + applicant: Applicant + + /** */ + listing: Id + + /** */ + user?: Id + + /** */ + mailingAddress: Address + + /** */ + alternateAddress: Address + + /** */ + alternateContact: AlternateContact + + /** */ + accessibility: Accessibility + + /** */ + demographics: Demographics + + /** */ + householdMembers: HouseholdMember[] + + /** */ + preferredUnit: UnitType[] + + /** */ + id: string + + /** */ + createdAt: Date + + /** */ + updatedAt: Date + + /** */ + deletedAt?: Date + + /** */ + appUrl?: string + + /** */ + additionalPhone?: boolean + + /** */ + additionalPhoneNumber?: string + + /** */ + additionalPhoneNumberType?: string + + /** */ + contactPreferences: string[] + + /** */ + householdSize?: number + + /** */ + housingStatus?: string + + /** */ + sendMailToMailingAddress?: boolean + + /** */ + householdExpectingChanges?: boolean + + /** */ + householdStudent?: boolean + + /** */ + incomeVouchers?: boolean + + /** */ + income?: string + + /** */ + preferences: ApplicationPreference[] + + /** */ + programs?: ApplicationProgram[] + + /** */ + acceptedTerms?: boolean + + /** */ + submissionDate?: Date + + /** */ + markedAsDuplicate: boolean + + /** */ + confirmationCode: string +} + +export interface ApplicationFlaggedSet { + /** */ + resolvingUser: Id + + /** */ + applications: Application[] + + /** */ + listing: Id + + /** */ + id: string + + /** */ + createdAt: Date + + /** */ + updatedAt: Date + + /** */ + rule: string + + /** */ + resolvedTime?: Date + + /** */ + status: EnumApplicationFlaggedSetStatus + + /** */ + listingId: string +} + +export interface ApplicationFlaggedSetPaginationMeta { + /** */ + totalFlagged: number + + /** */ + currentPage: number + + /** */ + itemCount: number + + /** */ + itemsPerPage: number + + /** */ + totalItems: number + + /** */ + totalPages: number +} + +export interface PaginatedApplicationFlaggedSet { + /** */ + items: ApplicationFlaggedSet[] + + /** */ + meta: ApplicationFlaggedSetPaginationMeta +} + +export interface ApplicationFlaggedSetResolve { + /** */ + afsId: string + + /** */ + applications: Id[] +} + +export interface Asset { + /** */ + id: string + + /** */ + createdAt: Date + + /** */ + updatedAt: Date + + /** */ + fileId: string + + /** */ + label: string +} + +export interface PaperApplication { + /** */ + language: Language + + /** */ + file?: CombinedFileTypes + + /** */ + id: string + + /** */ + createdAt: Date + + /** */ + updatedAt: Date +} + +export interface ApplicationMethod { + /** */ + type: ApplicationMethodType + + /** */ + paperApplications?: PaperApplication[] + + /** */ + listing: Id + + /** */ + id: string + + /** */ + createdAt: Date + + /** */ + updatedAt: Date + + /** */ + label?: string + + /** */ + externalReference?: string + + /** */ + acceptsPostmarkedApplications?: boolean + + /** */ + phoneNumber?: string +} + +export interface AssetCreate { + /** */ + fileId: string + + /** */ + label: string +} + +export interface PaperApplicationCreate { + /** */ + language: Language + + /** */ + file?: CombinedFileTypes +} + +export interface ApplicationMethodCreate { + /** */ + type: ApplicationMethodType + + /** */ + paperApplications?: PaperApplicationCreate[] + + /** */ + label?: string + + /** */ + externalReference?: string + + /** */ + acceptsPostmarkedApplications?: boolean + + /** */ + phoneNumber?: string + + /** */ + listing: Id +} + +export interface AssetUpdate { + /** */ + id?: string + + /** */ + createdAt?: Date + + /** */ + updatedAt?: Date + + /** */ + fileId: string + + /** */ + label: string +} + +export interface PaperApplicationUpdate { + /** */ + language: Language + + /** */ + id?: string + + /** */ + createdAt?: Date + + /** */ + updatedAt?: Date + + /** */ + file?: CombinedFileTypes +} + +export interface ApplicationMethodUpdate { + /** */ + type: ApplicationMethodType + + /** */ + id?: string + + /** */ + createdAt?: Date + + /** */ + updatedAt?: Date + + /** */ + paperApplications?: PaperApplicationUpdate[] + + /** */ + label?: string + + /** */ + externalReference?: string + + /** */ + acceptsPostmarkedApplications?: boolean + + /** */ + phoneNumber?: string + + /** */ + listing: Id +} + +export interface BooleanInput { + /** */ + type: InputType + + /** */ + key: string + + /** */ + value: boolean +} + +export interface TextInput { + /** */ + type: InputType + + /** */ + key: string + + /** */ + value: string +} + +export interface AddressCreate { + /** */ + placeName?: string + + /** */ + city: string + + /** */ + county?: string + + /** */ + state: string + + /** */ + street: string + + /** */ + street2?: string + + /** */ + zipCode: string + + /** */ + latitude?: number + + /** */ + longitude?: number +} + +export interface AddressInput { + /** */ + type: InputType + + /** */ + key: string + + /** */ + value: AddressCreate +} + +export interface ApplicationsApiExtraModel { + /** */ + orderBy?: EnumApplicationsApiExtraModelOrderBy + + /** */ + order?: EnumApplicationsApiExtraModelOrder +} + +export interface PaginationMeta { + /** */ + currentPage: number + + /** */ + itemCount: number + + /** */ + itemsPerPage: number + + /** */ + totalItems: number + + /** */ + totalPages: number +} + +export interface PaginatedApplication { + /** */ + items: Application[] + + /** */ + meta: PaginationMeta +} + +export interface ApplicantCreate { + /** */ + address: AddressCreate + + /** */ + workAddress: AddressCreate + + /** */ + firstName?: string + + /** */ + middleName?: string + + /** */ + lastName?: string + + /** */ + birthMonth?: string + + /** */ + birthDay?: string + + /** */ + birthYear?: string + + /** */ + emailAddress?: string + + /** */ + noEmail?: boolean + + /** */ + phoneNumber?: string + + /** */ + phoneNumberType?: string + + /** */ + noPhone?: boolean + + /** */ + workInRegion?: string +} + +export interface AlternateContactCreate { + /** */ + mailingAddress: AddressCreate + + /** */ + type?: string + + /** */ + otherType?: string + + /** */ + firstName?: string + + /** */ + lastName?: string + + /** */ + agency?: string + + /** */ + phoneNumber?: string + + /** */ + emailAddress?: string +} + +export interface AccessibilityCreate { + /** */ + mobility?: boolean + + /** */ + vision?: boolean + + /** */ + hearing?: boolean +} + +export interface DemographicsCreate { + /** */ + ethnicity?: string + + /** */ + gender?: string + + /** */ + sexualOrientation?: string + + /** */ + howDidYouHear: string[] + + /** */ + race?: string[] +} + +export interface HouseholdMemberCreate { + /** */ + address: AddressCreate + + /** */ + workAddress: AddressCreate + + /** */ + orderId?: number + + /** */ + firstName?: string + + /** */ + middleName?: string + + /** */ + lastName?: string + + /** */ + birthMonth?: string + + /** */ + birthDay?: string + + /** */ + birthYear?: string + + /** */ + emailAddress?: string + + /** */ + noEmail?: boolean + + /** */ + phoneNumber?: string + + /** */ + phoneNumberType?: string + + /** */ + noPhone?: boolean + + /** */ + sameAddress?: string + + /** */ + relationship?: string + + /** */ + workInRegion?: string +} + +export interface ApplicationCreate { + /** */ + incomePeriod?: IncomePeriod + + /** */ + status: ApplicationStatus + + /** */ + language?: Language + + /** */ + submissionType: ApplicationSubmissionType + + /** */ + listing: Id + + /** */ + applicant: ApplicantCreate + + /** */ + mailingAddress: AddressCreate + + /** */ + alternateAddress: AddressCreate + + /** */ + alternateContact: AlternateContactCreate + + /** */ + accessibility: AccessibilityCreate + + /** */ + demographics: DemographicsCreate + + /** */ + householdMembers: HouseholdMemberCreate[] + + /** */ + preferredUnit: Id[] + + /** */ + appUrl?: string + + /** */ + additionalPhone?: boolean + + /** */ + additionalPhoneNumber?: string + + /** */ + additionalPhoneNumberType?: string + + /** */ + contactPreferences: string[] + + /** */ + householdSize?: number + + /** */ + housingStatus?: string + + /** */ + sendMailToMailingAddress?: boolean + + /** */ + householdExpectingChanges?: boolean + + /** */ + householdStudent?: boolean + + /** */ + incomeVouchers?: boolean + + /** */ + income?: string + + /** */ + preferences: ApplicationPreference[] + + /** */ + programs?: ApplicationProgram[] + + /** */ + acceptedTerms?: boolean + + /** */ + submissionDate?: Date +} + +export interface AddressUpdate { + /** */ + id?: string + + /** */ + createdAt?: Date + + /** */ + updatedAt?: Date + + /** */ + placeName?: string + + /** */ + city: string + + /** */ + county?: string + + /** */ + state: string + + /** */ + street: string + + /** */ + street2?: string + + /** */ + zipCode: string + + /** */ + latitude?: number + + /** */ + longitude?: number +} + +export interface ApplicantUpdate { + /** */ + id?: string + + /** */ + createdAt?: Date + + /** */ + updatedAt?: Date + + /** */ + address: AddressUpdate + + /** */ + workAddress: AddressUpdate + + /** */ + firstName?: string + + /** */ + middleName?: string + + /** */ + lastName?: string + + /** */ + birthMonth?: string + + /** */ + birthDay?: string + + /** */ + birthYear?: string + + /** */ + emailAddress?: string + + /** */ + noEmail?: boolean + + /** */ + phoneNumber?: string + + /** */ + phoneNumberType?: string + + /** */ + noPhone?: boolean + + /** */ + workInRegion?: string +} + +export interface AlternateContactUpdate { + /** */ + id?: string + + /** */ + createdAt?: Date + + /** */ + updatedAt?: Date + + /** */ + mailingAddress: AddressUpdate + + /** */ + type?: string + + /** */ + otherType?: string + + /** */ + firstName?: string + + /** */ + lastName?: string + + /** */ + agency?: string + + /** */ + phoneNumber?: string + + /** */ + emailAddress?: string +} + +export interface AccessibilityUpdate { + /** */ + id?: string + + /** */ + createdAt?: Date + + /** */ + updatedAt?: Date + + /** */ + mobility?: boolean + + /** */ + vision?: boolean + + /** */ + hearing?: boolean +} + +export interface DemographicsUpdate { + /** */ + id?: string + + /** */ + createdAt?: Date + + /** */ + updatedAt?: Date + + /** */ + ethnicity?: string + + /** */ + gender?: string + + /** */ + sexualOrientation?: string + + /** */ + howDidYouHear: string[] + + /** */ + race?: string[] +} + +export interface HouseholdMemberUpdate { + /** */ + id?: string + + /** */ + createdAt?: Date + + /** */ + updatedAt?: Date + + /** */ + address: AddressUpdate + + /** */ + workAddress: AddressUpdate + + /** */ + orderId?: number + + /** */ + firstName?: string + + /** */ + middleName?: string + + /** */ + lastName?: string + + /** */ + birthMonth?: string + + /** */ + birthDay?: string + + /** */ + birthYear?: string + + /** */ + emailAddress?: string + + /** */ + noEmail?: boolean + + /** */ + phoneNumber?: string + + /** */ + phoneNumberType?: string + + /** */ + noPhone?: boolean + + /** */ + sameAddress?: string + + /** */ + relationship?: string + + /** */ + workInRegion?: string +} + +export interface ApplicationUpdate { + /** */ + incomePeriod?: IncomePeriod + + /** */ + status: ApplicationStatus + + /** */ + language?: Language + + /** */ + submissionType: ApplicationSubmissionType + + /** */ + id?: string + + /** */ + createdAt?: Date + + /** */ + updatedAt?: Date + + /** */ + deletedAt?: Date + + /** */ + listing: Id + + /** */ + applicant: ApplicantUpdate + + /** */ + mailingAddress: AddressUpdate + + /** */ + alternateAddress: AddressUpdate + + /** */ + alternateContact: AlternateContactUpdate + + /** */ + accessibility: AccessibilityUpdate + + /** */ + demographics: DemographicsUpdate + + /** */ + householdMembers: HouseholdMemberUpdate[] + + /** */ + preferredUnit: Id[] + + /** */ + appUrl?: string + + /** */ + additionalPhone?: boolean + + /** */ + additionalPhoneNumber?: string + + /** */ + additionalPhoneNumberType?: string + + /** */ + contactPreferences: string[] + + /** */ + householdSize?: number + + /** */ + housingStatus?: string + + /** */ + sendMailToMailingAddress?: boolean + + /** */ + householdExpectingChanges?: boolean + + /** */ + householdStudent?: boolean + + /** */ + incomeVouchers?: boolean + + /** */ + income?: string + + /** */ + preferences: ApplicationPreference[] + + /** */ + programs?: ApplicationProgram[] + + /** */ + acceptedTerms?: boolean + + /** */ + submissionDate?: Date +} + +export interface CreatePresignedUploadMetadata { + /** */ + parametersToSign: object +} + +export interface CreatePresignedUploadMetadataResponse { + /** */ + signature: string +} + +export interface PaginatedAssets { + /** */ + items: Asset[] + + /** */ + meta: PaginationMeta +} + +export interface UserErrorExtraModel { + /** */ + userErrorMessages: EnumUserErrorExtraModelUserErrorMessages +} + +export interface Login { + /** */ + email: string + + /** */ + password: string + + /** */ + mfaCode?: string + + /** */ + mfaType?: EnumLoginMfaType +} + +export interface LoginResponse { + /** */ + accessToken: string +} + +export interface RequestMfaCode { + /** */ + email: string + + /** */ + password: string + + /** */ + mfaType: EnumRequestMfaCodeMfaType + + /** */ + phoneNumber?: string +} + +export interface RequestMfaCodeResponse { + /** */ + phoneNumber?: string + + /** */ + email?: string + + /** */ + phoneNumberVerified?: boolean +} + +export interface GetMfaInfo { + /** */ + email: string + + /** */ + password: string +} + +export interface GetMfaInfoResponse { + /** */ + phoneNumber?: string + + /** */ + email?: string + + /** */ + isMfaEnabled: boolean + + /** */ + mfaUsedInThePast: boolean +} + +export interface IdName { + /** */ + id: string + + /** */ + name: string +} + +export interface UserRoles { + /** */ + user: Id + + /** */ + isAdmin?: boolean + + /** */ + isPartner?: boolean +} + +export interface UserPreferences { + /** */ + sendEmailNotifications?: boolean + + /** */ + sendSmsNotifications?: boolean + + /** */ + favoriteIds?: string[] +} + +export interface User { + /** */ + language?: Language + + /** */ + leasingAgentInListings?: IdName[] + + /** */ + roles?: CombinedRolesTypes + + /** */ + jurisdictions: Jurisdiction[] + + /** */ + preferences?: CombinedPreferencesTypes + + /** */ + id: string + + /** */ + passwordUpdatedAt: Date + + /** */ + passwordValidForDays: number + + /** */ + confirmedAt?: Date + + /** */ + email: string + + /** */ + firstName: string + + /** */ + middleName?: string + + /** */ + lastName: string + + /** */ + dob?: Date + + /** */ + phoneNumber?: string + + /** */ + createdAt: Date + + /** */ + updatedAt: Date + + /** */ + mfaEnabled?: boolean + + /** */ + lastLoginAt?: Date + + /** */ + failedLoginAttemptsCount?: number + + /** */ + phoneNumberVerified?: boolean + + /** */ + hitConfirmationURL?: Date +} + +export interface UserCreate { + /** */ + language?: Language + + /** */ + password: string + + /** */ + passwordConfirmation: string + + /** */ + emailConfirmation: string + + /** */ + appUrl?: string + + /** */ + jurisdictions?: Id[] + + /** */ + email: string + + /** */ + confirmedAt?: Date + + /** */ + firstName: string + + /** */ + middleName?: string + + /** */ + lastName: string + + /** */ + dob?: Date + + /** */ + phoneNumber?: string + + /** */ + phoneNumberVerified?: boolean + + /** */ + hitConfirmationURL?: Date + + /** */ + preferences?: CombinedPreferencesTypes +} + +export interface UserBasic { + /** */ + language?: Language + + /** */ + roles: UserRoles + + /** */ + jurisdictions: Jurisdiction[] + + /** */ + leasingAgentInListings?: Id[] + + /** */ + preferences?: CombinedPreferencesTypes + + /** */ + id: string + + /** */ + passwordUpdatedAt: Date + + /** */ + passwordValidForDays: number + + /** */ + confirmedAt?: Date + + /** */ + email: string + + /** */ + firstName: string + + /** */ + middleName?: string + + /** */ + lastName: string + + /** */ + dob?: Date + + /** */ + phoneNumber?: string + + /** */ + createdAt: Date + + /** */ + updatedAt: Date + + /** */ + mfaEnabled?: boolean + + /** */ + lastLoginAt?: Date + + /** */ + failedLoginAttemptsCount?: number + + /** */ + phoneNumberVerified?: boolean + + /** */ + hitConfirmationURL?: Date +} + +export interface Confirm { + /** */ + token: string + + /** */ + password?: string +} + +export interface Email { + /** */ + email: string + + /** */ + appUrl?: string +} + +export interface Status { + /** */ + status: string +} + +export interface ForgotPassword { + /** */ + email: string + + /** */ + appUrl?: string +} + +export interface ForgotPasswordResponse { + /** */ + message: string +} + +export interface UpdatePassword { + /** */ + password: string + + /** */ + passwordConfirmation: string + + /** */ + token: string +} + +export interface UserRolesUpdate { + /** */ + isAdmin?: boolean + + /** */ + isPartner?: boolean +} + +export interface UserUpdate { + /** */ + language?: Language + + /** */ + id?: string + + /** */ + email?: string + + /** */ + createdAt?: Date + + /** */ + updatedAt?: Date + + /** */ + password?: string + + /** */ + currentPassword?: string + + /** */ + roles?: CombinedRolesTypes + + /** */ + jurisdictions: Id[] + + /** */ + leasingAgentInListings?: Id[] + + /** */ + newEmail?: string + + /** */ + appUrl?: string + + /** */ + confirmedAt?: Date + + /** */ + firstName: string + + /** */ + middleName?: string + + /** */ + lastName: string + + /** */ + dob?: Date + + /** */ + phoneNumber?: string + + /** */ + phoneNumberVerified?: boolean + + /** */ + hitConfirmationURL?: Date + + /** */ + preferences?: CombinedPreferencesTypes +} + +export interface UserFilterParams { + /** */ + $comparison: EnumUserFilterParamsComparison + + /** */ + $include_nulls?: boolean + + /** */ + isPartner?: boolean + + /** */ + isPortalUser?: boolean +} + +export interface PaginatedUserList { + /** */ + items: User[] + + /** */ + meta: PaginationMeta +} + +export interface UserRolesCreate { + /** */ + isAdmin?: boolean + + /** */ + isPartner?: boolean +} + +export interface UserInvite { + /** */ + language?: Language + + /** */ + roles: CombinedRolesTypes + + /** */ + jurisdictions: Id[] + + /** */ + leasingAgentInListings?: Id[] + + /** */ + confirmedAt?: Date + + /** */ + email: string + + /** */ + firstName: string + + /** */ + middleName?: string + + /** */ + lastName: string + + /** */ + dob?: Date + + /** */ + phoneNumber?: string + + /** */ + phoneNumberVerified?: boolean + + /** */ + hitConfirmationURL?: Date + + /** */ + preferences?: CombinedPreferencesTypes +} + +export interface UserProfileUpdate { + /** */ + language?: Language + + /** */ + password?: string + + /** */ + currentPassword?: string + + /** */ + jurisdictions: Id[] + + /** */ + newEmail?: string + + /** */ + appUrl?: string + + /** */ + preferences?: UserPreferences + + /** */ + id: string + + /** */ + firstName: string + + /** */ + middleName?: string + + /** */ + lastName: string + + /** */ + dob?: Date + + /** */ + createdAt: Date + + /** */ + updatedAt: Date + + /** */ + phoneNumber?: string +} + +export interface JurisdictionCreate { + /** */ + name: string + + /** */ + notificationsSignUpURL?: string + + /** */ + languages: EnumJurisdictionCreateLanguages[] + + /** */ + partnerTerms?: string + + /** */ + publicUrl: string + + /** */ + emailFromAddress: string + + /** */ + programs: Id[] + + /** */ + preferences: Id[] +} + +export interface JurisdictionUpdate { + /** */ + id?: string + + /** */ + createdAt?: Date + + /** */ + updatedAt?: Date + + /** */ + name: string + + /** */ + notificationsSignUpURL?: string + + /** */ + languages: EnumJurisdictionUpdateLanguages[] + + /** */ + partnerTerms?: string + + /** */ + publicUrl: string + + /** */ + emailFromAddress: string + + /** */ + programs: Id[] + + /** */ + preferences: Id[] +} + +export interface ListingFilterParams { + /** */ + $comparison: EnumListingFilterParamsComparison + + /** */ + $include_nulls?: boolean + + /** */ + id?: string + + /** */ + name?: string + + /** */ + status?: EnumListingFilterParamsStatus + + /** */ + bedrooms?: string + + /** */ + zipcode?: string + + /** */ + leasingAgents?: string + + /** */ + availability?: EnumListingFilterParamsAvailability + + /** */ + program?: string + + /** */ + isVerified?: boolean + + /** */ + minRent?: number + + /** */ + maxRent?: number + + /** */ + minAmiPercentage?: number + + /** */ + elevator?: boolean + + /** */ + wheelchairRamp?: boolean + + /** */ + serviceAnimalsAllowed?: boolean + + /** */ + accessibleParking?: boolean + + /** */ + parkingOnSite?: boolean + + /** */ + inUnitWasherDryer?: boolean + + /** */ + laundryInBuilding?: boolean + + /** */ + barrierFreeEntrance?: boolean + + /** */ + rollInShower?: boolean + + /** */ + grabBars?: boolean + + /** */ + heatingInUnit?: boolean + + /** */ + acInUnit?: boolean + + /** */ + neighborhood?: string + + /** */ + region?: EnumListingFilterParamsRegion + + /** */ + jurisdiction?: string + + /** */ + marketingType?: EnumListingFilterParamsMarketingType + + /** */ + favorited?: string +} + +export interface MinMax { + /** */ + min: number + + /** */ + max: number +} + +export interface MinMaxCurrency { + /** */ + min: string + + /** */ + max: string +} + +export interface UnitGroupSummary { + /** */ + unitTypes?: string[] + + /** */ + rentAsPercentIncomeRange?: MinMax + + /** */ + rentRange?: MinMaxCurrency + + /** */ + amiPercentageRange: MinMax + + /** */ + openWaitlist: boolean + + /** */ + unitVacancies: number + + /** */ + floorRange?: MinMax + + /** */ + sqFeetRange?: MinMax + + /** */ + bathroomRange?: MinMax +} + +export interface HMIColumns { + /** */ + "20"?: number + + /** */ + "25"?: number + + /** */ + "30"?: number + + /** */ + "35"?: number + + /** */ + "40"?: number + + /** */ + "45"?: number + + /** */ + "50"?: number + + /** */ + "55"?: number + + /** */ + "60"?: number + + /** */ + "70"?: number + + /** */ + "80"?: number + + /** */ + "100"?: number + + /** */ + "120"?: number + + /** */ + "125"?: number + + /** */ + "140"?: number + + /** */ + "150"?: number + + /** */ + householdSize: string +} + +export interface HouseholdMaxIncomeSummary { + /** */ + columns: HMIColumns + + /** */ + rows: HMIColumns[] +} + +export interface UnitSummaries { + /** */ + unitGroupSummary: UnitGroupSummary[] + + /** */ + householdMaxIncomeSummary: HouseholdMaxIncomeSummary +} + +export interface Asset { + /** */ + fileId: string + + /** */ + label: string + + /** */ + id: string + + /** */ + createdAt: Date + + /** */ + updatedAt: Date +} + +export interface ListingEvent { + /** */ + type: ListingEventType + + /** */ + id: string + + /** */ + createdAt: Date + + /** */ + updatedAt: Date + + /** */ + startTime?: Date + + /** */ + endTime?: Date + + /** */ + url?: string + + /** */ + note?: string + + /** */ + label?: string + + /** */ + file?: Asset +} + +export interface ListingImage { + /** */ + image: AssetUpdate + + /** */ + ordinal?: number +} + +export interface FormMetadataExtraData { + /** */ + type: InputType + + /** */ + key: string +} + +export interface FormMetadataOptions { + /** */ + key: string + + /** */ + extraData?: FormMetadataExtraData[] + + /** */ + description: boolean + + /** */ + exclusive: boolean +} + +export interface FormMetadata { + /** */ + key: string + + /** */ + options: FormMetadataOptions[] + + /** */ + hideGenericDecline: boolean + + /** */ + customSelectText: string + + /** */ + hideFromListing: boolean + + /** */ + type: FormMetaDataType +} + +export interface Program { + /** */ + id: string + + /** */ + createdAt: Date + + /** */ + updatedAt: Date + + /** */ + title?: string + + /** */ + subtitle?: string + + /** */ + description?: string + + /** */ + formMetadata?: FormMetadata +} + +export interface ListingProgram { + /** */ + program: Program + + /** */ + ordinal?: number +} + +export interface PreferenceLink { + /** */ + title: string + + /** */ + url: string +} + +export interface Preference { + /** */ + links?: PreferenceLink[] + + /** */ + id: string + + /** */ + createdAt: Date + + /** */ + updatedAt: Date + + /** */ + title?: string + + /** */ + subtitle?: string + + /** */ + description?: string + + /** */ + formMetadata?: FormMetadata +} + +export interface ListingPreference { + /** */ + preference: Preference + + /** */ + ordinal?: number +} + +export interface JurisdictionSlim { + /** */ + id: string + + /** */ + name: string + + /** */ + publicUrl: string +} + +export interface ReservedCommunityType { + /** */ + jurisdiction: Jurisdiction + + /** */ + id: string + + /** */ + createdAt: Date + + /** */ + updatedAt: Date + + /** */ + name: string + + /** */ + description?: string +} + +export interface UnitRentType { + /** */ + id: string + + /** */ + createdAt: Date + + /** */ + updatedAt: Date + + /** */ + name: string +} + +export interface UnitAccessibilityPriorityType { + /** */ + id: string + + /** */ + createdAt: Date + + /** */ + updatedAt: Date + + /** */ + name: string +} + +export interface UnitAmiChartOverride { + /** */ + id: string + + /** */ + createdAt: Date + + /** */ + updatedAt: Date + + /** */ + items: AmiChartItem[] +} + +export interface Unit { + /** */ + status: UnitStatus + + /** */ + amiChart?: Id + + /** */ + unitType?: UnitType + + /** */ + unitRentType?: UnitRentType + + /** */ + priorityType?: UnitAccessibilityPriorityType + + /** */ + amiChartOverride?: UnitAmiChartOverride + + /** */ + id: string + + /** */ + createdAt: Date + + /** */ + updatedAt: Date + + /** */ + amiPercentage?: string + + /** */ + annualIncomeMin?: string + + /** */ + monthlyIncomeMin?: string + + /** */ + floor?: number + + /** */ + annualIncomeMax?: string + + /** */ + maxOccupancy?: number + + /** */ + minOccupancy?: number + + /** */ + monthlyRent?: string + + /** */ + numBathrooms?: number + + /** */ + numBedrooms?: number + + /** */ + number?: string + + /** */ + sqFeet?: string + + /** */ + monthlyRentAsPercentOfIncome?: string + + /** */ + bmrProgramChart?: boolean +} + +export interface UnitGroupAmiLevel { + /** */ + monthlyRentDeterminationType: MonthlyRentDeterminationType + + /** */ + amiChart?: Id + + /** */ + id: string + + /** */ + amiChartId?: string + + /** */ + amiPercentage: number + + /** */ + flatRentValue?: number + + /** */ + percentageOfIncomeValue?: number +} + +export interface UnitAccessibilityPriorityType { + /** */ + name: string + + /** */ + id: string + + /** */ + createdAt: Date + + /** */ + updatedAt: Date +} + +export interface UnitGroup { + /** */ + unitType: UnitType[] + + /** */ + amiLevels: UnitGroupAmiLevel[] + + /** */ + id: string + + /** */ + listingId: string + + /** */ + maxOccupancy?: number + + /** */ + minOccupancy?: number + + /** */ + floorMin?: number + + /** */ + floorMax?: number + + /** */ + sqFeetMin?: number + + /** */ + sqFeetMax?: number + + /** */ + priorityType?: CombinedPriorityTypeTypes + + /** */ + totalCount?: number + + /** */ + totalAvailable?: number + + /** */ + bathroomMin?: number + + /** */ + bathroomMax?: number + + /** */ + openWaitlist: boolean +} + +export interface ListingFeatures { + /** */ + elevator?: boolean + + /** */ + wheelchairRamp?: boolean + + /** */ + serviceAnimalsAllowed?: boolean + + /** */ + accessibleParking?: boolean + + /** */ + parkingOnSite?: boolean + + /** */ + inUnitWasherDryer?: boolean + + /** */ + laundryInBuilding?: boolean + + /** */ + barrierFreeEntrance?: boolean + + /** */ + rollInShower?: boolean + + /** */ + grabBars?: boolean + + /** */ + heatingInUnit?: boolean + + /** */ + acInUnit?: boolean + + /** */ + hearing?: boolean + + /** */ + visual?: boolean + + /** */ + mobility?: boolean +} + +export interface Listing { + /** */ + referralApplication?: ApplicationMethod + + /** */ + applicationPickUpAddressType?: ListingApplicationAddressType + + /** */ + applicationDropOffAddressType?: ListingApplicationAddressType + + /** */ + applicationMailingAddressType?: ListingApplicationAddressType + + /** */ + status: ListingStatus + + /** */ + reviewOrderType?: ListingReviewOrder + + /** */ + showWaitlist: boolean + + /** */ + unitSummaries: UnitSummaries + + /** */ + marketingType: ListingMarketingTypeEnum + + /** */ + marketingSeason?: ListingSeasonEnum + + /** */ + region?: Region + + /** */ + applicationMethods: ApplicationMethod[] + + /** */ + applicationPickUpAddress?: CombinedApplicationPickUpAddressTypes + + /** */ + applicationDropOffAddress: CombinedApplicationDropOffAddressTypes + + /** */ + applicationMailingAddress: CombinedApplicationMailingAddressTypes + + /** */ + buildingSelectionCriteriaFile?: CombinedBuildingSelectionCriteriaFileTypes + + /** */ + events: ListingEvent[] + + /** */ + images?: ListingImage[] + + /** */ + leasingAgentAddress?: CombinedLeasingAgentAddressTypes + + /** */ + leasingAgents?: UserBasic[] + + /** */ + listingPrograms?: ListingProgram[] + + /** */ + listingPreferences: ListingPreference[] + + /** */ + jurisdiction: JurisdictionSlim + + /** */ + reservedCommunityType?: ReservedCommunityType + + /** */ + result?: CombinedResultTypes + + /** */ + units: Unit[] + + /** */ + accessibility?: string + + /** */ + amenities?: string + + /** */ + buildingAddress: Address + + /** */ + buildingTotalUnits?: number + + /** */ + developer?: string + + /** */ + householdSizeMax?: number + + /** */ + householdSizeMin?: number + + /** */ + neighborhood?: string + + /** */ + petPolicy?: string + + /** */ + smokingPolicy?: string + + /** */ + unitsAvailable?: number + + /** */ + unitAmenities?: string + + /** */ + servicesOffered?: string + + /** */ + yearBuilt?: number + + /** */ + urlSlug: string + + /** */ + unitGroups?: UnitGroup[] + + /** */ + countyCode?: string + + /** */ + features?: ListingFeatures + + /** */ + id: string + + /** */ + createdAt: Date + + /** */ + updatedAt: Date + + /** */ + hrdId?: string + + /** */ + additionalApplicationSubmissionNotes?: string + + /** */ + digitalApplication?: boolean + + /** */ + commonDigitalApplication?: boolean + + /** */ + paperApplication?: boolean + + /** */ + referralOpportunity?: boolean + + /** */ + assets: AssetCreate[] + + /** */ + applicationDueDate?: Date + + /** */ + applicationOpenDate?: Date + + /** */ + applicationFee?: string + + /** */ + applicationOrganization?: string + + /** */ + applicationPickUpAddressOfficeHours?: string + + /** */ + applicationDropOffAddressOfficeHours?: string + + /** */ + buildingSelectionCriteria?: string + + /** */ + costsNotIncluded?: string + + /** */ + creditHistory?: string + + /** */ + criminalBackground?: string + + /** */ + depositMin?: string + + /** */ + depositMax?: string + + /** */ + depositHelperText?: string + + /** */ + disableUnitsAccordion?: boolean + + /** */ + leasingAgentEmail?: string + + /** */ + leasingAgentName?: string + + /** */ + leasingAgentOfficeHours?: string + + /** */ + leasingAgentPhone?: string + + /** */ + leasingAgentTitle?: string + + /** */ + name: string + + /** */ + postmarkedApplicationsReceivedByDate?: Date + + /** */ + programRules?: string + + /** */ + rentalAssistance?: string + + /** */ + rentalHistory?: string + + /** */ + requiredDocuments?: string + + /** */ + specialNotes?: string + + /** */ + waitlistCurrentSize?: number + + /** */ + waitlistMaxSize?: number + + /** */ + whatToExpect?: string + + /** */ + applicationConfig?: object + + /** */ + displayWaitlistSize: boolean + + /** */ + reservedCommunityDescription?: string + + /** */ + reservedCommunityMinAge?: number + + /** */ + resultLink?: string + + /** */ + isWaitlistOpen?: boolean + + /** */ + waitlistOpenSpots?: number + + /** */ + ownerCompany?: string + + /** */ + managementCompany?: string + + /** */ + managementWebsite?: string + + /** */ + amiPercentageMin?: number + + /** */ + amiPercentageMax?: number + + /** */ + customMapPin?: boolean + + /** */ + phoneNumber?: string + + /** */ + publishedAt?: Date + + /** */ + closedAt?: Date + + /** */ + isVerified?: boolean + + /** */ + temporaryListingId?: number + + /** */ + marketingDate?: Date +} + +export interface PaginatedListing { + /** */ + items: Listing[] + + /** */ + meta: PaginationMeta +} + +export interface ListingEventCreate { + /** */ + type: ListingEventType + + /** */ + file?: AssetCreate + + /** */ + startTime?: Date + + /** */ + endTime?: Date + + /** */ + url?: string + + /** */ + note?: string + + /** */ + label?: string +} + +export interface ListingImageUpdate { + /** */ + image: AssetUpdate + + /** */ + ordinal?: number +} + +export interface UnitAmiChartOverrideCreate { + /** */ + items: AmiChartItem[] +} + +export interface UnitCreate { + /** */ + status: UnitStatus + + /** */ + amiChart?: Id + + /** */ + unitType?: Id + + /** */ + unitRentType?: Id + + /** */ + priorityType?: Id + + /** */ + amiChartOverride?: UnitAmiChartOverrideCreate + + /** */ + amiPercentage?: string + + /** */ + annualIncomeMin?: string + + /** */ + monthlyIncomeMin?: string + + /** */ + floor?: number + + /** */ + annualIncomeMax?: string + + /** */ + maxOccupancy?: number + + /** */ + minOccupancy?: number + + /** */ + monthlyRent?: string + + /** */ + numBathrooms?: number + + /** */ + numBedrooms?: number + + /** */ + number?: string + + /** */ + sqFeet?: string + + /** */ + monthlyRentAsPercentOfIncome?: string + + /** */ + bmrProgramChart?: boolean +} + +export interface UnitGroupAmiLevelCreate { + /** */ + monthlyRentDeterminationType: MonthlyRentDeterminationType + + /** */ + amiChart?: Id + + /** */ + amiChartId?: string + + /** */ + amiPercentage: number + + /** */ + flatRentValue?: number + + /** */ + percentageOfIncomeValue?: number +} + +export interface UnitGroupCreate { + /** */ + unitType: Id[] + + /** */ + amiLevels: UnitGroupAmiLevelCreate[] + + /** */ + maxOccupancy?: number + + /** */ + minOccupancy?: number + + /** */ + floorMin?: number + + /** */ + floorMax?: number + + /** */ + sqFeetMin?: number + + /** */ + sqFeetMax?: number + + /** */ + priorityType?: CombinedPriorityTypeTypes + + /** */ + totalCount?: number + + /** */ + totalAvailable?: number + + /** */ + bathroomMin?: number + + /** */ + bathroomMax?: number + + /** */ + openWaitlist: boolean +} + +export interface ListingPreferenceUpdate { + /** */ + preference: Id + + /** */ + ordinal?: number +} + +export interface ListingProgramUpdate { + /** */ + program: Id + + /** */ + ordinal?: number +} + +export interface ListingCreate { + /** */ + applicationPickUpAddressType?: ListingApplicationAddressType + + /** */ + applicationDropOffAddressType?: ListingApplicationAddressType + + /** */ + applicationMailingAddressType?: ListingApplicationAddressType + + /** */ + status: ListingStatus + + /** */ + reviewOrderType?: ListingReviewOrder + + /** */ + marketingType: ListingMarketingTypeEnum + + /** */ + marketingSeason?: ListingSeasonEnum + + /** */ + region?: Region + + /** */ + applicationMethods: ApplicationMethodCreate[] + + /** */ + applicationPickUpAddress?: CombinedApplicationPickUpAddressTypes + + /** */ + applicationDropOffAddress: CombinedApplicationDropOffAddressTypes + + /** */ + applicationMailingAddress: CombinedApplicationMailingAddressTypes + + /** */ + buildingSelectionCriteriaFile?: CombinedBuildingSelectionCriteriaFileTypes + + /** */ + events: ListingEventCreate[] + + /** */ + images?: ListingImageUpdate[] + + /** */ + leasingAgentAddress?: CombinedLeasingAgentAddressTypes + + /** */ + leasingAgents?: Id[] + + /** */ + units: UnitCreate[] + + /** */ + accessibility?: string + + /** */ + amenities?: string + + /** */ + buildingAddress?: CombinedBuildingAddressTypes + + /** */ + buildingTotalUnits?: number + + /** */ + developer?: string + + /** */ + householdSizeMax?: number + + /** */ + householdSizeMin?: number + + /** */ + neighborhood?: string + + /** */ + petPolicy?: string + + /** */ + smokingPolicy?: string + + /** */ + unitsAvailable?: number + + /** */ + unitAmenities?: string + + /** */ + servicesOffered?: string + + /** */ + yearBuilt?: number + + /** */ + jurisdiction: Id + + /** */ + reservedCommunityType?: Id + + /** */ + result?: CombinedResultTypes + + /** */ + unitGroups?: UnitGroupCreate[] + + /** */ + listingPreferences: ListingPreferenceUpdate[] + + /** */ + listingPrograms?: ListingProgramUpdate[] + + /** */ + hrdId?: string + + /** */ + additionalApplicationSubmissionNotes?: string + + /** */ + digitalApplication?: boolean + + /** */ + commonDigitalApplication?: boolean + + /** */ + paperApplication?: boolean + + /** */ + referralOpportunity?: boolean + + /** */ + assets: AssetCreate[] + + /** */ + applicationDueDate?: Date + + /** */ + applicationOpenDate?: Date + + /** */ + applicationFee?: string + + /** */ + applicationOrganization?: string + + /** */ + applicationPickUpAddressOfficeHours?: string + + /** */ + applicationDropOffAddressOfficeHours?: string + + /** */ + buildingSelectionCriteria?: string + + /** */ + costsNotIncluded?: string + + /** */ + creditHistory?: string + + /** */ + criminalBackground?: string + + /** */ + depositMin?: string + + /** */ + depositMax?: string + + /** */ + depositHelperText?: string + + /** */ + disableUnitsAccordion?: boolean + + /** */ + leasingAgentEmail?: string + + /** */ + leasingAgentName?: string + + /** */ + leasingAgentOfficeHours?: string + + /** */ + leasingAgentPhone?: string + + /** */ + leasingAgentTitle?: string + + /** */ + name: string + + /** */ + postmarkedApplicationsReceivedByDate?: Date + + /** */ + programRules?: string + + /** */ + rentalAssistance?: string + + /** */ + rentalHistory?: string + + /** */ + requiredDocuments?: string + + /** */ + specialNotes?: string + + /** */ + waitlistCurrentSize?: number + + /** */ + waitlistMaxSize?: number + + /** */ + whatToExpect?: string + + /** */ + applicationConfig?: object + + /** */ + displayWaitlistSize: boolean + + /** */ + reservedCommunityDescription?: string + + /** */ + reservedCommunityMinAge?: number + + /** */ + resultLink?: string + + /** */ + isWaitlistOpen?: boolean + + /** */ + waitlistOpenSpots?: number + + /** */ + ownerCompany?: string + + /** */ + managementCompany?: string + + /** */ + managementWebsite?: string + + /** */ + amiPercentageMin?: number + + /** */ + amiPercentageMax?: number + + /** */ + customMapPin?: boolean + + /** */ + phoneNumber?: string + + /** */ + isVerified?: boolean + + /** */ + temporaryListingId?: number + + /** */ + marketingDate?: Date + + /** */ + countyCode?: string + + /** */ + features?: ListingFeatures +} + +export interface ListingEventUpdate { + /** */ + type: ListingEventType + + /** */ + id?: string + + /** */ + createdAt?: Date + + /** */ + updatedAt?: Date + + /** */ + file?: AssetUpdate + + /** */ + startTime?: Date + + /** */ + endTime?: Date + + /** */ + url?: string + + /** */ + note?: string + + /** */ + label?: string +} + +export interface UnitAmiChartOverrideUpdate { + /** */ + id?: string + + /** */ + createdAt?: Date + + /** */ + updatedAt?: Date + + /** */ + items: AmiChartItem[] +} + +export interface UnitUpdate { + /** */ + status: UnitStatus + + /** */ + id?: string + + /** */ + createdAt?: Date + + /** */ + updatedAt?: Date + + /** */ + amiChart?: Id + + /** */ + unitType?: Id + + /** */ + unitRentType?: Id + + /** */ + priorityType?: Id + + /** */ + amiChartOverride?: UnitAmiChartOverrideUpdate + + /** */ + amiPercentage?: string + + /** */ + annualIncomeMin?: string + + /** */ + monthlyIncomeMin?: string + + /** */ + floor?: number + + /** */ + annualIncomeMax?: string + + /** */ + maxOccupancy?: number + + /** */ + minOccupancy?: number + + /** */ + monthlyRent?: string + + /** */ + numBathrooms?: number + + /** */ + numBedrooms?: number + + /** */ + number?: string + + /** */ + sqFeet?: string + + /** */ + monthlyRentAsPercentOfIncome?: string + + /** */ + bmrProgramChart?: boolean +} + +export interface UnitGroupAmiLevelUpdate { + /** */ + monthlyRentDeterminationType: MonthlyRentDeterminationType + + /** */ + id?: string + + /** */ + amiChart?: Id + + /** */ + amiChartId?: string + + /** */ + amiPercentage: number + + /** */ + flatRentValue?: number + + /** */ + percentageOfIncomeValue?: number +} + +export interface UnitGroupUpdate { + /** */ + id?: string + + /** */ + amiLevels: UnitGroupAmiLevelUpdate[] + + /** */ + maxOccupancy?: number + + /** */ + minOccupancy?: number + + /** */ + floorMin?: number + + /** */ + floorMax?: number + + /** */ + sqFeetMin?: number + + /** */ + sqFeetMax?: number + + /** */ + priorityType?: CombinedPriorityTypeTypes + + /** */ + totalCount?: number + + /** */ + totalAvailable?: number + + /** */ + bathroomMin?: number + + /** */ + bathroomMax?: number + + /** */ + openWaitlist: boolean + + /** */ + unitType: Id[] +} + +export interface ListingUpdate { + /** */ + applicationPickUpAddressType?: ListingApplicationAddressType + + /** */ + applicationDropOffAddressType?: ListingApplicationAddressType + + /** */ + applicationMailingAddressType?: ListingApplicationAddressType + + /** */ + status: ListingStatus + + /** */ + reviewOrderType?: ListingReviewOrder + + /** */ + marketingType: ListingMarketingTypeEnum + + /** */ + marketingSeason?: ListingSeasonEnum + + /** */ + region?: Region + + /** */ + id?: string + + /** */ + createdAt?: Date + + /** */ + updatedAt?: Date + + /** */ + applicationMethods: ApplicationMethodUpdate[] + + /** */ + applicationPickUpAddress?: CombinedApplicationPickUpAddressTypes + + /** */ + applicationDropOffAddress: CombinedApplicationDropOffAddressTypes + + /** */ + applicationMailingAddress: CombinedApplicationMailingAddressTypes + + /** */ + buildingSelectionCriteriaFile?: CombinedBuildingSelectionCriteriaFileTypes + + /** */ + events: ListingEventUpdate[] + + /** */ + images?: ListingImageUpdate[] + + /** */ + leasingAgentAddress?: CombinedLeasingAgentAddressTypes + + /** */ + leasingAgents?: Id[] + + /** */ + units: UnitUpdate[] + + /** */ + accessibility?: string + + /** */ + amenities?: string + + /** */ + buildingAddress?: CombinedBuildingAddressTypes + + /** */ + buildingTotalUnits?: number + + /** */ + developer?: string + + /** */ + householdSizeMax?: number + + /** */ + householdSizeMin?: number + + /** */ + neighborhood?: string + + /** */ + petPolicy?: string + + /** */ + smokingPolicy?: string + + /** */ + unitsAvailable?: number + + /** */ + unitAmenities?: string + + /** */ + servicesOffered?: string + + /** */ + yearBuilt?: number + + /** */ + jurisdiction: Id + + /** */ + reservedCommunityType?: Id + + /** */ + result?: AssetUpdate + + /** */ + unitGroups?: UnitGroupUpdate[] + + /** */ + listingPreferences: ListingPreferenceUpdate[] + + /** */ + listingPrograms?: ListingProgramUpdate[] + + /** */ + hrdId?: string + + /** */ + additionalApplicationSubmissionNotes?: string + + /** */ + digitalApplication?: boolean + + /** */ + commonDigitalApplication?: boolean + + /** */ + paperApplication?: boolean + + /** */ + referralOpportunity?: boolean + + /** */ + assets: AssetCreate[] + + /** */ + applicationDueDate?: Date + + /** */ + applicationOpenDate?: Date + + /** */ + applicationFee?: string + + /** */ + applicationOrganization?: string + + /** */ + applicationPickUpAddressOfficeHours?: string + + /** */ + applicationDropOffAddressOfficeHours?: string + + /** */ + buildingSelectionCriteria?: string + + /** */ + costsNotIncluded?: string + + /** */ + creditHistory?: string + + /** */ + criminalBackground?: string + + /** */ + depositMin?: string + + /** */ + depositMax?: string + + /** */ + depositHelperText?: string + + /** */ + disableUnitsAccordion?: boolean + + /** */ + leasingAgentEmail?: string + + /** */ + leasingAgentName?: string + + /** */ + leasingAgentOfficeHours?: string + + /** */ + leasingAgentPhone?: string + + /** */ + leasingAgentTitle?: string + + /** */ + name: string + + /** */ + postmarkedApplicationsReceivedByDate?: Date + + /** */ + programRules?: string + + /** */ + rentalAssistance?: string + + /** */ + rentalHistory?: string + + /** */ + requiredDocuments?: string + + /** */ + specialNotes?: string + + /** */ + waitlistCurrentSize?: number + + /** */ + waitlistMaxSize?: number + + /** */ + whatToExpect?: string + + /** */ + applicationConfig?: object + + /** */ + displayWaitlistSize: boolean + + /** */ + reservedCommunityDescription?: string + + /** */ + reservedCommunityMinAge?: number + + /** */ + resultLink?: string + + /** */ + isWaitlistOpen?: boolean + + /** */ + waitlistOpenSpots?: number + + /** */ + ownerCompany?: string + + /** */ + managementCompany?: string + + /** */ + managementWebsite?: string + + /** */ + amiPercentageMin?: number + + /** */ + amiPercentageMax?: number + + /** */ + customMapPin?: boolean + + /** */ + phoneNumber?: string + + /** */ + isVerified?: boolean + + /** */ + temporaryListingId?: number + + /** */ + marketingDate?: Date + + /** */ + countyCode?: string + + /** */ + features?: ListingFeatures +} + +export interface PreferencesFilterParams { + /** */ + $comparison: EnumPreferencesFilterParamsComparison + + /** */ + $include_nulls?: boolean + + /** */ + jurisdiction?: string +} + +export interface PreferenceCreate { + /** */ + links?: PreferenceLink[] + + /** */ + title?: string + + /** */ + subtitle?: string + + /** */ + description?: string + + /** */ + formMetadata?: FormMetadata +} + +export interface PreferenceUpdate { + /** */ + links?: PreferenceLink[] + + /** */ + title?: string + + /** */ + subtitle?: string + + /** */ + description?: string + + /** */ + formMetadata?: FormMetadata + + /** */ + id: string +} + +export interface ProgramsFilterParams { + /** */ + $comparison: EnumProgramsFilterParamsComparison + + /** */ + $include_nulls?: boolean + + /** */ + jurisdiction?: string +} + +export interface ProgramCreate { + /** */ + title?: string + + /** */ + subtitle?: string + + /** */ + description?: string + + /** */ + formMetadata?: FormMetadata +} + +export interface ProgramUpdate { + /** */ + title?: string + + /** */ + subtitle?: string + + /** */ + description?: string + + /** */ + formMetadata?: FormMetadata + + /** */ + id: string +} + +export interface Property { + /** */ + region?: Region + + /** */ + units: Unit[] + + /** */ + buildingAddress: Address + + /** */ + id: string + + /** */ + createdAt: Date + + /** */ + updatedAt: Date + + /** */ + accessibility?: string + + /** */ + amenities?: string + + /** */ + buildingTotalUnits?: number + + /** */ + developer?: string + + /** */ + householdSizeMax?: number + + /** */ + householdSizeMin?: number + + /** */ + neighborhood?: string + + /** */ + petPolicy?: string + + /** */ + smokingPolicy?: string + + /** */ + unitsAvailable?: number + + /** */ + unitAmenities?: string + + /** */ + servicesOffered?: string + + /** */ + yearBuilt?: number +} + +export interface PropertyCreate { + /** */ + region?: Region + + /** */ + buildingAddress: AddressUpdate + + /** */ + units: UnitCreate[] + + /** */ + accessibility?: string + + /** */ + amenities?: string + + /** */ + buildingTotalUnits?: number + + /** */ + developer?: string + + /** */ + householdSizeMax?: number + + /** */ + householdSizeMin?: number + + /** */ + neighborhood?: string + + /** */ + petPolicy?: string + + /** */ + smokingPolicy?: string + + /** */ + unitsAvailable?: number + + /** */ + unitAmenities?: string + + /** */ + servicesOffered?: string + + /** */ + yearBuilt?: number +} + +export interface PropertyUpdate { + /** */ + region?: Region + + /** */ + id?: string + + /** */ + createdAt?: Date + + /** */ + updatedAt?: Date + + /** */ + buildingAddress: AddressUpdate + + /** */ + units: UnitUpdate[] + + /** */ + accessibility?: string + + /** */ + amenities?: string + + /** */ + buildingTotalUnits?: number + + /** */ + developer?: string + + /** */ + householdSizeMax?: number + + /** */ + householdSizeMin?: number + + /** */ + neighborhood?: string + + /** */ + petPolicy?: string + + /** */ + smokingPolicy?: string + + /** */ + unitsAvailable?: number + + /** */ + unitAmenities?: string + + /** */ + servicesOffered?: string + + /** */ + yearBuilt?: number +} + +export interface PropertyGroup { + /** */ + properties: Id[] + + /** */ + id: string + + /** */ + createdAt: Date + + /** */ + updatedAt: Date + + /** */ + name: string +} + +export interface PropertyGroupCreate { + /** */ + name: string + + /** */ + properties: Id[] +} + +export interface PropertyGroupUpdate { + /** */ + name: string + + /** */ + properties: Id[] + + /** */ + id: string +} + +export interface ReservedCommunityTypeCreate { + /** */ + jurisdiction: Id + + /** */ + name: string + + /** */ + description?: string +} + +export interface ReservedCommunityTypeUpdate { + /** */ + jurisdiction: Id + + /** */ + name: string + + /** */ + description?: string + + /** */ + id: string +} + +export interface Sms { + /** */ + body: string + + /** */ + phoneNumber: string +} + +export interface Translation { + /** */ + language: Language + + /** */ + jurisdiction: Id + + /** */ + id: string + + /** */ + createdAt: Date + + /** */ + updatedAt: Date + + /** */ + translations: object +} + +export interface TranslationCreate { + /** */ + language: Language + + /** */ + translations: object + + /** */ + jurisdiction: Id +} + +export interface TranslationUpdate { + /** */ + language: Language + + /** */ + id?: string + + /** */ + createdAt?: Date + + /** */ + updatedAt?: Date + + /** */ + translations: object + + /** */ + jurisdiction: Id +} + +export interface UnitTypeCreate { + /** */ + name: string + + /** */ + numBedrooms: number +} + +export interface UnitTypeUpdate { + /** */ + name: string + + /** */ + numBedrooms: number + + /** */ + id: string +} + +export interface UnitRentTypeCreate { + /** */ + name: string +} + +export interface UnitRentTypeUpdate { + /** */ + name: string + + /** */ + id: string +} + +export interface UnitAccessibilityPriorityTypeCreate { + /** */ + name: string +} + +export interface UnitAccessibilityPriorityTypeUpdate { + /** */ + name: string + + /** */ + id: string +} +export enum EnumJurisdictionLanguages { + "en" = "en", + "es" = "es", + "vi" = "vi", + "zh" = "zh", + "tl" = "tl", +} +export enum IncomePeriod { + "perMonth" = "perMonth", + "perYear" = "perYear", +} + +export enum ApplicationStatus { + "draft" = "draft", + "submitted" = "submitted", + "removed" = "removed", +} + +export enum Language { + "en" = "en", + "es" = "es", + "vi" = "vi", + "zh" = "zh", + "tl" = "tl", +} + +export enum ApplicationSubmissionType { + "paper" = "paper", + "electronical" = "electronical", +} +export type AllExtraDataTypes = BooleanInput | TextInput | AddressInput +export enum EnumApplicationFlaggedSetStatus { + "flagged" = "flagged", + "resolved" = "resolved", +} +export enum ApplicationMethodType { + "Internal" = "Internal", + "FileDownload" = "FileDownload", + "ExternalLink" = "ExternalLink", + "PaperPickup" = "PaperPickup", + "POBox" = "POBox", + "LeasingAgent" = "LeasingAgent", + "Referral" = "Referral", +} +export type CombinedFileTypes = AssetUpdate +export enum InputType { + "boolean" = "boolean", + "text" = "text", + "address" = "address", + "hhMemberSelect" = "hhMemberSelect", +} +export enum EnumApplicationsApiExtraModelOrderBy { + "firstName" = "firstName", + "lastName" = "lastName", + "submissionDate" = "submissionDate", + "createdAt" = "createdAt", +} +export enum EnumApplicationsApiExtraModelOrder { + "ASC" = "ASC", + "DESC" = "DESC", +} +export enum EnumUserErrorExtraModelUserErrorMessages { + "accountConfirmed" = "accountConfirmed", + "accountNotConfirmed" = "accountNotConfirmed", + "errorSaving" = "errorSaving", + "emailNotFound" = "emailNotFound", + "tokenExpired" = "tokenExpired", + "tokenMissing" = "tokenMissing", + "emailInUse" = "emailInUse", + "passwordOutdated" = "passwordOutdated", +} +export enum EnumLoginMfaType { + "sms" = "sms", + "email" = "email", +} +export enum EnumRequestMfaCodeMfaType { + "sms" = "sms", + "email" = "email", +} +export type CombinedRolesTypes = UserRolesCreate +export type CombinedPreferencesTypes = UserPreferences +export enum EnumUserFilterParamsComparison { + "=" = "=", + "<>" = "<>", + "IN" = "IN", + ">=" = ">=", + "<=" = "<=", + "NA" = "NA", +} +export enum EnumJurisdictionCreateLanguages { + "en" = "en", + "es" = "es", + "vi" = "vi", + "zh" = "zh", + "tl" = "tl", +} +export enum EnumJurisdictionUpdateLanguages { + "en" = "en", + "es" = "es", + "vi" = "vi", + "zh" = "zh", + "tl" = "tl", +} +export enum EnumListingFilterParamsComparison { + "=" = "=", + "<>" = "<>", + "IN" = "IN", + ">=" = ">=", + "<=" = "<=", + "NA" = "NA", +} +export enum EnumListingFilterParamsStatus { + "active" = "active", + "pending" = "pending", + "closed" = "closed", +} +export enum EnumListingFilterParamsAvailability { + "hasAvailability" = "hasAvailability", + "noAvailability" = "noAvailability", + "waitlist" = "waitlist", +} +export enum EnumListingFilterParamsRegion { + "Downtown" = "Downtown", + "Eastside" = "Eastside", + "MidtownNewCenter" = "MidtownNewCenter", + "Southwest" = "Southwest", + "Westside" = "Westside", +} +export enum EnumListingFilterParamsMarketingType { + "Marketing" = "Marketing", + "ComingSoon" = "ComingSoon", +} +export enum OrderByFieldsEnum { + "mostRecentlyUpdated" = "mostRecentlyUpdated", + "applicationDates" = "applicationDates", + "mostRecentlyClosed" = "mostRecentlyClosed", + "comingSoon" = "comingSoon", +} + +export enum ListingApplicationAddressType { + "leasingAgent" = "leasingAgent", +} + +export enum ListingStatus { + "active" = "active", + "pending" = "pending", + "closed" = "closed", +} + +export enum ListingReviewOrder { + "lottery" = "lottery", + "firstComeFirstServe" = "firstComeFirstServe", +} + +export enum ListingMarketingTypeEnum { + "marketing" = "marketing", + "comingSoon" = "comingSoon", +} + +export enum ListingSeasonEnum { + "spring" = "spring", + "summer" = "summer", + "fall" = "fall", + "winter" = "winter", +} + +export enum Region { + "Downtown" = "Downtown", + "Eastside" = "Eastside", + "Midtown - New Center" = "Midtown - New Center", + "Southwest" = "Southwest", + "Westside" = "Westside", +} + +export enum ListingEventType { + "openHouse" = "openHouse", + "publicLottery" = "publicLottery", + "lotteryResults" = "lotteryResults", +} + +export enum FormMetaDataType { + "radio" = "radio", + "checkbox" = "checkbox", +} + +export enum UnitStatus { + "unknown" = "unknown", + "available" = "available", + "occupied" = "occupied", + "unavailable" = "unavailable", +} + +export enum MonthlyRentDeterminationType { + "flatRent" = "flatRent", + "percentageOfIncome" = "percentageOfIncome", +} +export type CombinedPriorityTypeTypes = UnitAccessibilityPriorityType +export type CombinedApplicationPickUpAddressTypes = AddressUpdate +export type CombinedApplicationDropOffAddressTypes = AddressUpdate +export type CombinedApplicationMailingAddressTypes = AddressUpdate +export type CombinedBuildingSelectionCriteriaFileTypes = AssetUpdate +export type CombinedLeasingAgentAddressTypes = AddressUpdate +export type CombinedResultTypes = AssetCreate +export type CombinedBuildingAddressTypes = AddressUpdate +export enum EnumPreferencesFilterParamsComparison { + "=" = "=", + "<>" = "<>", + "IN" = "IN", + ">=" = ">=", + "<=" = "<=", + "NA" = "NA", +} +export enum EnumProgramsFilterParamsComparison { + "=" = "=", + "<>" = "<>", + "IN" = "IN", + ">=" = ">=", + "<=" = "<=", + "NA" = "NA", +} diff --git a/backend/core/types/src/filter-keys.ts b/backend/core/types/src/filter-keys.ts new file mode 100644 index 0000000000..8a0d57fc01 --- /dev/null +++ b/backend/core/types/src/filter-keys.ts @@ -0,0 +1 @@ +export * from "../../src/listings/types/listing-filter-keys-enum" diff --git a/backend/proxy/Dockerfile b/backend/proxy/Dockerfile new file mode 100644 index 0000000000..5ce7fc072c --- /dev/null +++ b/backend/proxy/Dockerfile @@ -0,0 +1,44 @@ +FROM nginx:1.11 + +MAINTAINER David Galoyan + +ENV NGX_CACHE_PURGE_VERSION=2.4.1 + +# Install basic packages and build tools +RUN apt-get update && \ + apt-get install --no-install-recommends --no-install-suggests -y \ + wget \ + build-essential \ + libssl-dev \ + libpcre3 \ + zlib1g \ + zlib1g-dev \ + libpcre3-dev && \ + apt-get clean && \ + rm -rf /var/lib/apt/lists/* + +# download and extract sources +RUN NGINX_VERSION=`nginx -V 2>&1 | grep "nginx version" | awk -F/ '{ print $2}'` && \ + cd /tmp && \ + wget http://nginx.org/download/nginx-$NGINX_VERSION.tar.gz && \ + wget https://github.com/nginx-modules/ngx_cache_purge/archive/$NGX_CACHE_PURGE_VERSION.tar.gz \ + -O ngx_cache_purge-$NGX_CACHE_PURGE_VERSION.tar.gz && \ + tar -xf nginx-$NGINX_VERSION.tar.gz && \ + mv nginx-$NGINX_VERSION nginx && \ + rm nginx-$NGINX_VERSION.tar.gz && \ + tar -xf ngx_cache_purge-$NGX_CACHE_PURGE_VERSION.tar.gz && \ + mv ngx_cache_purge-$NGX_CACHE_PURGE_VERSION ngx_cache_purge && \ + rm ngx_cache_purge-$NGX_CACHE_PURGE_VERSION.tar.gz + +## move copy to here so the above can build from cache +COPY ./default.conf /etc/nginx/conf.d/default.conf.template +COPY ./proxy.conf /etc/nginx/conf.d/proxy.conf + +# configure and build +RUN cd /tmp/nginx && \ + BASE_CONFIGURE_ARGS=`nginx -V 2>&1 | grep "configure arguments" | cut -d " " -f 3-` && \ + /bin/sh -c "./configure ${BASE_CONFIGURE_ARGS} --add-module=/tmp/ngx_cache_purge" && \ + make && make install && \ + rm -rf /tmp/nginx* + +CMD /bin/bash -c "envsubst '\$PORT,\$BACKEND_HOSTNAME' < /etc/nginx/conf.d/default.conf.template > /etc/nginx/conf.d/default.conf" && nginx -g 'daemon off;' diff --git a/backend/proxy/README.md b/backend/proxy/README.md new file mode 100644 index 0000000000..50e5cafed3 --- /dev/null +++ b/backend/proxy/README.md @@ -0,0 +1,56 @@ +### Rationale + +We want to serve different versions of the API under different paths e.g. `/v2`, `/v3` but not necessarily reflect that convention in the code. +To achieve that an NGINX proxy has been created and set up as an entrypoint to the entire API. It provides path level routing e.g. `/v2` will be routed to a different heroku APP then `/`. + +### Setup + +Based on [this tutorial](https://dashboard.heroku.com/apps/bloom-reference-backend-proxy/deploy/heroku-container). All values are for `bloom-reference-backend-proxy` and each environment requires it's own proxy. + +#### Install the Heroku CLI + +Download and install the Heroku CLI. + +If you haven't already, log in to your Heroku account and follow the prompts to create a new SSH public key. + +``` +$ heroku login +``` + +#### Log in to Container Registry + +You must have Docker set up locally to continue. You should see output when you run this command. + +``` +$ docker ps +``` + +Now you can sign into Container Registry. + +``` +$ heroku container:login +``` + +Push your Docker-based app +Build the Dockerfile in the current directory and push the Docker image. + +``` +# workdir: backend/proxy +$ heroku container:push --app bloom-reference-backend-proxy web +``` + +Deploy the changes +Release the newly pushed images to deploy your app. + +``` +$ heroku container:release --app bloom-reference-backend-proxy web +``` + +#### Configuration + +Heroku Proxy app requires two environment variables to work: + +``` +heroku config:set --app bloom-reference-backend-proxy BACKEND_V1_HOSTNAME=example.v1.hostname.com +heroku config:set --app bloom-reference-backend-proxy BACKEND_V2_HOSTNAME=example.v2.hostname.com +``` diff --git a/backend/proxy/default.conf b/backend/proxy/default.conf new file mode 100644 index 0000000000..fa5f442d18 --- /dev/null +++ b/backend/proxy/default.conf @@ -0,0 +1,62 @@ +proxy_cache_path /tmp/cache_nginx/ levels=1:2 keys_zone=webapp_cache:10m max_size=10g inactive=1440 use_temp_path=off; + +log_format upstreamlog '[$time_local] $remote_addr - $remote_user - $server_name $host to: $upstream_addr: $request $status upstream_response_time $upstream_response_time msec $msec request_time $request_time'; + +server { + listen $PORT; + + proxy_set_header Host $BACKEND_HOSTNAME; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + access_log /var/log/nginx/access.log upstreamlog; + error_log /var/log/nginx/error.log info; + proxy_ssl_server_name on; + proxy_cache_purge PURGE from 0.0.0.0/0; + proxy_cache_valid 200 24h; + proxy_cache_valid 404 15s; + proxy_cache_valid 500 0s; + + location ~* "\/listings\/[0-9A-F]{8}-[0-9A-F]{4}-4[0-9A-F]{3}-[89AB][0-9A-F]{3}-[0-9A-F]{12}" { + proxy_cache_min_uses 1; + proxy_cache_revalidate on; + proxy_cache_background_update on; + proxy_cache_lock on; + proxy_ssl_server_name on; + proxy_cache webapp_cache; + proxy_cache_use_stale error timeout http_500 http_502 http_503 http_504; + proxy_cache_key $uri$is_args$args$http_language; + if ($request_method = 'PURGE') { + # TODO: make vairable that's passed in for allow origin purge + add_header Access-Control-Allow-Origin *; + } + add_header X-Cache-Status $upstream_cache_status; + add_header Access-Control-Allow-Headers 'Content-Type, X-Language, X-JurisdictionName, Authorization'; + add_header Access-Control-Allow-Methods 'GET, POST, OPTIONS, PUT, DELETE, PURGE'; + proxy_pass https://$BACKEND_HOSTNAME; + } + + location /listings { + proxy_cache_min_uses 1; + proxy_cache_revalidate on; + proxy_cache_background_update on; + proxy_cache_lock on; + proxy_ssl_server_name on; + proxy_cache webapp_cache; + proxy_cache_use_stale error timeout http_500 http_502 http_503 http_504; + proxy_cache_key $uri$is_args$args$http_language; + if ($request_method = 'PURGE') { + # TODO: make vairable that's passed in for allow origin purge + add_header Access-Control-Allow-Origin *; + } + add_header X-Cache-Status $upstream_cache_status; + add_header Access-Control-Allow-Headers 'Content-Type, X-Language, X-JurisdictionName, Authorization'; + add_header Access-Control-Allow-Methods 'GET, POST, OPTIONS, PUT, DELETE, PURGE'; + proxy_pass https://$BACKEND_HOSTNAME; + } + + location / { + proxy_pass https://$BACKEND_HOSTNAME; + } +} diff --git a/backend/proxy/proxy.conf b/backend/proxy/proxy.conf new file mode 100644 index 0000000000..e69de29bb2 diff --git a/bin/.env.template b/bin/.env.template new file mode 100644 index 0000000000..5d793b2b4f --- /dev/null +++ b/bin/.env.template @@ -0,0 +1,4 @@ +INSTANCE_CONNECTION_NAME=gcp:database:instance +PGUSER=postgres-user +PGPASSWORD=postgres-user-password +DATABASE_URL=postgres://$PGUSER:$PGPASSWORD@localhost:5432/bloom diff --git a/bin/run_prod_database_migration.sh b/bin/run_prod_database_migration.sh new file mode 100755 index 0000000000..317a76e1a0 --- /dev/null +++ b/bin/run_prod_database_migration.sh @@ -0,0 +1,25 @@ +#!/bin/bash + +if [ -f ./.env ]; then + . ./.env +else + exit 1 ".env does not exist" +fi + +wget https://dl.google.com/cloudsql/cloud_sql_proxy.linux.amd64 -O cloud_sql_proxy +chmod +x cloud_sql_proxy +./cloud_sql_proxy -instances=$INSTANCE_CONNECTION_NAME=tcp:5432 & + +sleep 4s + +cd ../backend/core + +# Override the DATABASE_URL variable in backend/core/.env. +echo "DATABASE_URL=$DATABASE_URL" >> .env +yarn db:migration:run +# Now remove the last line. +head -n -1 .env | tee .env > /dev/null + +cd - +kill %1 +rm ./cloud_sql_proxy diff --git a/commitlint.config.js b/commitlint.config.js new file mode 100644 index 0000000000..bb9705e334 --- /dev/null +++ b/commitlint.config.js @@ -0,0 +1,3 @@ +/* eslint-env node */ + +module.exports = { extends: ["@commitlint/config-conventional"] } diff --git a/cypress.json b/cypress.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/cypress.json @@ -0,0 +1 @@ +{} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000000..413a4b7fea --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,114 @@ +version: "3.7" + +services: + sites-public: + container_name: sites-public + build: + context: . + dockerfile: Dockerfile.sites-public + target: development + volumes: + - ./sites/public:/usr/src/app/sites/public + - /usr/src/app/node_modules + - /usr/src/app/sites/public/node_modules + ports: + # TODO: configure via .env separate from ./sites/public/.env + - "3000:3000" + env_file: + - ./sites/public/.env + environment: + # The URLs are different here because requests made using BACKEND_API_BASE are done from + # the NextJS server, which must address other containers by container name. Requests made + # using INCOMING_HOOK_BODY are done from the client side browser and must use localhost. + BACKEND_API_BASE: "http://backend-core:3100" + INCOMING_HOOK_BODY: "http://localhost:3100" + NEXTJS_PORT: "3000" + command: yarn dev + depends_on: + - backend-core + networks: + - frontend + sites-partners: + container_name: sites-partners + build: + context: . + dockerfile: Dockerfile.sites-partners + target: development + volumes: + - ./sites/partners:/usr/src/app/sites/partners + - /usr/src/app/node_modules + - /usr/src/app/sites/partners/node_modules + ports: + # TODO: configure via .env separate from ./sites/partners/.env + - "3001:3001" + env_file: + - ./sites/partners/.env + environment: + # Using this as the BASE works here because all requests are sent from the client's browser + # (not the NextJS server). + BACKEND_API_BASE: "http://localhost:3100" + NEXTJS_PORT: "3001" + # yarn dev uses a separate node debugger port + command: yarn next -p 3001 + depends_on: + - backend-core + networks: + - frontend + backend-core: + container_name: backend-core + build: + context: ./backend/core + target: development + volumes: + - ./backend/core:/usr/src/app + - /usr/src/app/node_modules + ports: + # TODO: configure 3100 via .env separate from ./backend/core/.env + - "3100:3100" + # This is the debug port. + - "9229:9229" + networks: + - frontend + - backend + command: /bin/sh -c "yarn db:migration:run && yarn nest start --debug" + env_file: + - ./backend/core/.env + # Override database connections to point to the container instead of localhost. + environment: + POSTGRES_USER: "postgres" + DATABASE_URL: "postgres://postgres:5432/bloom" + REDIS_TLS_URL: "redis://redis:6379/0" + REDIS_URL: "redis://redis:6379/0" + PGUSER: "postgres" + PGPASSWORD: "postgres" + PGDATABASE: "bloom" + depends_on: + - redis + - postgres + redis: + container_name: redis + image: redis:latest + ports: + - "6379:6379" + networks: + - backend + postgres: + container_name: postgres + image: postgres:13 + environment: + POSTGRES_USER: "postgres" + POSTGRES_PASSWORD: "postgres" + POSTGRES_DB: "bloom" + PG_DATA: /var/lib/postgresql/data + ports: + - "5432:5432" + networks: + - backend + volumes: + - pgdata:/var/lib/postgresql/data + - /var/run/postgresql:/var/run/postgresql +volumes: + pgdata: +networks: + frontend: + backend: diff --git a/docs/Analytics.md b/docs/Analytics.md new file mode 100644 index 0000000000..baab2ada2f --- /dev/null +++ b/docs/Analytics.md @@ -0,0 +1,30 @@ +# Setting up User Analytics + +User Analytics within the Bloom reference apps is handled by Google Analytics (GA). In order to support additional analytics or tag integrations in the future, the default implementation is to use Google Tag Manager (GTM) from the reference code, which is then set up to call GA from within the GTM console. + +## GA and GTM Console Setup + +A basic setup can be accomplished by: + +1. Setting up a new Property in the GA console for each new app to be deployed + +2. Setting up a new matching Container in the GTM console + +3. Creating a GA tag in the new GTM container that is linked to the GA property with the correct GA tag + +4. Setting up triggers on the GA tag in GTM for both Page Views and History Changes. + + - NOTE: without the History Change trigger set up, only some pages will be captured, since react / next.js do not do a full page load to trigger the GTM page view event in all cases. + +5. Setting up GTM and GA events for any external links that need to be tracked, e.g. the download of a PDF application or referral to an external website. + - See [this GTM/GA support article](https://support.google.com/tagmanager/answer/6106716) for details on setting up external click events. + +## GTM Tag Setup in Environment Variables / Code + +Once all of the GTM and GA provisioning and configuration has been completed, the code-side changes should be as simple as setting the GTM_KEY environment variable to the key from the container created above. This should be set in .env for a local dev environment (see `.env.template`), or in netlify.toml for those apps being deployed via Netlify. + +See `sites/public/src/customScripts.ts` for use of the GTM_KEY, noting that translation to gtmKey in process.env is automatic. + +## Netlify Analytics + +As a complement to Google Analytics, we also recommend enabling analytics on the Netlify platform for apps that are deployed there. In addition to providing a point of comparison / validation for the GA data, it also tracks things like 404s or other HTTP errors that may prevent GA from loading in the first place. diff --git a/docs/Authentication.md b/docs/Authentication.md new file mode 100644 index 0000000000..9ae8e7ecd3 --- /dev/null +++ b/docs/Authentication.md @@ -0,0 +1,218 @@ +# Authentication + +The [backend/core](./backend/core) service handles authentication for the bloom system using the +[auth module](backend/core/src/auth). Routes can then use +[Nest.js guards and Passport](ihttps://docs.nestjs.com/techniques/authentication#authentication) to guard individual +routes. + +## Protect a Route + +```typescript +// Module definition +import { + // All modules implementing authentication guards must import `PassportModule` + // providers, exports, controllers, etc. + // Controller definition + DefaultAuthGuard, +} from "src/auth/default.guard" + +@Controller() +export class MyController { + @UseGuards(DefaultAuthGuard) + @Get("path") + path() { + // This route will be protected + } +} +``` + +Using the `DefaultAuthGuard` in this way requires the client to provide a valid JWT token as an +`Authorization` header using the standard `Bearer ` format. Tokens are checked for a valid signature and +valid expiry time (currently 10 minutes). Tokens may also be revoked by adding an entry to `revoked_tokens` table, or +using the auth route `revoke_token`. + +## Obtain a token + +To obtain a token, a user must first login. Currently, an email/password strategy is the only way to do this. A +client can `POST /auth/login` with body `{ email, password }`. This request will either return 401 or 200 with an +object containing `accessToken`. + +To renew a token, `POST /auth/token` with an existing valid token. + +## Registration + +A user may register using `POST /auth/register`. The app validates the user object, and if creation is successful, the +resulting user will be returned along with a valid `accessToken`. + +## Configuration + +The app must be configured with an app secret to sign JWT tokens. In development, the app uses a hard-coded value, but +the app will throw an error in production mode if `APP_SECRET` is not provided as an environment variable. + +# Front End Authentication/User Management + +A few tools are provided to help handle authentication/users in the front end. These are collected under +`shared/ui-components/authentication`. + +## AuthContext + +`AuthContext` is a React Context that keeps track of the current user state and provides user and auth related utility +functions. + +It provides: + +```typescript +type ContextProps = { + login: (email: string, password: string) => Promise + createUser: (user: UserCreateDto) => Promise + signOut: () => void + // True when an API request is processing + loading: boolean + profile?: User + accessToken?: string + initialStateLoaded: boolean +} +``` + +The context is provided to a React App using `AuthProvider`, which in turn requires a `ConfigProvider` to function +properly: + +```tsx +import { UserProvider, ConfigProvider } from "@bloom-housing/ui-components" + + + + {/* ...rest of app tree */} + + +``` + +`profile` and `accessToken` will be automatically populated if available. `accessToken` is stored in either Session +or Local storage depending on the config (default to session storage) and will be read on initial load. If the token +is current, the provider will attempt to fetch the user profile (and verify login status at the same time). The +provider also reads the token for the expiry and automatically schedules background updates to refresh the token +while the user remains signed in. + +## useAuthenticatedClient + +This is a convenience hook that allows a component to access a protected route on the API using an Axios client that +has been pre-configured to send an auth token to the API. It will return `undefined` if the user is not logged in. + +```tsx +const authClient = useAuthenticatedClient() +if (authClient) { + authClient.get("/protected-route") +} +``` + +This hook relies on access to the `AuthContext` and the `ConfigContext`, so it will not work if the component isn't +in a tree that has both of these providers. + +## RequireLogin + +This component waits for `UserProvider` to determine current login status before rendering its children. If no login +is found, the component will redirect to its `signInPath` component without rendering children. It can be configured +to require login for all paths other than `signInPath` (default), with a "whitelist" of paths to require login for +(`requireForRoutes`) or a "blacklist" of paths to skip authentication checks for (`skipForRoutes`). These props are +both lists of strings, and may contain RegEx strings. + +```tsx + + {/* will only render if logged in */} + + + + {/* login not required for /public/* or /other-path */} + +``` + +## useRequireLoggedInUser + +This is a hook that can be applied to protect a single component by requiring a login without modifying the full app +tree. It returns the logged in `User` if found, and `undefined` before the initial state has loaded. If no login is +found, it redirects to `signInPath`. This hook requires `UserProvider` to be defined on the app tree to work. + +```typescript +const user = useRequireLoggedInUser("/sign-in") + +// Make sure not to render the component before the user state has loaded +if (!user) { + return null // or loading screen +} +``` + +# Authorization + +For API authorization, we use a combination of Role Based Access Control (RBAC) and ABAC (Attribute Based Access +Control) implemented using the [casbin library](https://casbin.org/). We define authorizations in the context of +performing a given _action_ on _resource type_ as a _role_. Actions are strings defined in +[authz.service.ts](../backend/core/src/auth/authz.service.ts) as an enum `authzActions`. + +For high-level Role-Based Access Control (RBAC), a Nest.js guard, `AuthzGuard` is provided. It can be defined on either +a controller or individual request handler (method) scope, although the former is probably a more common use case. It +should be used in conjunction with the `@ResourceType` decorator to specify what type of entity (e.g. `"application"`) +this controller/handler will be requesting access to. It then checks access to all the requests based on the current +loaded `req.user` (so it must run after a passport-based `AuthGuard` that loads the user onto the request object), the +`ResourceType`, and the requested action. The action is either automatically inferred from the request (e.g. a `PUT` +corresponds to `"update"`, `GET` corresponds to `"read"`, etc.), or can be specified on a per-handler basis using the +`@ResourceType("edit")` decorator. + +The other method for enforcing authorization allows for per-object/attribute based access control (ABAC). In this mode, +we are checking specific attributes about the resource access is requested on, so it must be checked in the body of the +handler rather than as a guard (since the resource must be loaded from the DB). This is accomplished using the +`AuthzService.can` or `AuthzService.canOrThrow` methods. + +The rules themselves are defined in [authz_policy.csv](../backend/core/src/auth/authz_policy.csv). Each line in this +CSV starting with `p` is a policy and follows the following format: + +``` +p, role, resourceType, evalRule, action +``` + +An example: + +``` +p, admin, application, true, .* +``` + +In this case, this specifies a policy applying to the `admin` role accessing `application` objects. `evalRule` is a +bit of arbitrary code that is evaluated by Casbin. See +[the docs](https://casbin.org/docs/en/abac#scaling-the-model-for-complex-and-large-number-of-abac-rules) for more +info on how this works. In this example, `true` simply will always evaluate to `true`. Finally, the action is a regex +-enabled matcher for the action verb requested - in this case a wildcard means that an admin can perform all actions. + +A more complicated example: + +``` +p, user, application, !r.obj || (r.sub == r.obj.user_id), (read)|(update)|(delete) +``` + +In this case, the rules are for the `user` role (which is the default role for logged in users with no other +permissions), again on `application` objects. The first line, the `evalRule` is an expression that uses the +special variable `r.sub` (short for request.subject) to get the _subject_ of this request, which the Authorization +framework sets to the current `userId`. It checks this user id against the `user_id` attribute of `r.obj` (the +_object_ of this request). `r.obj` is an arbitrary Javascript object passed to the authorization framework during +the authorization call - note that it will _only_ work during ABAC (since during RBAC authorization we don't yet have +the database object loaded). The `!r.obj` at the start of this rule allows this rule to apply to RBAC _or_ ABAC, +depending on the context. Finally, the `actions` block is a regex union of `read`, `update` and `delete` actions. + +For applications, we also have this line: + +``` +p, anonymous, application, true, create +``` + +This allows `anonymous` users (the default permission for non-logged-in users) to perform `create` operations on +`application` resources. Note that the way that the roles are defined is _hierarchical_ - any permissions that are +defined on "lower" level role are also granted to a "higher" role. In this case, both `user` and `admin` inherit all +permissions granted to `anonymous`, so this line also grants `create` permissions to both `user` and `admin` roles. + +To define group hierarchies, add lines beginning with `g` to `authz_policy.csv`: + +``` +g, admin, user +g, user, anonymous +``` + +The first line denotes that `admin` inherits the `user` role. The second line grants `anonymous` permissions to the +`user` role (and by extension, the `admin` role). diff --git a/docs/Bloom apps.png b/docs/Bloom apps.png new file mode 100644 index 0000000000..35d5fd2af1 Binary files /dev/null and b/docs/Bloom apps.png differ diff --git a/docs/BuildAndRelease.md b/docs/BuildAndRelease.md new file mode 100644 index 0000000000..59b42c6e33 --- /dev/null +++ b/docs/BuildAndRelease.md @@ -0,0 +1,18 @@ +# Checklist for Publishing shared packages to NPM. + +0. Make sure your git workspace is on the master branch, pulled with the latest, and there are no extraneous files. + +1. Manually update the monorepo root package version to match the expected new version number and do a git commit of that single change. + + - commiting directly to master is ok in this one case. + - unfortunately lerna doesn't have a good way to do this automatically at the moment, but maybe it's a config problem? + +2. `lerna publish` + +3. Check to make sure that Heroku and Netlify deploys haven't failed because they won the race condition between NPM publishing and auto-deploy of the new master push. + + - Suggestions welcomed for how to improve the tooling around this problem. + +4. Verify that the reference web sites are still working correctly after the new build is deployed. + + - The build should functionally be a no-op so the chance of a problem should be low, but it's important to check for anything unexpected. diff --git a/docs/DeployAppsNetlify.md b/docs/DeployAppsNetlify.md new file mode 100644 index 0000000000..e3a6ae91ef --- /dev/null +++ b/docs/DeployAppsNetlify.md @@ -0,0 +1,26 @@ +# Deplying Bloom Apps to Netlify + +The Bloom front-end applications are designed to be stateless, and thus can be deployed as static sites to Netlify or directly to any major hosting environment (e.g. AWS, GCP, Azure). The following information is intended to help guide the deployment of the reference implementation to Netlify, but can also serve as a starting point for other front-end deployment scenarios + +## Per-app Deployment + +Because Bloom uses a monorepo style of organization, there are likely multiple apps (e.g. public and partners) that will each need their own deployment configuration. Each build configuation should make sure to work from the correct code base directory, for example the public app build command might be: + + cd sites/public; yarn run build ; yarn run export + +### Changing the Base Directory + +Netlify offers the option to set a "Base Directory" for each deployed app, which can offer increased isolation from the rest of the monorepo and ensures that shared packages are imported from the officially published versions rather than elsewhere in the monorepo tree. + +In order to make this configuration work, make sure to have an empty yarn.lock in your base directory in addition to having YARN_VERSION defined (see below). Netlify will not install or use yarn correctly unless it sees a yarn.lock file in the build root. + +## Server-side Rendering + +## Environment Variables + +In addition to those environment variables defined in .env.template for the relevant applications, make sure to define the following variables to ensure the release process is consistent with the Bloom supported versions: + +- NODE_VERSION +- YARN_VERSION + +Note that there is a `netlify.toml` file in each reference app directory so that settings can be specified in a version controlled manner. If there are any environment variables that should not be publically accessible as part of the source, they can be set direcly in the Netlify console so long as they're not in the toml file. diff --git a/docs/DeployServicesHeroku.md b/docs/DeployServicesHeroku.md new file mode 100644 index 0000000000..7023bf97ae --- /dev/null +++ b/docs/DeployServicesHeroku.md @@ -0,0 +1,48 @@ +# Deploying Bloom Services to Heroku + +Bloom is designed to use a set of independently run services that provide the data and business logic processing needed by the front-end apps. While the Bloom architecture accomodates services built and operated in a variety of environments, the reference implementation includes services that can be easily run within the [Heroku PaaS environment](https://www.heroku.com/). + +## Resources + +- [Heroku Postgres](https://www.heroku.com/postgres) +- [Heroku Redis](https://www.heroku.com/redis) + +## Heroku Buildpacks + +### Monorepo Buildpack + +Since the Bloom repository uses a monorepo layout, all Heroku services must use the [monorepo buildpack](https://elements.heroku.com/buildpacks/lstoll/heroku-buildpack-monorepo). + +### Node.js Buildpack + +Bloom's backend runs on Node.js and Heroku must be setup with [Heroku Buildpack for Node.js](https://elements.heroku.com/buildpacks/heroku/heroku-buildpack-nodejs). + +## Procfile + +release: yarn herokusetup + +web: yarn start + +## Environment Variables + +APP_BASE=backend/core + +APP_SECRET='YOUR-LONG-SECRET-KEY' + +CLOUDINARY_SECRET= + +CLOUDINARY_KEY= + +DATABASE_URL= + +EMAIL_API_KEY='SENDGRID-API-KEY' + +EMAIL_FROM_ADDRESS= + +PARTNERS_BASE_URL='PARTNER-PORTAL-URL' + +REDIS_TLS_URL= + +REDIS_URL= + +REDIS_USE_TLS=1 diff --git a/docs/Styling.md b/docs/Styling.md new file mode 100644 index 0000000000..d3aa7c0d7f --- /dev/null +++ b/docs/Styling.md @@ -0,0 +1,15 @@ +# Understand and Customizing the Bloom Look and Feel + +This document provides an overview of how to customize a specific Bloom implementation, as well as how to customize the core components. + +## CSS Frameworks + +Bloom relies on the [Tailwind CSS framework](https://tailwindcss.com/) for most component styling and utility classes. + +## SASS Structure + +## Customization Points + +## CSS Naming Conventions + +## Build Process diff --git a/docs/bloom-logo.png b/docs/bloom-logo.png new file mode 100644 index 0000000000..4ed419610c Binary files /dev/null and b/docs/bloom-logo.png differ diff --git a/docs/pull_request_template.md b/docs/pull_request_template.md new file mode 100644 index 0000000000..60a80b04bb --- /dev/null +++ b/docs/pull_request_template.md @@ -0,0 +1,48 @@ +## Issue + +- Closes #issue +- Addresses #issue +- [ ] This change is a dependency for another issue +- [ ] This change has a dependency from another issue + +## Description + +Please include a summary of the change and which issue(s) is addressed. Please also include relevant motivation and context. List any dependencies that are required for this change. + +## How Can This Be Tested/Reviewed? + +Provide instructions so we can review. + +Describe the tests that you ran to verify your changes. Please also list any relevant details for your test configuration. + +## Checklist: + +- [ ] My code follows the style guidelines of this project +- [ ] I have performed a self-review of my own code +- [ ] I have reviewed the changes in a desktop view +- [ ] I have reviewed the changes in a mobile view +- [ ] I have commented my code, particularly in hard-to-understand areas +- [ ] I have made corresponding changes to the documentation +- [ ] My changes generate no new warnings +- [ ] I have added tests that prove my fix is effective or that my feature works +- [ ] New and existing unit tests pass locally with my changes +- [ ] Any dependent changes have been merged and published in downstream modules +- [ ] I have assigned reviewers +- [ ] I have run `yarn generate:client` and/or created a migration if I made backend changes that require them +- [ ] I have exported any new pieces added to ui-components +- [ ] My commit message(s) is/are polished, and any breaking changes are indicated in the message and are well-described +- [ ] Commits made across packages purposefully have the same commit message/version change, else are separated into different commits + +## Reviewer Notes: + +Steps to review a PR: + +- Read and understand the issue +- Review the code itself from a style point of view +- Pull the changes down locally and test that the acceptance criteria is met +- Also review the acceptance criteria on the Netlify deploy preview (noting that these do not yet include any backend changes made in the PR) +- Either explicitly ask a clarifying question, request changes, or approve the PR if there are small remaining changes but the PR is otherwise good to go + +## On Merge: + +If you have one commit and message, squash. If you need each message to be applied, rebase and merge. diff --git a/lerna.json b/lerna.json new file mode 100644 index 0000000000..e3bf452bf9 --- /dev/null +++ b/lerna.json @@ -0,0 +1,16 @@ +{ + "packages": ["sites/public", "sites/partners", "backend/core", "shared-helpers", "ui-components"], + "version": "independent", + "npmClient": "yarn", + "useWorkspaces": true, + "command": { + "publish": { + "conventionalCommits": true, + "message": "chore(release): publish" + }, + "version": { + "conventionalCommits": true, + "message": "chore(release): version" + } + } +} diff --git a/listings-service/README.md b/listings-service/README.md deleted file mode 100644 index fd702fd84e..0000000000 --- a/listings-service/README.md +++ /dev/null @@ -1,3 +0,0 @@ -# Listings Service v0 - -This is a minimal listings service which stores listings in individual JSON files and serves them via the [micro](https://github.com/zeit/micro) framework. \ No newline at end of file diff --git a/listings-service/listings/archer.json b/listings-service/listings/archer.json deleted file mode 100644 index 70441564b0..0000000000 --- a/listings-service/listings/archer.json +++ /dev/null @@ -1,74 +0,0 @@ -{ - "id": 5, - "accepting_applications_at_leasing_agent": true, - "accepting_applications_by_po_box": false, - "accepting_online_applications": false, - "accessibility": "There is a total of 5 ADA units in the complex, all others are adaptable. Exterior Wheelchair ramp (front entry)", - "amenities": "Community Room, Laundry Room, Assigned Parking, Bike Storage, Roof Top Garden, Part-time Resident Service Coordinator", - "application_due_date": "2019-08-31T15:22:57.000-07:00", - "application_organization": "98 Archer Street", - "application_city": "San Jose", - "application_phone": "(408) 217-8562", - "application_postal_code": "95112", - "application_state": null, - "application_street_address": null, - "blank_paper_application_can_be_picked_up": true, - "building_city": "San Jose", - "building_name": "Archer Studios", - "building_selection_criteria": "Tenant Selection Criteria will be available to all applicants upon request.", - "building_state": "CA", - "building_street_address": "98 Archer Street", - "building_zip_code": "95112", - "costs_not_included": "Resident responsible for PG&E, internet and phone. Owner pays for water, trash, and sewage. Residents encouraged to obtain renter's insurance but this is not a requirement. Rent is due by the 5th of each month. Late fee $35 and returned check fee is $35 additional.", - "credit_history": "Applications will be rated on a score system for housing. An applicant's score may be impacted by negative tenant peformance information provided to the credit reporting agency. All applicants are expected have a passing acore of 70 points out of 100 to be considered for housing. Applicants with no credit history will receive a maximum of 80 points to fairly outweigh positive and/or negative trades as would an applicant with established credit history. Refer to Tenant Selection Criteria or Qualification Criteria for details related to the qualification process. ", - "deposit_max": "1140.0", - "deposit_min": "1104.0", - "developer": "Charities Housing ", - "image_url": "https://regional-dahlia-staging.s3-us-west-1.amazonaws.com/listings/archer/archer-studios.jpg", - "program_rules": "Applicants must adhere to minimum & maximum income limits. Tenant Selection Criteria applies.", - "external_id": null, - "waitlist_max_size": 300, - "name": "Archer Studios", - "neighborhood": "Rosemary Gardens Park", - "waitlist_current_size": 300, - "pet_policy": "No pets allowed. Accommodation animals may be granted to persons with disabilities via a reasonable accommodation request.", - "priorities_descriptor": null, - "required_documents": "Completed application and government issued IDs", - "reserved_community_maximum_age": null, - "reserved_community_minimum_age": null, - "reserved_descriptor": null, - "smoking_policy": "Non-smoking building", - "year_built": 2012, - "created_at": "2019-07-08T15:37:19.565-07:00", - "updated_at": "2019-07-09T14:35:11.142-07:00", - "group_id": 1, - "hide_unit_features": false, - "unit_amenities": "Dishwasher", - "application_download_url": null, - "application_fee": "30.0", - "criminal_background": "A criminal background investigation will be obtained on each applicant. As criminal background checks are done county by county and will be ran for all counties in which the applicant lived, Applicants will be disqualified for tenancy if they have been convicted of a felony or misdemeanor. Refer to Tenant Selection Criteria or Qualification Criteria for details related to the qualification process. ", - "leasing_agent_city": "San Jose", - "leasing_agent_email": "mbaca@charitieshousing.org", - "leasing_agent_name": "Marisela Baca", - "leasing_agent_office_hours": "Monday, Tuesday & Friday, 9:00AM - 5:00PM", - "leasing_agent_phone": "(408) 217-8562", - "leasing_agent_state": "CA", - "leasing_agent_street": null, - "leasing_agent_title": null, - "leasing_agent_zip": "95112", - "rental_history": "Two years of rental history will be verified with all applicable landlords. Household family members and/or personal friends are not acceptable landlord references. Two professional character references may be used in lieu of rental history for applicants with no prior rental history. An unlawful detainer report will be processed thourhg the U.D. Registry, Inc. Applicants will be disqualified if they have any evictions filing within the last 7 years. Refer to Tenant Selection Criteria or Qualification Criteria for details related to the qualification process.", - "building_total_units": 35, - "unit_summaries": { - "general": [ - { - "min_income_range": { "min": "1438.0", "max": "2208.0" }, - "occupancy_range": { "min": 1, "max": 2 }, - "rent_as_percent_income_range": { "min": null, "max": null }, - "rent_range": { "min": "719.0", "max": "1104.0" }, - "unit_type": "Studio" - } - ] - }, - "total_units": 2, - "units_available": 0 -} diff --git a/listings-service/listings/gish.json b/listings-service/listings/gish.json deleted file mode 100644 index d52d63115f..0000000000 --- a/listings-service/listings/gish.json +++ /dev/null @@ -1,74 +0,0 @@ -{ - "id": 4, - "accepting_applications_at_leasing_agent": true, - "accepting_applications_by_po_box": false, - "accepting_online_applications": false, - "accessibility": "Accessibility features in common areas like lobby – wheelchair ramps, wheelchair accessible bathrooms and elevators.", - "amenities": "Community Room, Laundry", - "application_due_date": "2019-08-09T12:58:21.000-07:00", - "application_organization": "35 East Gish Road", - "application_city": "San Jose", - "application_phone": "(408) 436-8972", - "application_postal_code": "95112", - "application_state": null, - "application_street_address": null, - "blank_paper_application_can_be_picked_up": false, - "building_city": "San Jose", - "building_name": "Gish Apartments", - "building_selection_criteria": null, - "building_state": "CA", - "building_street_address": "35 East Gish Road", - "building_zip_code": "95112", - "costs_not_included": "Residents responsible for PG&E and Internet. Residents encourage to obtain renter’s insurance but this is not a requirement. Rent is due by the 5rd of each month. Late fee is $20.00. Owner pays for water, trash, and sewage. Resident to pay $25 for each returned check or rejected electronic payment.", - "credit_history": "A credit reference and background check will be required for all household members age 18 or older. A poor credit history may be grounds to deem an applicant ineligible for housing. Applicants will have the option to explain mitigating circumstances and/or include supplemental information with their application to explain any issues such as foreclosure, bankruptcy and negative credit. Any of the following circumstances may be defined as Poor Credit History or grounds for denial: \n• Total unmet credit problems in excess of $3,000. \n• A bankruptcy (within the last three years).\n• A total of seven (7) unmet credit problems of any value.\n• An Unlawful Detainer and/or judgment against an applicant obtained by the current or any previous landlord.\n• An unmet obligation owed to previous landlord.\n• The applicant must have made timely payments of last year’s rental payments.", - "deposit_max": "1500.0", - "deposit_min": "800.0", - "developer": "First Community Housing", - "image_url": "https://regional-dahlia-staging.s3-us-west-1.amazonaws.com/listings/gish/gish.jpg", - "program_rules": null, - "external_id": null, - "waitlist_max_size": 150, - "name": "Gish Apartments", - "neighborhood": "San Jose", - "waitlist_current_size": 89, - "pet_policy": "No Pets Allowed", - "priorities_descriptor": null, - "required_documents": "3 Months Paystubs or Award Letter for Social Security, 6 Months Bank Statements, 401K/ Retirement Statement dated with in 120 days.", - "reserved_community_maximum_age": null, - "reserved_community_minimum_age": null, - "reserved_descriptor": "Special needs / multi-family", - "smoking_policy": "No Smoking Building", - "year_built": 2009, - "created_at": "2019-06-19T15:11:26.446-07:00", - "updated_at": "2019-06-24T11:47:47.150-07:00", - "group_id": 1, - "hide_unit_features": false, - "unit_amenities": "Dishwasher", - "application_download_url": "https://drive.google.com/file/d/0B4CsVae6UWpLRDJBUzlFbHBFa0t0WTJ6em5EdEwtU0VoMWs4/view?usp=sharing", - "application_fee": "38.0", - "criminal_background": "A check will be made of criminal conviction records for the past seven years for all adult Applicants of the household. Reports will be obtained from local and/or state records and may also include local Police records. If the Applicant has resided in a state other than California and has a past felony conviction, a report will be required from that state or federal organization. Generally, public records of this sort are only available for the past seven (7) years. However, if information becomes known during the screening process regarding criminal activity that happened before the past seven year period which could impact the Applicant household’s eligibility to live at the property, the Management Agent reserves the right to consider this information as well. . Serious felony offenses and/or continued and ongoing criminal activity will be grounds for rejection if such offenses involve physical violence to persons or property, domestic violence, sexual abuse, the manufacture or sale narcotics, possession of an illegal weapon, breaking and entering, burglary or drug related criminal offenses. The nature, severity and recency of such offenses and/or ongoing criminal activity will be considered when reviewing the Applicant and only those potentially impacting the health, safety, security or right to peaceful enjoyment of the property of and by other residents, visitors or employees will be considered. Additionally, applicants may be rejected due to: • A history of violence or abuse (physical or verbal), in which the applicant was determined to be the antagonist.\n• A household in which any member is currently engaged in illegal use of drugs or for which the owner has reasonable cause to believe that a member’s illegal use or pattern of use of a drug may interfere with the health, safety, security, or right to peaceful enjoyment of the property of and by other residents, visitors or employees.\n• Any household member, if there is reasonable cause to believe that a member’s behavior, from abuse or pattern of abuse of alcohol, may interfere with the health, safety, security or right to peaceful enjoyment of the property of and by other residents, visitors or employees.", - "leasing_agent_city": "San Jose", - "leasing_agent_email": "gish@jsco.net", - "leasing_agent_name": "Jonaye Wilbert", - "leasing_agent_office_hours": "Monday-Friday, 9:00 am - 3:00 pm", - "leasing_agent_phone": "(408) 436-8972", - "leasing_agent_state": "CA", - "leasing_agent_street": "35 East Gish Road", - "leasing_agent_title": null, - "leasing_agent_zip": "95112", - "rental_history": "The applicants’ landlord references must verify a history of responsible occupancy, behavior, and conduct. Current landlord references will be requested along with a third party unlawful detainer search. All previous landlords during the past three years will also be contacted. Landlord references will help to determine whether or not the applicant has a good rent paying history, whether or not there have been any disturbing behavior patterns including repeated lease violations, destruction of property, etc. Any documented behavior which would constitute a material violation of the standard lease to be used at this location may be considered grounds for ineligibility.", - "building_total_units": 5, - "unit_summaries": { - "general": [ - { - "min_income_range": { "min": "2135.0", "max": "2135.0" }, - "occupancy_range": { "min": 1, "max": 2 }, - "rent_as_percent_income_range": { "min": null, "max": null }, - "rent_range": { "min": "854.0", "max": "854.0" }, - "unit_type": "Studio" - } - ] - }, - "total_units": 5, - "units_available": 0 -} diff --git a/listings-service/listings/triton.json b/listings-service/listings/triton.json deleted file mode 100644 index 2aeb2d3439..0000000000 --- a/listings-service/listings/triton.json +++ /dev/null @@ -1,81 +0,0 @@ -{ - "id": 3, - "accepting_applications_at_leasing_agent": true, - "accepting_applications_by_po_box": false, - "accepting_online_applications": false, - "accessibility": "Accessibility features in common areas like lobby – wheelchair ramps, wheelchair accessible bathrooms and elevators.", - "amenities": "Gym, Clubhouse, Business Lounge, View Lounge, Pool, Spa", - "application_due_date": "2019-08-19T17:00:00.000-07:00", - "application_organization": "Triton", - "application_city": "Foster City", - "application_phone": "(650) 437-2039", - "application_postal_code": "94404", - "application_state": "CA", - "application_street_address": "55 Triton Park Lane", - "blank_paper_application_can_be_picked_up": true, - "building_city": "Foster City", - "building_name": "The Triton", - "building_selection_criteria": "https://regional-dahlia-staging.s3-us-west-1.amazonaws.com/listings/triton/The_Triton_BMR_rental_information.pdf", - "building_state": "CA", - "building_street_address": "55 Triton Park Lane", - "building_zip_code": "94404", - "costs_not_included": "Residents responsible for PG&E, Internet, Utilities - water, sewer, trash, admin fee. Pet Deposit is $500 with a $60 monthly pet rent. Residents required to maintain a renter's insurance policy as outlined in the lease agreement. Rent is due by the 3rd of each month. Late fee is $50.00. Resident to pay $25 for each returned check or rejected electronic payment. For additional returned checks, resident will pay a charge of $50.00.", - "credit_history": "No collections, no bankruptcy, income is twice monthly rent.
A credit report will be completed on all applicants to verify credit ratings. Income plus verified credit history will be entered into a credit scoring model to determine rental eligibility and security deposit levels. All decisions for residency are based on a system which considers credit history, rent history, income qualifications, and employment history. An approved decision based on the system does not automatically constittute an approval of residency. Applicant(s) and occupant(s) aged 18 years or older MUST also pass the criminal background check based on the criteria contained herein to be approved for residency.
Credit recommendations other than an accept decision, will require a rental verification. Applications for residency will automatically be denied for the following reasons:
a. An outstanding debt to a previous landlord or an outstanding NSF check must be paid in full
b. An unsatisfied breach of a prior lease or a prior eviction of any applicant or occupant
c. More than four (4) late pays and two (2) NSF's in the last twenty-four (24) months ", - "deposit_max": "800.0", - "deposit_min": "500.0", - "developer": "Thompson Dorfman, LLC", - "image_url": "https://regional-dahlia-staging.s3-us-west-1.amazonaws.com/listings/triton/thetriton.png", - "program_rules": null, - "external_id": null, - "waitlist_max_size": 600, - "name": "The Triton", - "neighborhood": "Foster City", - "waitlist_current_size": 400, - "pet_policy": "Pets allowed except the following; pit bull, malamute, akita, rottweiler, doberman, staffordshire terrier, presa canario, chowchow, american bull dog, karelian bear dog, st bernard, german shepherd, husky, great dane, any hybrid or mixed breed of the aforementioned breeds. 50 pound weight limit. 2 pets per household limit. $500 pet deposit per pet. $60 pet rent per pet.", - "priorities_descriptor": null, - "required_documents": "Due at interview - Paystubs, 3 months’ bank statements, recent tax returns or non-tax affidavit, recent retirement statement, application to lease, application qualifying criteria, social security card, state or nation ID. For self-employed, copy of IRS Tax Return including schedule C and current or most recent clients. Unemployment if applicable. Child support/Alimony; current notice from DA office, a court order or a letter from the provider with copies of last two checks. Any other income etc", - "reserved_community_maximum_age": 0, - "reserved_community_minimum_age": 0, - "reserved_descriptor": null, - "smoking_policy": "Non-Smoking", - "year_built": 2018, - "created_at": "2019-06-03T10:30:41.039-07:00", - "updated_at": "2019-06-14T11:54:15.443-07:00", - "group_id": 2, - "hide_unit_features": false, - "unit_amenities": "Washer and dryer, AC and Heater, Gas Stove", - "application_download_url": "https://regional-dahlia-staging.s3-us-west-1.amazonaws.com/listings/triton/Triton_BMR_Application.pdf", - "application_fee": "38.0", - "criminal_background": null, - "leasing_agent_city": "Foster City", - "leasing_agent_email": "thetriton@legacypartners.com", - "leasing_agent_name": "Francis Santos", - "leasing_agent_office_hours": "Monday - Friday, 9:00 am - 5:00 pm", - "leasing_agent_phone": "650-437-2039", - "leasing_agent_state": "CA", - "leasing_agent_street": "55 Triton Park Lane", - "leasing_agent_title": "Business Manager", - "leasing_agent_zip": "94404", - "rental_history": "No evictions.", - "building_total_units": 48, - "unit_summaries": { - "general": [ - { - "min_income_range": { "min": "4858.0", "max": "4858.0" }, - "occupancy_range": { "min": 1, "max": 2 }, - "rent_as_percent_income_range": { "min": null, "max": null }, - "rent_range": { "min": "2429.0", "max": "2429.0" }, - "unit_type": "1 BR" - }, - { - "min_income_range": { "min": "5992.0", "max": "5992.0" }, - "occupancy_range": { "min": 5, "max": 7 }, - "rent_as_percent_income_range": { "min": null, "max": null }, - "rent_range": { "min": "2996.0", "max": "2996.0" }, - "unit_type": "3 BR" - } - ] - }, - "total_units": 2, - "units_available": 2 -} diff --git a/listings-service/package.json b/listings-service/package.json deleted file mode 100644 index f32c783d1d..0000000000 --- a/listings-service/package.json +++ /dev/null @@ -1,32 +0,0 @@ -{ - "name": "dahlia-x", - "version": "0.0.1", - "description": "Experimental ideas for next generation Dahlia", - "main": "dist/index.js", - "author": "Ben Kutler ", - "license": "GPL-3.0", - "private": true, - "devDependencies": { - "@types/micro": "^7.3.3", - "@types/microrouter": "^3.1.0", - "@types/node": "^12.6.8", - "concurrently": "^4.1.1", - "eslint": "^6.0.1", - "eslint-config-prettier": "^6.0.0", - "eslint-plugin-prettier": "^3.1.0", - "micro-dev": "^3.0.0", - "prettier": "^1.18.2", - "rimraf": "^2.6.3", - "typescript": "^3.5.3" - }, - "dependencies": { - "micro": "^9.3.4", - "microrouter": "^3.1.3" - }, - "scripts": { - "clean": "rimraf dist", - "build": "yarn run clean && tsc", - "start": "yarn run build && micro", - "dev": "yarn run build && concurrently \"tsc --watch\" \"micro-dev\"" - } -} diff --git a/listings-service/src/index.ts b/listings-service/src/index.ts deleted file mode 100644 index aef8acd303..0000000000 --- a/listings-service/src/index.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { RequestHandler, send } from "micro"; -import { router, get } from "microrouter"; - -import { IncomingMessage, ServerResponse } from "http"; - -const archer = require("../listings/archer.json"); -const gish = require("../listings/gish.json"); -const triton = require("../listings/triton.json"); - -const service: RequestHandler = (req, res) => { - const data = { - status: "ok", - listings: [triton, gish, archer] - }; - send(res, 200, data); -}; - -module.exports = router(get("/", service)); diff --git a/listings-service/tsconfig.json b/listings-service/tsconfig.json deleted file mode 100644 index 68c8efc2e7..0000000000 --- a/listings-service/tsconfig.json +++ /dev/null @@ -1,63 +0,0 @@ -{ - "compilerOptions": { - /* Basic Options */ - // "incremental": true, /* Enable incremental compilation */ - "target": "es2018" /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019' or 'ESNEXT'. */, - "module": "commonjs" /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */, - // "lib": [], /* Specify library files to be included in the compilation. */ - // "allowJs": true, /* Allow javascript files to be compiled. */ - // "checkJs": true, /* Report errors in .js files. */ - // "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */ - // "declaration": true, /* Generates corresponding '.d.ts' file. */ - // "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */ - "sourceMap": true /* Generates corresponding '.map' file. */, - // "outFile": "./", /* Concatenate and emit output to single file. */ - "outDir": "dist/" /* Redirect output structure to the directory. */, - // "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ - // "composite": true, /* Enable project compilation */ - // "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */ - // "removeComments": true, /* Do not emit comments to output. */ - // "noEmit": true, /* Do not emit outputs. */ - // "importHelpers": true, /* Import emit helpers from 'tslib'. */ - // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ - // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ - - /* Strict Type-Checking Options */ - "strict": true /* Enable all strict type-checking options. */, - "noImplicitAny": true /* Raise error on expressions and declarations with an implied 'any' type. */, - // "strictNullChecks": true, /* Enable strict null checks. */ - // "strictFunctionTypes": true, /* Enable strict checking of function types. */ - // "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */ - // "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */ - // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ - // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ - - /* Additional Checks */ - // "noUnusedLocals": true, /* Report errors on unused locals. */ - // "noUnusedParameters": true, /* Report errors on unused parameters. */ - // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ - // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ - - /* Module Resolution Options */ - "moduleResolution": "node" /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */, - // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ - // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ - // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ - // "typeRoots": [], /* List of folders to include type definitions from. */ - // "types": [], /* Type declaration files to be included in compilation. */ - // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ - "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */ - // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ - // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ - - /* Source Map Options */ - // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ - // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ - // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ - // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ - - /* Experimental Options */ - // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ - // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ - } -} diff --git a/listings-service/yarn.lock b/listings-service/yarn.lock deleted file mode 100644 index aaf28c229c..0000000000 --- a/listings-service/yarn.lock +++ /dev/null @@ -1,2745 +0,0 @@ -# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. -# yarn lockfile v1 - - -"@babel/code-frame@^7.0.0": - version "7.5.5" - resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.5.5.tgz#bc0782f6d69f7b7d49531219699b988f669a8f9d" - integrity sha512-27d4lZoomVyo51VegxI20xZPuSHusqbQag/ztrBC7wegWoQ1nLREPVSKSW8byhTlzTKyNE4ifaTA6lCp7JjpFw== - dependencies: - "@babel/highlight" "^7.0.0" - -"@babel/highlight@^7.0.0": - version "7.5.0" - resolved "https://registry.yarnpkg.com/@babel/highlight/-/highlight-7.5.0.tgz#56d11312bd9248fa619591d02472be6e8cb32540" - integrity sha512-7dV4eu9gBxoM0dAnj/BCFDW9LFU0zvTrkq0ugM7pnHEgguOEeOz1so2ZghEdzviYzQEED0r4EAgpsBChKy1TRQ== - dependencies: - chalk "^2.0.0" - esutils "^2.0.2" - js-tokens "^4.0.0" - -"@types/micro@*", "@types/micro@^7.3.3": - version "7.3.3" - resolved "https://registry.yarnpkg.com/@types/micro/-/micro-7.3.3.tgz#31ead8df18ac10d58b7be1186d4b2d977b13a938" - integrity sha512-I3n3QYT7lqAxkyAoTZyg1yrvo38BxW/7ZafLAXZF/zZQOnAnQzg6j9XOuSmUEL5GGVFKWw4iqM+ZLnqb2154TA== - dependencies: - "@types/node" "*" - -"@types/microrouter@^3.1.0": - version "3.1.0" - resolved "https://registry.yarnpkg.com/@types/microrouter/-/microrouter-3.1.0.tgz#f24476e153f8ebf680144a5abc9b618207e43827" - integrity sha512-uNXXdtwIMbwgaOeQWS7CE0gAaYW9csoQ1k7T/Ak+x+4nApua5Mbex5a97fDR3EGB5O4AJ0d4Febo5L94M23A1g== - dependencies: - "@types/micro" "*" - "@types/node" "*" - -"@types/node@*", "@types/node@^12.6.8": - version "12.6.8" - resolved "https://registry.yarnpkg.com/@types/node/-/node-12.6.8.tgz#e469b4bf9d1c9832aee4907ba8a051494357c12c" - integrity sha512-aX+gFgA5GHcDi89KG5keey2zf0WfZk/HAQotEamsK2kbey+8yGKcson0hbK8E+v0NArlCJQCqMP161YhV6ZXLg== - -abbrev@1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/abbrev/-/abbrev-1.1.1.tgz#f8f2c887ad10bf67f634f005b6987fed3179aac8" - integrity sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q== - -acorn-jsx@^5.0.0: - version "5.0.1" - resolved "https://registry.yarnpkg.com/acorn-jsx/-/acorn-jsx-5.0.1.tgz#32a064fd925429216a09b141102bfdd185fae40e" - integrity sha512-HJ7CfNHrfJLlNTzIEUTj43LNWGkqpRLxm3YjAlcD0ACydk9XynzYsCBHxut+iqt+1aBXkx9UP/w/ZqMr13XIzg== - -acorn@^6.0.7: - version "6.2.0" - resolved "https://registry.yarnpkg.com/acorn/-/acorn-6.2.0.tgz#67f0da2fc339d6cfb5d6fb244fd449f33cd8bbe3" - integrity sha512-8oe72N3WPMjA+2zVG71Ia0nXZ8DpQH+QyyHO+p06jT8eg8FGG3FbcUIi8KziHlAfheJQZeoqbvq1mQSQHXKYLw== - -ajv@^6.10.0, ajv@^6.10.2: - version "6.10.2" - resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.10.2.tgz#d3cea04d6b017b2894ad69040fec8b623eb4bd52" - integrity sha512-TXtUUEYHuaTEbLZWIKUr5pmBuhDLy+8KYtPYdcV8qC+pOZL+NKqYwvWSRrVXHn+ZmRRAu8vJTAznH7Oag6RVRw== - dependencies: - fast-deep-equal "^2.0.1" - fast-json-stable-stringify "^2.0.0" - json-schema-traverse "^0.4.1" - uri-js "^4.2.2" - -ansi-align@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/ansi-align/-/ansi-align-2.0.0.tgz#c36aeccba563b89ceb556f3690f0b1d9e3547f7f" - integrity sha1-w2rsy6VjuJzrVW82kPCx2eNUf38= - dependencies: - string-width "^2.0.0" - -ansi-escapes@^3.2.0: - version "3.2.0" - resolved "https://registry.yarnpkg.com/ansi-escapes/-/ansi-escapes-3.2.0.tgz#8780b98ff9dbf5638152d1f1fe5c1d7b4442976b" - integrity sha512-cBhpre4ma+U0T1oM5fXg7Dy1Jw7zzwv7lt/GoCpr+hDQJoYnKVPLL4dCvSEFMmQurOQvSrwT7SL/DAlhBI97RQ== - -ansi-regex@^2.0.0: - version "2.1.1" - resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-2.1.1.tgz#c3b33ab5ee360d86e0e628f0468ae7ef27d654df" - integrity sha1-w7M6te42DYbg5ijwRorn7yfWVN8= - -ansi-regex@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-3.0.0.tgz#ed0317c322064f79466c02966bddb605ab37d998" - integrity sha1-7QMXwyIGT3lGbAKWa922Bas32Zg= - -ansi-regex@^4.1.0: - version "4.1.0" - resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-4.1.0.tgz#8b9f8f08cf1acb843756a839ca8c7e3168c51997" - integrity sha512-1apePfXM1UOSqw0o9IiFAovVz9M5S1Dg+4TrDwfMewQ6p/rmMueb7tWZjQ1rx4Loy1ArBggoqGpfqqdI4rondg== - -ansi-styles@^3.2.0, ansi-styles@^3.2.1: - version "3.2.1" - resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-3.2.1.tgz#41fbb20243e50b12be0f04b8dedbf07520ce841d" - integrity sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA== - dependencies: - color-convert "^1.9.0" - -anymatch@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-2.0.0.tgz#bcb24b4f37934d9aa7ac17b4adaf89e7c76ef2eb" - integrity sha512-5teOsQWABXHHBFP9y3skS5P3d/WfWXpv3FUpy+LorMrNYaT9pI4oLMQX7jzQ2KklNpGpWHzdCXTDT2Y3XGlZBw== - dependencies: - micromatch "^3.1.4" - normalize-path "^2.1.1" - -aproba@^1.0.3: - version "1.2.0" - resolved "https://registry.yarnpkg.com/aproba/-/aproba-1.2.0.tgz#6802e6264efd18c790a1b0d517f0f2627bf2c94a" - integrity sha512-Y9J6ZjXtoYh8RnXVCMOU/ttDmk1aBjunq9vO0ta5x85WDQiQfUF9sIPBITdbiiIVcBo03Hi3jMxigBtsddlXRw== - -arch@^2.1.0: - version "2.1.1" - resolved "https://registry.yarnpkg.com/arch/-/arch-2.1.1.tgz#8f5c2731aa35a30929221bb0640eed65175ec84e" - integrity sha512-BLM56aPo9vLLFVa8+/+pJLnrZ7QGGTVHWsCwieAWT9o9K8UeGaQbzZbGoabWLOo2ksBCztoXdqBZBplqLDDCSg== - -are-we-there-yet@~1.1.2: - version "1.1.5" - resolved "https://registry.yarnpkg.com/are-we-there-yet/-/are-we-there-yet-1.1.5.tgz#4b35c2944f062a8bfcda66410760350fe9ddfc21" - integrity sha512-5hYdAkZlcG8tOLujVDTgCT+uPX0VnpAH28gWsLfzpXYm7wP6mp5Q/gYyR7YQ0cKVJcXJnl3j2kpBan13PtQf6w== - dependencies: - delegates "^1.0.0" - readable-stream "^2.0.6" - -arg@4.1.0: - version "4.1.0" - resolved "https://registry.yarnpkg.com/arg/-/arg-4.1.0.tgz#583c518199419e0037abb74062c37f8519e575f0" - integrity sha512-ZWc51jO3qegGkVh8Hwpv636EkbesNV5ZNQPCtRa+0qytRYPEs9IYT9qITY9buezqUH5uqyzlWLcufrzU2rffdg== - -argparse@^1.0.7: - version "1.0.10" - resolved "https://registry.yarnpkg.com/argparse/-/argparse-1.0.10.tgz#bcd6791ea5ae09725e17e5ad988134cd40b3d911" - integrity sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg== - dependencies: - sprintf-js "~1.0.2" - -arr-diff@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/arr-diff/-/arr-diff-4.0.0.tgz#d6461074febfec71e7e15235761a329a5dc7c520" - integrity sha1-1kYQdP6/7HHn4VI1dhoyml3HxSA= - -arr-flatten@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/arr-flatten/-/arr-flatten-1.1.0.tgz#36048bbff4e7b47e136644316c99669ea5ae91f1" - integrity sha512-L3hKV5R/p5o81R7O02IGnwpDmkp6E982XhtbuwSe3O4qOtMMMtodicASA1Cny2U+aCXcNpml+m4dPsvsJ3jatg== - -arr-union@^3.1.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/arr-union/-/arr-union-3.1.0.tgz#e39b09aea9def866a8f206e288af63919bae39c4" - integrity sha1-45sJrqne+Gao8gbiiK9jkZuuOcQ= - -array-unique@^0.3.2: - version "0.3.2" - resolved "https://registry.yarnpkg.com/array-unique/-/array-unique-0.3.2.tgz#a894b75d4bc4f6cd679ef3244a9fd8f46ae2d428" - integrity sha1-qJS3XUvE9s1nnvMkSp/Y9Gri1Cg= - -assign-symbols@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/assign-symbols/-/assign-symbols-1.0.0.tgz#59667f41fadd4f20ccbc2bb96b8d4f7f78ec0367" - integrity sha1-WWZ/QfrdTyDMvCu5a41Pf3jsA2c= - -astral-regex@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/astral-regex/-/astral-regex-1.0.0.tgz#6c8c3fb827dd43ee3918f27b82782ab7658a6fd9" - integrity sha512-+Ryf6g3BKoRc7jfp7ad8tM4TtMiaWvbF/1/sQcZPkkS7ag3D5nMBCe2UfOTONtAkaG0tO0ij3C5Lwmf1EiyjHg== - -async-each@^1.0.0: - version "1.0.3" - resolved "https://registry.yarnpkg.com/async-each/-/async-each-1.0.3.tgz#b727dbf87d7651602f06f4d4ac387f47d91b0cbf" - integrity sha512-z/WhQ5FPySLdvREByI2vZiTWwCnF0moMJ1hK9YQwDTHKh6I7/uSckMetoRGb5UBZPC1z0jlw+n/XCgjeH7y1AQ== - -atob@^2.1.1: - version "2.1.2" - resolved "https://registry.yarnpkg.com/atob/-/atob-2.1.2.tgz#6d9517eb9e030d2436666651e86bd9f6f13533c9" - integrity sha512-Wm6ukoaOGJi/73p/cl2GvLjTI5JM1k/O14isD73YML8StrH/7/lRFgmg8nICZgD3bZZvjwCGxtMOD3wWNAu8cg== - -balanced-match@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.0.tgz#89b4d199ab2bee49de164ea02b89ce462d71b767" - integrity sha1-ibTRmasr7kneFk6gK4nORi1xt2c= - -base@^0.11.1: - version "0.11.2" - resolved "https://registry.yarnpkg.com/base/-/base-0.11.2.tgz#7bde5ced145b6d551a90db87f83c558b4eb48a8f" - integrity sha512-5T6P4xPgpp0YDFvSWwEZ4NoE3aM4QBQXDzmVbraCkFj8zHM+mba8SyqB5DbZWyR7mYHo6Y7BdQo3MoA4m0TeQg== - dependencies: - cache-base "^1.0.1" - class-utils "^0.3.5" - component-emitter "^1.2.1" - define-property "^1.0.0" - isobject "^3.0.1" - mixin-deep "^1.2.0" - pascalcase "^0.1.1" - -binary-extensions@^1.0.0: - version "1.13.1" - resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-1.13.1.tgz#598afe54755b2868a5330d2aff9d4ebb53209b65" - integrity sha512-Un7MIEDdUC5gNpcGDV97op1Ywk748MpHcFTHoYs6qnj1Z3j7I53VG3nwZhKzoBZmbdRNnb6WRdFlwl7tSDuZGw== - -boolbase@~1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/boolbase/-/boolbase-1.0.0.tgz#68dff5fbe60c51eb37725ea9e3ed310dcc1e776e" - integrity sha1-aN/1++YMUes3cl6p4+0xDcwed24= - -boxen@1.3.0: - version "1.3.0" - resolved "https://registry.yarnpkg.com/boxen/-/boxen-1.3.0.tgz#55c6c39a8ba58d9c61ad22cd877532deb665a20b" - integrity sha512-TNPjfTr432qx7yOjQyaXm3dSR0MH9vXp7eT1BFSl/C51g+EFnOR9hTg1IreahGBmDNCehscshe45f+C1TBZbLw== - dependencies: - ansi-align "^2.0.0" - camelcase "^4.0.0" - chalk "^2.0.1" - cli-boxes "^1.0.0" - string-width "^2.0.0" - term-size "^1.2.0" - widest-line "^2.0.0" - -brace-expansion@^1.1.7: - version "1.1.11" - resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.11.tgz#3c7fcbf529d87226f3d2f52b966ff5271eb441dd" - integrity sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA== - dependencies: - balanced-match "^1.0.0" - concat-map "0.0.1" - -braces@^2.3.0, braces@^2.3.1: - version "2.3.2" - resolved "https://registry.yarnpkg.com/braces/-/braces-2.3.2.tgz#5979fd3f14cd531565e5fa2df1abfff1dfaee729" - integrity sha512-aNdbnj9P8PjdXU4ybaWLK2IF3jc/EoDYbC7AazW6to3TRsfXxscC9UXOB5iDiEQrkyIbWp2SLQda4+QAa7nc3w== - dependencies: - arr-flatten "^1.1.0" - array-unique "^0.3.2" - extend-shallow "^2.0.1" - fill-range "^4.0.0" - isobject "^3.0.1" - repeat-element "^1.1.2" - snapdragon "^0.8.1" - snapdragon-node "^2.0.1" - split-string "^3.0.2" - to-regex "^3.0.1" - -bytes@3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.0.0.tgz#d32815404d689699f85a4ea4fa8755dd13a96048" - integrity sha1-0ygVQE1olpn4Wk6k+odV3ROpYEg= - -cache-base@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/cache-base/-/cache-base-1.0.1.tgz#0a7f46416831c8b662ee36fe4e7c59d76f666ab2" - integrity sha512-AKcdTnFSWATd5/GCPRxr2ChwIJ85CeyrEyjRHlKxQ56d4XJMGym0uAiKn0xbLOGOl3+yRpOTi484dVCEc5AUzQ== - dependencies: - collection-visit "^1.0.0" - component-emitter "^1.2.1" - get-value "^2.0.6" - has-value "^1.0.0" - isobject "^3.0.1" - set-value "^2.0.0" - to-object-path "^0.3.0" - union-value "^1.0.0" - unset-value "^1.0.0" - -callsites@^3.0.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/callsites/-/callsites-3.1.0.tgz#b3630abd8943432f54b3f0519238e33cd7df2f73" - integrity sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ== - -camelcase@^4.0.0, camelcase@^4.1.0: - version "4.1.0" - resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-4.1.0.tgz#d545635be1e33c542649c69173e5de6acfae34dd" - integrity sha1-1UVjW+HjPFQmScaRc+Xeas+uNN0= - -camelcase@^5.0.0: - version "5.3.1" - resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-5.3.1.tgz#e3c9b31569e106811df242f715725a1f4c494320" - integrity sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg== - -chalk@2.4.1: - version "2.4.1" - resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.4.1.tgz#18c49ab16a037b6eb0152cc83e3471338215b66e" - integrity sha512-ObN6h1v2fTJSmUXoS3nMQ92LbDK9be4TV+6G+omQlGJFdcUX5heKi1LZ1YnRMIgwTLEj3E24bT6tYni50rlCfQ== - dependencies: - ansi-styles "^3.2.1" - escape-string-regexp "^1.0.5" - supports-color "^5.3.0" - -chalk@^2.0.0, chalk@^2.0.1, chalk@^2.1.0, chalk@^2.3.0, chalk@^2.4.1, chalk@^2.4.2: - version "2.4.2" - resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.4.2.tgz#cd42541677a54333cf541a49108c1432b44c9424" - integrity sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ== - dependencies: - ansi-styles "^3.2.1" - escape-string-regexp "^1.0.5" - supports-color "^5.3.0" - -chardet@^0.7.0: - version "0.7.0" - resolved "https://registry.yarnpkg.com/chardet/-/chardet-0.7.0.tgz#90094849f0937f2eedc2425d0d28a9e5f0cbad9e" - integrity sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA== - -chokidar@2.0.3: - version "2.0.3" - resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-2.0.3.tgz#dcbd4f6cbb2a55b4799ba8a840ac527e5f4b1176" - integrity sha512-zW8iXYZtXMx4kux/nuZVXjkLP+CyIK5Al5FHnj1OgTKGZfp4Oy6/ymtMSKFv3GD8DviEmUPmJg9eFdJ/JzudMg== - dependencies: - anymatch "^2.0.0" - async-each "^1.0.0" - braces "^2.3.0" - glob-parent "^3.1.0" - inherits "^2.0.1" - is-binary-path "^1.0.0" - is-glob "^4.0.0" - normalize-path "^2.1.1" - path-is-absolute "^1.0.0" - readdirp "^2.0.0" - upath "^1.0.0" - optionalDependencies: - fsevents "^1.1.2" - -chownr@^1.1.1: - version "1.1.2" - resolved "https://registry.yarnpkg.com/chownr/-/chownr-1.1.2.tgz#a18f1e0b269c8a6a5d3c86eb298beb14c3dd7bf6" - integrity sha512-GkfeAQh+QNy3wquu9oIZr6SS5x7wGdSgNQvD10X3r+AZr1Oys22HW8kAmDMvNg2+Dm0TeGaEuO8gFwdBXxwO8A== - -class-utils@^0.3.5: - version "0.3.6" - resolved "https://registry.yarnpkg.com/class-utils/-/class-utils-0.3.6.tgz#f93369ae8b9a7ce02fd41faad0ca83033190c463" - integrity sha512-qOhPa/Fj7s6TY8H8esGu5QNpMMQxz79h+urzrNYN6mn+9BnxlDGf5QZ+XeCDsxSjPqsSR56XOZOJmpeurnLMeg== - dependencies: - arr-union "^3.1.0" - define-property "^0.2.5" - isobject "^3.0.0" - static-extend "^0.1.1" - -cli-boxes@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/cli-boxes/-/cli-boxes-1.0.0.tgz#4fa917c3e59c94a004cd61f8ee509da651687143" - integrity sha1-T6kXw+WclKAEzWH47lCdplFocUM= - -cli-cursor@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/cli-cursor/-/cli-cursor-2.1.0.tgz#b35dac376479facc3e94747d41d0d0f5238ffcb5" - integrity sha1-s12sN2R5+sw+lHR9QdDQ9SOP/LU= - dependencies: - restore-cursor "^2.0.0" - -cli-width@^2.0.0: - version "2.2.0" - resolved "https://registry.yarnpkg.com/cli-width/-/cli-width-2.2.0.tgz#ff19ede8a9a5e579324147b0c11f0fbcbabed639" - integrity sha1-/xnt6Kml5XkyQUewwR8PvLq+1jk= - -clipboardy@1.2.3: - version "1.2.3" - resolved "https://registry.yarnpkg.com/clipboardy/-/clipboardy-1.2.3.tgz#0526361bf78724c1f20be248d428e365433c07ef" - integrity sha512-2WNImOvCRe6r63Gk9pShfkwXsVtKCroMAevIbiae021mS850UkWPbevxsBz3tnvjZIEGvlwaqCPsw+4ulzNgJA== - dependencies: - arch "^2.1.0" - execa "^0.8.0" - -cliui@^4.0.0: - version "4.1.0" - resolved "https://registry.yarnpkg.com/cliui/-/cliui-4.1.0.tgz#348422dbe82d800b3022eef4f6ac10bf2e4d1b49" - integrity sha512-4FG+RSG9DL7uEwRUZXZn3SS34DiDPfzP0VOiEwtUWlE+AR2EIg+hSyvrIgUUfhdgR/UkAeW2QHgeP+hWrXs7jQ== - dependencies: - string-width "^2.1.1" - strip-ansi "^4.0.0" - wrap-ansi "^2.0.0" - -code-point-at@^1.0.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/code-point-at/-/code-point-at-1.1.0.tgz#0d070b4d043a5bea33a2f1a40e2edb3d9a4ccf77" - integrity sha1-DQcLTQQ6W+ozovGkDi7bPZpMz3c= - -collection-visit@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/collection-visit/-/collection-visit-1.0.0.tgz#4bc0373c164bc3291b4d368c829cf1a80a59dca0" - integrity sha1-S8A3PBZLwykbTTaMgpzxqApZ3KA= - dependencies: - map-visit "^1.0.0" - object-visit "^1.0.0" - -color-convert@^1.9.0: - version "1.9.3" - resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-1.9.3.tgz#bb71850690e1f136567de629d2d5471deda4c1e8" - integrity sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg== - dependencies: - color-name "1.1.3" - -color-name@1.1.3: - version "1.1.3" - resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.3.tgz#a7d0558bd89c42f795dd42328f740831ca53bc25" - integrity sha1-p9BVi9icQveV3UIyj3QIMcpTvCU= - -component-emitter@^1.2.1: - version "1.3.0" - resolved "https://registry.yarnpkg.com/component-emitter/-/component-emitter-1.3.0.tgz#16e4070fba8ae29b679f2215853ee181ab2eabc0" - integrity sha512-Rd3se6QB+sO1TwqZjscQrurpEPIfO0/yYnSin6Q/rD3mOutHvUrCAhJub3r90uNb+SESBuE0QYoB90YdfatsRg== - -concat-map@0.0.1: - version "0.0.1" - resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b" - integrity sha1-2Klr13/Wjfd5OnMDajug1UBdR3s= - -concurrently@^4.1.1: - version "4.1.1" - resolved "https://registry.yarnpkg.com/concurrently/-/concurrently-4.1.1.tgz#42cf84d625163f3f5b2e2262568211ad76e1dbe8" - integrity sha512-48+FE5RJ0qc8azwKv4keVQWlni1hZeSjcWr8shBelOBtBHcKj1aJFM9lHRiSc1x7lq416pkvsqfBMhSRja+Lhw== - dependencies: - chalk "^2.4.1" - date-fns "^1.23.0" - lodash "^4.17.10" - read-pkg "^4.0.1" - rxjs "^6.3.3" - spawn-command "^0.0.2-1" - supports-color "^4.5.0" - tree-kill "^1.1.0" - yargs "^12.0.1" - -console-control-strings@^1.0.0, console-control-strings@~1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/console-control-strings/-/console-control-strings-1.1.0.tgz#3d7cf4464db6446ea644bf4b39507f9851008e8e" - integrity sha1-PXz0Rk22RG6mRL9LOVB/mFEAjo4= - -content-type@1.0.4: - version "1.0.4" - resolved "https://registry.yarnpkg.com/content-type/-/content-type-1.0.4.tgz#e138cc75e040c727b1966fe5e5f8c9aee256fe3b" - integrity sha512-hIP3EEPs8tB9AT1L+NUqtwOAps4mk2Zob89MWXMHjHWg9milF/j4osnnQLXBCBFBk/tvIG/tUc9mOUJiPBhPXA== - -copy-descriptor@^0.1.0: - version "0.1.1" - resolved "https://registry.yarnpkg.com/copy-descriptor/-/copy-descriptor-0.1.1.tgz#676f6eb3c39997c2ee1ac3a924fd6124748f578d" - integrity sha1-Z29us8OZl8LuGsOpJP1hJHSPV40= - -core-util-is@~1.0.0: - version "1.0.2" - resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.2.tgz#b5fd54220aa2bc5ab57aab7140c940754503c1a7" - integrity sha1-tf1UIgqivFq1eqtxQMlAdUUDwac= - -cross-spawn@^5.0.1: - version "5.1.0" - resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-5.1.0.tgz#e8bd0efee58fcff6f8f94510a0a554bbfa235449" - integrity sha1-6L0O/uWPz/b4+UUQoKVUu/ojVEk= - dependencies: - lru-cache "^4.0.1" - shebang-command "^1.2.0" - which "^1.2.9" - -cross-spawn@^6.0.0, cross-spawn@^6.0.5: - version "6.0.5" - resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-6.0.5.tgz#4a5ec7c64dfae22c3a14124dbacdee846d80cbc4" - integrity sha512-eTVLrBSt7fjbDygz805pMnstIs2VTBNkRm0qxZd+M7A5XDdxVRWO5MxGBXZhjY4cqLYLdtrGqRf8mBPmzwSpWQ== - dependencies: - nice-try "^1.0.4" - path-key "^2.0.1" - semver "^5.5.0" - shebang-command "^1.2.0" - which "^1.2.9" - -css-select@^1.1.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/css-select/-/css-select-1.2.0.tgz#2b3a110539c5355f1cd8d314623e870b121ec858" - integrity sha1-KzoRBTnFNV8c2NMUYj6HCxIeyFg= - dependencies: - boolbase "~1.0.0" - css-what "2.1" - domutils "1.5.1" - nth-check "~1.0.1" - -css-what@2.1: - version "2.1.3" - resolved "https://registry.yarnpkg.com/css-what/-/css-what-2.1.3.tgz#a6d7604573365fe74686c3f311c56513d88285f2" - integrity sha512-a+EPoD+uZiNfh+5fxw2nO9QwFa6nJe2Or35fGY6Ipw1R3R4AGz1d1TEZrCegvw2YTmZ0jXirGYlzxxpYSHwpEg== - -date-fns@^1.23.0: - version "1.30.1" - resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-1.30.1.tgz#2e71bf0b119153dbb4cc4e88d9ea5acfb50dc05c" - integrity sha512-hBSVCvSmWC+QypYObzwGOd9wqdDpOt+0wl0KbU+R+uuZBS1jN8VsD1ss3irQDknRj5NvxiTF6oj/nDRnN/UQNw== - -debounce@1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/debounce/-/debounce-1.1.0.tgz#6a1a4ee2a9dc4b7c24bb012558dbcdb05b37f408" - integrity sha512-ZQVKfRVlwRfD150ndzEK8M90ABT+Y/JQKs4Y7U4MXdpuoUkkrr4DwKbVux3YjylA5bUMUj0Nc3pMxPJX6N2QQQ== - -debug@^2.2.0, debug@^2.3.3: - version "2.6.9" - resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f" - integrity sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA== - dependencies: - ms "2.0.0" - -debug@^3.2.6: - version "3.2.6" - resolved "https://registry.yarnpkg.com/debug/-/debug-3.2.6.tgz#e83d17de16d8a7efb7717edbe5fb10135eee629b" - integrity sha512-mel+jf7nrtEl5Pn1Qx46zARXKDpBbvzezse7p7LqINmdoIk8PYP5SySaxEmYv6TZ0JyEKA1hsCId6DIhgITtWQ== - dependencies: - ms "^2.1.1" - -debug@^4.0.1: - version "4.1.1" - resolved "https://registry.yarnpkg.com/debug/-/debug-4.1.1.tgz#3b72260255109c6b589cee050f1d516139664791" - integrity sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw== - dependencies: - ms "^2.1.1" - -decamelize@^1.1.1, decamelize@^1.2.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/decamelize/-/decamelize-1.2.0.tgz#f6534d15148269b20352e7bee26f501f9a191290" - integrity sha1-9lNNFRSCabIDUue+4m9QH5oZEpA= - -decode-uri-component@^0.2.0: - version "0.2.0" - resolved "https://registry.yarnpkg.com/decode-uri-component/-/decode-uri-component-0.2.0.tgz#eb3913333458775cb84cd1a1fae062106bb87545" - integrity sha1-6zkTMzRYd1y4TNGh+uBiEGu4dUU= - -deep-extend@^0.6.0: - version "0.6.0" - resolved "https://registry.yarnpkg.com/deep-extend/-/deep-extend-0.6.0.tgz#c4fa7c95404a17a9c3e8ca7e1537312b736330ac" - integrity sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA== - -deep-is@~0.1.3: - version "0.1.3" - resolved "https://registry.yarnpkg.com/deep-is/-/deep-is-0.1.3.tgz#b369d6fb5dbc13eecf524f91b070feedc357cf34" - integrity sha1-s2nW+128E+7PUk+RsHD+7cNXzzQ= - -define-property@^0.2.5: - version "0.2.5" - resolved "https://registry.yarnpkg.com/define-property/-/define-property-0.2.5.tgz#c35b1ef918ec3c990f9a5bc57be04aacec5c8116" - integrity sha1-w1se+RjsPJkPmlvFe+BKrOxcgRY= - dependencies: - is-descriptor "^0.1.0" - -define-property@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/define-property/-/define-property-1.0.0.tgz#769ebaaf3f4a63aad3af9e8d304c9bbe79bfb0e6" - integrity sha1-dp66rz9KY6rTr56NMEybvnm/sOY= - dependencies: - is-descriptor "^1.0.0" - -define-property@^2.0.2: - version "2.0.2" - resolved "https://registry.yarnpkg.com/define-property/-/define-property-2.0.2.tgz#d459689e8d654ba77e02a817f8710d702cb16e9d" - integrity sha512-jwK2UV4cnPpbcG7+VRARKTZPUWowwXA8bzH5NP6ud0oeAxyYPuGZUAC7hMugpCdz4BeSZl2Dl9k66CHJ/46ZYQ== - dependencies: - is-descriptor "^1.0.2" - isobject "^3.0.1" - -delegates@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/delegates/-/delegates-1.0.0.tgz#84c6e159b81904fdca59a0ef44cd870d31250f9a" - integrity sha1-hMbhWbgZBP3KWaDvRM2HDTElD5o= - -depd@1.1.1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/depd/-/depd-1.1.1.tgz#5783b4e1c459f06fa5ca27f991f3d06e7a310359" - integrity sha1-V4O04cRZ8G+lyif5kfPQbnoxA1k= - -detect-libc@^1.0.2: - version "1.0.3" - resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-1.0.3.tgz#fa137c4bd698edf55cd5cd02ac559f91a4c4ba9b" - integrity sha1-+hN8S9aY7fVc1c0CrFWfkaTEups= - -doctrine@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/doctrine/-/doctrine-3.0.0.tgz#addebead72a6574db783639dc87a121773973961" - integrity sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w== - dependencies: - esutils "^2.0.2" - -dom-converter@^0.2: - version "0.2.0" - resolved "https://registry.yarnpkg.com/dom-converter/-/dom-converter-0.2.0.tgz#6721a9daee2e293682955b6afe416771627bb768" - integrity sha512-gd3ypIPfOMr9h5jIKq8E3sHOTCjeirnl0WK5ZdS1AW0Odt0b1PaWaHdJ4Qk4klv+YB9aJBS7mESXjFoDQPu6DA== - dependencies: - utila "~0.4" - -dom-serializer@0: - version "0.1.1" - resolved "https://registry.yarnpkg.com/dom-serializer/-/dom-serializer-0.1.1.tgz#1ec4059e284babed36eec2941d4a970a189ce7c0" - integrity sha512-l0IU0pPzLWSHBcieZbpOKgkIn3ts3vAh7ZuFyXNwJxJXk/c4Gwj9xaTJwIDVQCXawWD0qb3IzMGH5rglQaO0XA== - dependencies: - domelementtype "^1.3.0" - entities "^1.1.1" - -domelementtype@1, domelementtype@^1.3.0, domelementtype@^1.3.1: - version "1.3.1" - resolved "https://registry.yarnpkg.com/domelementtype/-/domelementtype-1.3.1.tgz#d048c44b37b0d10a7f2a3d5fee3f4333d790481f" - integrity sha512-BSKB+TSpMpFI/HOxCNr1O8aMOTZ8hT3pM3GQ0w/mWRmkhEDSFJkkyzz4XQsBV44BChwGkrDfMyjVD0eA2aFV3w== - -domhandler@^2.3.0: - version "2.4.2" - resolved "https://registry.yarnpkg.com/domhandler/-/domhandler-2.4.2.tgz#8805097e933d65e85546f726d60f5eb88b44f803" - integrity sha512-JiK04h0Ht5u/80fdLMCEmV4zkNh2BcoMFBmZ/91WtYZ8qVXSKjiw7fXMgFPnHcSZgOo3XdinHvmnDUeMf5R4wA== - dependencies: - domelementtype "1" - -domutils@1.5.1: - version "1.5.1" - resolved "https://registry.yarnpkg.com/domutils/-/domutils-1.5.1.tgz#dcd8488a26f563d61079e48c9f7b7e32373682cf" - integrity sha1-3NhIiib1Y9YQeeSMn3t+Mjc2gs8= - dependencies: - dom-serializer "0" - domelementtype "1" - -domutils@^1.5.1: - version "1.7.0" - resolved "https://registry.yarnpkg.com/domutils/-/domutils-1.7.0.tgz#56ea341e834e06e6748af7a1cb25da67ea9f8c2a" - integrity sha512-Lgd2XcJ/NjEw+7tFvfKxOzCYKZsdct5lczQ2ZaQY8Djz7pfAD3Gbp8ySJWtreII/vDlMVmxwa6pHmdxIYgttDg== - dependencies: - dom-serializer "0" - domelementtype "1" - -dotenv@5.0.1: - version "5.0.1" - resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-5.0.1.tgz#a5317459bd3d79ab88cff6e44057a6a3fbb1fcef" - integrity sha512-4As8uPrjfwb7VXC+WnLCbXK7y+Ueb2B3zgNCePYfhxS1PYeaO1YTeplffTEcbfLhvFNGLAz90VvJs9yomG7bow== - -emoji-regex@^7.0.1: - version "7.0.3" - resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-7.0.3.tgz#933a04052860c85e83c122479c4748a8e4c72156" - integrity sha512-CwBLREIQ7LvYFB0WyRvwhq5N5qPhc6PMjD6bYggFlI5YyDgl+0vxq5VHbMOFqLg7hfWzmu8T5Z1QofhmTIhItA== - -end-of-stream@^1.1.0: - version "1.4.1" - resolved "https://registry.yarnpkg.com/end-of-stream/-/end-of-stream-1.4.1.tgz#ed29634d19baba463b6ce6b80a37213eab71ec43" - integrity sha512-1MkrZNvWTKCaigbn+W15elq2BB/L22nqrSY5DKlo3X6+vclJm8Bb5djXJBmEX6fS3+zCh/F4VBK5Z2KxJt4s2Q== - dependencies: - once "^1.4.0" - -entities@^1.1.1: - version "1.1.2" - resolved "https://registry.yarnpkg.com/entities/-/entities-1.1.2.tgz#bdfa735299664dfafd34529ed4f8522a275fea56" - integrity sha512-f2LZMYl1Fzu7YSBKg+RoROelpOaNrcGmE9AZubeDfrCEia483oW4MI4VyFd5VNHIgQ/7qm1I0wUHK1eJnn2y2w== - -error-ex@^1.3.1: - version "1.3.2" - resolved "https://registry.yarnpkg.com/error-ex/-/error-ex-1.3.2.tgz#b4ac40648107fdcdcfae242f428bea8a14d4f1bf" - integrity sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g== - dependencies: - is-arrayish "^0.2.1" - -escape-string-regexp@^1.0.5: - version "1.0.5" - resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz#1b61c0562190a8dff6ae3bb2cf0200ca130b86d4" - integrity sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ= - -eslint-config-prettier@^6.0.0: - version "6.0.0" - resolved "https://registry.yarnpkg.com/eslint-config-prettier/-/eslint-config-prettier-6.0.0.tgz#f429a53bde9fc7660e6353910fd996d6284d3c25" - integrity sha512-vDrcCFE3+2ixNT5H83g28bO/uYAwibJxerXPj+E7op4qzBCsAV36QfvdAyVOoNxKAH2Os/e01T/2x++V0LPukA== - dependencies: - get-stdin "^6.0.0" - -eslint-plugin-prettier@^3.1.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/eslint-plugin-prettier/-/eslint-plugin-prettier-3.1.0.tgz#8695188f95daa93b0dc54b249347ca3b79c4686d" - integrity sha512-XWX2yVuwVNLOUhQijAkXz+rMPPoCr7WFiAl8ig6I7Xn+pPVhDhzg4DxHpmbeb0iqjO9UronEA3Tb09ChnFVHHA== - dependencies: - prettier-linter-helpers "^1.0.0" - -eslint-scope@^4.0.3: - version "4.0.3" - resolved "https://registry.yarnpkg.com/eslint-scope/-/eslint-scope-4.0.3.tgz#ca03833310f6889a3264781aa82e63eb9cfe7848" - integrity sha512-p7VutNr1O/QrxysMo3E45FjYDTeXBy0iTltPFNSqKAIfjDSXC+4dj+qfyuD8bfAXrW/y6lW3O76VaYNPKfpKrg== - dependencies: - esrecurse "^4.1.0" - estraverse "^4.1.1" - -eslint-utils@^1.3.1: - version "1.4.0" - resolved "https://registry.yarnpkg.com/eslint-utils/-/eslint-utils-1.4.0.tgz#e2c3c8dba768425f897cf0f9e51fe2e241485d4c" - integrity sha512-7ehnzPaP5IIEh1r1tkjuIrxqhNkzUJa9z3R92tLJdZIVdWaczEhr3EbhGtsMrVxi1KeR8qA7Off6SWc5WNQqyQ== - dependencies: - eslint-visitor-keys "^1.0.0" - -eslint-visitor-keys@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-1.0.0.tgz#3f3180fb2e291017716acb4c9d6d5b5c34a6a81d" - integrity sha512-qzm/XxIbxm/FHyH341ZrbnMUpe+5Bocte9xkmFMzPMjRaZMcXww+MpBptFvtU+79L362nqiLhekCxCxDPaUMBQ== - -eslint@^6.0.1: - version "6.0.1" - resolved "https://registry.yarnpkg.com/eslint/-/eslint-6.0.1.tgz#4a32181d72cb999d6f54151df7d337131f81cda7" - integrity sha512-DyQRaMmORQ+JsWShYsSg4OPTjY56u1nCjAmICrE8vLWqyLKxhFXOthwMj1SA8xwfrv0CofLNVnqbfyhwCkaO0w== - dependencies: - "@babel/code-frame" "^7.0.0" - ajv "^6.10.0" - chalk "^2.1.0" - cross-spawn "^6.0.5" - debug "^4.0.1" - doctrine "^3.0.0" - eslint-scope "^4.0.3" - eslint-utils "^1.3.1" - eslint-visitor-keys "^1.0.0" - espree "^6.0.0" - esquery "^1.0.1" - esutils "^2.0.2" - file-entry-cache "^5.0.1" - functional-red-black-tree "^1.0.1" - glob-parent "^3.1.0" - globals "^11.7.0" - ignore "^4.0.6" - import-fresh "^3.0.0" - imurmurhash "^0.1.4" - inquirer "^6.2.2" - is-glob "^4.0.0" - js-yaml "^3.13.1" - json-stable-stringify-without-jsonify "^1.0.1" - levn "^0.3.0" - lodash "^4.17.11" - minimatch "^3.0.4" - mkdirp "^0.5.1" - natural-compare "^1.4.0" - optionator "^0.8.2" - progress "^2.0.0" - regexpp "^2.0.1" - semver "^5.5.1" - strip-ansi "^4.0.0" - strip-json-comments "^2.0.1" - table "^5.2.3" - text-table "^0.2.0" - -espree@^6.0.0: - version "6.0.0" - resolved "https://registry.yarnpkg.com/espree/-/espree-6.0.0.tgz#716fc1f5a245ef5b9a7fdb1d7b0d3f02322e75f6" - integrity sha512-lJvCS6YbCn3ImT3yKkPe0+tJ+mH6ljhGNjHQH9mRtiO6gjhVAOhVXW1yjnwqGwTkK3bGbye+hb00nFNmu0l/1Q== - dependencies: - acorn "^6.0.7" - acorn-jsx "^5.0.0" - eslint-visitor-keys "^1.0.0" - -esprima@^4.0.0: - version "4.0.1" - resolved "https://registry.yarnpkg.com/esprima/-/esprima-4.0.1.tgz#13b04cdb3e6c5d19df91ab6987a8695619b0aa71" - integrity sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A== - -esquery@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/esquery/-/esquery-1.0.1.tgz#406c51658b1f5991a5f9b62b1dc25b00e3e5c708" - integrity sha512-SmiyZ5zIWH9VM+SRUReLS5Q8a7GxtRdxEBVZpm98rJM7Sb+A9DVCndXfkeFUd3byderg+EbDkfnevfCwynWaNA== - dependencies: - estraverse "^4.0.0" - -esrecurse@^4.1.0: - version "4.2.1" - resolved "https://registry.yarnpkg.com/esrecurse/-/esrecurse-4.2.1.tgz#007a3b9fdbc2b3bb87e4879ea19c92fdbd3942cf" - integrity sha512-64RBB++fIOAXPw3P9cy89qfMlvZEXZkqqJkjqqXIvzP5ezRZjW+lPWjw35UX/3EhUPFYbg5ER4JYgDw4007/DQ== - dependencies: - estraverse "^4.1.0" - -estraverse@^4.0.0, estraverse@^4.1.0, estraverse@^4.1.1: - version "4.2.0" - resolved "https://registry.yarnpkg.com/estraverse/-/estraverse-4.2.0.tgz#0dee3fed31fcd469618ce7342099fc1afa0bdb13" - integrity sha1-De4/7TH81GlhjOc0IJn8GvoL2xM= - -esutils@^2.0.2: - version "2.0.2" - resolved "https://registry.yarnpkg.com/esutils/-/esutils-2.0.2.tgz#0abf4f1caa5bcb1f7a9d8acc6dea4faaa04bac9b" - integrity sha1-Cr9PHKpbyx96nYrMbepPqqBLrJs= - -execa@^0.7.0: - version "0.7.0" - resolved "https://registry.yarnpkg.com/execa/-/execa-0.7.0.tgz#944becd34cc41ee32a63a9faf27ad5a65fc59777" - integrity sha1-lEvs00zEHuMqY6n68nrVpl/Fl3c= - dependencies: - cross-spawn "^5.0.1" - get-stream "^3.0.0" - is-stream "^1.1.0" - npm-run-path "^2.0.0" - p-finally "^1.0.0" - signal-exit "^3.0.0" - strip-eof "^1.0.0" - -execa@^0.8.0: - version "0.8.0" - resolved "https://registry.yarnpkg.com/execa/-/execa-0.8.0.tgz#d8d76bbc1b55217ed190fd6dd49d3c774ecfc8da" - integrity sha1-2NdrvBtVIX7RkP1t1J08d07PyNo= - dependencies: - cross-spawn "^5.0.1" - get-stream "^3.0.0" - is-stream "^1.1.0" - npm-run-path "^2.0.0" - p-finally "^1.0.0" - signal-exit "^3.0.0" - strip-eof "^1.0.0" - -execa@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/execa/-/execa-1.0.0.tgz#c6236a5bb4df6d6f15e88e7f017798216749ddd8" - integrity sha512-adbxcyWV46qiHyvSp50TKt05tB4tK3HcmF7/nxfAdhnox83seTDbwnaqKO4sXRy7roHAIFqJP/Rw/AuEbX61LA== - dependencies: - cross-spawn "^6.0.0" - get-stream "^4.0.0" - is-stream "^1.1.0" - npm-run-path "^2.0.0" - p-finally "^1.0.0" - signal-exit "^3.0.0" - strip-eof "^1.0.0" - -expand-brackets@^2.1.4: - version "2.1.4" - resolved "https://registry.yarnpkg.com/expand-brackets/-/expand-brackets-2.1.4.tgz#b77735e315ce30f6b6eff0f83b04151a22449622" - integrity sha1-t3c14xXOMPa27/D4OwQVGiJEliI= - dependencies: - debug "^2.3.3" - define-property "^0.2.5" - extend-shallow "^2.0.1" - posix-character-classes "^0.1.0" - regex-not "^1.0.0" - snapdragon "^0.8.1" - to-regex "^3.0.1" - -extend-shallow@^2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/extend-shallow/-/extend-shallow-2.0.1.tgz#51af7d614ad9a9f610ea1bafbb989d6b1c56890f" - integrity sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8= - dependencies: - is-extendable "^0.1.0" - -extend-shallow@^3.0.0, extend-shallow@^3.0.2: - version "3.0.2" - resolved "https://registry.yarnpkg.com/extend-shallow/-/extend-shallow-3.0.2.tgz#26a71aaf073b39fb2127172746131c2704028db8" - integrity sha1-Jqcarwc7OfshJxcnRhMcJwQCjbg= - dependencies: - assign-symbols "^1.0.0" - is-extendable "^1.0.1" - -external-editor@^3.0.3: - version "3.1.0" - resolved "https://registry.yarnpkg.com/external-editor/-/external-editor-3.1.0.tgz#cb03f740befae03ea4d283caed2741a83f335495" - integrity sha512-hMQ4CX1p1izmuLYyZqLMO/qGNw10wSv9QDCPfzXfyFrOaCSSoRfqE1Kf1s5an66J5JZC62NewG+mK49jOCtQew== - dependencies: - chardet "^0.7.0" - iconv-lite "^0.4.24" - tmp "^0.0.33" - -extglob@^2.0.4: - version "2.0.4" - resolved "https://registry.yarnpkg.com/extglob/-/extglob-2.0.4.tgz#ad00fe4dc612a9232e8718711dc5cb5ab0285543" - integrity sha512-Nmb6QXkELsuBr24CJSkilo6UHHgbekK5UiZgfE6UHD3Eb27YC6oD+bhcT+tJ6cl8dmsgdQxnWlcry8ksBIBLpw== - dependencies: - array-unique "^0.3.2" - define-property "^1.0.0" - expand-brackets "^2.1.4" - extend-shallow "^2.0.1" - fragment-cache "^0.2.1" - regex-not "^1.0.0" - snapdragon "^0.8.1" - to-regex "^3.0.1" - -fast-deep-equal@^2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-2.0.1.tgz#7b05218ddf9667bf7f370bf7fdb2cb15fdd0aa49" - integrity sha1-ewUhjd+WZ79/Nwv3/bLLFf3Qqkk= - -fast-diff@^1.1.2: - version "1.2.0" - resolved "https://registry.yarnpkg.com/fast-diff/-/fast-diff-1.2.0.tgz#73ee11982d86caaf7959828d519cfe927fac5f03" - integrity sha512-xJuoT5+L99XlZ8twedaRf6Ax2TgQVxvgZOYoPKqZufmJib0tL2tegPBOZb1pVNgIhlqDlA0eO0c3wBvQcmzx4w== - -fast-json-stable-stringify@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.0.0.tgz#d5142c0caee6b1189f87d3a76111064f86c8bbf2" - integrity sha1-1RQsDK7msRifh9OnYREGT4bIu/I= - -fast-levenshtein@~2.0.4: - version "2.0.6" - resolved "https://registry.yarnpkg.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz#3d8a5c66883a16a30ca8643e851f19baa7797917" - integrity sha1-PYpcZog6FqMMqGQ+hR8Zuqd5eRc= - -figures@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/figures/-/figures-2.0.0.tgz#3ab1a2d2a62c8bfb431a0c94cb797a2fce27c962" - integrity sha1-OrGi0qYsi/tDGgyUy3l6L84nyWI= - dependencies: - escape-string-regexp "^1.0.5" - -file-entry-cache@^5.0.1: - version "5.0.1" - resolved "https://registry.yarnpkg.com/file-entry-cache/-/file-entry-cache-5.0.1.tgz#ca0f6efa6dd3d561333fb14515065c2fafdf439c" - integrity sha512-bCg29ictuBaKUwwArK4ouCaqDgLZcysCFLmM/Yn/FDoqndh/9vNuQfXRDvTuXKLxfD/JtZQGKFT8MGcJBK644g== - dependencies: - flat-cache "^2.0.1" - -fill-range@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-4.0.0.tgz#d544811d428f98eb06a63dc402d2403c328c38f7" - integrity sha1-1USBHUKPmOsGpj3EAtJAPDKMOPc= - dependencies: - extend-shallow "^2.0.1" - is-number "^3.0.0" - repeat-string "^1.6.1" - to-regex-range "^2.1.0" - -find-up@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/find-up/-/find-up-2.1.0.tgz#45d1b7e506c717ddd482775a2b77920a3c0c57a7" - integrity sha1-RdG35QbHF93UgndaK3eSCjwMV6c= - dependencies: - locate-path "^2.0.0" - -find-up@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/find-up/-/find-up-3.0.0.tgz#49169f1d7993430646da61ecc5ae355c21c97b73" - integrity sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg== - dependencies: - locate-path "^3.0.0" - -flat-cache@^2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/flat-cache/-/flat-cache-2.0.1.tgz#5d296d6f04bda44a4630a301413bdbc2ec085ec0" - integrity sha512-LoQe6yDuUMDzQAEH8sgmh4Md6oZnc/7PjtwjNFSzveXqSHt6ka9fPBuso7IGf9Rz4uqnSnWiFH2B/zj24a5ReA== - dependencies: - flatted "^2.0.0" - rimraf "2.6.3" - write "1.0.3" - -flatted@^2.0.0: - version "2.0.1" - resolved "https://registry.yarnpkg.com/flatted/-/flatted-2.0.1.tgz#69e57caa8f0eacbc281d2e2cb458d46fdb449e08" - integrity sha512-a1hQMktqW9Nmqr5aktAux3JMNqaucxGcjtjWnZLHX7yyPCmlSV3M54nGYbqT8K+0GhF3NBgmJCc3ma+WOgX8Jg== - -for-in@^1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/for-in/-/for-in-1.0.2.tgz#81068d295a8142ec0ac726c6e2200c30fb6d5e80" - integrity sha1-gQaNKVqBQuwKxybG4iAMMPttXoA= - -fragment-cache@^0.2.1: - version "0.2.1" - resolved "https://registry.yarnpkg.com/fragment-cache/-/fragment-cache-0.2.1.tgz#4290fad27f13e89be7f33799c6bc5a0abfff0d19" - integrity sha1-QpD60n8T6Jvn8zeZxrxaCr//DRk= - dependencies: - map-cache "^0.2.2" - -fs-minipass@^1.2.5: - version "1.2.6" - resolved "https://registry.yarnpkg.com/fs-minipass/-/fs-minipass-1.2.6.tgz#2c5cc30ded81282bfe8a0d7c7c1853ddeb102c07" - integrity sha512-crhvyXcMejjv3Z5d2Fa9sf5xLYVCF5O1c71QxbVnbLsmYMBEvDAftewesN/HhY03YRoA7zOMxjNGrF5svGaaeQ== - dependencies: - minipass "^2.2.1" - -fs.realpath@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f" - integrity sha1-FQStJSMVjKpA20onh8sBQRmU6k8= - -fsevents@^1.1.2: - version "1.2.9" - resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-1.2.9.tgz#3f5ed66583ccd6f400b5a00db6f7e861363e388f" - integrity sha512-oeyj2H3EjjonWcFjD5NvZNE9Rqe4UW+nQBU2HNeKw0koVLEFIhtyETyAakeAM3de7Z/SW5kcA+fZUait9EApnw== - dependencies: - nan "^2.12.1" - node-pre-gyp "^0.12.0" - -functional-red-black-tree@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz#1b0ab3bd553b2a0d6399d29c0e3ea0b252078327" - integrity sha1-GwqzvVU7Kg1jmdKcDj6gslIHgyc= - -gauge@~2.7.3: - version "2.7.4" - resolved "https://registry.yarnpkg.com/gauge/-/gauge-2.7.4.tgz#2c03405c7538c39d7eb37b317022e325fb018bf7" - integrity sha1-LANAXHU4w51+s3sxcCLjJfsBi/c= - dependencies: - aproba "^1.0.3" - console-control-strings "^1.0.0" - has-unicode "^2.0.0" - object-assign "^4.1.0" - signal-exit "^3.0.0" - string-width "^1.0.1" - strip-ansi "^3.0.1" - wide-align "^1.1.0" - -get-caller-file@^1.0.1: - version "1.0.3" - resolved "https://registry.yarnpkg.com/get-caller-file/-/get-caller-file-1.0.3.tgz#f978fa4c90d1dfe7ff2d6beda2a515e713bdcf4a" - integrity sha512-3t6rVToeoZfYSGd8YoLFR2DJkiQrIiUrGcjvFX2mDw3bn6k2OtwHN0TNCLbBO+w8qTvimhDkv+LSscbJY1vE6w== - -get-port@3.2.0: - version "3.2.0" - resolved "https://registry.yarnpkg.com/get-port/-/get-port-3.2.0.tgz#dd7ce7de187c06c8bf353796ac71e099f0980ebc" - integrity sha1-3Xzn3hh8Bsi/NTeWrHHgmfCYDrw= - -get-stdin@^6.0.0: - version "6.0.0" - resolved "https://registry.yarnpkg.com/get-stdin/-/get-stdin-6.0.0.tgz#9e09bf712b360ab9225e812048f71fde9c89657b" - integrity sha512-jp4tHawyV7+fkkSKyvjuLZswblUtz+SQKzSWnBbii16BuZksJlU1wuBYXY75r+duh/llF1ur6oNwi+2ZzjKZ7g== - -get-stream@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-3.0.0.tgz#8e943d1358dc37555054ecbe2edb05aa174ede14" - integrity sha1-jpQ9E1jcN1VQVOy+LtsFqhdO3hQ= - -get-stream@^4.0.0: - version "4.1.0" - resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-4.1.0.tgz#c1b255575f3dc21d59bfc79cd3d2b46b1c3a54b5" - integrity sha512-GMat4EJ5161kIy2HevLlr4luNjBgvmj413KaQA7jt4V8B4RDsfpHk7WQ9GVqfYyyx8OS/L66Kox+rJRNklLK7w== - dependencies: - pump "^3.0.0" - -get-value@^2.0.3, get-value@^2.0.6: - version "2.0.6" - resolved "https://registry.yarnpkg.com/get-value/-/get-value-2.0.6.tgz#dc15ca1c672387ca76bd37ac0a395ba2042a2c28" - integrity sha1-3BXKHGcjh8p2vTesCjlbogQqLCg= - -glob-parent@^3.1.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-3.1.0.tgz#9e6af6299d8d3bd2bd40430832bd113df906c5ae" - integrity sha1-nmr2KZ2NO9K9QEMIMr0RPfkGxa4= - dependencies: - is-glob "^3.1.0" - path-dirname "^1.0.0" - -glob@^7.1.3: - version "7.1.4" - resolved "https://registry.yarnpkg.com/glob/-/glob-7.1.4.tgz#aa608a2f6c577ad357e1ae5a5c26d9a8d1969255" - integrity sha512-hkLPepehmnKk41pUGm3sYxoFs/umurYfYJCerbXEyFIWcAzvpipAgVkBqqT9RBKMGjnq6kMuyYwha6csxbiM1A== - dependencies: - fs.realpath "^1.0.0" - inflight "^1.0.4" - inherits "2" - minimatch "^3.0.4" - once "^1.3.0" - path-is-absolute "^1.0.0" - -globals@^11.7.0: - version "11.12.0" - resolved "https://registry.yarnpkg.com/globals/-/globals-11.12.0.tgz#ab8795338868a0babd8525758018c2a7eb95c42e" - integrity sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA== - -graceful-fs@^4.1.11: - version "4.2.0" - resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.0.tgz#8d8fdc73977cb04104721cb53666c1ca64cd328b" - integrity sha512-jpSvDPV4Cq/bgtpndIWbI5hmYxhQGHPC4d4cqBPb4DLniCfhJokdXhwhaDuLBGLQdvvRum/UiX6ECVIPvDXqdg== - -has-flag@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-2.0.0.tgz#e8207af1cc7b30d446cc70b734b5e8be18f88d51" - integrity sha1-6CB68cx7MNRGzHC3NLXovhj4jVE= - -has-flag@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-3.0.0.tgz#b5d454dc2199ae225699f3467e5a07f3b955bafd" - integrity sha1-tdRU3CGZriJWmfNGfloH87lVuv0= - -has-unicode@^2.0.0: - version "2.0.1" - resolved "https://registry.yarnpkg.com/has-unicode/-/has-unicode-2.0.1.tgz#e0e6fe6a28cf51138855e086d1691e771de2a8b9" - integrity sha1-4Ob+aijPUROIVeCG0Wkedx3iqLk= - -has-value@^0.3.1: - version "0.3.1" - resolved "https://registry.yarnpkg.com/has-value/-/has-value-0.3.1.tgz#7b1f58bada62ca827ec0a2078025654845995e1f" - integrity sha1-ex9YutpiyoJ+wKIHgCVlSEWZXh8= - dependencies: - get-value "^2.0.3" - has-values "^0.1.4" - isobject "^2.0.0" - -has-value@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/has-value/-/has-value-1.0.0.tgz#18b281da585b1c5c51def24c930ed29a0be6b177" - integrity sha1-GLKB2lhbHFxR3vJMkw7SmgvmsXc= - dependencies: - get-value "^2.0.6" - has-values "^1.0.0" - isobject "^3.0.0" - -has-values@^0.1.4: - version "0.1.4" - resolved "https://registry.yarnpkg.com/has-values/-/has-values-0.1.4.tgz#6d61de95d91dfca9b9a02089ad384bff8f62b771" - integrity sha1-bWHeldkd/Km5oCCJrThL/49it3E= - -has-values@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/has-values/-/has-values-1.0.0.tgz#95b0b63fec2146619a6fe57fe75628d5a39efe4f" - integrity sha1-lbC2P+whRmGab+V/51Yo1aOe/k8= - dependencies: - is-number "^3.0.0" - kind-of "^4.0.0" - -hosted-git-info@^2.1.4: - version "2.7.1" - resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-2.7.1.tgz#97f236977bd6e125408930ff6de3eec6281ec047" - integrity sha512-7T/BxH19zbcCTa8XkMlbK5lTo1WtgkFi3GvdWEyNuc4Vex7/9Dqbnpsf4JMydcfj9HCg4zUWFTL3Za6lapg5/w== - -htmlparser2@^3.3.0: - version "3.10.1" - resolved "https://registry.yarnpkg.com/htmlparser2/-/htmlparser2-3.10.1.tgz#bd679dc3f59897b6a34bb10749c855bb53a9392f" - integrity sha512-IgieNijUMbkDovyoKObU1DUhm1iwNYE/fuifEoEHfd1oZKZDaONBSkal7Y01shxsM49R4XaMdGez3WnF9UfiCQ== - dependencies: - domelementtype "^1.3.1" - domhandler "^2.3.0" - domutils "^1.5.1" - entities "^1.1.1" - inherits "^2.0.1" - readable-stream "^3.1.1" - -http-errors@1.6.2: - version "1.6.2" - resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-1.6.2.tgz#0a002cc85707192a7e7946ceedc11155f60ec736" - integrity sha1-CgAsyFcHGSp+eUbO7cERVfYOxzY= - dependencies: - depd "1.1.1" - inherits "2.0.3" - setprototypeof "1.0.3" - statuses ">= 1.3.1 < 2" - -iconv-lite@0.4.19: - version "0.4.19" - resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.19.tgz#f7468f60135f5e5dad3399c0a81be9a1603a082b" - integrity sha512-oTZqweIP51xaGPI4uPa56/Pri/480R+mo7SeU+YETByQNhDG55ycFyNLIgta9vXhILrxXDmF7ZGhqZIcuN0gJQ== - -iconv-lite@^0.4.24, iconv-lite@^0.4.4: - version "0.4.24" - resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b" - integrity sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA== - dependencies: - safer-buffer ">= 2.1.2 < 3" - -ignore-walk@^3.0.1: - version "3.0.1" - resolved "https://registry.yarnpkg.com/ignore-walk/-/ignore-walk-3.0.1.tgz#a83e62e7d272ac0e3b551aaa82831a19b69f82f8" - integrity sha512-DTVlMx3IYPe0/JJcYP7Gxg7ttZZu3IInhuEhbchuqneY9wWe5Ojy2mXLBaQFUQmo0AW2r3qG7m1mg86js+gnlQ== - dependencies: - minimatch "^3.0.4" - -ignore@^4.0.6: - version "4.0.6" - resolved "https://registry.yarnpkg.com/ignore/-/ignore-4.0.6.tgz#750e3db5862087b4737ebac8207ffd1ef27b25fc" - integrity sha512-cyFDKrqc/YdcWFniJhzI42+AzS+gNwmUzOSFcRCQYwySuBBBy/KjuxWLZ/FHEH6Moq1NizMOBWyTcv8O4OZIMg== - -import-fresh@^3.0.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/import-fresh/-/import-fresh-3.1.0.tgz#6d33fa1dcef6df930fae003446f33415af905118" - integrity sha512-PpuksHKGt8rXfWEr9m9EHIpgyyaltBy8+eF6GJM0QCAxMgxCfucMF3mjecK2QsJr0amJW7gTqh5/wht0z2UhEQ== - dependencies: - parent-module "^1.0.0" - resolve-from "^4.0.0" - -imurmurhash@^0.1.4: - version "0.1.4" - resolved "https://registry.yarnpkg.com/imurmurhash/-/imurmurhash-0.1.4.tgz#9218b9b2b928a238b13dc4fb6b6d576f231453ea" - integrity sha1-khi5srkoojixPcT7a21XbyMUU+o= - -inflight@^1.0.4: - version "1.0.6" - resolved "https://registry.yarnpkg.com/inflight/-/inflight-1.0.6.tgz#49bd6331d7d02d0c09bc910a1075ba8165b56df9" - integrity sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk= - dependencies: - once "^1.3.0" - wrappy "1" - -inherits@2, inherits@^2.0.1, inherits@^2.0.3, inherits@~2.0.3: - version "2.0.4" - resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" - integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== - -inherits@2.0.3: - version "2.0.3" - resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.3.tgz#633c2c83e3da42a502f52466022480f4208261de" - integrity sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4= - -ini@~1.3.0: - version "1.3.5" - resolved "https://registry.yarnpkg.com/ini/-/ini-1.3.5.tgz#eee25f56db1c9ec6085e0c22778083f596abf927" - integrity sha512-RZY5huIKCMRWDUqZlEi72f/lmXKMvuszcMBduliQ3nnWbx9X/ZBQO7DijMEYS9EhHBb2qacRUMtC7svLwe0lcw== - -inquirer@^6.2.2: - version "6.5.0" - resolved "https://registry.yarnpkg.com/inquirer/-/inquirer-6.5.0.tgz#2303317efc9a4ea7ec2e2df6f86569b734accf42" - integrity sha512-scfHejeG/lVZSpvCXpsB4j/wQNPM5JC8kiElOI0OUTwmc1RTpXr4H32/HOlQHcZiYl2z2VElwuCVDRG8vFmbnA== - dependencies: - ansi-escapes "^3.2.0" - chalk "^2.4.2" - cli-cursor "^2.1.0" - cli-width "^2.0.0" - external-editor "^3.0.3" - figures "^2.0.0" - lodash "^4.17.12" - mute-stream "0.0.7" - run-async "^2.2.0" - rxjs "^6.4.0" - string-width "^2.1.0" - strip-ansi "^5.1.0" - through "^2.3.6" - -invert-kv@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/invert-kv/-/invert-kv-1.0.0.tgz#104a8e4aaca6d3d8cd157a8ef8bfab2d7a3ffdb6" - integrity sha1-EEqOSqym09jNFXqO+L+rLXo//bY= - -invert-kv@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/invert-kv/-/invert-kv-2.0.0.tgz#7393f5afa59ec9ff5f67a27620d11c226e3eec02" - integrity sha512-wPVv/y/QQ/Uiirj/vh3oP+1Ww+AWehmi1g5fFWGPF6IpCBCDVrhgHRMvrLfdYcwDh3QJbGXDW4JAuzxElLSqKA== - -ip@1.1.5: - version "1.1.5" - resolved "https://registry.yarnpkg.com/ip/-/ip-1.1.5.tgz#bdded70114290828c0a039e72ef25f5aaec4354a" - integrity sha1-vd7XARQpCCjAoDnnLvJfWq7ENUo= - -is-accessor-descriptor@^0.1.6: - version "0.1.6" - resolved "https://registry.yarnpkg.com/is-accessor-descriptor/-/is-accessor-descriptor-0.1.6.tgz#a9e12cb3ae8d876727eeef3843f8a0897b5c98d6" - integrity sha1-qeEss66Nh2cn7u84Q/igiXtcmNY= - dependencies: - kind-of "^3.0.2" - -is-accessor-descriptor@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/is-accessor-descriptor/-/is-accessor-descriptor-1.0.0.tgz#169c2f6d3df1f992618072365c9b0ea1f6878656" - integrity sha512-m5hnHTkcVsPfqx3AKlyttIPb7J+XykHvJP2B9bZDjlhLIoEq4XoK64Vg7boZlVWYK6LUY94dYPEE7Lh0ZkZKcQ== - dependencies: - kind-of "^6.0.0" - -is-arrayish@^0.2.1: - version "0.2.1" - resolved "https://registry.yarnpkg.com/is-arrayish/-/is-arrayish-0.2.1.tgz#77c99840527aa8ecb1a8ba697b80645a7a926a9d" - integrity sha1-d8mYQFJ6qOyxqLppe4BkWnqSap0= - -is-binary-path@^1.0.0: - version "1.0.1" - resolved "https://registry.yarnpkg.com/is-binary-path/-/is-binary-path-1.0.1.tgz#75f16642b480f187a711c814161fd3a4a7655898" - integrity sha1-dfFmQrSA8YenEcgUFh/TpKdlWJg= - dependencies: - binary-extensions "^1.0.0" - -is-buffer@^1.1.5: - version "1.1.6" - resolved "https://registry.yarnpkg.com/is-buffer/-/is-buffer-1.1.6.tgz#efaa2ea9daa0d7ab2ea13a97b2b8ad51fefbe8be" - integrity sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w== - -is-data-descriptor@^0.1.4: - version "0.1.4" - resolved "https://registry.yarnpkg.com/is-data-descriptor/-/is-data-descriptor-0.1.4.tgz#0b5ee648388e2c860282e793f1856fec3f301b56" - integrity sha1-C17mSDiOLIYCgueT8YVv7D8wG1Y= - dependencies: - kind-of "^3.0.2" - -is-data-descriptor@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/is-data-descriptor/-/is-data-descriptor-1.0.0.tgz#d84876321d0e7add03990406abbbbd36ba9268c7" - integrity sha512-jbRXy1FmtAoCjQkVmIVYwuuqDFUbaOeDjmed1tOGPrsMhtJA4rD9tkgA0F1qJ3gRFRXcHYVkdeaP50Q5rE/jLQ== - dependencies: - kind-of "^6.0.0" - -is-descriptor@^0.1.0: - version "0.1.6" - resolved "https://registry.yarnpkg.com/is-descriptor/-/is-descriptor-0.1.6.tgz#366d8240dde487ca51823b1ab9f07a10a78251ca" - integrity sha512-avDYr0SB3DwO9zsMov0gKCESFYqCnE4hq/4z3TdUlukEy5t9C0YRq7HLrsN52NAcqXKaepeCD0n+B0arnVG3Hg== - dependencies: - is-accessor-descriptor "^0.1.6" - is-data-descriptor "^0.1.4" - kind-of "^5.0.0" - -is-descriptor@^1.0.0, is-descriptor@^1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/is-descriptor/-/is-descriptor-1.0.2.tgz#3b159746a66604b04f8c81524ba365c5f14d86ec" - integrity sha512-2eis5WqQGV7peooDyLmNEPUrps9+SXX5c9pL3xEB+4e9HnGuDa7mB7kHxHw4CbqS9k1T2hOH3miL8n8WtiYVtg== - dependencies: - is-accessor-descriptor "^1.0.0" - is-data-descriptor "^1.0.0" - kind-of "^6.0.2" - -is-extendable@^0.1.0, is-extendable@^0.1.1: - version "0.1.1" - resolved "https://registry.yarnpkg.com/is-extendable/-/is-extendable-0.1.1.tgz#62b110e289a471418e3ec36a617d472e301dfc89" - integrity sha1-YrEQ4omkcUGOPsNqYX1HLjAd/Ik= - -is-extendable@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/is-extendable/-/is-extendable-1.0.1.tgz#a7470f9e426733d81bd81e1155264e3a3507cab4" - integrity sha512-arnXMxT1hhoKo9k1LZdmlNyJdDDfy2v0fXjFlmok4+i8ul/6WlbVge9bhM74OpNPQPMGUToDtz+KXa1PneJxOA== - dependencies: - is-plain-object "^2.0.4" - -is-extglob@^2.1.0, is-extglob@^2.1.1: - version "2.1.1" - resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-2.1.1.tgz#a88c02535791f02ed37c76a1b9ea9773c833f8c2" - integrity sha1-qIwCU1eR8C7TfHahueqXc8gz+MI= - -is-fullwidth-code-point@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-1.0.0.tgz#ef9e31386f031a7f0d643af82fde50c457ef00cb" - integrity sha1-754xOG8DGn8NZDr4L95QxFfvAMs= - dependencies: - number-is-nan "^1.0.0" - -is-fullwidth-code-point@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz#a3b30a5c4f199183167aaab93beefae3ddfb654f" - integrity sha1-o7MKXE8ZkYMWeqq5O+764937ZU8= - -is-glob@^3.1.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-3.1.0.tgz#7ba5ae24217804ac70707b96922567486cc3e84a" - integrity sha1-e6WuJCF4BKxwcHuWkiVnSGzD6Eo= - dependencies: - is-extglob "^2.1.0" - -is-glob@^4.0.0: - version "4.0.1" - resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-4.0.1.tgz#7567dbe9f2f5e2467bc77ab83c4a29482407a5dc" - integrity sha512-5G0tKtBTFImOqDnLB2hG6Bp2qcKEFduo4tZu9MT/H6NQv/ghhy30o55ufafxJ/LdH79LLs2Kfrn85TLKyA7BUg== - dependencies: - is-extglob "^2.1.1" - -is-number@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/is-number/-/is-number-3.0.0.tgz#24fd6201a4782cf50561c810276afc7d12d71195" - integrity sha1-JP1iAaR4LPUFYcgQJ2r8fRLXEZU= - dependencies: - kind-of "^3.0.2" - -is-plain-object@^2.0.3, is-plain-object@^2.0.4: - version "2.0.4" - resolved "https://registry.yarnpkg.com/is-plain-object/-/is-plain-object-2.0.4.tgz#2c163b3fafb1b606d9d17928f05c2a1c38e07677" - integrity sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og== - dependencies: - isobject "^3.0.1" - -is-promise@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/is-promise/-/is-promise-2.1.0.tgz#79a2a9ece7f096e80f36d2b2f3bc16c1ff4bf3fa" - integrity sha1-eaKp7OfwlugPNtKy87wWwf9L8/o= - -is-stream@1.1.0, is-stream@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-1.1.0.tgz#12d4a3dd4e68e0b79ceb8dbc84173ae80d91ca44" - integrity sha1-EtSj3U5o4Lec6428hBc66A2RykQ= - -is-windows@^1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/is-windows/-/is-windows-1.0.2.tgz#d1850eb9791ecd18e6182ce12a30f396634bb19d" - integrity sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA== - -isarray@1.0.0, isarray@~1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/isarray/-/isarray-1.0.0.tgz#bb935d48582cba168c06834957a54a3e07124f11" - integrity sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE= - -isexe@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10" - integrity sha1-6PvzdNxVb/iUehDcsFctYz8s+hA= - -isobject@^2.0.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/isobject/-/isobject-2.1.0.tgz#f065561096a3f1da2ef46272f815c840d87e0c89" - integrity sha1-8GVWEJaj8dou9GJy+BXIQNh+DIk= - dependencies: - isarray "1.0.0" - -isobject@^3.0.0, isobject@^3.0.1: - version "3.0.1" - resolved "https://registry.yarnpkg.com/isobject/-/isobject-3.0.1.tgz#4e431e92b11a9731636aa1f9c8d1ccbcfdab78df" - integrity sha1-TkMekrEalzFjaqH5yNHMvP2reN8= - -js-tokens@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499" - integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ== - -js-yaml@^3.13.1: - version "3.13.1" - resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-3.13.1.tgz#aff151b30bfdfa8e49e05da22e7415e9dfa37847" - integrity sha512-YfbcO7jXDdyj0DGxYVSlSeQNHbD7XPWvrVWeVUujrQEoZzWJIRrCPoyk6kL6IAjAG2IolMK4T0hNUe0HOUs5Jw== - dependencies: - argparse "^1.0.7" - esprima "^4.0.0" - -jsome@2.5.0: - version "2.5.0" - resolved "https://registry.yarnpkg.com/jsome/-/jsome-2.5.0.tgz#5e417eef4341ffeb83ee8bfa9265b36d56fe49ed" - integrity sha1-XkF+70NB/+uD7ov6kmWzbVb+Se0= - dependencies: - chalk "^2.3.0" - json-stringify-safe "^5.0.1" - yargs "^11.0.0" - -json-parse-better-errors@^1.0.1: - version "1.0.2" - resolved "https://registry.yarnpkg.com/json-parse-better-errors/-/json-parse-better-errors-1.0.2.tgz#bb867cfb3450e69107c131d1c514bab3dc8bcaa9" - integrity sha512-mrqyZKfX5EhL7hvqcV6WG1yYjnjeuYDzDhhcAAUrq8Po85NBQBJP+ZDUT75qZQ98IkUoBqdkExkukOU7Ts2wrw== - -json-schema-traverse@^0.4.1: - version "0.4.1" - resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz#69f6a87d9513ab8bb8fe63bdb0979c448e684660" - integrity sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg== - -json-stable-stringify-without-jsonify@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz#9db7b59496ad3f3cfef30a75142d2d930ad72651" - integrity sha1-nbe1lJatPzz+8wp1FC0tkwrXJlE= - -json-stringify-safe@^5.0.1: - version "5.0.1" - resolved "https://registry.yarnpkg.com/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz#1296a2d58fd45f19a0f6ce01d65701e2c735b6eb" - integrity sha1-Epai1Y/UXxmg9s4B1lcB4sc1tus= - -kind-of@^3.0.2, kind-of@^3.0.3, kind-of@^3.2.0: - version "3.2.2" - resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-3.2.2.tgz#31ea21a734bab9bbb0f32466d893aea51e4a3c64" - integrity sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ= - dependencies: - is-buffer "^1.1.5" - -kind-of@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-4.0.0.tgz#20813df3d712928b207378691a45066fae72dd57" - integrity sha1-IIE989cSkosgc3hpGkUGb65y3Vc= - dependencies: - is-buffer "^1.1.5" - -kind-of@^5.0.0: - version "5.1.0" - resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-5.1.0.tgz#729c91e2d857b7a419a1f9aa65685c4c33f5845d" - integrity sha512-NGEErnH6F2vUuXDh+OlbcKW7/wOcfdRHaZ7VWtqCztfHri/++YKmP51OdWeGPuqCOba6kk2OTe5d02VmTB80Pw== - -kind-of@^6.0.0, kind-of@^6.0.2: - version "6.0.2" - resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-6.0.2.tgz#01146b36a6218e64e58f3a8d66de5d7fc6f6d051" - integrity sha512-s5kLOcnH0XqDO+FvuaLX8DDjZ18CGFk7VygH40QoKPUQhW4e2rvM0rwUq0t8IQDOwYSeLK01U90OjzBTme2QqA== - -lcid@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/lcid/-/lcid-1.0.0.tgz#308accafa0bc483a3867b4b6f2b9506251d1b835" - integrity sha1-MIrMr6C8SDo4Z7S28rlQYlHRuDU= - dependencies: - invert-kv "^1.0.0" - -lcid@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/lcid/-/lcid-2.0.0.tgz#6ef5d2df60e52f82eb228a4c373e8d1f397253cf" - integrity sha512-avPEb8P8EGnwXKClwsNUgryVjllcRqtMYa49NTsbQagYuT1DcXnl1915oxWjoyGrXR6zH/Y0Zc96xWsPcoDKeA== - dependencies: - invert-kv "^2.0.0" - -levn@^0.3.0, levn@~0.3.0: - version "0.3.0" - resolved "https://registry.yarnpkg.com/levn/-/levn-0.3.0.tgz#3b09924edf9f083c0490fdd4c0bc4421e04764ee" - integrity sha1-OwmSTt+fCDwEkP3UwLxEIeBHZO4= - dependencies: - prelude-ls "~1.1.2" - type-check "~0.3.2" - -locate-path@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-2.0.0.tgz#2b568b265eec944c6d9c0de9c3dbbbca0354cd8e" - integrity sha1-K1aLJl7slExtnA3pw9u7ygNUzY4= - dependencies: - p-locate "^2.0.0" - path-exists "^3.0.0" - -locate-path@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-3.0.0.tgz#dbec3b3ab759758071b58fe59fc41871af21400e" - integrity sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A== - dependencies: - p-locate "^3.0.0" - path-exists "^3.0.0" - -lodash@^4.17.10, lodash@^4.17.11, lodash@^4.17.12, lodash@^4.17.14: - version "4.17.14" - resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.14.tgz#9ce487ae66c96254fe20b599f21b6816028078ba" - integrity sha512-mmKYbW3GLuJeX+iGP+Y7Gp1AiGHGbXHCOh/jZmrawMmsE7MS4znI3RL2FsjbqOyMayHInjOeykW7PEajUk1/xw== - -lru-cache@^4.0.1: - version "4.1.5" - resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-4.1.5.tgz#8bbe50ea85bed59bc9e33dcab8235ee9bcf443cd" - integrity sha512-sWZlbEP2OsHNkXrMl5GYk/jKk70MBng6UU4YI/qGDYbgf6YbP4EvmqISbXCoJiRKs+1bSpFHVgQxvJ17F2li5g== - dependencies: - pseudomap "^1.0.2" - yallist "^2.1.2" - -map-age-cleaner@^0.1.1: - version "0.1.3" - resolved "https://registry.yarnpkg.com/map-age-cleaner/-/map-age-cleaner-0.1.3.tgz#7d583a7306434c055fe474b0f45078e6e1b4b92a" - integrity sha512-bJzx6nMoP6PDLPBFmg7+xRKeFZvFboMrGlxmNj9ClvX53KrmvM5bXFXEWjbz4cz1AFn+jWJ9z/DJSz7hrs0w3w== - dependencies: - p-defer "^1.0.0" - -map-cache@^0.2.2: - version "0.2.2" - resolved "https://registry.yarnpkg.com/map-cache/-/map-cache-0.2.2.tgz#c32abd0bd6525d9b051645bb4f26ac5dc98a0dbf" - integrity sha1-wyq9C9ZSXZsFFkW7TyasXcmKDb8= - -map-visit@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/map-visit/-/map-visit-1.0.0.tgz#ecdca8f13144e660f1b5bd41f12f3479d98dfb8f" - integrity sha1-7Nyo8TFE5mDxtb1B8S80edmN+48= - dependencies: - object-visit "^1.0.0" - -mem@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/mem/-/mem-1.1.0.tgz#5edd52b485ca1d900fe64895505399a0dfa45f76" - integrity sha1-Xt1StIXKHZAP5kiVUFOZoN+kX3Y= - dependencies: - mimic-fn "^1.0.0" - -mem@^4.0.0: - version "4.3.0" - resolved "https://registry.yarnpkg.com/mem/-/mem-4.3.0.tgz#461af497bc4ae09608cdb2e60eefb69bff744178" - integrity sha512-qX2bG48pTqYRVmDB37rn/6PT7LcR8T7oAX3bf99u1Tt1nzxYfxkgqDwUwolPlXweM0XzBOBFzSx4kfp7KP1s/w== - dependencies: - map-age-cleaner "^0.1.1" - mimic-fn "^2.0.0" - p-is-promise "^2.0.0" - -micro-dev@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/micro-dev/-/micro-dev-3.0.0.tgz#cea7ef2b4318765b74004e98ec92ac7e12fcff37" - integrity sha512-hxI93KdT7Y8utmVn3NsXqMEKOQj0RpHiY1+PW8rtbiDyd3e/D6NN7OEdG8R0uSFI3GuJbkYN76Z2QTZafyfN/w== - dependencies: - boxen "1.3.0" - chalk "2.4.1" - chokidar "2.0.3" - clipboardy "1.2.3" - debounce "1.1.0" - dotenv "5.0.1" - get-port "3.2.0" - ip "1.1.5" - jsome "2.5.0" - mri "1.1.1" - pkg-up "2.0.0" - pretty-error "2.1.1" - string-length "2.0.0" - -micro@^9.3.4: - version "9.3.4" - resolved "https://registry.yarnpkg.com/micro/-/micro-9.3.4.tgz#745a494e53c8916f64fb6a729f8cbf2a506b35ad" - integrity sha512-smz9naZwTG7qaFnEZ2vn248YZq9XR+XoOH3auieZbkhDL4xLOxiE+KqG8qqnBeKfXA9c1uEFGCxPN1D+nT6N7w== - dependencies: - arg "4.1.0" - content-type "1.0.4" - is-stream "1.1.0" - raw-body "2.3.2" - -micromatch@^3.1.10, micromatch@^3.1.4: - version "3.1.10" - resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-3.1.10.tgz#70859bc95c9840952f359a068a3fc49f9ecfac23" - integrity sha512-MWikgl9n9M3w+bpsY3He8L+w9eF9338xRl8IAO5viDizwSzziFEyUzo2xrrloB64ADbTf8uA8vRqqttDTOmccg== - dependencies: - arr-diff "^4.0.0" - array-unique "^0.3.2" - braces "^2.3.1" - define-property "^2.0.2" - extend-shallow "^3.0.2" - extglob "^2.0.4" - fragment-cache "^0.2.1" - kind-of "^6.0.2" - nanomatch "^1.2.9" - object.pick "^1.3.0" - regex-not "^1.0.0" - snapdragon "^0.8.1" - to-regex "^3.0.2" - -microrouter@^3.1.3: - version "3.1.3" - resolved "https://registry.yarnpkg.com/microrouter/-/microrouter-3.1.3.tgz#1e45df77d3e2d773be5da129cfc7d5e6e6c86f4e" - integrity sha1-HkXfd9Pi13O+XaEpz8fV5ubIb04= - dependencies: - url-pattern "^1.0.3" - -mimic-fn@^1.0.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-1.2.0.tgz#820c86a39334640e99516928bd03fca88057d022" - integrity sha512-jf84uxzwiuiIVKiOLpfYk7N46TSy8ubTonmneY9vrpHNAnp0QBt2BxWV9dO3/j+BoVAb+a5G6YDPW3M5HOdMWQ== - -mimic-fn@^2.0.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-2.1.0.tgz#7ed2c2ccccaf84d3ffcb7a69b57711fc2083401b" - integrity sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg== - -minimatch@^3.0.4: - version "3.0.4" - resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.0.4.tgz#5166e286457f03306064be5497e8dbb0c3d32083" - integrity sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA== - dependencies: - brace-expansion "^1.1.7" - -minimist@0.0.8: - version "0.0.8" - resolved "https://registry.yarnpkg.com/minimist/-/minimist-0.0.8.tgz#857fcabfc3397d2625b8228262e86aa7a011b05d" - integrity sha1-hX/Kv8M5fSYluCKCYuhqp6ARsF0= - -minimist@^1.2.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.0.tgz#a35008b20f41383eec1fb914f4cd5df79a264284" - integrity sha1-o1AIsg9BOD7sH7kU9M1d95omQoQ= - -minipass@^2.2.1, minipass@^2.3.5: - version "2.3.5" - resolved "https://registry.yarnpkg.com/minipass/-/minipass-2.3.5.tgz#cacebe492022497f656b0f0f51e2682a9ed2d848" - integrity sha512-Gi1W4k059gyRbyVUZQ4mEqLm0YIUiGYfvxhF6SIlk3ui1WVxMTGfGdQ2SInh3PDrRTVvPKgULkpJtT4RH10+VA== - dependencies: - safe-buffer "^5.1.2" - yallist "^3.0.0" - -minizlib@^1.2.1: - version "1.2.1" - resolved "https://registry.yarnpkg.com/minizlib/-/minizlib-1.2.1.tgz#dd27ea6136243c7c880684e8672bb3a45fd9b614" - integrity sha512-7+4oTUOWKg7AuL3vloEWekXY2/D20cevzsrNT2kGWm+39J9hGTCBv8VI5Pm5lXZ/o3/mdR4f8rflAPhnQb8mPA== - dependencies: - minipass "^2.2.1" - -mixin-deep@^1.2.0: - version "1.3.2" - resolved "https://registry.yarnpkg.com/mixin-deep/-/mixin-deep-1.3.2.tgz#1120b43dc359a785dce65b55b82e257ccf479566" - integrity sha512-WRoDn//mXBiJ1H40rqa3vH0toePwSsGb45iInWlTySa+Uu4k3tYUSxa2v1KqAiLtvlrSzaExqS1gtk96A9zvEA== - dependencies: - for-in "^1.0.2" - is-extendable "^1.0.1" - -mkdirp@^0.5.0, mkdirp@^0.5.1: - version "0.5.1" - resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.5.1.tgz#30057438eac6cf7f8c4767f38648d6697d75c903" - integrity sha1-MAV0OOrGz3+MR2fzhkjWaX11yQM= - dependencies: - minimist "0.0.8" - -mri@1.1.1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/mri/-/mri-1.1.1.tgz#85aa26d3daeeeedf80dc5984af95cc5ca5cad9f1" - integrity sha1-haom09ru7t+A3FmEr5XMXKXK2fE= - -ms@2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8" - integrity sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g= - -ms@^2.1.1: - version "2.1.2" - resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009" - integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w== - -mute-stream@0.0.7: - version "0.0.7" - resolved "https://registry.yarnpkg.com/mute-stream/-/mute-stream-0.0.7.tgz#3075ce93bc21b8fab43e1bc4da7e8115ed1e7bab" - integrity sha1-MHXOk7whuPq0PhvE2n6BFe0ee6s= - -nan@^2.12.1: - version "2.14.0" - resolved "https://registry.yarnpkg.com/nan/-/nan-2.14.0.tgz#7818f722027b2459a86f0295d434d1fc2336c52c" - integrity sha512-INOFj37C7k3AfaNTtX8RhsTw7qRy7eLET14cROi9+5HAVbbHuIWUHEauBv5qT4Av2tWasiTY1Jw6puUNqRJXQg== - -nanomatch@^1.2.9: - version "1.2.13" - resolved "https://registry.yarnpkg.com/nanomatch/-/nanomatch-1.2.13.tgz#b87a8aa4fc0de8fe6be88895b38983ff265bd119" - integrity sha512-fpoe2T0RbHwBTBUOftAfBPaDEi06ufaUai0mE6Yn1kacc3SnTErfb/h+X94VXzI64rKFHYImXSvdwGGCmwOqCA== - dependencies: - arr-diff "^4.0.0" - array-unique "^0.3.2" - define-property "^2.0.2" - extend-shallow "^3.0.2" - fragment-cache "^0.2.1" - is-windows "^1.0.2" - kind-of "^6.0.2" - object.pick "^1.3.0" - regex-not "^1.0.0" - snapdragon "^0.8.1" - to-regex "^3.0.1" - -natural-compare@^1.4.0: - version "1.4.0" - resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7" - integrity sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc= - -needle@^2.2.1: - version "2.4.0" - resolved "https://registry.yarnpkg.com/needle/-/needle-2.4.0.tgz#6833e74975c444642590e15a750288c5f939b57c" - integrity sha512-4Hnwzr3mi5L97hMYeNl8wRW/Onhy4nUKR/lVemJ8gJedxxUyBLm9kkrDColJvoSfwi0jCNhD+xCdOtiGDQiRZg== - dependencies: - debug "^3.2.6" - iconv-lite "^0.4.4" - sax "^1.2.4" - -nice-try@^1.0.4: - version "1.0.5" - resolved "https://registry.yarnpkg.com/nice-try/-/nice-try-1.0.5.tgz#a3378a7696ce7d223e88fc9b764bd7ef1089e366" - integrity sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ== - -node-pre-gyp@^0.12.0: - version "0.12.0" - resolved "https://registry.yarnpkg.com/node-pre-gyp/-/node-pre-gyp-0.12.0.tgz#39ba4bb1439da030295f899e3b520b7785766149" - integrity sha512-4KghwV8vH5k+g2ylT+sLTjy5wmUOb9vPhnM8NHvRf9dHmnW/CndrFXy2aRPaPST6dugXSdHXfeaHQm77PIz/1A== - dependencies: - detect-libc "^1.0.2" - mkdirp "^0.5.1" - needle "^2.2.1" - nopt "^4.0.1" - npm-packlist "^1.1.6" - npmlog "^4.0.2" - rc "^1.2.7" - rimraf "^2.6.1" - semver "^5.3.0" - tar "^4" - -nopt@^4.0.1: - version "4.0.1" - resolved "https://registry.yarnpkg.com/nopt/-/nopt-4.0.1.tgz#d0d4685afd5415193c8c7505602d0d17cd64474d" - integrity sha1-0NRoWv1UFRk8jHUFYC0NF81kR00= - dependencies: - abbrev "1" - osenv "^0.1.4" - -normalize-package-data@^2.3.2: - version "2.5.0" - resolved "https://registry.yarnpkg.com/normalize-package-data/-/normalize-package-data-2.5.0.tgz#e66db1838b200c1dfc233225d12cb36520e234a8" - integrity sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA== - dependencies: - hosted-git-info "^2.1.4" - resolve "^1.10.0" - semver "2 || 3 || 4 || 5" - validate-npm-package-license "^3.0.1" - -normalize-path@^2.1.1: - version "2.1.1" - resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-2.1.1.tgz#1ab28b556e198363a8c1a6f7e6fa20137fe6aed9" - integrity sha1-GrKLVW4Zg2Oowab35vogE3/mrtk= - dependencies: - remove-trailing-separator "^1.0.1" - -npm-bundled@^1.0.1: - version "1.0.6" - resolved "https://registry.yarnpkg.com/npm-bundled/-/npm-bundled-1.0.6.tgz#e7ba9aadcef962bb61248f91721cd932b3fe6bdd" - integrity sha512-8/JCaftHwbd//k6y2rEWp6k1wxVfpFzB6t1p825+cUb7Ym2XQfhwIC5KwhrvzZRJu+LtDE585zVaS32+CGtf0g== - -npm-packlist@^1.1.6: - version "1.4.4" - resolved "https://registry.yarnpkg.com/npm-packlist/-/npm-packlist-1.4.4.tgz#866224233850ac534b63d1a6e76050092b5d2f44" - integrity sha512-zTLo8UcVYtDU3gdeaFu2Xu0n0EvelfHDGuqtNIn5RO7yQj4H1TqNdBc/yZjxnWA0PVB8D3Woyp0i5B43JwQ6Vw== - dependencies: - ignore-walk "^3.0.1" - npm-bundled "^1.0.1" - -npm-run-path@^2.0.0: - version "2.0.2" - resolved "https://registry.yarnpkg.com/npm-run-path/-/npm-run-path-2.0.2.tgz#35a9232dfa35d7067b4cb2ddf2357b1871536c5f" - integrity sha1-NakjLfo11wZ7TLLd8jV7GHFTbF8= - dependencies: - path-key "^2.0.0" - -npmlog@^4.0.2: - version "4.1.2" - resolved "https://registry.yarnpkg.com/npmlog/-/npmlog-4.1.2.tgz#08a7f2a8bf734604779a9efa4ad5cc717abb954b" - integrity sha512-2uUqazuKlTaSI/dC8AzicUck7+IrEaOnN/e0jd3Xtt1KcGpwx30v50mL7oPyr/h9bL3E4aZccVwpwP+5W9Vjkg== - dependencies: - are-we-there-yet "~1.1.2" - console-control-strings "~1.1.0" - gauge "~2.7.3" - set-blocking "~2.0.0" - -nth-check@~1.0.1: - version "1.0.2" - resolved "https://registry.yarnpkg.com/nth-check/-/nth-check-1.0.2.tgz#b2bd295c37e3dd58a3bf0700376663ba4d9cf05c" - integrity sha512-WeBOdju8SnzPN5vTUJYxYUxLeXpCaVP5i5e0LF8fg7WORF2Wd7wFX/pk0tYZk7s8T+J7VLy0Da6J1+wCT0AtHg== - dependencies: - boolbase "~1.0.0" - -number-is-nan@^1.0.0: - version "1.0.1" - resolved "https://registry.yarnpkg.com/number-is-nan/-/number-is-nan-1.0.1.tgz#097b602b53422a522c1afb8790318336941a011d" - integrity sha1-CXtgK1NCKlIsGvuHkDGDNpQaAR0= - -object-assign@^4.1.0: - version "4.1.1" - resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863" - integrity sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM= - -object-copy@^0.1.0: - version "0.1.0" - resolved "https://registry.yarnpkg.com/object-copy/-/object-copy-0.1.0.tgz#7e7d858b781bd7c991a41ba975ed3812754e998c" - integrity sha1-fn2Fi3gb18mRpBupde04EnVOmYw= - dependencies: - copy-descriptor "^0.1.0" - define-property "^0.2.5" - kind-of "^3.0.3" - -object-visit@^1.0.0: - version "1.0.1" - resolved "https://registry.yarnpkg.com/object-visit/-/object-visit-1.0.1.tgz#f79c4493af0c5377b59fe39d395e41042dd045bb" - integrity sha1-95xEk68MU3e1n+OdOV5BBC3QRbs= - dependencies: - isobject "^3.0.0" - -object.pick@^1.3.0: - version "1.3.0" - resolved "https://registry.yarnpkg.com/object.pick/-/object.pick-1.3.0.tgz#87a10ac4c1694bd2e1cbf53591a66141fb5dd747" - integrity sha1-h6EKxMFpS9Lhy/U1kaZhQftd10c= - dependencies: - isobject "^3.0.1" - -once@^1.3.0, once@^1.3.1, once@^1.4.0: - version "1.4.0" - resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1" - integrity sha1-WDsap3WWHUsROsF9nFC6753Xa9E= - dependencies: - wrappy "1" - -onetime@^2.0.0: - version "2.0.1" - resolved "https://registry.yarnpkg.com/onetime/-/onetime-2.0.1.tgz#067428230fd67443b2794b22bba528b6867962d4" - integrity sha1-BnQoIw/WdEOyeUsiu6UotoZ5YtQ= - dependencies: - mimic-fn "^1.0.0" - -optionator@^0.8.2: - version "0.8.2" - resolved "https://registry.yarnpkg.com/optionator/-/optionator-0.8.2.tgz#364c5e409d3f4d6301d6c0b4c05bba50180aeb64" - integrity sha1-NkxeQJ0/TWMB1sC0wFu6UBgK62Q= - dependencies: - deep-is "~0.1.3" - fast-levenshtein "~2.0.4" - levn "~0.3.0" - prelude-ls "~1.1.2" - type-check "~0.3.2" - wordwrap "~1.0.0" - -os-homedir@^1.0.0: - version "1.0.2" - resolved "https://registry.yarnpkg.com/os-homedir/-/os-homedir-1.0.2.tgz#ffbc4988336e0e833de0c168c7ef152121aa7fb3" - integrity sha1-/7xJiDNuDoM94MFox+8VISGqf7M= - -os-locale@^2.0.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/os-locale/-/os-locale-2.1.0.tgz#42bc2900a6b5b8bd17376c8e882b65afccf24bf2" - integrity sha512-3sslG3zJbEYcaC4YVAvDorjGxc7tv6KVATnLPZONiljsUncvihe9BQoVCEs0RZ1kmf4Hk9OBqlZfJZWI4GanKA== - dependencies: - execa "^0.7.0" - lcid "^1.0.0" - mem "^1.1.0" - -os-locale@^3.0.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/os-locale/-/os-locale-3.1.0.tgz#a802a6ee17f24c10483ab9935719cef4ed16bf1a" - integrity sha512-Z8l3R4wYWM40/52Z+S265okfFj8Kt2cC2MKY+xNi3kFs+XGI7WXu/I309QQQYbRW4ijiZ+yxs9pqEhJh0DqW3Q== - dependencies: - execa "^1.0.0" - lcid "^2.0.0" - mem "^4.0.0" - -os-tmpdir@^1.0.0, os-tmpdir@~1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/os-tmpdir/-/os-tmpdir-1.0.2.tgz#bbe67406c79aa85c5cfec766fe5734555dfa1274" - integrity sha1-u+Z0BseaqFxc/sdm/lc0VV36EnQ= - -osenv@^0.1.4: - version "0.1.5" - resolved "https://registry.yarnpkg.com/osenv/-/osenv-0.1.5.tgz#85cdfafaeb28e8677f416e287592b5f3f49ea410" - integrity sha512-0CWcCECdMVc2Rw3U5w9ZjqX6ga6ubk1xDVKxtBQPK7wis/0F2r9T6k4ydGYhecl7YUBxBVxhL5oisPsNxAPe2g== - dependencies: - os-homedir "^1.0.0" - os-tmpdir "^1.0.0" - -p-defer@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/p-defer/-/p-defer-1.0.0.tgz#9f6eb182f6c9aa8cd743004a7d4f96b196b0fb0c" - integrity sha1-n26xgvbJqozXQwBKfU+WsZaw+ww= - -p-finally@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/p-finally/-/p-finally-1.0.0.tgz#3fbcfb15b899a44123b34b6dcc18b724336a2cae" - integrity sha1-P7z7FbiZpEEjs0ttzBi3JDNqLK4= - -p-is-promise@^2.0.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/p-is-promise/-/p-is-promise-2.1.0.tgz#918cebaea248a62cf7ffab8e3bca8c5f882fc42e" - integrity sha512-Y3W0wlRPK8ZMRbNq97l4M5otioeA5lm1z7bkNkxCka8HSPjR0xRWmpCmc9utiaLP9Jb1eD8BgeIxTW4AIF45Pg== - -p-limit@^1.1.0: - version "1.3.0" - resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-1.3.0.tgz#b86bd5f0c25690911c7590fcbfc2010d54b3ccb8" - integrity sha512-vvcXsLAJ9Dr5rQOPk7toZQZJApBl2K4J6dANSsEuh6QI41JYcsS/qhTGa9ErIUUgK3WNQoJYvylxvjqmiqEA9Q== - dependencies: - p-try "^1.0.0" - -p-limit@^2.0.0: - version "2.2.0" - resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-2.2.0.tgz#417c9941e6027a9abcba5092dd2904e255b5fbc2" - integrity sha512-pZbTJpoUsCzV48Mc9Nh51VbwO0X9cuPFE8gYwx9BTCt9SF8/b7Zljd2fVgOxhIF/HDTKgpVzs+GPhyKfjLLFRQ== - dependencies: - p-try "^2.0.0" - -p-locate@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-2.0.0.tgz#20a0103b222a70c8fd39cc2e580680f3dde5ec43" - integrity sha1-IKAQOyIqcMj9OcwuWAaA893l7EM= - dependencies: - p-limit "^1.1.0" - -p-locate@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-3.0.0.tgz#322d69a05c0264b25997d9f40cd8a891ab0064a4" - integrity sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ== - dependencies: - p-limit "^2.0.0" - -p-try@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/p-try/-/p-try-1.0.0.tgz#cbc79cdbaf8fd4228e13f621f2b1a237c1b207b3" - integrity sha1-y8ec26+P1CKOE/Yh8rGiN8GyB7M= - -p-try@^2.0.0: - version "2.2.0" - resolved "https://registry.yarnpkg.com/p-try/-/p-try-2.2.0.tgz#cb2868540e313d61de58fafbe35ce9004d5540e6" - integrity sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ== - -parent-module@^1.0.0: - version "1.0.1" - resolved "https://registry.yarnpkg.com/parent-module/-/parent-module-1.0.1.tgz#691d2709e78c79fae3a156622452d00762caaaa2" - integrity sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g== - dependencies: - callsites "^3.0.0" - -parse-json@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/parse-json/-/parse-json-4.0.0.tgz#be35f5425be1f7f6c747184f98a788cb99477ee0" - integrity sha1-vjX1Qlvh9/bHRxhPmKeIy5lHfuA= - dependencies: - error-ex "^1.3.1" - json-parse-better-errors "^1.0.1" - -pascalcase@^0.1.1: - version "0.1.1" - resolved "https://registry.yarnpkg.com/pascalcase/-/pascalcase-0.1.1.tgz#b363e55e8006ca6fe21784d2db22bd15d7917f14" - integrity sha1-s2PlXoAGym/iF4TS2yK9FdeRfxQ= - -path-dirname@^1.0.0: - version "1.0.2" - resolved "https://registry.yarnpkg.com/path-dirname/-/path-dirname-1.0.2.tgz#cc33d24d525e099a5388c0336c6e32b9160609e0" - integrity sha1-zDPSTVJeCZpTiMAzbG4yuRYGCeA= - -path-exists@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-3.0.0.tgz#ce0ebeaa5f78cb18925ea7d810d7b59b010fd515" - integrity sha1-zg6+ql94yxiSXqfYENe1mwEP1RU= - -path-is-absolute@^1.0.0: - version "1.0.1" - resolved "https://registry.yarnpkg.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz#174b9268735534ffbc7ace6bf53a5a9e1b5c5f5f" - integrity sha1-F0uSaHNVNP+8es5r9TpanhtcX18= - -path-key@^2.0.0, path-key@^2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/path-key/-/path-key-2.0.1.tgz#411cadb574c5a140d3a4b1910d40d80cc9f40b40" - integrity sha1-QRyttXTFoUDTpLGRDUDYDMn0C0A= - -path-parse@^1.0.6: - version "1.0.6" - resolved "https://registry.yarnpkg.com/path-parse/-/path-parse-1.0.6.tgz#d62dbb5679405d72c4737ec58600e9ddcf06d24c" - integrity sha512-GSmOT2EbHrINBf9SR7CDELwlJ8AENk3Qn7OikK4nFYAu3Ote2+JYNVvkpAEQm3/TLNEJFD/xZJjzyxg3KBWOzw== - -pify@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/pify/-/pify-3.0.0.tgz#e5a4acd2c101fdf3d9a4d07f0dbc4db49dd28176" - integrity sha1-5aSs0sEB/fPZpNB/DbxNtJ3SgXY= - -pkg-up@2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/pkg-up/-/pkg-up-2.0.0.tgz#c819ac728059a461cab1c3889a2be3c49a004d7f" - integrity sha1-yBmscoBZpGHKscOImivjxJoATX8= - dependencies: - find-up "^2.1.0" - -posix-character-classes@^0.1.0: - version "0.1.1" - resolved "https://registry.yarnpkg.com/posix-character-classes/-/posix-character-classes-0.1.1.tgz#01eac0fe3b5af71a2a6c02feabb8c1fef7e00eab" - integrity sha1-AerA/jta9xoqbAL+q7jB/vfgDqs= - -prelude-ls@~1.1.2: - version "1.1.2" - resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.1.2.tgz#21932a549f5e52ffd9a827f570e04be62a97da54" - integrity sha1-IZMqVJ9eUv/ZqCf1cOBL5iqX2lQ= - -prettier-linter-helpers@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/prettier-linter-helpers/-/prettier-linter-helpers-1.0.0.tgz#d23d41fe1375646de2d0104d3454a3008802cf7b" - integrity sha512-GbK2cP9nraSSUF9N2XwUwqfzlAFlMNYYl+ShE/V+H8a9uNl/oUqB1w2EL54Jh0OlyRSd8RfWYJ3coVS4TROP2w== - dependencies: - fast-diff "^1.1.2" - -prettier@^1.18.2: - version "1.18.2" - resolved "https://registry.yarnpkg.com/prettier/-/prettier-1.18.2.tgz#6823e7c5900017b4bd3acf46fe9ac4b4d7bda9ea" - integrity sha512-OeHeMc0JhFE9idD4ZdtNibzY0+TPHSpSSb9h8FqtP+YnoZZ1sl8Vc9b1sasjfymH3SonAF4QcA2+mzHPhMvIiw== - -pretty-error@2.1.1: - version "2.1.1" - resolved "https://registry.yarnpkg.com/pretty-error/-/pretty-error-2.1.1.tgz#5f4f87c8f91e5ae3f3ba87ab4cf5e03b1a17f1a3" - integrity sha1-X0+HyPkeWuPzuoerTPXgOxoX8aM= - dependencies: - renderkid "^2.0.1" - utila "~0.4" - -process-nextick-args@~2.0.0: - version "2.0.1" - resolved "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-2.0.1.tgz#7820d9b16120cc55ca9ae7792680ae7dba6d7fe2" - integrity sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag== - -progress@^2.0.0: - version "2.0.3" - resolved "https://registry.yarnpkg.com/progress/-/progress-2.0.3.tgz#7e8cf8d8f5b8f239c1bc68beb4eb78567d572ef8" - integrity sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA== - -pseudomap@^1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/pseudomap/-/pseudomap-1.0.2.tgz#f052a28da70e618917ef0a8ac34c1ae5a68286b3" - integrity sha1-8FKijacOYYkX7wqKw0wa5aaChrM= - -pump@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/pump/-/pump-3.0.0.tgz#b4a2116815bde2f4e1ea602354e8c75565107a64" - integrity sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww== - dependencies: - end-of-stream "^1.1.0" - once "^1.3.1" - -punycode@^2.1.0: - version "2.1.1" - resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.1.1.tgz#b58b010ac40c22c5657616c8d2c2c02c7bf479ec" - integrity sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A== - -raw-body@2.3.2: - version "2.3.2" - resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-2.3.2.tgz#bcd60c77d3eb93cde0050295c3f379389bc88f89" - integrity sha1-vNYMd9Prk83gBQKVw/N5OJvIj4k= - dependencies: - bytes "3.0.0" - http-errors "1.6.2" - iconv-lite "0.4.19" - unpipe "1.0.0" - -rc@^1.2.7: - version "1.2.8" - resolved "https://registry.yarnpkg.com/rc/-/rc-1.2.8.tgz#cd924bf5200a075b83c188cd6b9e211b7fc0d3ed" - integrity sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw== - dependencies: - deep-extend "^0.6.0" - ini "~1.3.0" - minimist "^1.2.0" - strip-json-comments "~2.0.1" - -read-pkg@^4.0.1: - version "4.0.1" - resolved "https://registry.yarnpkg.com/read-pkg/-/read-pkg-4.0.1.tgz#963625378f3e1c4d48c85872b5a6ec7d5d093237" - integrity sha1-ljYlN48+HE1IyFhytabsfV0JMjc= - dependencies: - normalize-package-data "^2.3.2" - parse-json "^4.0.0" - pify "^3.0.0" - -readable-stream@^2.0.2, readable-stream@^2.0.6: - version "2.3.6" - resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.3.6.tgz#b11c27d88b8ff1fbe070643cf94b0c79ae1b0aaf" - integrity sha512-tQtKA9WIAhBF3+VLAseyMqZeBjW0AHJoxOtYqSUZNJxauErmLbVm2FW1y+J/YA9dUrAC39ITejlZWhVIwawkKw== - dependencies: - core-util-is "~1.0.0" - inherits "~2.0.3" - isarray "~1.0.0" - process-nextick-args "~2.0.0" - safe-buffer "~5.1.1" - string_decoder "~1.1.1" - util-deprecate "~1.0.1" - -readable-stream@^3.1.1: - version "3.4.0" - resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-3.4.0.tgz#a51c26754658e0a3c21dbf59163bd45ba6f447fc" - integrity sha512-jItXPLmrSR8jmTRmRWJXCnGJsfy85mB3Wd/uINMXA65yrnFo0cPClFIUWzo2najVNSl+mx7/4W8ttlLWJe99pQ== - dependencies: - inherits "^2.0.3" - string_decoder "^1.1.1" - util-deprecate "^1.0.1" - -readdirp@^2.0.0: - version "2.2.1" - resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-2.2.1.tgz#0e87622a3325aa33e892285caf8b4e846529a525" - integrity sha512-1JU/8q+VgFZyxwrJ+SVIOsh+KywWGpds3NTqikiKpDMZWScmAYyKIgqkO+ARvNWJfXeXR1zxz7aHF4u4CyH6vQ== - dependencies: - graceful-fs "^4.1.11" - micromatch "^3.1.10" - readable-stream "^2.0.2" - -regex-not@^1.0.0, regex-not@^1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/regex-not/-/regex-not-1.0.2.tgz#1f4ece27e00b0b65e0247a6810e6a85d83a5752c" - integrity sha512-J6SDjUgDxQj5NusnOtdFxDwN/+HWykR8GELwctJ7mdqhcyy1xEc4SRFHUXvxTp661YaVKAjfRLZ9cCqS6tn32A== - dependencies: - extend-shallow "^3.0.2" - safe-regex "^1.1.0" - -regexpp@^2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/regexpp/-/regexpp-2.0.1.tgz#8d19d31cf632482b589049f8281f93dbcba4d07f" - integrity sha512-lv0M6+TkDVniA3aD1Eg0DVpfU/booSu7Eev3TDO/mZKHBfVjgCGTV4t4buppESEYDtkArYFOxTJWv6S5C+iaNw== - -remove-trailing-separator@^1.0.1: - version "1.1.0" - resolved "https://registry.yarnpkg.com/remove-trailing-separator/-/remove-trailing-separator-1.1.0.tgz#c24bce2a283adad5bc3f58e0d48249b92379d8ef" - integrity sha1-wkvOKig62tW8P1jg1IJJuSN52O8= - -renderkid@^2.0.1: - version "2.0.3" - resolved "https://registry.yarnpkg.com/renderkid/-/renderkid-2.0.3.tgz#380179c2ff5ae1365c522bf2fcfcff01c5b74149" - integrity sha512-z8CLQp7EZBPCwCnncgf9C4XAi3WR0dv+uWu/PjIyhhAb5d6IJ/QZqlHFprHeKT+59//V6BNUsLbvN8+2LarxGA== - dependencies: - css-select "^1.1.0" - dom-converter "^0.2" - htmlparser2 "^3.3.0" - strip-ansi "^3.0.0" - utila "^0.4.0" - -repeat-element@^1.1.2: - version "1.1.3" - resolved "https://registry.yarnpkg.com/repeat-element/-/repeat-element-1.1.3.tgz#782e0d825c0c5a3bb39731f84efee6b742e6b1ce" - integrity sha512-ahGq0ZnV5m5XtZLMb+vP76kcAM5nkLqk0lpqAuojSKGgQtn4eRi4ZZGm2olo2zKFH+sMsWaqOCW1dqAnOru72g== - -repeat-string@^1.6.1: - version "1.6.1" - resolved "https://registry.yarnpkg.com/repeat-string/-/repeat-string-1.6.1.tgz#8dcae470e1c88abc2d600fff4a776286da75e637" - integrity sha1-jcrkcOHIirwtYA//Sndihtp15jc= - -require-directory@^2.1.1: - version "2.1.1" - resolved "https://registry.yarnpkg.com/require-directory/-/require-directory-2.1.1.tgz#8c64ad5fd30dab1c976e2344ffe7f792a6a6df42" - integrity sha1-jGStX9MNqxyXbiNE/+f3kqam30I= - -require-main-filename@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/require-main-filename/-/require-main-filename-1.0.1.tgz#97f717b69d48784f5f526a6c5aa8ffdda055a4d1" - integrity sha1-l/cXtp1IeE9fUmpsWqj/3aBVpNE= - -resolve-from@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-4.0.0.tgz#4abcd852ad32dd7baabfe9b40e00a36db5f392e6" - integrity sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g== - -resolve-url@^0.2.1: - version "0.2.1" - resolved "https://registry.yarnpkg.com/resolve-url/-/resolve-url-0.2.1.tgz#2c637fe77c893afd2a663fe21aa9080068e2052a" - integrity sha1-LGN/53yJOv0qZj/iGqkIAGjiBSo= - -resolve@^1.10.0: - version "1.11.1" - resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.11.1.tgz#ea10d8110376982fef578df8fc30b9ac30a07a3e" - integrity sha512-vIpgF6wfuJOZI7KKKSP+HmiKggadPQAdsp5HiC1mvqnfp0gF1vdwgBWZIdrVft9pgqoMFQN+R7BSWZiBxx+BBw== - dependencies: - path-parse "^1.0.6" - -restore-cursor@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/restore-cursor/-/restore-cursor-2.0.0.tgz#9f7ee287f82fd326d4fd162923d62129eee0dfaf" - integrity sha1-n37ih/gv0ybU/RYpI9YhKe7g368= - dependencies: - onetime "^2.0.0" - signal-exit "^3.0.2" - -ret@~0.1.10: - version "0.1.15" - resolved "https://registry.yarnpkg.com/ret/-/ret-0.1.15.tgz#b8a4825d5bdb1fc3f6f53c2bc33f81388681c7bc" - integrity sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg== - -rimraf@2.6.3, rimraf@^2.6.1, rimraf@^2.6.3: - version "2.6.3" - resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-2.6.3.tgz#b2d104fe0d8fb27cf9e0a1cda8262dd3833c6cab" - integrity sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA== - dependencies: - glob "^7.1.3" - -run-async@^2.2.0: - version "2.3.0" - resolved "https://registry.yarnpkg.com/run-async/-/run-async-2.3.0.tgz#0371ab4ae0bdd720d4166d7dfda64ff7a445a6c0" - integrity sha1-A3GrSuC91yDUFm19/aZP96RFpsA= - dependencies: - is-promise "^2.1.0" - -rxjs@^6.3.3, rxjs@^6.4.0: - version "6.5.2" - resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-6.5.2.tgz#2e35ce815cd46d84d02a209fb4e5921e051dbec7" - integrity sha512-HUb7j3kvb7p7eCUHE3FqjoDsC1xfZQ4AHFWfTKSpZ+sAhhz5X1WX0ZuUqWbzB2QhSLp3DoLUG+hMdEDKqWo2Zg== - dependencies: - tslib "^1.9.0" - -safe-buffer@^5.1.2: - version "5.2.0" - resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.0.tgz#b74daec49b1148f88c64b68d49b1e815c1f2f519" - integrity sha512-fZEwUGbVl7kouZs1jCdMLdt95hdIv0ZeHg6L7qPeciMZhZ+/gdesW4wgTARkrFWEpspjEATAzUGPG8N2jJiwbg== - -safe-buffer@~5.1.0, safe-buffer@~5.1.1: - version "5.1.2" - resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d" - integrity sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g== - -safe-regex@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/safe-regex/-/safe-regex-1.1.0.tgz#40a3669f3b077d1e943d44629e157dd48023bf2e" - integrity sha1-QKNmnzsHfR6UPURinhV91IAjvy4= - dependencies: - ret "~0.1.10" - -"safer-buffer@>= 2.1.2 < 3": - version "2.1.2" - resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" - integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg== - -sax@^1.2.4: - version "1.2.4" - resolved "https://registry.yarnpkg.com/sax/-/sax-1.2.4.tgz#2816234e2378bddc4e5354fab5caa895df7100d9" - integrity sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw== - -"semver@2 || 3 || 4 || 5", semver@^5.3.0, semver@^5.5.0, semver@^5.5.1: - version "5.7.0" - resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.0.tgz#790a7cf6fea5459bac96110b29b60412dc8ff96b" - integrity sha512-Ya52jSX2u7QKghxeoFGpLwCtGlt7j0oY9DYb5apt9nPlJ42ID+ulTXESnt/qAQcoSERyZ5sl3LDIOw0nAn/5DA== - -set-blocking@^2.0.0, set-blocking@~2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/set-blocking/-/set-blocking-2.0.0.tgz#045f9782d011ae9a6803ddd382b24392b3d890f7" - integrity sha1-BF+XgtARrppoA93TgrJDkrPYkPc= - -set-value@^2.0.0, set-value@^2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/set-value/-/set-value-2.0.1.tgz#a18d40530e6f07de4228c7defe4227af8cad005b" - integrity sha512-JxHc1weCN68wRY0fhCoXpyK55m/XPHafOmK4UWD7m2CI14GMcFypt4w/0+NV5f/ZMby2F6S2wwA7fgynh9gWSw== - dependencies: - extend-shallow "^2.0.1" - is-extendable "^0.1.1" - is-plain-object "^2.0.3" - split-string "^3.0.1" - -setprototypeof@1.0.3: - version "1.0.3" - resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.0.3.tgz#66567e37043eeb4f04d91bd658c0cbefb55b8e04" - integrity sha1-ZlZ+NwQ+608E2RvWWMDL77VbjgQ= - -shebang-command@^1.2.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-1.2.0.tgz#44aac65b695b03398968c39f363fee5deafdf1ea" - integrity sha1-RKrGW2lbAzmJaMOfNj/uXer98eo= - dependencies: - shebang-regex "^1.0.0" - -shebang-regex@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/shebang-regex/-/shebang-regex-1.0.0.tgz#da42f49740c0b42db2ca9728571cb190c98efea3" - integrity sha1-2kL0l0DAtC2yypcoVxyxkMmO/qM= - -signal-exit@^3.0.0, signal-exit@^3.0.2: - version "3.0.2" - resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.2.tgz#b5fdc08f1287ea1178628e415e25132b73646c6d" - integrity sha1-tf3AjxKH6hF4Yo5BXiUTK3NkbG0= - -slice-ansi@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/slice-ansi/-/slice-ansi-2.1.0.tgz#cacd7693461a637a5788d92a7dd4fba068e81636" - integrity sha512-Qu+VC3EwYLldKa1fCxuuvULvSJOKEgk9pi8dZeCVK7TqBfUNTH4sFkk4joj8afVSfAYgJoSOetjx9QWOJ5mYoQ== - dependencies: - ansi-styles "^3.2.0" - astral-regex "^1.0.0" - is-fullwidth-code-point "^2.0.0" - -snapdragon-node@^2.0.1: - version "2.1.1" - resolved "https://registry.yarnpkg.com/snapdragon-node/-/snapdragon-node-2.1.1.tgz#6c175f86ff14bdb0724563e8f3c1b021a286853b" - integrity sha512-O27l4xaMYt/RSQ5TR3vpWCAB5Kb/czIcqUFOM/C4fYcLnbZUc1PkjTAMjof2pBWaSTwOUd6qUHcFGVGj7aIwnw== - dependencies: - define-property "^1.0.0" - isobject "^3.0.0" - snapdragon-util "^3.0.1" - -snapdragon-util@^3.0.1: - version "3.0.1" - resolved "https://registry.yarnpkg.com/snapdragon-util/-/snapdragon-util-3.0.1.tgz#f956479486f2acd79700693f6f7b805e45ab56e2" - integrity sha512-mbKkMdQKsjX4BAL4bRYTj21edOf8cN7XHdYUJEe+Zn99hVEYcMvKPct1IqNe7+AZPirn8BCDOQBHQZknqmKlZQ== - dependencies: - kind-of "^3.2.0" - -snapdragon@^0.8.1: - version "0.8.2" - resolved "https://registry.yarnpkg.com/snapdragon/-/snapdragon-0.8.2.tgz#64922e7c565b0e14204ba1aa7d6964278d25182d" - integrity sha512-FtyOnWN/wCHTVXOMwvSv26d+ko5vWlIDD6zoUJ7LW8vh+ZBC8QdljveRP+crNrtBwioEUWy/4dMtbBjA4ioNlg== - dependencies: - base "^0.11.1" - debug "^2.2.0" - define-property "^0.2.5" - extend-shallow "^2.0.1" - map-cache "^0.2.2" - source-map "^0.5.6" - source-map-resolve "^0.5.0" - use "^3.1.0" - -source-map-resolve@^0.5.0: - version "0.5.2" - resolved "https://registry.yarnpkg.com/source-map-resolve/-/source-map-resolve-0.5.2.tgz#72e2cc34095543e43b2c62b2c4c10d4a9054f259" - integrity sha512-MjqsvNwyz1s0k81Goz/9vRBe9SZdB09Bdw+/zYyO+3CuPk6fouTaxscHkgtE8jKvf01kVfl8riHzERQ/kefaSA== - dependencies: - atob "^2.1.1" - decode-uri-component "^0.2.0" - resolve-url "^0.2.1" - source-map-url "^0.4.0" - urix "^0.1.0" - -source-map-url@^0.4.0: - version "0.4.0" - resolved "https://registry.yarnpkg.com/source-map-url/-/source-map-url-0.4.0.tgz#3e935d7ddd73631b97659956d55128e87b5084a3" - integrity sha1-PpNdfd1zYxuXZZlW1VEo6HtQhKM= - -source-map@^0.5.6: - version "0.5.7" - resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.5.7.tgz#8a039d2d1021d22d1ea14c80d8ea468ba2ef3fcc" - integrity sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w= - -spawn-command@^0.0.2-1: - version "0.0.2-1" - resolved "https://registry.yarnpkg.com/spawn-command/-/spawn-command-0.0.2-1.tgz#62f5e9466981c1b796dc5929937e11c9c6921bd0" - integrity sha1-YvXpRmmBwbeW3Fkpk34RycaSG9A= - -spdx-correct@^3.0.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/spdx-correct/-/spdx-correct-3.1.0.tgz#fb83e504445268f154b074e218c87c003cd31df4" - integrity sha512-lr2EZCctC2BNR7j7WzJ2FpDznxky1sjfxvvYEyzxNyb6lZXHODmEoJeFu4JupYlkfha1KZpJyoqiJ7pgA1qq8Q== - dependencies: - spdx-expression-parse "^3.0.0" - spdx-license-ids "^3.0.0" - -spdx-exceptions@^2.1.0: - version "2.2.0" - resolved "https://registry.yarnpkg.com/spdx-exceptions/-/spdx-exceptions-2.2.0.tgz#2ea450aee74f2a89bfb94519c07fcd6f41322977" - integrity sha512-2XQACfElKi9SlVb1CYadKDXvoajPgBVPn/gOQLrTvHdElaVhr7ZEbqJaRnJLVNeaI4cMEAgVCeBMKF6MWRDCRA== - -spdx-expression-parse@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/spdx-expression-parse/-/spdx-expression-parse-3.0.0.tgz#99e119b7a5da00e05491c9fa338b7904823b41d0" - integrity sha512-Yg6D3XpRD4kkOmTpdgbUiEJFKghJH03fiC1OPll5h/0sO6neh2jqRDVHOQ4o/LMea0tgCkbMgea5ip/e+MkWyg== - dependencies: - spdx-exceptions "^2.1.0" - spdx-license-ids "^3.0.0" - -spdx-license-ids@^3.0.0: - version "3.0.5" - resolved "https://registry.yarnpkg.com/spdx-license-ids/-/spdx-license-ids-3.0.5.tgz#3694b5804567a458d3c8045842a6358632f62654" - integrity sha512-J+FWzZoynJEXGphVIS+XEh3kFSjZX/1i9gFBaWQcB+/tmpe2qUsSBABpcxqxnAxFdiUFEgAX1bjYGQvIZmoz9Q== - -split-string@^3.0.1, split-string@^3.0.2: - version "3.1.0" - resolved "https://registry.yarnpkg.com/split-string/-/split-string-3.1.0.tgz#7cb09dda3a86585705c64b39a6466038682e8fe2" - integrity sha512-NzNVhJDYpwceVVii8/Hu6DKfD2G+NrQHlS/V/qgv763EYudVwEcMQNxd2lh+0VrUByXN/oJkl5grOhYWvQUYiw== - dependencies: - extend-shallow "^3.0.0" - -sprintf-js@~1.0.2: - version "1.0.3" - resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.0.3.tgz#04e6926f662895354f3dd015203633b857297e2c" - integrity sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw= - -static-extend@^0.1.1: - version "0.1.2" - resolved "https://registry.yarnpkg.com/static-extend/-/static-extend-0.1.2.tgz#60809c39cbff55337226fd5e0b520f341f1fb5c6" - integrity sha1-YICcOcv/VTNyJv1eC1IPNB8ftcY= - dependencies: - define-property "^0.2.5" - object-copy "^0.1.0" - -"statuses@>= 1.3.1 < 2": - version "1.5.0" - resolved "https://registry.yarnpkg.com/statuses/-/statuses-1.5.0.tgz#161c7dac177659fd9811f43771fa99381478628c" - integrity sha1-Fhx9rBd2Wf2YEfQ3cfqZOBR4Yow= - -string-length@2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/string-length/-/string-length-2.0.0.tgz#d40dbb686a3ace960c1cffca562bf2c45f8363ed" - integrity sha1-1A27aGo6zpYMHP/KVivyxF+DY+0= - dependencies: - astral-regex "^1.0.0" - strip-ansi "^4.0.0" - -string-width@^1.0.1: - version "1.0.2" - resolved "https://registry.yarnpkg.com/string-width/-/string-width-1.0.2.tgz#118bdf5b8cdc51a2a7e70d211e07e2b0b9b107d3" - integrity sha1-EYvfW4zcUaKn5w0hHgfisLmxB9M= - dependencies: - code-point-at "^1.0.0" - is-fullwidth-code-point "^1.0.0" - strip-ansi "^3.0.0" - -"string-width@^1.0.2 || 2", string-width@^2.0.0, string-width@^2.1.0, string-width@^2.1.1: - version "2.1.1" - resolved "https://registry.yarnpkg.com/string-width/-/string-width-2.1.1.tgz#ab93f27a8dc13d28cac815c462143a6d9012ae9e" - integrity sha512-nOqH59deCq9SRHlxq1Aw85Jnt4w6KvLKqWVik6oA9ZklXLNIOlqg4F2yrT1MVaTjAqvVwdfeZ7w7aCvJD7ugkw== - dependencies: - is-fullwidth-code-point "^2.0.0" - strip-ansi "^4.0.0" - -string-width@^3.0.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/string-width/-/string-width-3.1.0.tgz#22767be21b62af1081574306f69ac51b62203961" - integrity sha512-vafcv6KjVZKSgz06oM/H6GDBrAtz8vdhQakGjFIvNrHA6y3HCF1CInLy+QLq8dTJPQ1b+KDUqDFctkdRW44e1w== - dependencies: - emoji-regex "^7.0.1" - is-fullwidth-code-point "^2.0.0" - strip-ansi "^5.1.0" - -string_decoder@^1.1.1: - version "1.2.0" - resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.2.0.tgz#fe86e738b19544afe70469243b2a1ee9240eae8d" - integrity sha512-6YqyX6ZWEYguAxgZzHGL7SsCeGx3V2TtOTqZz1xSTSWnqsbWwbptafNyvf/ACquZUXV3DANr5BDIwNYe1mN42w== - dependencies: - safe-buffer "~5.1.0" - -string_decoder@~1.1.1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.1.1.tgz#9cf1611ba62685d7030ae9e4ba34149c3af03fc8" - integrity sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg== - dependencies: - safe-buffer "~5.1.0" - -strip-ansi@^3.0.0, strip-ansi@^3.0.1: - version "3.0.1" - resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-3.0.1.tgz#6a385fb8853d952d5ff05d0e8aaf94278dc63dcf" - integrity sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8= - dependencies: - ansi-regex "^2.0.0" - -strip-ansi@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-4.0.0.tgz#a8479022eb1ac368a871389b635262c505ee368f" - integrity sha1-qEeQIusaw2iocTibY1JixQXuNo8= - dependencies: - ansi-regex "^3.0.0" - -strip-ansi@^5.1.0: - version "5.2.0" - resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-5.2.0.tgz#8c9a536feb6afc962bdfa5b104a5091c1ad9c0ae" - integrity sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA== - dependencies: - ansi-regex "^4.1.0" - -strip-eof@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/strip-eof/-/strip-eof-1.0.0.tgz#bb43ff5598a6eb05d89b59fcd129c983313606bf" - integrity sha1-u0P/VZim6wXYm1n80SnJgzE2Br8= - -strip-json-comments@^2.0.1, strip-json-comments@~2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-2.0.1.tgz#3c531942e908c2697c0ec344858c286c7ca0a60a" - integrity sha1-PFMZQukIwml8DsNEhYwobHygpgo= - -supports-color@^4.5.0: - version "4.5.0" - resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-4.5.0.tgz#be7a0de484dec5c5cddf8b3d59125044912f635b" - integrity sha1-vnoN5ITexcXN34s9WRJQRJEvY1s= - dependencies: - has-flag "^2.0.0" - -supports-color@^5.3.0: - version "5.5.0" - resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-5.5.0.tgz#e2e69a44ac8772f78a1ec0b35b689df6530efc8f" - integrity sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow== - dependencies: - has-flag "^3.0.0" - -table@^5.2.3: - version "5.4.4" - resolved "https://registry.yarnpkg.com/table/-/table-5.4.4.tgz#6e0f88fdae3692793d1077fd172a4667afe986a6" - integrity sha512-IIfEAUx5QlODLblLrGTTLJA7Tk0iLSGBvgY8essPRVNGHAzThujww1YqHLs6h3HfTg55h++RzLHH5Xw/rfv+mg== - dependencies: - ajv "^6.10.2" - lodash "^4.17.14" - slice-ansi "^2.1.0" - string-width "^3.0.0" - -tar@^4: - version "4.4.10" - resolved "https://registry.yarnpkg.com/tar/-/tar-4.4.10.tgz#946b2810b9a5e0b26140cf78bea6b0b0d689eba1" - integrity sha512-g2SVs5QIxvo6OLp0GudTqEf05maawKUxXru104iaayWA09551tFCTI8f1Asb4lPfkBr91k07iL4c11XO3/b0tA== - dependencies: - chownr "^1.1.1" - fs-minipass "^1.2.5" - minipass "^2.3.5" - minizlib "^1.2.1" - mkdirp "^0.5.0" - safe-buffer "^5.1.2" - yallist "^3.0.3" - -term-size@^1.2.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/term-size/-/term-size-1.2.0.tgz#458b83887f288fc56d6fffbfad262e26638efa69" - integrity sha1-RYuDiH8oj8Vtb/+/rSYuJmOO+mk= - dependencies: - execa "^0.7.0" - -text-table@^0.2.0: - version "0.2.0" - resolved "https://registry.yarnpkg.com/text-table/-/text-table-0.2.0.tgz#7f5ee823ae805207c00af2df4a84ec3fcfa570b4" - integrity sha1-f17oI66AUgfACvLfSoTsP8+lcLQ= - -through@^2.3.6: - version "2.3.8" - resolved "https://registry.yarnpkg.com/through/-/through-2.3.8.tgz#0dd4c9ffaabc357960b1b724115d7e0e86a2e1f5" - integrity sha1-DdTJ/6q8NXlgsbckEV1+Doai4fU= - -tmp@^0.0.33: - version "0.0.33" - resolved "https://registry.yarnpkg.com/tmp/-/tmp-0.0.33.tgz#6d34335889768d21b2bcda0aa277ced3b1bfadf9" - integrity sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw== - dependencies: - os-tmpdir "~1.0.2" - -to-object-path@^0.3.0: - version "0.3.0" - resolved "https://registry.yarnpkg.com/to-object-path/-/to-object-path-0.3.0.tgz#297588b7b0e7e0ac08e04e672f85c1f4999e17af" - integrity sha1-KXWIt7Dn4KwI4E5nL4XB9JmeF68= - dependencies: - kind-of "^3.0.2" - -to-regex-range@^2.1.0: - version "2.1.1" - resolved "https://registry.yarnpkg.com/to-regex-range/-/to-regex-range-2.1.1.tgz#7c80c17b9dfebe599e27367e0d4dd5590141db38" - integrity sha1-fIDBe53+vlmeJzZ+DU3VWQFB2zg= - dependencies: - is-number "^3.0.0" - repeat-string "^1.6.1" - -to-regex@^3.0.1, to-regex@^3.0.2: - version "3.0.2" - resolved "https://registry.yarnpkg.com/to-regex/-/to-regex-3.0.2.tgz#13cfdd9b336552f30b51f33a8ae1b42a7a7599ce" - integrity sha512-FWtleNAtZ/Ki2qtqej2CXTOayOH9bHDQF+Q48VpWyDXjbYxA4Yz8iDB31zXOBUlOHHKidDbqGVrTUvQMPmBGBw== - dependencies: - define-property "^2.0.2" - extend-shallow "^3.0.2" - regex-not "^1.0.2" - safe-regex "^1.1.0" - -tree-kill@^1.1.0: - version "1.2.1" - resolved "https://registry.yarnpkg.com/tree-kill/-/tree-kill-1.2.1.tgz#5398f374e2f292b9dcc7b2e71e30a5c3bb6c743a" - integrity sha512-4hjqbObwlh2dLyW4tcz0Ymw0ggoaVDMveUB9w8kFSQScdRLo0gxO9J7WFcUBo+W3C1TLdFIEwNOWebgZZ0RH9Q== - -tslib@^1.9.0: - version "1.10.0" - resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.10.0.tgz#c3c19f95973fb0a62973fb09d90d961ee43e5c8a" - integrity sha512-qOebF53frne81cf0S9B41ByenJ3/IuH8yJKngAX35CmiZySA0khhkovshKK+jGCaMnVomla7gVlIcc3EvKPbTQ== - -type-check@~0.3.2: - version "0.3.2" - resolved "https://registry.yarnpkg.com/type-check/-/type-check-0.3.2.tgz#5884cab512cf1d355e3fb784f30804b2b520db72" - integrity sha1-WITKtRLPHTVeP7eE8wgEsrUg23I= - dependencies: - prelude-ls "~1.1.2" - -typescript@^3.5.3: - version "3.5.3" - resolved "https://registry.yarnpkg.com/typescript/-/typescript-3.5.3.tgz#c830f657f93f1ea846819e929092f5fe5983e977" - integrity sha512-ACzBtm/PhXBDId6a6sDJfroT2pOWt/oOnk4/dElG5G33ZL776N3Y6/6bKZJBFpd+b05F3Ct9qDjMeJmRWtE2/g== - -union-value@^1.0.0: - version "1.0.1" - resolved "https://registry.yarnpkg.com/union-value/-/union-value-1.0.1.tgz#0b6fe7b835aecda61c6ea4d4f02c14221e109847" - integrity sha512-tJfXmxMeWYnczCVs7XAEvIV7ieppALdyepWMkHkwciRpZraG/xwT+s2JN8+pr1+8jCRf80FFzvr+MpQeeoF4Xg== - dependencies: - arr-union "^3.1.0" - get-value "^2.0.6" - is-extendable "^0.1.1" - set-value "^2.0.1" - -unpipe@1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/unpipe/-/unpipe-1.0.0.tgz#b2bf4ee8514aae6165b4817829d21b2ef49904ec" - integrity sha1-sr9O6FFKrmFltIF4KdIbLvSZBOw= - -unset-value@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/unset-value/-/unset-value-1.0.0.tgz#8376873f7d2335179ffb1e6fc3a8ed0dfc8ab559" - integrity sha1-g3aHP30jNRef+x5vw6jtDfyKtVk= - dependencies: - has-value "^0.3.1" - isobject "^3.0.0" - -upath@^1.0.0: - version "1.1.2" - resolved "https://registry.yarnpkg.com/upath/-/upath-1.1.2.tgz#3db658600edaeeccbe6db5e684d67ee8c2acd068" - integrity sha512-kXpym8nmDmlCBr7nKdIx8P2jNBa+pBpIUFRnKJ4dr8htyYGJFokkr2ZvERRtUN+9SY+JqXouNgUPtv6JQva/2Q== - -uri-js@^4.2.2: - version "4.2.2" - resolved "https://registry.yarnpkg.com/uri-js/-/uri-js-4.2.2.tgz#94c540e1ff772956e2299507c010aea6c8838eb0" - integrity sha512-KY9Frmirql91X2Qgjry0Wd4Y+YTdrdZheS8TFwvkbLWf/G5KNJDCh6pKL5OZctEW4+0Baa5idK2ZQuELRwPznQ== - dependencies: - punycode "^2.1.0" - -urix@^0.1.0: - version "0.1.0" - resolved "https://registry.yarnpkg.com/urix/-/urix-0.1.0.tgz#da937f7a62e21fec1fd18d49b35c2935067a6c72" - integrity sha1-2pN/emLiH+wf0Y1Js1wpNQZ6bHI= - -url-pattern@^1.0.3: - version "1.0.3" - resolved "https://registry.yarnpkg.com/url-pattern/-/url-pattern-1.0.3.tgz#0409292471b24f23c50d65a47931793d2b5acfc1" - integrity sha1-BAkpJHGyTyPFDWWkeTF5PStaz8E= - -use@^3.1.0: - version "3.1.1" - resolved "https://registry.yarnpkg.com/use/-/use-3.1.1.tgz#d50c8cac79a19fbc20f2911f56eb973f4e10070f" - integrity sha512-cwESVXlO3url9YWlFW/TA9cshCEhtu7IKJ/p5soJ/gGpj7vbvFrAY/eIioQ6Dw23KjZhYgiIo8HOs1nQ2vr/oQ== - -util-deprecate@^1.0.1, util-deprecate@~1.0.1: - version "1.0.2" - resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf" - integrity sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8= - -utila@^0.4.0, utila@~0.4: - version "0.4.0" - resolved "https://registry.yarnpkg.com/utila/-/utila-0.4.0.tgz#8a16a05d445657a3aea5eecc5b12a4fa5379772c" - integrity sha1-ihagXURWV6Oupe7MWxKk+lN5dyw= - -validate-npm-package-license@^3.0.1: - version "3.0.4" - resolved "https://registry.yarnpkg.com/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz#fc91f6b9c7ba15c857f4cb2c5defeec39d4f410a" - integrity sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew== - dependencies: - spdx-correct "^3.0.0" - spdx-expression-parse "^3.0.0" - -which-module@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/which-module/-/which-module-2.0.0.tgz#d9ef07dce77b9902b8a3a8fa4b31c3e3f7e6e87a" - integrity sha1-2e8H3Od7mQK4o6j6SzHD4/fm6Ho= - -which@^1.2.9: - version "1.3.1" - resolved "https://registry.yarnpkg.com/which/-/which-1.3.1.tgz#a45043d54f5805316da8d62f9f50918d3da70b0a" - integrity sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ== - dependencies: - isexe "^2.0.0" - -wide-align@^1.1.0: - version "1.1.3" - resolved "https://registry.yarnpkg.com/wide-align/-/wide-align-1.1.3.tgz#ae074e6bdc0c14a431e804e624549c633b000457" - integrity sha512-QGkOQc8XL6Bt5PwnsExKBPuMKBxnGxWWW3fU55Xt4feHozMUhdUMaBCk290qpm/wG5u/RSKzwdAC4i51YigihA== - dependencies: - string-width "^1.0.2 || 2" - -widest-line@^2.0.0: - version "2.0.1" - resolved "https://registry.yarnpkg.com/widest-line/-/widest-line-2.0.1.tgz#7438764730ec7ef4381ce4df82fb98a53142a3fc" - integrity sha512-Ba5m9/Fa4Xt9eb2ELXt77JxVDV8w7qQrH0zS/TWSJdLyAwQjWoOzpzj5lwVftDz6n/EOu3tNACS84v509qwnJA== - dependencies: - string-width "^2.1.1" - -wordwrap@~1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-1.0.0.tgz#27584810891456a4171c8d0226441ade90cbcaeb" - integrity sha1-J1hIEIkUVqQXHI0CJkQa3pDLyus= - -wrap-ansi@^2.0.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-2.1.0.tgz#d8fc3d284dd05794fe84973caecdd1cf824fdd85" - integrity sha1-2Pw9KE3QV5T+hJc8rs3Rz4JP3YU= - dependencies: - string-width "^1.0.1" - strip-ansi "^3.0.1" - -wrappy@1: - version "1.0.2" - resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f" - integrity sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8= - -write@1.0.3: - version "1.0.3" - resolved "https://registry.yarnpkg.com/write/-/write-1.0.3.tgz#0800e14523b923a387e415123c865616aae0f5c3" - integrity sha512-/lg70HAjtkUgWPVZhZcm+T4hkL8Zbtp1nFNOn3lRrxnlv50SRBv7cR7RqR+GMsd3hUXy9hWBo4CHTbFTcOYwig== - dependencies: - mkdirp "^0.5.1" - -y18n@^3.2.1: - version "3.2.1" - resolved "https://registry.yarnpkg.com/y18n/-/y18n-3.2.1.tgz#6d15fba884c08679c0d77e88e7759e811e07fa41" - integrity sha1-bRX7qITAhnnA136I53WegR4H+kE= - -"y18n@^3.2.1 || ^4.0.0": - version "4.0.0" - resolved "https://registry.yarnpkg.com/y18n/-/y18n-4.0.0.tgz#95ef94f85ecc81d007c264e190a120f0a3c8566b" - integrity sha512-r9S/ZyXu/Xu9q1tYlpsLIsa3EeLXXk0VwlxqTcFRfg9EhMW+17kbt9G0NrgCmhGb5vT2hyhJZLfDGx+7+5Uj/w== - -yallist@^2.1.2: - version "2.1.2" - resolved "https://registry.yarnpkg.com/yallist/-/yallist-2.1.2.tgz#1c11f9218f076089a47dd512f93c6699a6a81d52" - integrity sha1-HBH5IY8HYImkfdUS+TxmmaaoHVI= - -yallist@^3.0.0, yallist@^3.0.3: - version "3.0.3" - resolved "https://registry.yarnpkg.com/yallist/-/yallist-3.0.3.tgz#b4b049e314be545e3ce802236d6cd22cd91c3de9" - integrity sha512-S+Zk8DEWE6oKpV+vI3qWkaK+jSbIK86pCwe2IF/xwIpQ8jEuxpw9NyaGjmp9+BoJv5FV2piqCDcoCtStppiq2A== - -yargs-parser@^11.1.1: - version "11.1.1" - resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-11.1.1.tgz#879a0865973bca9f6bab5cbdf3b1c67ec7d3bcf4" - integrity sha512-C6kB/WJDiaxONLJQnF8ccx9SEeoTTLek8RVbaOIsrAUS8VrBEXfmeSnCZxygc+XC2sNMBIwOOnfcxiynjHsVSQ== - dependencies: - camelcase "^5.0.0" - decamelize "^1.2.0" - -yargs-parser@^9.0.2: - version "9.0.2" - resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-9.0.2.tgz#9ccf6a43460fe4ed40a9bb68f48d43b8a68cc077" - integrity sha1-nM9qQ0YP5O1Aqbto9I1DuKaMwHc= - dependencies: - camelcase "^4.1.0" - -yargs@^11.0.0: - version "11.1.0" - resolved "https://registry.yarnpkg.com/yargs/-/yargs-11.1.0.tgz#90b869934ed6e871115ea2ff58b03f4724ed2d77" - integrity sha512-NwW69J42EsCSanF8kyn5upxvjp5ds+t3+udGBeTbFnERA+lF541DDpMawzo4z6W/QrzNM18D+BPMiOBibnFV5A== - dependencies: - cliui "^4.0.0" - decamelize "^1.1.1" - find-up "^2.1.0" - get-caller-file "^1.0.1" - os-locale "^2.0.0" - require-directory "^2.1.1" - require-main-filename "^1.0.1" - set-blocking "^2.0.0" - string-width "^2.0.0" - which-module "^2.0.0" - y18n "^3.2.1" - yargs-parser "^9.0.2" - -yargs@^12.0.1: - version "12.0.5" - resolved "https://registry.yarnpkg.com/yargs/-/yargs-12.0.5.tgz#05f5997b609647b64f66b81e3b4b10a368e7ad13" - integrity sha512-Lhz8TLaYnxq/2ObqHDql8dX8CJi97oHxrjUcYtzKbbykPtVW9WB+poxI+NM2UIzsMgNCZTIf0AQwsjK5yMAqZw== - dependencies: - cliui "^4.0.0" - decamelize "^1.2.0" - find-up "^3.0.0" - get-caller-file "^1.0.1" - os-locale "^3.0.0" - require-directory "^2.1.1" - require-main-filename "^1.0.1" - set-blocking "^2.0.0" - string-width "^2.0.0" - which-module "^2.0.0" - y18n "^3.2.1 || ^4.0.0" - yargs-parser "^11.1.1" diff --git a/package.json b/package.json new file mode 100644 index 0000000000..4879a73385 --- /dev/null +++ b/package.json @@ -0,0 +1,99 @@ +{ + "name": "bloom-housing", + "version": "2.0.0-pre-tailwind", + "author": "Sean Albert ", + "description": "Bloom is a system to manage processes for affordable housing", + "workspaces": { + "packages": [ + "sites/public", + "sites/partners", + "backend/core", + "shared-helpers", + "ui-components" + ], + "nohoist": [ + "**/@anchan828/nest-sendgrid" + ] + }, + "repository": "https://github.com/CityOfDetroit/bloom.git", + "license": "Apache-2.0", + "private": true, + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org" + }, + "scripts": { + "dev:app:public": "wait-on \"http-get://localhost:${PORT:-3100}/listings\" && cd sites/public && yarn dev", + "test:app:public": "cd sites/public && yarn test", + "test:app:public:headless": "cd sites/public && yarn test:headless", + "build:app:public": "cd sites/public && yarn build", + "dev:app:partners": "wait-on \"http-get://localhost:${PORT:-3100}/listings\" && cd sites/partners && yarn dev", + "test:app:partners": "wait-on \"http-get://localhost:${PORT:-3100}/listings\" && cd sites/partners && yarn test", + "test:app:partners:headless": "wait-on \"http-get://localhost:${PORT:-3100}/listings\" && cd sites/partners && yarn test:headless", + "test:app:partners:unit": "cd sites/partners && yarn test:unit", + "build:app:partners": "cd sites/partners && yarn build", + "dev:backend": "cd backend/core && yarn dev", + "dev:all": "concurrently --names \" BACKEND_CORE,APP_PUBLIC,APP_PARTNERS\" --prefix \"{name}\" \"yarn dev:backend\" \"yarn dev:app:public\" \"yarn dev:app:partners\"", + "dev:frontend": "concurrently --names \" APP_PUBLIC,APP_PARTNERS\" --prefix \"{name}\" \"yarn dev:app:public\" \"yarn dev:app:partners\"", + "dev:partners": "concurrently \"yarn dev:backend\" \"yarn dev:app:partners\"", + "dev:public": "concurrently \"yarn dev:backend\" \"yarn dev:app:public\"", + "test:shared:helpers": "cd shared-helpers && yarn && yarn test", + "test:shared:ui": "cd ui-components && yarn && yarn test", + "test:shared:ui:a11y": "cd ui-components && yarn && yarn test:a11y", + "test:backend:core:dbsetup": "cd backend/core && yarn db:migration:run && yarn db:seed", + "test:backend:core:testdbsetup": "cd backend/core && yarn test:db:setup", + "test:backend:core": "cd backend/core && yarn test", + "test:e2e:backend:core": "cd backend/core && yarn test:e2e:local", + "test:apps": "concurrently \"yarn dev:backend\" \"yarn test:app:public\"", + "test:apps:headless": "concurrently \"yarn dev:backend\" \"yarn test:app:public:headless\"", + "test:public:unit-tests": "cd sites/public && yarn test:unit-tests", + "lint": "eslint '**/*.ts' '**/*.tsx' '**/*.js'", + "db:reseed": "cd backend/core && yarn db:reseed", + "install:all": "yarn install && cd backend/core && yarn install", + "setup": "yarn install:all && yarn db:reseed", + "clean": "rm -rf backend/core/dist/ backend/core/node_modules/ node_modules/ sites/partners/.next/ sites/partners/node_modules/ sites/public/.next/ sites/public/node_modules/ ui-components/node_modules/" + }, + "dependencies": { + "tailwindcss": "npm:@tailwindcss/postcss7-compat@2.2.10" + }, + "devDependencies": { + "@commitlint/cli": "^13.1.0", + "@commitlint/config-conventional": "^13.1.0", + "@types/jest": "^26.0.14", + "@typescript-eslint/eslint-plugin": "^4.5.0", + "@typescript-eslint/parser": "^4.5.0", + "commitizen": "^4.2.4", + "concurrently": "^5.3.0", + "cz-conventional-changelog": "^3.3.0", + "eslint": "^7.11.0", + "eslint-config-prettier": "^6.11.0", + "eslint-plugin-import": "^2.22.1", + "eslint-plugin-prettier": "^3.1.4", + "eslint-plugin-react": "^7.21.4", + "eslint-plugin-react-hooks": "^4.1.2", + "husky": "^4.3.0", + "jest": "^26.5.3", + "lerna": "^4.0.0", + "lint-staged": "^10.4.0", + "prettier": "^2.1.0", + "react": "^17.0.2", + "react-test-renderer": "^17.0.2", + "rimraf": "^3.0.2", + "ts-jest": "^26.4.1", + "typescript": "^3.9.7", + "wait-on": "^5.2.0" + }, + "prettier": { + "singleQuote": false, + "printWidth": 100, + "semi": false + }, + "lint-staged": { + "*.{js,ts,tsx}": "eslint --max-warnings 0" + }, + "config": { + "commitizen": { + "path": "./node_modules/cz-conventional-changelog" + } + } +} diff --git a/shared-helpers/.jest/setup-tests.js b/shared-helpers/.jest/setup-tests.js new file mode 100644 index 0000000000..2e32959c7f --- /dev/null +++ b/shared-helpers/.jest/setup-tests.js @@ -0,0 +1,21 @@ +// Future home of additional Jest config +import "@testing-library/jest-dom/extend-expect" + +import { addTranslation } from "@bloom-housing/ui-components" +import generalTranslations from "@bloom-housing/ui-components/src/locales/general.json" + +// see: https://jestjs.io/docs/en/manual-mocks#mocking-methods-which-are-not-implemented-in-jsdom +window.matchMedia = jest.fn().mockImplementation((query) => { + return { + matches: false, + media: query, + onchange: null, + addListener: jest.fn(), // deprecated + removeListener: jest.fn(), // deprecated + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + dispatchEvent: jest.fn(), + } +}) + +addTranslation(generalTranslations) diff --git a/shared-helpers/.npmignore b/shared-helpers/.npmignore new file mode 100644 index 0000000000..165bd38bfb --- /dev/null +++ b/shared-helpers/.npmignore @@ -0,0 +1,76 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Next.js cache and compiled artifacts +.next/** + +# Tests +__tests__/** + +# Environment vars for dev/test +.env +.env.template + +# Complied Typescript +dist + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (http://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/** + +# Typescript v1 declaration files +typings/ + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Mac files +.DS_Store + +# Yarn +yarn-error.log +.pnp/ +.pnp.js +# Yarn Integrity file +.yarn-integrity +.yarn/** + +# IDE configs +.idea +.vscode diff --git a/shared-helpers/CHANGELOG.md b/shared-helpers/CHANGELOG.md new file mode 100644 index 0000000000..275482ad19 --- /dev/null +++ b/shared-helpers/CHANGELOG.md @@ -0,0 +1,1676 @@ +# Change Log + +All notable changes to this project will be documented in this file. +See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. + +# [4.2.0](https://github.com/seanmalbert/bloom/compare/@bloom-housing/shared-helpers@4.1.2...@bloom-housing/shared-helpers@4.2.0) (2022-04-06) + + +* 2022-04-05 release (#2627) ([485fb48](https://github.com/seanmalbert/bloom/commit/485fb48cfbad48bcabfef5e2e704025f608aee89)), closes [#2627](https://github.com/seanmalbert/bloom/issues/2627) [#2352](https://github.com/seanmalbert/bloom/issues/2352) [#2316](https://github.com/seanmalbert/bloom/issues/2316) [#2356](https://github.com/seanmalbert/bloom/issues/2356) [#2353](https://github.com/seanmalbert/bloom/issues/2353) [#2338](https://github.com/seanmalbert/bloom/issues/2338) [#2377](https://github.com/seanmalbert/bloom/issues/2377) [#2320](https://github.com/seanmalbert/bloom/issues/2320) [#2386](https://github.com/seanmalbert/bloom/issues/2386) [#2362](https://github.com/seanmalbert/bloom/issues/2362) [#2395](https://github.com/seanmalbert/bloom/issues/2395) [#2410](https://github.com/seanmalbert/bloom/issues/2410) [#2407](https://github.com/seanmalbert/bloom/issues/2407) [#2430](https://github.com/seanmalbert/bloom/issues/2430) [#2418](https://github.com/seanmalbert/bloom/issues/2418) [#2434](https://github.com/seanmalbert/bloom/issues/2434) [#2374](https://github.com/seanmalbert/bloom/issues/2374) [#2435](https://github.com/seanmalbert/bloom/issues/2435) [#2420](https://github.com/seanmalbert/bloom/issues/2420) [#2412](https://github.com/seanmalbert/bloom/issues/2412) [#2438](https://github.com/seanmalbert/bloom/issues/2438) [#2429](https://github.com/seanmalbert/bloom/issues/2429) [#2452](https://github.com/seanmalbert/bloom/issues/2452) [#2458](https://github.com/seanmalbert/bloom/issues/2458) [#2423](https://github.com/seanmalbert/bloom/issues/2423) [#2432](https://github.com/seanmalbert/bloom/issues/2432) [#2437](https://github.com/seanmalbert/bloom/issues/2437) [#2440](https://github.com/seanmalbert/bloom/issues/2440) [#2441](https://github.com/seanmalbert/bloom/issues/2441) [#2460](https://github.com/seanmalbert/bloom/issues/2460) [#2459](https://github.com/seanmalbert/bloom/issues/2459) [#2464](https://github.com/seanmalbert/bloom/issues/2464) [#2465](https://github.com/seanmalbert/bloom/issues/2465) [#2466](https://github.com/seanmalbert/bloom/issues/2466) [#2436](https://github.com/seanmalbert/bloom/issues/2436) [#2451](https://github.com/seanmalbert/bloom/issues/2451) [#2415](https://github.com/seanmalbert/bloom/issues/2415) [#2354](https://github.com/seanmalbert/bloom/issues/2354) [#2455](https://github.com/seanmalbert/bloom/issues/2455) [#2484](https://github.com/seanmalbert/bloom/issues/2484) [#2482](https://github.com/seanmalbert/bloom/issues/2482) [#2483](https://github.com/seanmalbert/bloom/issues/2483) [#2476](https://github.com/seanmalbert/bloom/issues/2476) [#2485](https://github.com/seanmalbert/bloom/issues/2485) [#2470](https://github.com/seanmalbert/bloom/issues/2470) [#2488](https://github.com/seanmalbert/bloom/issues/2488) [#2487](https://github.com/seanmalbert/bloom/issues/2487) [#2496](https://github.com/seanmalbert/bloom/issues/2496) [#2498](https://github.com/seanmalbert/bloom/issues/2498) [#2499](https://github.com/seanmalbert/bloom/issues/2499) [#2291](https://github.com/seanmalbert/bloom/issues/2291) [#2461](https://github.com/seanmalbert/bloom/issues/2461) [#2485](https://github.com/seanmalbert/bloom/issues/2485) [#2494](https://github.com/seanmalbert/bloom/issues/2494) [#2503](https://github.com/seanmalbert/bloom/issues/2503) [#2495](https://github.com/seanmalbert/bloom/issues/2495) [#2477](https://github.com/seanmalbert/bloom/issues/2477) [#2505](https://github.com/seanmalbert/bloom/issues/2505) [#2372](https://github.com/seanmalbert/bloom/issues/2372) [#2489](https://github.com/seanmalbert/bloom/issues/2489) [#2497](https://github.com/seanmalbert/bloom/issues/2497) [#2506](https://github.com/seanmalbert/bloom/issues/2506) [#2486](https://github.com/seanmalbert/bloom/issues/2486) +* 2022-04-04 release (#2614) ([fecab85](https://github.com/seanmalbert/bloom/commit/fecab85c748a55ab4aff5d591c8e0ac702254559)), closes [#2614](https://github.com/seanmalbert/bloom/issues/2614) [#2349](https://github.com/seanmalbert/bloom/issues/2349) [#2350](https://github.com/seanmalbert/bloom/issues/2350) [#2351](https://github.com/seanmalbert/bloom/issues/2351) [#2348](https://github.com/seanmalbert/bloom/issues/2348) [#2352](https://github.com/seanmalbert/bloom/issues/2352) [#2316](https://github.com/seanmalbert/bloom/issues/2316) [#2356](https://github.com/seanmalbert/bloom/issues/2356) [#2353](https://github.com/seanmalbert/bloom/issues/2353) [#2338](https://github.com/seanmalbert/bloom/issues/2338) [#2377](https://github.com/seanmalbert/bloom/issues/2377) [#2320](https://github.com/seanmalbert/bloom/issues/2320) [#2386](https://github.com/seanmalbert/bloom/issues/2386) [#2362](https://github.com/seanmalbert/bloom/issues/2362) [#2395](https://github.com/seanmalbert/bloom/issues/2395) [#2410](https://github.com/seanmalbert/bloom/issues/2410) [#2407](https://github.com/seanmalbert/bloom/issues/2407) [#2430](https://github.com/seanmalbert/bloom/issues/2430) [#2418](https://github.com/seanmalbert/bloom/issues/2418) [#2434](https://github.com/seanmalbert/bloom/issues/2434) [#2374](https://github.com/seanmalbert/bloom/issues/2374) [#2435](https://github.com/seanmalbert/bloom/issues/2435) [#2420](https://github.com/seanmalbert/bloom/issues/2420) [#2412](https://github.com/seanmalbert/bloom/issues/2412) [#2438](https://github.com/seanmalbert/bloom/issues/2438) [#2429](https://github.com/seanmalbert/bloom/issues/2429) [#2452](https://github.com/seanmalbert/bloom/issues/2452) [#2458](https://github.com/seanmalbert/bloom/issues/2458) [#2423](https://github.com/seanmalbert/bloom/issues/2423) [#2432](https://github.com/seanmalbert/bloom/issues/2432) [#2437](https://github.com/seanmalbert/bloom/issues/2437) [#2440](https://github.com/seanmalbert/bloom/issues/2440) [#2441](https://github.com/seanmalbert/bloom/issues/2441) [#2460](https://github.com/seanmalbert/bloom/issues/2460) [#2459](https://github.com/seanmalbert/bloom/issues/2459) [#2464](https://github.com/seanmalbert/bloom/issues/2464) [#2465](https://github.com/seanmalbert/bloom/issues/2465) [#2466](https://github.com/seanmalbert/bloom/issues/2466) [#2436](https://github.com/seanmalbert/bloom/issues/2436) [#2451](https://github.com/seanmalbert/bloom/issues/2451) [#2415](https://github.com/seanmalbert/bloom/issues/2415) [#2354](https://github.com/seanmalbert/bloom/issues/2354) [#2455](https://github.com/seanmalbert/bloom/issues/2455) [#2484](https://github.com/seanmalbert/bloom/issues/2484) [#2482](https://github.com/seanmalbert/bloom/issues/2482) [#2483](https://github.com/seanmalbert/bloom/issues/2483) [#2476](https://github.com/seanmalbert/bloom/issues/2476) [#2485](https://github.com/seanmalbert/bloom/issues/2485) [#2470](https://github.com/seanmalbert/bloom/issues/2470) [#2488](https://github.com/seanmalbert/bloom/issues/2488) [#2487](https://github.com/seanmalbert/bloom/issues/2487) [#2496](https://github.com/seanmalbert/bloom/issues/2496) [#2498](https://github.com/seanmalbert/bloom/issues/2498) [#2499](https://github.com/seanmalbert/bloom/issues/2499) [#2291](https://github.com/seanmalbert/bloom/issues/2291) [#2461](https://github.com/seanmalbert/bloom/issues/2461) [#2485](https://github.com/seanmalbert/bloom/issues/2485) [#2494](https://github.com/seanmalbert/bloom/issues/2494) [#2503](https://github.com/seanmalbert/bloom/issues/2503) [#2495](https://github.com/seanmalbert/bloom/issues/2495) [#2477](https://github.com/seanmalbert/bloom/issues/2477) [#2505](https://github.com/seanmalbert/bloom/issues/2505) [#2372](https://github.com/seanmalbert/bloom/issues/2372) [#2489](https://github.com/seanmalbert/bloom/issues/2489) [#2497](https://github.com/seanmalbert/bloom/issues/2497) [#2506](https://github.com/seanmalbert/bloom/issues/2506) [#2486](https://github.com/seanmalbert/bloom/issues/2486) + + +### BREAKING CHANGES + +* remove applicationDueTime field and consolidated into applicationDueDate + +* chore(release): version + + - @bloom-housing/backend-core@3.0.2-alpha.13 + - @bloom-housing/shared-helpers@4.0.1-alpha.21 + - @bloom-housing/partners@4.0.1-alpha.23 + - @bloom-housing/public@4.0.1-alpha.22 + - @bloom-housing/ui-components@4.0.1-alpha.21 +* consolidated all event section components in one new component, uptake will require removing the deprecated components and uptaking EventSection + +* chore(release): version + + - @bloom-housing/backend-core@3.0.2-alpha.38 + - @bloom-housing/shared-helpers@4.0.1-alpha.63 + - @bloom-housing/partners@4.0.1-alpha.67 + - @bloom-housing/public@4.0.1-alpha.66 + - @bloom-housing/ui-components@4.0.1-alpha.62 +* remove applicationDueTime field and consolidated into applicationDueDate + +* chore(release): version + + - @bloom-housing/backend-core@3.0.2-alpha.13 + - @bloom-housing/shared-helpers@4.0.1-alpha.21 + - @bloom-housing/partners@4.0.1-alpha.23 + - @bloom-housing/public@4.0.1-alpha.22 + - @bloom-housing/ui-components@4.0.1-alpha.21 +* consolidated all event section components in one new component, uptake will require removing the deprecated components and uptaking EventSection + +* chore(release): version + + - @bloom-housing/backend-core@3.0.2-alpha.38 + - @bloom-housing/shared-helpers@4.0.1-alpha.63 + - @bloom-housing/partners@4.0.1-alpha.67 + - @bloom-housing/public@4.0.1-alpha.66 + - @bloom-housing/ui-components@4.0.1-alpha.62 + + + + + +## [4.1.3-alpha.5](https://github.com/bloom-housing/bloom/compare/@bloom-housing/shared-helpers@4.1.3-alpha.4...@bloom-housing/shared-helpers@4.1.3-alpha.5) (2022-04-05) + +**Note:** Version bump only for package @bloom-housing/shared-helpers + + + + + +## [4.1.3-alpha.4](https://github.com/bloom-housing/bloom/compare/@bloom-housing/shared-helpers@4.1.3-alpha.3...@bloom-housing/shared-helpers@4.1.3-alpha.4) (2022-04-05) + +**Note:** Version bump only for package @bloom-housing/shared-helpers + + + + + +## [4.1.3-alpha.3](https://github.com/bloom-housing/bloom/compare/@bloom-housing/shared-helpers@4.1.3-alpha.2...@bloom-housing/shared-helpers@4.1.3-alpha.3) (2022-04-04) + +**Note:** Version bump only for package @bloom-housing/shared-helpers + + + + + +## [4.1.3-alpha.2](https://github.com/bloom-housing/bloom/compare/@bloom-housing/shared-helpers@4.1.3-alpha.1...@bloom-housing/shared-helpers@4.1.3-alpha.2) (2022-04-04) + +**Note:** Version bump only for package @bloom-housing/shared-helpers + + + + + +## [4.1.3-alpha.1](https://github.com/bloom-housing/bloom/compare/@bloom-housing/shared-helpers@4.1.3-alpha.0...@bloom-housing/shared-helpers@4.1.3-alpha.1) (2022-04-04) + +**Note:** Version bump only for package @bloom-housing/shared-helpers + + + + + +## [4.1.3-alpha.0](https://github.com/bloom-housing/bloom/compare/@bloom-housing/shared-helpers@4.1.2-alpha.3...@bloom-housing/shared-helpers@4.1.3-alpha.0) (2022-03-30) + +**Note:** Version bump only for package @bloom-housing/shared-helpers + + + + + +## [4.1.2](https://github.com/seanmalbert/bloom/compare/@bloom-housing/shared-helpers@4.1.1...@bloom-housing/shared-helpers@4.1.2) (2022-03-29) + +**Note:** Version bump only for package @bloom-housing/shared-helpers + + + + + +## [4.1.2-alpha.3](https://github.com/bloom-housing/bloom/compare/@bloom-housing/shared-helpers@4.1.2-alpha.2...@bloom-housing/shared-helpers@4.1.2-alpha.3) (2022-03-29) + +**Note:** Version bump only for package @bloom-housing/shared-helpers + + + + + +## [4.1.2-alpha.2](https://github.com/bloom-housing/bloom/compare/@bloom-housing/shared-helpers@4.1.2-alpha.1...@bloom-housing/shared-helpers@4.1.2-alpha.2) (2022-03-29) + +**Note:** Version bump only for package @bloom-housing/shared-helpers + + + + + +## [4.1.2-alpha.1](https://github.com/bloom-housing/bloom/compare/@bloom-housing/shared-helpers@4.1.2-alpha.0...@bloom-housing/shared-helpers@4.1.2-alpha.1) (2022-03-28) + +**Note:** Version bump only for package @bloom-housing/shared-helpers + + + + + +## [4.1.2-alpha.0](https://github.com/bloom-housing/bloom/compare/@bloom-housing/shared-helpers@4.1.1-alpha.5...@bloom-housing/shared-helpers@4.1.2-alpha.0) (2022-03-28) + + +* 2022 03 28 sync master (#2593) ([580283d](https://github.com/bloom-housing/bloom/commit/580283da22246b7d39978e7dfa08016b2c0c3757)), closes [#2593](https://github.com/bloom-housing/bloom/issues/2593) [#2037](https://github.com/bloom-housing/bloom/issues/2037) [#2095](https://github.com/bloom-housing/bloom/issues/2095) [#2162](https://github.com/bloom-housing/bloom/issues/2162) [#2293](https://github.com/bloom-housing/bloom/issues/2293) [#2295](https://github.com/bloom-housing/bloom/issues/2295) [#2296](https://github.com/bloom-housing/bloom/issues/2296) [#2294](https://github.com/bloom-housing/bloom/issues/2294) [#2277](https://github.com/bloom-housing/bloom/issues/2277) [#2299](https://github.com/bloom-housing/bloom/issues/2299) [#2292](https://github.com/bloom-housing/bloom/issues/2292) [#2308](https://github.com/bloom-housing/bloom/issues/2308) [#2239](https://github.com/bloom-housing/bloom/issues/2239) [#2311](https://github.com/bloom-housing/bloom/issues/2311) [#2230](https://github.com/bloom-housing/bloom/issues/2230) [#2302](https://github.com/bloom-housing/bloom/issues/2302) [#2288](https://github.com/bloom-housing/bloom/issues/2288) [#2317](https://github.com/bloom-housing/bloom/issues/2317) [#2386](https://github.com/bloom-housing/bloom/issues/2386) [#2215](https://github.com/bloom-housing/bloom/issues/2215) [#2303](https://github.com/bloom-housing/bloom/issues/2303) [#2410](https://github.com/bloom-housing/bloom/issues/2410) [#2430](https://github.com/bloom-housing/bloom/issues/2430) [#2439](https://github.com/bloom-housing/bloom/issues/2439) [#2196](https://github.com/bloom-housing/bloom/issues/2196) [#2238](https://github.com/bloom-housing/bloom/issues/2238) [#2226](https://github.com/bloom-housing/bloom/issues/2226) [#2230](https://github.com/bloom-housing/bloom/issues/2230) [#2243](https://github.com/bloom-housing/bloom/issues/2243) [#2195](https://github.com/bloom-housing/bloom/issues/2195) [#2215](https://github.com/bloom-housing/bloom/issues/2215) [#2266](https://github.com/bloom-housing/bloom/issues/2266) [#2188](https://github.com/bloom-housing/bloom/issues/2188) [#2270](https://github.com/bloom-housing/bloom/issues/2270) [#2188](https://github.com/bloom-housing/bloom/issues/2188) [#2213](https://github.com/bloom-housing/bloom/issues/2213) [#2234](https://github.com/bloom-housing/bloom/issues/2234) [#1901](https://github.com/bloom-housing/bloom/issues/1901) [#2260](https://github.com/bloom-housing/bloom/issues/2260) [#1927](https://github.com/bloom-housing/bloom/issues/1927) [#1927](https://github.com/bloom-housing/bloom/issues/1927) [#1927](https://github.com/bloom-housing/bloom/issues/1927) [#1927](https://github.com/bloom-housing/bloom/issues/1927) [#1927](https://github.com/bloom-housing/bloom/issues/1927) [#1927](https://github.com/bloom-housing/bloom/issues/1927) [#1927](https://github.com/bloom-housing/bloom/issues/1927) [#1927](https://github.com/bloom-housing/bloom/issues/1927) [#1927](https://github.com/bloom-housing/bloom/issues/1927) [#1927](https://github.com/bloom-housing/bloom/issues/1927) [#1927](https://github.com/bloom-housing/bloom/issues/1927) [#1927](https://github.com/bloom-housing/bloom/issues/1927) [#1927](https://github.com/bloom-housing/bloom/issues/1927) [#2280](https://github.com/bloom-housing/bloom/issues/2280) [#2253](https://github.com/bloom-housing/bloom/issues/2253) [#2276](https://github.com/bloom-housing/bloom/issues/2276) [#2282](https://github.com/bloom-housing/bloom/issues/2282) [#2262](https://github.com/bloom-housing/bloom/issues/2262) [#2278](https://github.com/bloom-housing/bloom/issues/2278) [#2293](https://github.com/bloom-housing/bloom/issues/2293) [#2295](https://github.com/bloom-housing/bloom/issues/2295) [#2296](https://github.com/bloom-housing/bloom/issues/2296) [#2294](https://github.com/bloom-housing/bloom/issues/2294) [#2277](https://github.com/bloom-housing/bloom/issues/2277) [#2290](https://github.com/bloom-housing/bloom/issues/2290) [#2299](https://github.com/bloom-housing/bloom/issues/2299) [#2292](https://github.com/bloom-housing/bloom/issues/2292) [#2303](https://github.com/bloom-housing/bloom/issues/2303) [#2305](https://github.com/bloom-housing/bloom/issues/2305) [#2306](https://github.com/bloom-housing/bloom/issues/2306) [#2308](https://github.com/bloom-housing/bloom/issues/2308) [#2190](https://github.com/bloom-housing/bloom/issues/2190) [#2239](https://github.com/bloom-housing/bloom/issues/2239) [#2311](https://github.com/bloom-housing/bloom/issues/2311) [#2302](https://github.com/bloom-housing/bloom/issues/2302) [#2301](https://github.com/bloom-housing/bloom/issues/2301) [#1927](https://github.com/bloom-housing/bloom/issues/1927) [#2313](https://github.com/bloom-housing/bloom/issues/2313) [#2289](https://github.com/bloom-housing/bloom/issues/2289) [#2279](https://github.com/bloom-housing/bloom/issues/2279) [#2288](https://github.com/bloom-housing/bloom/issues/2288) [#2317](https://github.com/bloom-housing/bloom/issues/2317) [#2319](https://github.com/bloom-housing/bloom/issues/2319) [#2108](https://github.com/bloom-housing/bloom/issues/2108) [#2326](https://github.com/bloom-housing/bloom/issues/2326) [#2349](https://github.com/bloom-housing/bloom/issues/2349) [#2350](https://github.com/bloom-housing/bloom/issues/2350) [#2351](https://github.com/bloom-housing/bloom/issues/2351) [#2348](https://github.com/bloom-housing/bloom/issues/2348) [#2352](https://github.com/bloom-housing/bloom/issues/2352) [#2316](https://github.com/bloom-housing/bloom/issues/2316) [#2356](https://github.com/bloom-housing/bloom/issues/2356) [#2353](https://github.com/bloom-housing/bloom/issues/2353) [#2338](https://github.com/bloom-housing/bloom/issues/2338) [#2377](https://github.com/bloom-housing/bloom/issues/2377) [#2320](https://github.com/bloom-housing/bloom/issues/2320) [#2386](https://github.com/bloom-housing/bloom/issues/2386) [#2362](https://github.com/bloom-housing/bloom/issues/2362) [#2395](https://github.com/bloom-housing/bloom/issues/2395) [#2410](https://github.com/bloom-housing/bloom/issues/2410) [#2407](https://github.com/bloom-housing/bloom/issues/2407) [#2430](https://github.com/bloom-housing/bloom/issues/2430) [#2418](https://github.com/bloom-housing/bloom/issues/2418) [#2434](https://github.com/bloom-housing/bloom/issues/2434) [#2374](https://github.com/bloom-housing/bloom/issues/2374) [#2435](https://github.com/bloom-housing/bloom/issues/2435) [#2420](https://github.com/bloom-housing/bloom/issues/2420) [#2412](https://github.com/bloom-housing/bloom/issues/2412) [#2434](https://github.com/bloom-housing/bloom/issues/2434) [#2458](https://github.com/bloom-housing/bloom/issues/2458) [#2460](https://github.com/bloom-housing/bloom/issues/2460) [#2465](https://github.com/bloom-housing/bloom/issues/2465) [#2436](https://github.com/bloom-housing/bloom/issues/2436) [#2484](https://github.com/bloom-housing/bloom/issues/2484) [#2482](https://github.com/bloom-housing/bloom/issues/2482) [#2483](https://github.com/bloom-housing/bloom/issues/2483) [#2488](https://github.com/bloom-housing/bloom/issues/2488) [#2485](https://github.com/bloom-housing/bloom/issues/2485) [#2519](https://github.com/bloom-housing/bloom/issues/2519) [#2506](https://github.com/bloom-housing/bloom/issues/2506) [#2534](https://github.com/bloom-housing/bloom/issues/2534) [#2544](https://github.com/bloom-housing/bloom/issues/2544) [#2550](https://github.com/bloom-housing/bloom/issues/2550) [#2288](https://github.com/bloom-housing/bloom/issues/2288) [#2317](https://github.com/bloom-housing/bloom/issues/2317) [#2319](https://github.com/bloom-housing/bloom/issues/2319) [#2108](https://github.com/bloom-housing/bloom/issues/2108) [#2326](https://github.com/bloom-housing/bloom/issues/2326) [#2349](https://github.com/bloom-housing/bloom/issues/2349) [#2350](https://github.com/bloom-housing/bloom/issues/2350) [#2351](https://github.com/bloom-housing/bloom/issues/2351) [#2348](https://github.com/bloom-housing/bloom/issues/2348) [#2352](https://github.com/bloom-housing/bloom/issues/2352) [#2316](https://github.com/bloom-housing/bloom/issues/2316) [#2356](https://github.com/bloom-housing/bloom/issues/2356) [#2353](https://github.com/bloom-housing/bloom/issues/2353) [#2338](https://github.com/bloom-housing/bloom/issues/2338) [#2377](https://github.com/bloom-housing/bloom/issues/2377) [#2320](https://github.com/bloom-housing/bloom/issues/2320) [#2386](https://github.com/bloom-housing/bloom/issues/2386) [#2362](https://github.com/bloom-housing/bloom/issues/2362) [#2395](https://github.com/bloom-housing/bloom/issues/2395) [#2410](https://github.com/bloom-housing/bloom/issues/2410) [#2407](https://github.com/bloom-housing/bloom/issues/2407) [#2430](https://github.com/bloom-housing/bloom/issues/2430) [#2418](https://github.com/bloom-housing/bloom/issues/2418) [#2434](https://github.com/bloom-housing/bloom/issues/2434) [#2374](https://github.com/bloom-housing/bloom/issues/2374) [#2435](https://github.com/bloom-housing/bloom/issues/2435) [#2420](https://github.com/bloom-housing/bloom/issues/2420) [#2412](https://github.com/bloom-housing/bloom/issues/2412) [#2438](https://github.com/bloom-housing/bloom/issues/2438) [#2429](https://github.com/bloom-housing/bloom/issues/2429) [#2452](https://github.com/bloom-housing/bloom/issues/2452) [#2458](https://github.com/bloom-housing/bloom/issues/2458) [#2423](https://github.com/bloom-housing/bloom/issues/2423) [#2432](https://github.com/bloom-housing/bloom/issues/2432) [#2437](https://github.com/bloom-housing/bloom/issues/2437) [#2440](https://github.com/bloom-housing/bloom/issues/2440) [#2441](https://github.com/bloom-housing/bloom/issues/2441) [#2460](https://github.com/bloom-housing/bloom/issues/2460) [#2459](https://github.com/bloom-housing/bloom/issues/2459) [#2464](https://github.com/bloom-housing/bloom/issues/2464) [#2465](https://github.com/bloom-housing/bloom/issues/2465) [#2466](https://github.com/bloom-housing/bloom/issues/2466) [#2436](https://github.com/bloom-housing/bloom/issues/2436) [#2451](https://github.com/bloom-housing/bloom/issues/2451) [#2415](https://github.com/bloom-housing/bloom/issues/2415) [#2354](https://github.com/bloom-housing/bloom/issues/2354) [#2455](https://github.com/bloom-housing/bloom/issues/2455) [#2484](https://github.com/bloom-housing/bloom/issues/2484) [#2482](https://github.com/bloom-housing/bloom/issues/2482) [#2483](https://github.com/bloom-housing/bloom/issues/2483) [#2476](https://github.com/bloom-housing/bloom/issues/2476) [#2485](https://github.com/bloom-housing/bloom/issues/2485) [#2470](https://github.com/bloom-housing/bloom/issues/2470) [#2488](https://github.com/bloom-housing/bloom/issues/2488) [#2487](https://github.com/bloom-housing/bloom/issues/2487) [#2496](https://github.com/bloom-housing/bloom/issues/2496) [#2498](https://github.com/bloom-housing/bloom/issues/2498) [#2499](https://github.com/bloom-housing/bloom/issues/2499) [#2291](https://github.com/bloom-housing/bloom/issues/2291) [#2461](https://github.com/bloom-housing/bloom/issues/2461) [#2485](https://github.com/bloom-housing/bloom/issues/2485) [#2494](https://github.com/bloom-housing/bloom/issues/2494) [#2503](https://github.com/bloom-housing/bloom/issues/2503) [#2495](https://github.com/bloom-housing/bloom/issues/2495) [#2477](https://github.com/bloom-housing/bloom/issues/2477) [#2505](https://github.com/bloom-housing/bloom/issues/2505) [#2372](https://github.com/bloom-housing/bloom/issues/2372) [#2489](https://github.com/bloom-housing/bloom/issues/2489) [#2497](https://github.com/bloom-housing/bloom/issues/2497) [#2506](https://github.com/bloom-housing/bloom/issues/2506) [#2486](https://github.com/bloom-housing/bloom/issues/2486) + + +### BREAKING CHANGES + +* preferences model and relationships changed + +* feat: feat(backend): extend UserUpdateDto to support email change + +picked from dev 3e1fdbd0ea91d4773973d5c485a5ba61303db90a + +* fix: 2056/user account edit fix + +picked from dev a15618c0cb548ff5b2ae913b802c9e08bb673f30 + +* refactor: 2085/adds top level catchAll exception filter + +picked from dev aeaa63d1af1fa3d11671e169cb3bd23d356fface + +* feat: feat: Change unit number field type to text + +picked from dev f54be7c7ba6aac8e00fee610dc86584b60cc212d + +* feat(backend): improve application flagged set saving efficiency + +* fix: fix: updates address order + +picked from dev 252e014dcbd2e4c305384ed552135f5a8e4e4767 + +* fix: sets programs to optoinal and updates versions + +* chore: chore(deps): bump electron from 13.1.7 to 13.3.0 + +* chore: chore(deps): bump axios from 0.21.1 to 0.21.2 + +* fix: adds programs service + +* fix: fix lisitng e2e tests + +* fix: fix member tests + +* fix: adds jurisdictionId to useSWR path + +* fix: recalculate units available on listing update + +picked form dev f1a3dbce6478b16542ed61ab20de5dfb9b797262 + +* feat: feat(backend): make use of new application confirmation codes + +picked from dev 3c45c2904818200eed4568931d4cc352fd2f449e + +* revert: revert "chore(deps): bump axios from 0.21.1 to 0.21.2 + +picked from dev 2b83bc0393afc42eed542e326d5ef75502ce119c + +* fix: app submission w/ no due date + +picked from dev 4af1f5a8448f16d347b4a65ecb85fda4d6ed71fc + +* feat: adds new preferences, reserved community type + +* feat: adds bottom border to preferences + +* feat: updates preference string + +* fix: preference cleanup for avance + +* refactor: remove applicationAddress + +picked from dev bf10632a62bf2f14922948c046ea3352ed010f4f + +* feat: refactor and add public site application flow cypress tests + +picked from dev 9ec0e8d05f9570773110754e7fdaf49254d1eab8 + +* feat: better seed data for ami-charts + +picked from dev d8b1d4d185731a589c563a32bd592d01537785f3 + +* feat: adds listing management cypress tests to partner portal + +* fix: listings management keep empty strings, remove empty objects + +picked from dev c4b1e833ec128f457015ac7ffa421ee6047083d9 + +* feat: one month rent + +picked from dev 883b0d53030e1c4d54f2f75bd5e188bb1d255f64 + +* test: view.spec.ts test + +picked from dev 324446c90138d8fac50aba445f515009b5a58bfb + +* refactor: removes jsonpath + +picked from dev deb39acc005607ce3076942b1f49590d08afc10c + +* feat: adds jurisdictions to pref seeds + +picked from dev 9e47cec3b1acfe769207ccbb33c07019cd742e33 + +* feat: new demographics sub-race questions + +picked from dev 9ab892694c1ad2fa8890b411b3b32af68ade1fc3 + +* feat: updates email confirmation for lottery + +picked from dev 1a5e824c96d8e23674c32ea92688b9f7255528d3 + +* fix: add ariaHidden to Icon component + +picked from dev c7bb86aec6fd5ad386c7ca50087d0113b14503be + +* fix: add ariaLabel prop to Button component + +picked from dev 509ddc898ba44c05e26f8ed8c777f1ba456eeee5 + +* fix: change the yes/no radio text to be more descriptive + +picked from dev 0c46054574535523d6f217bb0677bbe732b8945f + +* fix: remove alameda reference in demographics + +picked from dev 7d5991cbf6dbe0b61f2b14d265e87ce3687f743d + +* chore: release version + +picked from dev fe82f25dc349877d974ae62d228fea0354978fb7 + +* feat: ami chart jurisdictionalized + +picked from dev 0a5cbc88a9d9e3c2ff716fe0f44ca6c48f5dcc50 + +* refactor: make backend a peer dependency in ui-components + +picked from dev 952aaa14a77e0960312ff0eeee51399d1d6af9f3 + +* feat: add a phone number column to the user_accounts table + +picked from dev 2647df9ab9888a525cc8a164d091dda6482c502a + +* chore: removes application program partners + +* chore: removes application program display + +* Revert "chore: removes application program display" + +This reverts commit 14825b4a6c9cd1a7235e32074e32af18a71b5c26. + +* Revert "chore: removes application program partners" + +This reverts commit d7aa38c777972a2e21d9f816441caa27f98d3f86. + +* chore: yarn.lock and backend-swagger + +* fix: removes Duplicate identifier fieldGroupObjectToArray + +* feat: skip preferences if not on listing + +* chore(release): version + +* fix: cannot save custom mailing, dropoff, or pickup address + +* chore(release): version + +* chore: converge on one axios version, remove peer dependency + +* chore(release): version + +* feat: simplify Waitlist component and use more flexible schema + +* chore(release): version + +* fix: lottery results uploads now save + +* chore(release): version + +* feat: add SRO unit type + +* chore(release): version + +* fix: paper application submission + +* chore(release): version + +* fix: choose-language context + +* chore(release): version + +* fix: applications/view hide prefs + +* chore(release): version + +* feat: overrides fallback to english, tagalog support + +* chore(release): version + +* fix: account translations + +* chore(release): version + +* fix: units with invalid ami chart + +* chore(release): version + +* fix: remove description for the partners programs + +* fix: fix modal styles on mobile + +* fix: visual improvement to programs form display + +* fix: submission tests not running +* sign-in pages have been updated +* moved some helpers from ui-components to shared-helpers +* remove applicationDueTime field and consolidated into applicationDueDate + +* chore(release): version + + - @bloom-housing/backend-core@3.0.2-alpha.13 + - @bloom-housing/shared-helpers@4.0.1-alpha.21 + - @bloom-housing/partners@4.0.1-alpha.23 + - @bloom-housing/public@4.0.1-alpha.22 + - @bloom-housing/ui-components@4.0.1-alpha.21 +* moved some helpers from ui-components to shared-helpers +* remove applicationDueTime field and consolidated into applicationDueDate + +* chore(release): version + + - @bloom-housing/backend-core@3.0.2-alpha.13 + - @bloom-housing/shared-helpers@4.0.1-alpha.21 + - @bloom-housing/partners@4.0.1-alpha.23 + - @bloom-housing/public@4.0.1-alpha.22 + - @bloom-housing/ui-components@4.0.1-alpha.21 +* consolidated all event section components in one new component, uptake will require removing the deprecated components and uptaking EventSection + +* chore(release): version + + - @bloom-housing/backend-core@3.0.2-alpha.38 + - @bloom-housing/shared-helpers@4.0.1-alpha.63 + - @bloom-housing/partners@4.0.1-alpha.67 + - @bloom-housing/public@4.0.1-alpha.66 + - @bloom-housing/ui-components@4.0.1-alpha.62 + + + + + +## [4.1.1](https://github.com/seanmalbert/bloom/compare/@bloom-housing/shared-helpers@4.1.0...@bloom-housing/shared-helpers@4.1.1) (2022-03-28) +## [4.1.1-alpha.5](https://github.com/bloom-housing/bloom/compare/@bloom-housing/shared-helpers@4.1.1-alpha.4...@bloom-housing/shared-helpers@4.1.1-alpha.5) (2022-03-28) + +**Note:** Version bump only for package @bloom-housing/shared-helpers + + + + + +## [4.1.1-alpha.4](https://github.com/bloom-housing/bloom/compare/@bloom-housing/shared-helpers@4.1.1-alpha.3...@bloom-housing/shared-helpers@4.1.1-alpha.4) (2022-03-25) + +**Note:** Version bump only for package @bloom-housing/shared-helpers + + + + + +## [4.1.1-alpha.3](https://github.com/bloom-housing/bloom/compare/@bloom-housing/shared-helpers@4.1.1-alpha.2...@bloom-housing/shared-helpers@4.1.1-alpha.3) (2022-03-22) + +**Note:** Version bump only for package @bloom-housing/shared-helpers + + + + + +## [4.1.1-alpha.2](https://github.com/bloom-housing/bloom/compare/@bloom-housing/shared-helpers@4.1.1-alpha.1...@bloom-housing/shared-helpers@4.1.1-alpha.2) (2022-03-16) + +**Note:** Version bump only for package @bloom-housing/shared-helpers + + + + + +## [4.1.1-alpha.1](https://github.com/bloom-housing/bloom/compare/@bloom-housing/shared-helpers@4.1.1-alpha.0...@bloom-housing/shared-helpers@4.1.1-alpha.1) (2022-03-10) + +**Note:** Version bump only for package @bloom-housing/shared-helpers + + + + + +## [4.1.1-alpha.0](https://github.com/bloom-housing/bloom/compare/@bloom-housing/shared-helpers@4.0.1-alpha.80...@bloom-housing/shared-helpers@4.1.1-alpha.0) (2022-03-02) + +**Note:** Version bump only for package @bloom-housing/shared-helpers + + + + + +## [4.0.1](https://github.com/seanmalbert/bloom/compare/@bloom-housing/shared-helpers@4.0.1-alpha.80...@bloom-housing/shared-helpers@4.0.1) (2022-03-02) + +**Note:** Version bump only for package @bloom-housing/shared-helpers +# [4.1.0](https://github.com/seanmalbert/bloom/compare/@bloom-housing/shared-helpers@4.0.3...@bloom-housing/shared-helpers@4.1.0) (2022-03-02) + + +* 2022-03-01 release (#2550) ([2f2264c](https://github.com/seanmalbert/bloom/commit/2f2264cffe41d0cc1ebb79ef5c894458694d9340)), closes [#2550](https://github.com/seanmalbert/bloom/issues/2550) [#2288](https://github.com/seanmalbert/bloom/issues/2288) [#2317](https://github.com/seanmalbert/bloom/issues/2317) [#2319](https://github.com/seanmalbert/bloom/issues/2319) [#2108](https://github.com/seanmalbert/bloom/issues/2108) [#2326](https://github.com/seanmalbert/bloom/issues/2326) [#2349](https://github.com/seanmalbert/bloom/issues/2349) [#2350](https://github.com/seanmalbert/bloom/issues/2350) [#2351](https://github.com/seanmalbert/bloom/issues/2351) [#2348](https://github.com/seanmalbert/bloom/issues/2348) [#2352](https://github.com/seanmalbert/bloom/issues/2352) [#2316](https://github.com/seanmalbert/bloom/issues/2316) [#2356](https://github.com/seanmalbert/bloom/issues/2356) [#2353](https://github.com/seanmalbert/bloom/issues/2353) [#2338](https://github.com/seanmalbert/bloom/issues/2338) [#2377](https://github.com/seanmalbert/bloom/issues/2377) [#2320](https://github.com/seanmalbert/bloom/issues/2320) [#2386](https://github.com/seanmalbert/bloom/issues/2386) [#2362](https://github.com/seanmalbert/bloom/issues/2362) [#2395](https://github.com/seanmalbert/bloom/issues/2395) [#2410](https://github.com/seanmalbert/bloom/issues/2410) [#2407](https://github.com/seanmalbert/bloom/issues/2407) [#2430](https://github.com/seanmalbert/bloom/issues/2430) [#2418](https://github.com/seanmalbert/bloom/issues/2418) [#2434](https://github.com/seanmalbert/bloom/issues/2434) [#2374](https://github.com/seanmalbert/bloom/issues/2374) [#2435](https://github.com/seanmalbert/bloom/issues/2435) [#2420](https://github.com/seanmalbert/bloom/issues/2420) [#2412](https://github.com/seanmalbert/bloom/issues/2412) [#2438](https://github.com/seanmalbert/bloom/issues/2438) [#2429](https://github.com/seanmalbert/bloom/issues/2429) [#2452](https://github.com/seanmalbert/bloom/issues/2452) [#2458](https://github.com/seanmalbert/bloom/issues/2458) [#2423](https://github.com/seanmalbert/bloom/issues/2423) [#2432](https://github.com/seanmalbert/bloom/issues/2432) [#2437](https://github.com/seanmalbert/bloom/issues/2437) [#2440](https://github.com/seanmalbert/bloom/issues/2440) [#2441](https://github.com/seanmalbert/bloom/issues/2441) [#2460](https://github.com/seanmalbert/bloom/issues/2460) [#2459](https://github.com/seanmalbert/bloom/issues/2459) [#2464](https://github.com/seanmalbert/bloom/issues/2464) [#2465](https://github.com/seanmalbert/bloom/issues/2465) [#2466](https://github.com/seanmalbert/bloom/issues/2466) [#2436](https://github.com/seanmalbert/bloom/issues/2436) [#2451](https://github.com/seanmalbert/bloom/issues/2451) [#2415](https://github.com/seanmalbert/bloom/issues/2415) [#2354](https://github.com/seanmalbert/bloom/issues/2354) [#2455](https://github.com/seanmalbert/bloom/issues/2455) [#2484](https://github.com/seanmalbert/bloom/issues/2484) [#2482](https://github.com/seanmalbert/bloom/issues/2482) [#2483](https://github.com/seanmalbert/bloom/issues/2483) [#2476](https://github.com/seanmalbert/bloom/issues/2476) [#2485](https://github.com/seanmalbert/bloom/issues/2485) [#2470](https://github.com/seanmalbert/bloom/issues/2470) [#2488](https://github.com/seanmalbert/bloom/issues/2488) [#2487](https://github.com/seanmalbert/bloom/issues/2487) [#2496](https://github.com/seanmalbert/bloom/issues/2496) [#2498](https://github.com/seanmalbert/bloom/issues/2498) [#2499](https://github.com/seanmalbert/bloom/issues/2499) [#2291](https://github.com/seanmalbert/bloom/issues/2291) [#2461](https://github.com/seanmalbert/bloom/issues/2461) [#2485](https://github.com/seanmalbert/bloom/issues/2485) [#2494](https://github.com/seanmalbert/bloom/issues/2494) [#2503](https://github.com/seanmalbert/bloom/issues/2503) [#2495](https://github.com/seanmalbert/bloom/issues/2495) [#2477](https://github.com/seanmalbert/bloom/issues/2477) [#2505](https://github.com/seanmalbert/bloom/issues/2505) [#2372](https://github.com/seanmalbert/bloom/issues/2372) [#2489](https://github.com/seanmalbert/bloom/issues/2489) [#2497](https://github.com/seanmalbert/bloom/issues/2497) [#2506](https://github.com/seanmalbert/bloom/issues/2506) [#2486](https://github.com/seanmalbert/bloom/issues/2486) + + +### BREAKING CHANGES + +* moved some helpers from ui-components to shared-helpers +* remove applicationDueTime field and consolidated into applicationDueDate + +* chore(release): version + + - @bloom-housing/backend-core@3.0.2-alpha.13 + - @bloom-housing/shared-helpers@4.0.1-alpha.21 + - @bloom-housing/partners@4.0.1-alpha.23 + - @bloom-housing/public@4.0.1-alpha.22 + - @bloom-housing/ui-components@4.0.1-alpha.21 +* consolidated all event section components in one new component, uptake will require removing the deprecated components and uptaking EventSection + +* chore(release): version + + - @bloom-housing/backend-core@3.0.2-alpha.38 + - @bloom-housing/shared-helpers@4.0.1-alpha.63 + - @bloom-housing/partners@4.0.1-alpha.67 + - @bloom-housing/public@4.0.1-alpha.66 + - @bloom-housing/ui-components@4.0.1-alpha.62 + + + + + +## [4.0.1-alpha.80](https://github.com/bloom-housing/bloom/compare/@bloom-housing/shared-helpers@4.0.1-alpha.79...@bloom-housing/shared-helpers@4.0.1-alpha.80) (2022-02-28) + + +### Features + +* updates to mfa styling ([#2532](https://github.com/bloom-housing/bloom/issues/2532)) ([7654efc](https://github.com/bloom-housing/bloom/commit/7654efc8a7c5cba0f7436fda62b886f646fe8a03)) + + + + + +## [4.0.1-alpha.79](https://github.com/bloom-housing/bloom/compare/@bloom-housing/shared-helpers@4.0.1-alpha.78...@bloom-housing/shared-helpers@4.0.1-alpha.79) (2022-02-28) + +**Note:** Version bump only for package @bloom-housing/shared-helpers + + + + + +## [4.0.1-alpha.78](https://github.com/bloom-housing/bloom/compare/@bloom-housing/shared-helpers@4.0.1-alpha.77...@bloom-housing/shared-helpers@4.0.1-alpha.78) (2022-02-26) + + +### Features + +* adds gtm tracking to rest of pages ([#2545](https://github.com/bloom-housing/bloom/issues/2545)) ([1c96f71](https://github.com/bloom-housing/bloom/commit/1c96f7101017aefd8bca70731265f6efb1ab5cf0)) + + + + + +## [4.0.3](https://github.com/seanmalbert/bloom/compare/@bloom-housing/shared-helpers@4.0.2...@bloom-housing/shared-helpers@4.0.3) (2022-02-25) + + + + + +## [4.0.1-alpha.77](https://github.com/bloom-housing/bloom/compare/@bloom-housing/shared-helpers@4.0.1-alpha.76...@bloom-housing/shared-helpers@4.0.1-alpha.77) (2022-02-25) + +**Note:** Version bump only for package @bloom-housing/shared-helpers + + + + + +## [4.0.1-alpha.76](https://github.com/bloom-housing/bloom/compare/@bloom-housing/shared-helpers@4.0.1-alpha.75...@bloom-housing/shared-helpers@4.0.1-alpha.76) (2022-02-25) + +**Note:** Version bump only for package @bloom-housing/shared-helpers + + + + + +## [4.0.1-alpha.75](https://github.com/bloom-housing/bloom/compare/@bloom-housing/shared-helpers@4.0.1-alpha.74...@bloom-housing/shared-helpers@4.0.1-alpha.75) (2022-02-22) + +**Note:** Version bump only for package @bloom-housing/shared-helpers + + + + + +## [4.0.1-alpha.74](https://github.com/bloom-housing/bloom/compare/@bloom-housing/shared-helpers@4.0.1-alpha.73...@bloom-housing/shared-helpers@4.0.1-alpha.74) (2022-02-18) + +**Note:** Version bump only for package @bloom-housing/shared-helpers + + + + + +## [4.0.1-alpha.73](https://github.com/bloom-housing/bloom/compare/@bloom-housing/shared-helpers@4.0.1-alpha.72...@bloom-housing/shared-helpers@4.0.1-alpha.73) (2022-02-17) + +**Note:** Version bump only for package @bloom-housing/shared-helpers + + + + + +## [4.0.1-alpha.72](https://github.com/bloom-housing/bloom/compare/@bloom-housing/shared-helpers@4.0.1-alpha.71...@bloom-housing/shared-helpers@4.0.1-alpha.72) (2022-02-17) + +**Note:** Version bump only for package @bloom-housing/shared-helpers + + + + + +## [4.0.1-alpha.71](https://github.com/bloom-housing/bloom/compare/@bloom-housing/shared-helpers@4.0.1-alpha.70...@bloom-housing/shared-helpers@4.0.1-alpha.71) (2022-02-17) + +**Note:** Version bump only for package @bloom-housing/shared-helpers + + + + + +## [4.0.1-alpha.70](https://github.com/bloom-housing/bloom/compare/@bloom-housing/shared-helpers@4.0.1-alpha.69...@bloom-housing/shared-helpers@4.0.1-alpha.70) (2022-02-16) + +**Note:** Version bump only for package @bloom-housing/shared-helpers + + + + + +## [4.0.1-alpha.69](https://github.com/bloom-housing/bloom/compare/@bloom-housing/shared-helpers@4.0.1-alpha.68...@bloom-housing/shared-helpers@4.0.1-alpha.69) (2022-02-16) + +**Note:** Version bump only for package @bloom-housing/shared-helpers + + + + + +## [4.0.1-alpha.68](https://github.com/bloom-housing/bloom/compare/@bloom-housing/shared-helpers@4.0.1-alpha.67...@bloom-housing/shared-helpers@4.0.1-alpha.68) (2022-02-16) + +**Note:** Version bump only for package @bloom-housing/shared-helpers + + + + + +## [4.0.1-alpha.67](https://github.com/bloom-housing/bloom/compare/@bloom-housing/shared-helpers@4.0.1-alpha.66...@bloom-housing/shared-helpers@4.0.1-alpha.67) (2022-02-16) + +**Note:** Version bump only for package @bloom-housing/shared-helpers + + + + + +## [4.0.1-alpha.66](https://github.com/bloom-housing/bloom/compare/@bloom-housing/shared-helpers@4.0.1-alpha.65...@bloom-housing/shared-helpers@4.0.1-alpha.66) (2022-02-16) + +**Note:** Version bump only for package @bloom-housing/shared-helpers + + + + + +## [4.0.1-alpha.65](https://github.com/bloom-housing/bloom/compare/@bloom-housing/shared-helpers@4.0.1-alpha.64...@bloom-housing/shared-helpers@4.0.1-alpha.65) (2022-02-16) + +**Note:** Version bump only for package @bloom-housing/shared-helpers + + + + + +## [4.0.1-alpha.64](https://github.com/bloom-housing/bloom/compare/@bloom-housing/shared-helpers@4.0.1-alpha.63...@bloom-housing/shared-helpers@4.0.1-alpha.64) (2022-02-15) + + +### Features + +* **backend:** make listing image an array ([#2477](https://github.com/bloom-housing/bloom/issues/2477)) ([cab9800](https://github.com/bloom-housing/bloom/commit/cab98003e640c880be2218fa42321eadeec35e9c)) + + + + + +## [4.0.1-alpha.63](https://github.com/bloom-housing/bloom/compare/@bloom-housing/shared-helpers@4.0.1-alpha.62...@bloom-housing/shared-helpers@4.0.1-alpha.63) (2022-02-15) + + +### Code Refactoring + +* remove backend dependencies from events components, consolidate ([#2495](https://github.com/bloom-housing/bloom/issues/2495)) ([d884689](https://github.com/bloom-housing/bloom/commit/d88468965bc67c74b8b3eaced20c77472e90331f)) + + +### BREAKING CHANGES + +* consolidated all event section components in one new component, uptake will require removing the deprecated components and uptaking EventSection + + + + + +## [4.0.1-alpha.62](https://github.com/bloom-housing/bloom/compare/@bloom-housing/shared-helpers@4.0.1-alpha.61...@bloom-housing/shared-helpers@4.0.1-alpha.62) (2022-02-15) + +**Note:** Version bump only for package @bloom-housing/shared-helpers + + + + + +## [4.0.1-alpha.61](https://github.com/bloom-housing/bloom/compare/@bloom-housing/shared-helpers@4.0.1-alpha.60...@bloom-housing/shared-helpers@4.0.1-alpha.61) (2022-02-15) + +**Note:** Version bump only for package @bloom-housing/shared-helpers + + + + + +## [4.0.1-alpha.60](https://github.com/bloom-housing/bloom/compare/@bloom-housing/shared-helpers@4.0.1-alpha.59...@bloom-housing/shared-helpers@4.0.1-alpha.60) (2022-02-15) + + +### Features + +* **backend:** add partners portal users multi factor authentication ([#2291](https://github.com/bloom-housing/bloom/issues/2291)) ([5b10098](https://github.com/bloom-housing/bloom/commit/5b10098d8668f9f42c60e90236db16d6cc517793)), closes [#2461](https://github.com/bloom-housing/bloom/issues/2461) [#2485](https://github.com/bloom-housing/bloom/issues/2485) + + + + + +## [4.0.1-alpha.59](https://github.com/bloom-housing/bloom/compare/@bloom-housing/shared-helpers@4.0.1-alpha.58...@bloom-housing/shared-helpers@4.0.1-alpha.59) (2022-02-14) + +**Note:** Version bump only for package @bloom-housing/shared-helpers + + + + + +## [4.0.1-alpha.58](https://github.com/bloom-housing/bloom/compare/@bloom-housing/shared-helpers@4.0.1-alpha.57...@bloom-housing/shared-helpers@4.0.1-alpha.58) (2022-02-14) + +**Note:** Version bump only for package @bloom-housing/shared-helpers + + + + + +## [4.0.1-alpha.57](https://github.com/bloom-housing/bloom/compare/@bloom-housing/shared-helpers@4.0.1-alpha.56...@bloom-housing/shared-helpers@4.0.1-alpha.57) (2022-02-12) + +**Note:** Version bump only for package @bloom-housing/shared-helpers + + + + + +## [4.0.1-alpha.56](https://github.com/bloom-housing/bloom/compare/@bloom-housing/shared-helpers@4.0.1-alpha.55...@bloom-housing/shared-helpers@4.0.1-alpha.56) (2022-02-10) + +**Note:** Version bump only for package @bloom-housing/shared-helpers + + + + + +## [4.0.1-alpha.55](https://github.com/bloom-housing/bloom/compare/@bloom-housing/shared-helpers@4.0.1-alpha.54...@bloom-housing/shared-helpers@4.0.1-alpha.55) (2022-02-10) + +**Note:** Version bump only for package @bloom-housing/shared-helpers + + + + + +## [4.0.1-alpha.54](https://github.com/bloom-housing/bloom/compare/@bloom-housing/shared-helpers@4.0.1-alpha.53...@bloom-housing/shared-helpers@4.0.1-alpha.54) (2022-02-10) + +**Note:** Version bump only for package @bloom-housing/shared-helpers + + + + + +## [4.0.2](https://github.com/seanmalbert/bloom/compare/@bloom-housing/shared-helpers@4.0.1...@bloom-housing/shared-helpers@4.0.2) (2022-02-09) + + + + + +## [4.0.1-alpha.53](https://github.com/bloom-housing/bloom/compare/@bloom-housing/shared-helpers@4.0.1-alpha.52...@bloom-housing/shared-helpers@4.0.1-alpha.53) (2022-02-10) + +**Note:** Version bump only for package @bloom-housing/shared-helpers + + + + + +## [4.0.1-alpha.52](https://github.com/bloom-housing/bloom/compare/@bloom-housing/shared-helpers@4.0.1-alpha.51...@bloom-housing/shared-helpers@4.0.1-alpha.52) (2022-02-09) + +**Note:** Version bump only for package @bloom-housing/shared-helpers + + + + + +## [4.0.1-alpha.51](https://github.com/bloom-housing/bloom/compare/@bloom-housing/shared-helpers@4.0.1-alpha.50...@bloom-housing/shared-helpers@4.0.1-alpha.51) (2022-02-09) + +**Note:** Version bump only for package @bloom-housing/shared-helpers + + + + + +## [4.0.1-alpha.50](https://github.com/bloom-housing/bloom/compare/@bloom-housing/shared-helpers@4.0.1-alpha.49...@bloom-housing/shared-helpers@4.0.1-alpha.50) (2022-02-09) + +**Note:** Version bump only for package @bloom-housing/shared-helpers + + + + + +## [4.0.1-alpha.49](https://github.com/bloom-housing/bloom/compare/@bloom-housing/shared-helpers@4.0.1-alpha.48...@bloom-housing/shared-helpers@4.0.1-alpha.49) (2022-02-09) + +**Note:** Version bump only for package @bloom-housing/shared-helpers + + + + + +## [4.0.1-alpha.48](https://github.com/bloom-housing/bloom/compare/@bloom-housing/shared-helpers@4.0.1-alpha.47...@bloom-housing/shared-helpers@4.0.1-alpha.48) (2022-02-09) + +**Note:** Version bump only for package @bloom-housing/shared-helpers + + + + + +## [4.0.1-alpha.47](https://github.com/bloom-housing/bloom/compare/@bloom-housing/shared-helpers@4.0.1-alpha.46...@bloom-housing/shared-helpers@4.0.1-alpha.47) (2022-02-08) + +**Note:** Version bump only for package @bloom-housing/shared-helpers + + + + + +## [4.0.1-alpha.46](https://github.com/bloom-housing/bloom/compare/@bloom-housing/shared-helpers@4.0.1-alpha.45...@bloom-housing/shared-helpers@4.0.1-alpha.46) (2022-02-07) + +**Note:** Version bump only for package @bloom-housing/shared-helpers + + + + + +## [4.0.1](https://github.com/seanmalbert/bloom/compare/@bloom-housing/shared-helpers@3.0.1...@bloom-housing/shared-helpers@4.0.1) (2022-02-03) + + +* 2022-01-27 release (#2439) ([860f6af](https://github.com/seanmalbert/bloom/commit/860f6af6204903e4dcddf671d7ba54f3ec04f121)), closes [#2439](https://github.com/seanmalbert/bloom/issues/2439) [#2196](https://github.com/seanmalbert/bloom/issues/2196) [#2238](https://github.com/seanmalbert/bloom/issues/2238) [#2226](https://github.com/seanmalbert/bloom/issues/2226) [#2230](https://github.com/seanmalbert/bloom/issues/2230) [#2243](https://github.com/seanmalbert/bloom/issues/2243) [#2195](https://github.com/seanmalbert/bloom/issues/2195) [#2215](https://github.com/seanmalbert/bloom/issues/2215) [#2266](https://github.com/seanmalbert/bloom/issues/2266) [#2188](https://github.com/seanmalbert/bloom/issues/2188) [#2270](https://github.com/seanmalbert/bloom/issues/2270) [#2188](https://github.com/seanmalbert/bloom/issues/2188) [#2213](https://github.com/seanmalbert/bloom/issues/2213) [#2234](https://github.com/seanmalbert/bloom/issues/2234) [#1901](https://github.com/seanmalbert/bloom/issues/1901) [#2260](https://github.com/seanmalbert/bloom/issues/2260) [#1927](https://github.com/seanmalbert/bloom/issues/1927) [#1927](https://github.com/seanmalbert/bloom/issues/1927) [#1927](https://github.com/seanmalbert/bloom/issues/1927) [#1927](https://github.com/seanmalbert/bloom/issues/1927) [#1927](https://github.com/seanmalbert/bloom/issues/1927) [#1927](https://github.com/seanmalbert/bloom/issues/1927) [#1927](https://github.com/seanmalbert/bloom/issues/1927) [#1927](https://github.com/seanmalbert/bloom/issues/1927) [#1927](https://github.com/seanmalbert/bloom/issues/1927) [#1927](https://github.com/seanmalbert/bloom/issues/1927) [#1927](https://github.com/seanmalbert/bloom/issues/1927) [#1927](https://github.com/seanmalbert/bloom/issues/1927) [#1927](https://github.com/seanmalbert/bloom/issues/1927) [#2280](https://github.com/seanmalbert/bloom/issues/2280) [#2253](https://github.com/seanmalbert/bloom/issues/2253) [#2276](https://github.com/seanmalbert/bloom/issues/2276) [#2282](https://github.com/seanmalbert/bloom/issues/2282) [#2262](https://github.com/seanmalbert/bloom/issues/2262) [#2278](https://github.com/seanmalbert/bloom/issues/2278) [#2293](https://github.com/seanmalbert/bloom/issues/2293) [#2295](https://github.com/seanmalbert/bloom/issues/2295) [#2296](https://github.com/seanmalbert/bloom/issues/2296) [#2294](https://github.com/seanmalbert/bloom/issues/2294) [#2277](https://github.com/seanmalbert/bloom/issues/2277) [#2290](https://github.com/seanmalbert/bloom/issues/2290) [#2299](https://github.com/seanmalbert/bloom/issues/2299) [#2292](https://github.com/seanmalbert/bloom/issues/2292) [#2303](https://github.com/seanmalbert/bloom/issues/2303) [#2305](https://github.com/seanmalbert/bloom/issues/2305) [#2306](https://github.com/seanmalbert/bloom/issues/2306) [#2308](https://github.com/seanmalbert/bloom/issues/2308) [#2190](https://github.com/seanmalbert/bloom/issues/2190) [#2239](https://github.com/seanmalbert/bloom/issues/2239) [#2311](https://github.com/seanmalbert/bloom/issues/2311) [#2302](https://github.com/seanmalbert/bloom/issues/2302) [#2301](https://github.com/seanmalbert/bloom/issues/2301) [#1927](https://github.com/seanmalbert/bloom/issues/1927) [#2313](https://github.com/seanmalbert/bloom/issues/2313) [#2289](https://github.com/seanmalbert/bloom/issues/2289) [#2279](https://github.com/seanmalbert/bloom/issues/2279) [#2288](https://github.com/seanmalbert/bloom/issues/2288) [#2317](https://github.com/seanmalbert/bloom/issues/2317) [#2319](https://github.com/seanmalbert/bloom/issues/2319) [#2108](https://github.com/seanmalbert/bloom/issues/2108) [#2326](https://github.com/seanmalbert/bloom/issues/2326) [#2349](https://github.com/seanmalbert/bloom/issues/2349) [#2350](https://github.com/seanmalbert/bloom/issues/2350) [#2351](https://github.com/seanmalbert/bloom/issues/2351) [#2348](https://github.com/seanmalbert/bloom/issues/2348) [#2352](https://github.com/seanmalbert/bloom/issues/2352) [#2316](https://github.com/seanmalbert/bloom/issues/2316) [#2356](https://github.com/seanmalbert/bloom/issues/2356) [#2353](https://github.com/seanmalbert/bloom/issues/2353) [#2338](https://github.com/seanmalbert/bloom/issues/2338) [#2377](https://github.com/seanmalbert/bloom/issues/2377) [#2320](https://github.com/seanmalbert/bloom/issues/2320) [#2386](https://github.com/seanmalbert/bloom/issues/2386) [#2362](https://github.com/seanmalbert/bloom/issues/2362) [#2395](https://github.com/seanmalbert/bloom/issues/2395) [#2410](https://github.com/seanmalbert/bloom/issues/2410) [#2407](https://github.com/seanmalbert/bloom/issues/2407) [#2430](https://github.com/seanmalbert/bloom/issues/2430) [#2418](https://github.com/seanmalbert/bloom/issues/2418) [#2434](https://github.com/seanmalbert/bloom/issues/2434) [#2374](https://github.com/seanmalbert/bloom/issues/2374) [#2435](https://github.com/seanmalbert/bloom/issues/2435) [#2420](https://github.com/seanmalbert/bloom/issues/2420) [#2412](https://github.com/seanmalbert/bloom/issues/2412) [#2434](https://github.com/seanmalbert/bloom/issues/2434) + +### BREAKING CHANGES + +* sign-in pages have been updated +* moved some helpers from ui-components to shared-helpers +* remove applicationDueTime field and consolidated into applicationDueDate + +* chore(release): version + + - @bloom-housing/backend-core@3.0.2-alpha.13 + - @bloom-housing/shared-helpers@4.0.1-alpha.21 + - @bloom-housing/partners@4.0.1-alpha.23 + - @bloom-housing/public@4.0.1-alpha.22 + - @bloom-housing/ui-components@4.0.1-alpha.21 + + + + + +## [4.0.1-alpha.45](https://github.com/bloom-housing/bloom/compare/@bloom-housing/shared-helpers@4.0.1-alpha.44...@bloom-housing/shared-helpers@4.0.1-alpha.45) (2022-02-02) + +**Note:** Version bump only for package @bloom-housing/shared-helpers + + + + + +## [4.0.1-alpha.44](https://github.com/bloom-housing/bloom/compare/@bloom-housing/shared-helpers@4.0.1-alpha.43...@bloom-housing/shared-helpers@4.0.1-alpha.44) (2022-02-02) + +**Note:** Version bump only for package @bloom-housing/shared-helpers + + + + + +## [4.0.1-alpha.43](https://github.com/bloom-housing/bloom/compare/@bloom-housing/shared-helpers@4.0.1-alpha.42...@bloom-housing/shared-helpers@4.0.1-alpha.43) (2022-02-02) + +**Note:** Version bump only for package @bloom-housing/shared-helpers + + + + + +## [4.0.1-alpha.42](https://github.com/bloom-housing/bloom/compare/@bloom-housing/shared-helpers@4.0.1-alpha.41...@bloom-housing/shared-helpers@4.0.1-alpha.42) (2022-02-02) + +**Note:** Version bump only for package @bloom-housing/shared-helpers + + + + + +## [4.0.1-alpha.41](https://github.com/bloom-housing/bloom/compare/@bloom-housing/shared-helpers@4.0.1-alpha.40...@bloom-housing/shared-helpers@4.0.1-alpha.41) (2022-02-01) + +**Note:** Version bump only for package @bloom-housing/shared-helpers + + + + + +## [4.0.1-alpha.40](https://github.com/bloom-housing/bloom/compare/@bloom-housing/shared-helpers@4.0.1-alpha.39...@bloom-housing/shared-helpers@4.0.1-alpha.40) (2022-02-01) + +**Note:** Version bump only for package @bloom-housing/shared-helpers + + + + + +## [4.0.1-alpha.39](https://github.com/bloom-housing/bloom/compare/@bloom-housing/shared-helpers@4.0.1-alpha.38...@bloom-housing/shared-helpers@4.0.1-alpha.39) (2022-02-01) + +**Note:** Version bump only for package @bloom-housing/shared-helpers + + + + + +## [4.0.1-alpha.38](https://github.com/bloom-housing/bloom/compare/@bloom-housing/shared-helpers@4.0.1-alpha.37...@bloom-housing/shared-helpers@4.0.1-alpha.38) (2022-02-01) + +**Note:** Version bump only for package @bloom-housing/shared-helpers + + + + + +## [4.0.1-alpha.37](https://github.com/bloom-housing/bloom/compare/@bloom-housing/shared-helpers@4.0.1-alpha.36...@bloom-housing/shared-helpers@4.0.1-alpha.37) (2022-02-01) + +**Note:** Version bump only for package @bloom-housing/shared-helpers + + + + + +## [4.0.1-alpha.36](https://github.com/bloom-housing/bloom/compare/@bloom-housing/shared-helpers@4.0.1-alpha.35...@bloom-housing/shared-helpers@4.0.1-alpha.36) (2022-02-01) + +**Note:** Version bump only for package @bloom-housing/shared-helpers + + + + + +## [4.0.1-alpha.35](https://github.com/bloom-housing/bloom/compare/@bloom-housing/shared-helpers@4.0.1-alpha.34...@bloom-housing/shared-helpers@4.0.1-alpha.35) (2022-02-01) + +**Note:** Version bump only for package @bloom-housing/shared-helpers + + + + + +## [4.0.1-alpha.34](https://github.com/bloom-housing/bloom/compare/@bloom-housing/shared-helpers@4.0.1-alpha.33...@bloom-housing/shared-helpers@4.0.1-alpha.34) (2022-02-01) + +**Note:** Version bump only for package @bloom-housing/shared-helpers + + + + + +## [4.0.1-alpha.33](https://github.com/bloom-housing/bloom/compare/@bloom-housing/shared-helpers@4.0.1-alpha.32...@bloom-housing/shared-helpers@4.0.1-alpha.33) (2022-02-01) + +**Note:** Version bump only for package @bloom-housing/shared-helpers + + + + + +## [4.0.1-alpha.32](https://github.com/bloom-housing/bloom/compare/@bloom-housing/shared-helpers@4.0.1-alpha.31...@bloom-housing/shared-helpers@4.0.1-alpha.32) (2022-01-31) + +**Note:** Version bump only for package @bloom-housing/shared-helpers + + + + + +## [4.0.1-alpha.31](https://github.com/bloom-housing/bloom/compare/@bloom-housing/shared-helpers@4.0.1-alpha.30...@bloom-housing/shared-helpers@4.0.1-alpha.31) (2022-01-31) + +**Note:** Version bump only for package @bloom-housing/shared-helpers + + + + + +## [4.0.1-alpha.30](https://github.com/bloom-housing/bloom/compare/@bloom-housing/shared-helpers@4.0.1-alpha.29...@bloom-housing/shared-helpers@4.0.1-alpha.30) (2022-01-27) + + +### Features + +* outdated password messaging updates ([b14e19d](https://github.com/bloom-housing/bloom/commit/b14e19d43099af2ba721d8aaaeeb2be886d05111)) + + + + + +## [4.0.1-alpha.29](https://github.com/bloom-housing/bloom/compare/@bloom-housing/shared-helpers@4.0.1-alpha.28...@bloom-housing/shared-helpers@4.0.1-alpha.29) (2022-01-26) + +**Note:** Version bump only for package @bloom-housing/shared-helpers + + + + + +## [4.0.1-alpha.28](https://github.com/bloom-housing/bloom/compare/@bloom-housing/shared-helpers@4.0.1-alpha.27...@bloom-housing/shared-helpers@4.0.1-alpha.28) (2022-01-26) + +**Note:** Version bump only for package @bloom-housing/shared-helpers + + + + + +## [4.0.1-alpha.27](https://github.com/bloom-housing/bloom/compare/@bloom-housing/shared-helpers@4.0.1-alpha.26...@bloom-housing/shared-helpers@4.0.1-alpha.27) (2022-01-24) + +**Note:** Version bump only for package @bloom-housing/shared-helpers + + + + + +## [4.0.1-alpha.26](https://github.com/bloom-housing/bloom/compare/@bloom-housing/shared-helpers@4.0.1-alpha.25...@bloom-housing/shared-helpers@4.0.1-alpha.26) (2022-01-24) + +**Note:** Version bump only for package @bloom-housing/shared-helpers + + + + + +## [4.0.1-alpha.25](https://github.com/bloom-housing/bloom/compare/@bloom-housing/shared-helpers@4.0.1-alpha.24...@bloom-housing/shared-helpers@4.0.1-alpha.25) (2022-01-21) + + +### Bug Fixes + +* user status enum to camel case; gtm types ([fbb8004](https://github.com/bloom-housing/bloom/commit/fbb800496fa1c5f37d3d7738acf28755dd66f1dd)) + + +### Features + +* adds event logging to most of the pages ([dc88c0a](https://github.com/bloom-housing/bloom/commit/dc88c0a8b6be317cd624921b868bb17e77e31f11)) +* updates for gtm ([0251cd3](https://github.com/bloom-housing/bloom/commit/0251cd3d73be19d60c148aae01d12ab35f29bc85)) +* updates for gtm ([13578bb](https://github.com/bloom-housing/bloom/commit/13578bb864ea1b1918d5908982cb3095756811c7)) + + + + + +## [4.0.1-alpha.24](https://github.com/bloom-housing/bloom/compare/@bloom-housing/shared-helpers@4.0.1-alpha.23...@bloom-housing/shared-helpers@4.0.1-alpha.24) (2022-01-20) + +**Note:** Version bump only for package @bloom-housing/shared-helpers + + + + + +## [4.0.1-alpha.23](https://github.com/bloom-housing/bloom/compare/@bloom-housing/shared-helpers@4.0.1-alpha.22...@bloom-housing/shared-helpers@4.0.1-alpha.23) (2022-01-14) + +**Note:** Version bump only for package @bloom-housing/shared-helpers + + + + + +## [3.0.1](https://github.com/seanmalbert/bloom/compare/@bloom-housing/shared-helpers@3.0.1-alpha.20...@bloom-housing/shared-helpers@3.0.1) (2022-01-13) + + + +### Bug Fixes + +* versioning issues ([#2311](https://github.com/seanmalbert/bloom/issues/2311)) ([c274a29](https://github.com/seanmalbert/bloom/commit/c274a2985061b389c2cae6386137a4caacd7f7c0)) + + + +### Features + +* add SRO unit type ([a4c1403](https://github.com/seanmalbert/bloom/commit/a4c140350a84a5bacfa65fb6714aa594e406945d)) + + +### Reverts + +* Revert "chore(release): version" ([47a2c67](https://github.com/seanmalbert/bloom/commit/47a2c67af5c7c41f360fafc6c5386476866ea403)) +* Revert "chore: removes application program partners" ([91e22d8](https://github.com/seanmalbert/bloom/commit/91e22d891104e8d4fc024d709a6a14cec1400733)) +* Revert "chore: removes application program display" ([740cf00](https://github.com/seanmalbert/bloom/commit/740cf00dc3a729eed037d56a8dfc5988decd2651)) + + + + + +## [4.0.1-alpha.22](https://github.com/bloom-housing/bloom/compare/@bloom-housing/shared-helpers@4.0.1-alpha.21...@bloom-housing/shared-helpers@4.0.1-alpha.22) (2022-01-13) + +**Note:** Version bump only for package @bloom-housing/shared-helpers + + + + + +## [4.0.1-alpha.21](https://github.com/bloom-housing/bloom/compare/@bloom-housing/shared-helpers@4.0.1-alpha.20...@bloom-housing/shared-helpers@4.0.1-alpha.21) (2022-01-13) + +**Note:** Version bump only for package @bloom-housing/shared-helpers + + + + + +## [4.0.1-alpha.20](https://github.com/bloom-housing/bloom/compare/@bloom-housing/shared-helpers@4.0.1-alpha.19...@bloom-housing/shared-helpers@4.0.1-alpha.20) (2022-01-13) + +**Note:** Version bump only for package @bloom-housing/shared-helpers + + + + + +## [4.0.1-alpha.19](https://github.com/bloom-housing/bloom/compare/@bloom-housing/shared-helpers@4.0.1-alpha.18...@bloom-housing/shared-helpers@4.0.1-alpha.19) (2022-01-11) + +**Note:** Version bump only for package @bloom-housing/shared-helpers + + + + + +## [4.0.1-alpha.18](https://github.com/bloom-housing/bloom/compare/@bloom-housing/shared-helpers@4.0.1-alpha.17...@bloom-housing/shared-helpers@4.0.1-alpha.18) (2022-01-08) + +**Note:** Version bump only for package @bloom-housing/shared-helpers + + + + + +## [4.0.1-alpha.17](https://github.com/bloom-housing/bloom/compare/@bloom-housing/shared-helpers@4.0.1-alpha.16...@bloom-housing/shared-helpers@4.0.1-alpha.17) (2022-01-07) + +**Note:** Version bump only for package @bloom-housing/shared-helpers + + + + + +## [4.0.1-alpha.16](https://github.com/bloom-housing/bloom/compare/@bloom-housing/shared-helpers@4.0.1-alpha.15...@bloom-housing/shared-helpers@4.0.1-alpha.16) (2022-01-07) + +**Note:** Version bump only for package @bloom-housing/shared-helpers + + + + + +## [4.0.1-alpha.15](https://github.com/bloom-housing/bloom/compare/@bloom-housing/shared-helpers@4.0.1-alpha.14...@bloom-housing/shared-helpers@4.0.1-alpha.15) (2022-01-07) + +**Note:** Version bump only for package @bloom-housing/shared-helpers + + + + + +## [4.0.1-alpha.14](https://github.com/bloom-housing/bloom/compare/@bloom-housing/shared-helpers@4.0.1-alpha.13...@bloom-housing/shared-helpers@4.0.1-alpha.14) (2022-01-04) + +**Note:** Version bump only for package @bloom-housing/shared-helpers + + + + + +## [4.0.1-alpha.13](https://github.com/bloom-housing/bloom/compare/@bloom-housing/shared-helpers@4.0.1-alpha.12...@bloom-housing/shared-helpers@4.0.1-alpha.13) (2022-01-04) + +**Note:** Version bump only for package @bloom-housing/shared-helpers + + + + + +## [4.0.1-alpha.12](https://github.com/bloom-housing/bloom/compare/@bloom-housing/shared-helpers@4.0.1-alpha.11...@bloom-housing/shared-helpers@4.0.1-alpha.12) (2022-01-04) + +**Note:** Version bump only for package @bloom-housing/shared-helpers + + + + + +## [4.0.1-alpha.11](https://github.com/bloom-housing/bloom/compare/@bloom-housing/shared-helpers@4.0.1-alpha.10...@bloom-housing/shared-helpers@4.0.1-alpha.11) (2022-01-03) + +**Note:** Version bump only for package @bloom-housing/shared-helpers + + + + + +## [4.0.1-alpha.10](https://github.com/bloom-housing/bloom/compare/@bloom-housing/shared-helpers@4.0.1-alpha.9...@bloom-housing/shared-helpers@4.0.1-alpha.10) (2022-01-03) + +**Note:** Version bump only for package @bloom-housing/shared-helpers + + + + + +## [4.0.1-alpha.9](https://github.com/bloom-housing/bloom/compare/@bloom-housing/shared-helpers@4.0.1-alpha.8...@bloom-housing/shared-helpers@4.0.1-alpha.9) (2022-01-03) + +**Note:** Version bump only for package @bloom-housing/shared-helpers + + + + + +## [4.0.1-alpha.8](https://github.com/bloom-housing/bloom/compare/@bloom-housing/shared-helpers@4.0.1-alpha.7...@bloom-housing/shared-helpers@4.0.1-alpha.8) (2022-01-03) + +**Note:** Version bump only for package @bloom-housing/shared-helpers + + + + + +## [4.0.1-alpha.7](https://github.com/bloom-housing/bloom/compare/@bloom-housing/shared-helpers@4.0.0...@bloom-housing/shared-helpers@4.0.1-alpha.7) (2022-01-03) + + +### Bug Fixes + +* bump version ([#2349](https://github.com/bloom-housing/bloom/issues/2349)) ([b9e3ba1](https://github.com/bloom-housing/bloom/commit/b9e3ba10aebd6534090f8be231a9ea77b3c929b6)) +* bump version ([#2350](https://github.com/bloom-housing/bloom/issues/2350)) ([05863f5](https://github.com/bloom-housing/bloom/commit/05863f55f3939bea4387bd7cf4eb1f34df106124)) + + +* 2227/lock login attempts frontend (#2260) ([281ea43](https://github.com/bloom-housing/bloom/commit/281ea435e618a73a73f233a7a494f961fbac8fa2)), closes [#2260](https://github.com/bloom-housing/bloom/issues/2260) [#1927](https://github.com/bloom-housing/bloom/issues/1927) [#1927](https://github.com/bloom-housing/bloom/issues/1927) [#1927](https://github.com/bloom-housing/bloom/issues/1927) [#1927](https://github.com/bloom-housing/bloom/issues/1927) [#1927](https://github.com/bloom-housing/bloom/issues/1927) [#1927](https://github.com/bloom-housing/bloom/issues/1927) [#1927](https://github.com/bloom-housing/bloom/issues/1927) [#1927](https://github.com/bloom-housing/bloom/issues/1927) [#1927](https://github.com/bloom-housing/bloom/issues/1927) [#1927](https://github.com/bloom-housing/bloom/issues/1927) [#1927](https://github.com/bloom-housing/bloom/issues/1927) [#1927](https://github.com/bloom-housing/bloom/issues/1927) [#1927](https://github.com/bloom-housing/bloom/issues/1927) + + +### BREAKING CHANGES + +* sign-in pages have been updated + + + + + +## [4.0.1-alpha.6](https://github.com/bloom-housing/bloom/compare/@bloom-housing/shared-helpers@4.0.0...@bloom-housing/shared-helpers@4.0.1-alpha.6) (2022-01-03) + + +### Bug Fixes + +* bump version ([#2349](https://github.com/bloom-housing/bloom/issues/2349)) ([b9e3ba1](https://github.com/bloom-housing/bloom/commit/b9e3ba10aebd6534090f8be231a9ea77b3c929b6)) +* bump version ([#2350](https://github.com/bloom-housing/bloom/issues/2350)) ([05863f5](https://github.com/bloom-housing/bloom/commit/05863f55f3939bea4387bd7cf4eb1f34df106124)) + + +* 2227/lock login attempts frontend (#2260) ([281ea43](https://github.com/bloom-housing/bloom/commit/281ea435e618a73a73f233a7a494f961fbac8fa2)), closes [#2260](https://github.com/bloom-housing/bloom/issues/2260) [#1927](https://github.com/bloom-housing/bloom/issues/1927) [#1927](https://github.com/bloom-housing/bloom/issues/1927) [#1927](https://github.com/bloom-housing/bloom/issues/1927) [#1927](https://github.com/bloom-housing/bloom/issues/1927) [#1927](https://github.com/bloom-housing/bloom/issues/1927) [#1927](https://github.com/bloom-housing/bloom/issues/1927) [#1927](https://github.com/bloom-housing/bloom/issues/1927) [#1927](https://github.com/bloom-housing/bloom/issues/1927) [#1927](https://github.com/bloom-housing/bloom/issues/1927) [#1927](https://github.com/bloom-housing/bloom/issues/1927) [#1927](https://github.com/bloom-housing/bloom/issues/1927) [#1927](https://github.com/bloom-housing/bloom/issues/1927) [#1927](https://github.com/bloom-housing/bloom/issues/1927) + + +### BREAKING CHANGES + +* sign-in pages have been updated + + + + + +## [4.0.1-alpha.1](https://github.com/seanmalbert/bloom/compare/@bloom-housing/shared-helpers@4.0.0...@bloom-housing/shared-helpers@4.0.1-alpha.1) (2021-12-23) + + +* 2227/lock login attempts frontend (#2260) ([281ea43](https://github.com/seanmalbert/bloom/commit/281ea435e618a73a73f233a7a494f961fbac8fa2)), closes [#2260](https://github.com/seanmalbert/bloom/issues/2260) [#1927](https://github.com/seanmalbert/bloom/issues/1927) [#1927](https://github.com/seanmalbert/bloom/issues/1927) [#1927](https://github.com/seanmalbert/bloom/issues/1927) [#1927](https://github.com/seanmalbert/bloom/issues/1927) [#1927](https://github.com/seanmalbert/bloom/issues/1927) [#1927](https://github.com/seanmalbert/bloom/issues/1927) [#1927](https://github.com/seanmalbert/bloom/issues/1927) [#1927](https://github.com/seanmalbert/bloom/issues/1927) [#1927](https://github.com/seanmalbert/bloom/issues/1927) [#1927](https://github.com/seanmalbert/bloom/issues/1927) [#1927](https://github.com/seanmalbert/bloom/issues/1927) [#1927](https://github.com/seanmalbert/bloom/issues/1927) [#1927](https://github.com/seanmalbert/bloom/issues/1927) + + +### BREAKING CHANGES + +* sign-in pages have been updated + + + + + +## [4.0.1-alpha.0](https://github.com/seanmalbert/bloom/compare/@bloom-housing/shared-helpers@4.0.0...@bloom-housing/shared-helpers@4.0.1-alpha.0) (2021-12-23) + + +* 2227/lock login attempts frontend (#2260) ([281ea43](https://github.com/seanmalbert/bloom/commit/281ea435e618a73a73f233a7a494f961fbac8fa2)), closes [#2260](https://github.com/seanmalbert/bloom/issues/2260) [#1927](https://github.com/seanmalbert/bloom/issues/1927) [#1927](https://github.com/seanmalbert/bloom/issues/1927) [#1927](https://github.com/seanmalbert/bloom/issues/1927) [#1927](https://github.com/seanmalbert/bloom/issues/1927) [#1927](https://github.com/seanmalbert/bloom/issues/1927) [#1927](https://github.com/seanmalbert/bloom/issues/1927) [#1927](https://github.com/seanmalbert/bloom/issues/1927) [#1927](https://github.com/seanmalbert/bloom/issues/1927) [#1927](https://github.com/seanmalbert/bloom/issues/1927) [#1927](https://github.com/seanmalbert/bloom/issues/1927) [#1927](https://github.com/seanmalbert/bloom/issues/1927) [#1927](https://github.com/seanmalbert/bloom/issues/1927) [#1927](https://github.com/seanmalbert/bloom/issues/1927) + + +### BREAKING CHANGES + +* sign-in pages have been updated + + + + + +# [4.0.0](https://github.com/seanmalbert/bloom/compare/@bloom-housing/shared-helpers@3.0.1-alpha.47...@bloom-housing/shared-helpers@4.0.0) (2021-12-22) + + +### Code Refactoring + +* removing helpers from ui-components that are backend dependent ([#2108](https://github.com/seanmalbert/bloom/issues/2108)) ([1d0c1f3](https://github.com/seanmalbert/bloom/commit/1d0c1f340781a3ba76c89462d8bee954dd40b889)) + + +### BREAKING CHANGES + +* moved some helpers from ui-components to shared-helpers + + + + + +## [3.0.1-alpha.47](https://github.com/bloom-housing/bloom/compare/@bloom-housing/shared-helpers@3.0.1-alpha.46...@bloom-housing/shared-helpers@3.0.1-alpha.47) (2021-12-15) + +**Note:** Version bump only for package @bloom-housing/shared-helpers + + + + + +## [3.0.1-alpha.46](https://github.com/bloom-housing/bloom/compare/@bloom-housing/shared-helpers@3.0.1-alpha.45...@bloom-housing/shared-helpers@3.0.1-alpha.46) (2021-12-15) + +**Note:** Version bump only for package @bloom-housing/shared-helpers + + + + + +## [3.0.1-alpha.45](https://github.com/bloom-housing/bloom/compare/@bloom-housing/shared-helpers@3.0.1-alpha.44...@bloom-housing/shared-helpers@3.0.1-alpha.45) (2021-12-15) + +**Note:** Version bump only for package @bloom-housing/shared-helpers + + + + + +## [3.0.1-alpha.44](https://github.com/bloom-housing/bloom/compare/@bloom-housing/shared-helpers@3.0.1-alpha.43...@bloom-housing/shared-helpers@3.0.1-alpha.44) (2021-12-14) + +**Note:** Version bump only for package @bloom-housing/shared-helpers + + + + + +## [3.0.1-alpha.43](https://github.com/bloom-housing/bloom/compare/@bloom-housing/shared-helpers@3.0.1-alpha.42...@bloom-housing/shared-helpers@3.0.1-alpha.43) (2021-12-14) + +**Note:** Version bump only for package @bloom-housing/shared-helpers + + + + + +## [3.0.1-alpha.42](https://github.com/bloom-housing/bloom/compare/@bloom-housing/shared-helpers@3.0.1-alpha.41...@bloom-housing/shared-helpers@3.0.1-alpha.42) (2021-12-13) + +**Note:** Version bump only for package @bloom-housing/shared-helpers + + + + + +## [3.0.1-alpha.41](https://github.com/bloom-housing/bloom/compare/@bloom-housing/shared-helpers@3.0.1-alpha.39...@bloom-housing/shared-helpers@3.0.1-alpha.41) (2021-12-13) + + +### Bug Fixes + +* versioning issues ([#2311](https://github.com/bloom-housing/bloom/issues/2311)) ([0b1d143](https://github.com/bloom-housing/bloom/commit/0b1d143ab8b17add9d52533560f28d7a1f6dfd3d)) + + + + + +## [3.0.1-alpha.39](https://github.com/bloom-housing/bloom/compare/@bloom-housing/shared-helpers@3.0.1-alpha.38...@bloom-housing/shared-helpers@3.0.1-alpha.39) (2021-12-10) + +**Note:** Version bump only for package @bloom-housing/shared-helpers + + + + + +## [3.0.1-alpha.38](https://github.com/bloom-housing/bloom/compare/@bloom-housing/shared-helpers@3.0.1-alpha.37...@bloom-housing/shared-helpers@3.0.1-alpha.38) (2021-12-09) + +**Note:** Version bump only for package @bloom-housing/shared-helpers + + + + + +## [3.0.1-alpha.37](https://github.com/bloom-housing/bloom/compare/@bloom-housing/shared-helpers@3.0.1-alpha.36...@bloom-housing/shared-helpers@3.0.1-alpha.37) (2021-12-09) + +**Note:** Version bump only for package @bloom-housing/shared-helpers + + + + + +## [3.0.1-alpha.36](https://github.com/bloom-housing/bloom/compare/@bloom-housing/shared-helpers@3.0.1-alpha.35...@bloom-housing/shared-helpers@3.0.1-alpha.36) (2021-12-09) + +**Note:** Version bump only for package @bloom-housing/shared-helpers + + + + + +## [3.0.1-alpha.35](https://github.com/bloom-housing/bloom/compare/@bloom-housing/shared-helpers@3.0.1-alpha.34...@bloom-housing/shared-helpers@3.0.1-alpha.35) (2021-12-09) + +**Note:** Version bump only for package @bloom-housing/shared-helpers + + + + + +## [3.0.1-alpha.34](https://github.com/bloom-housing/bloom/compare/@bloom-housing/shared-helpers@3.0.1-alpha.33...@bloom-housing/shared-helpers@3.0.1-alpha.34) (2021-12-08) + +**Note:** Version bump only for package @bloom-housing/shared-helpers + + + + + +## [3.0.1-alpha.33](https://github.com/bloom-housing/bloom/compare/@bloom-housing/shared-helpers@3.0.1-alpha.32...@bloom-housing/shared-helpers@3.0.1-alpha.33) (2021-12-07) + +**Note:** Version bump only for package @bloom-housing/shared-helpers + + + + + +## [3.0.1-alpha.32](https://github.com/bloom-housing/bloom/compare/@bloom-housing/shared-helpers@3.0.1-alpha.31...@bloom-housing/shared-helpers@3.0.1-alpha.32) (2021-12-07) + +**Note:** Version bump only for package @bloom-housing/shared-helpers + + + + + +## [3.0.1-alpha.31](https://github.com/bloom-housing/bloom/compare/@bloom-housing/shared-helpers@3.0.1-alpha.30...@bloom-housing/shared-helpers@3.0.1-alpha.31) (2021-12-07) + +**Note:** Version bump only for package @bloom-housing/shared-helpers + + + + + +## [3.0.1-alpha.30](https://github.com/bloom-housing/bloom/compare/@bloom-housing/shared-helpers@3.0.1-alpha.29...@bloom-housing/shared-helpers@3.0.1-alpha.30) (2021-12-06) + +**Note:** Version bump only for package @bloom-housing/shared-helpers + + + + + +## [3.0.1-alpha.29](https://github.com/bloom-housing/bloom/compare/@bloom-housing/shared-helpers@3.0.1-alpha.28...@bloom-housing/shared-helpers@3.0.1-alpha.29) (2021-12-03) + +**Note:** Version bump only for package @bloom-housing/shared-helpers + + + + + +## [3.0.1-alpha.28](https://github.com/bloom-housing/bloom/compare/@bloom-housing/shared-helpers@3.0.1-alpha.27...@bloom-housing/shared-helpers@3.0.1-alpha.28) (2021-12-03) + +**Note:** Version bump only for package @bloom-housing/shared-helpers + + + + + +## [3.0.1-alpha.27](https://github.com/bloom-housing/bloom/compare/@bloom-housing/shared-helpers@3.0.1-alpha.26...@bloom-housing/shared-helpers@3.0.1-alpha.27) (2021-12-03) + +**Note:** Version bump only for package @bloom-housing/shared-helpers + + + + + +## [3.0.1-alpha.26](https://github.com/bloom-housing/bloom/compare/@bloom-housing/shared-helpers@3.0.1-alpha.25...@bloom-housing/shared-helpers@3.0.1-alpha.26) (2021-12-01) + +**Note:** Version bump only for package @bloom-housing/shared-helpers + + + + + +## [3.0.1-alpha.25](https://github.com/bloom-housing/bloom/compare/@bloom-housing/shared-helpers@3.0.1-alpha.24...@bloom-housing/shared-helpers@3.0.1-alpha.25) (2021-12-01) + +**Note:** Version bump only for package @bloom-housing/shared-helpers + + + + + +## [3.0.1-alpha.24](https://github.com/bloom-housing/bloom/compare/@bloom-housing/shared-helpers@3.0.1-alpha.23...@bloom-housing/shared-helpers@3.0.1-alpha.24) (2021-11-30) + +**Note:** Version bump only for package @bloom-housing/shared-helpers + + + + + +## [3.0.1-alpha.23](https://github.com/bloom-housing/bloom/compare/@bloom-housing/shared-helpers@3.0.1-alpha.22...@bloom-housing/shared-helpers@3.0.1-alpha.23) (2021-11-29) + +**Note:** Version bump only for package @bloom-housing/shared-helpers + + + + + +## [3.0.1-alpha.22](https://github.com/bloom-housing/bloom/compare/@bloom-housing/shared-helpers@3.0.1-alpha.21...@bloom-housing/shared-helpers@3.0.1-alpha.22) (2021-11-29) + +**Note:** Version bump only for package @bloom-housing/shared-helpers + + + + + +## [3.0.1-alpha.21](https://github.com/bloom-housing/bloom/compare/@bloom-housing/shared-helpers@3.0.1-alpha.20...@bloom-housing/shared-helpers@3.0.1-alpha.21) (2021-11-29) + +**Note:** Version bump only for package @bloom-housing/shared-helpers + + + + + +## [3.0.1-alpha.20](https://github.com/bloom-housing/bloom/compare/@bloom-housing/shared-helpers@3.0.1-alpha.19...@bloom-housing/shared-helpers@3.0.1-alpha.20) (2021-11-23) + + +### Bug Fixes + +* remove alameda reference in demographics ([#2209](https://github.com/bloom-housing/bloom/issues/2209)) ([7d5991c](https://github.com/bloom-housing/bloom/commit/7d5991cbf6dbe0b61f2b14d265e87ce3687f743d)) + + + + + +## [3.0.1-alpha.19](https://github.com/bloom-housing/bloom/compare/@bloom-housing/shared-helpers@3.0.1-alpha.18...@bloom-housing/shared-helpers@3.0.1-alpha.19) (2021-11-23) + +**Note:** Version bump only for package @bloom-housing/shared-helpers + + + + + +## [3.0.1-alpha.18](https://github.com/bloom-housing/bloom/compare/@bloom-housing/shared-helpers@3.0.1-alpha.17...@bloom-housing/shared-helpers@3.0.1-alpha.18) (2021-11-23) + +**Note:** Version bump only for package @bloom-housing/shared-helpers + + + + + +## [3.0.1-alpha.17](https://github.com/bloom-housing/bloom/compare/@bloom-housing/shared-helpers@3.0.1-alpha.16...@bloom-housing/shared-helpers@3.0.1-alpha.17) (2021-11-23) + +**Note:** Version bump only for package @bloom-housing/shared-helpers + + + + + +## [3.0.1-alpha.16](https://github.com/bloom-housing/bloom/compare/@bloom-housing/shared-helpers@3.0.1-alpha.15...@bloom-housing/shared-helpers@3.0.1-alpha.16) (2021-11-23) + +**Note:** Version bump only for package @bloom-housing/shared-helpers + + + + + +## [3.0.1-alpha.15](https://github.com/bloom-housing/bloom/compare/@bloom-housing/shared-helpers@3.0.1-alpha.14...@bloom-housing/shared-helpers@3.0.1-alpha.15) (2021-11-23) + + +### Features + +* new demographics sub-race questions ([#2109](https://github.com/bloom-housing/bloom/issues/2109)) ([9ab8926](https://github.com/bloom-housing/bloom/commit/9ab892694c1ad2fa8890b411b3b32af68ade1fc3)) + + + + + +## [3.0.1-alpha.14](https://github.com/bloom-housing/bloom/compare/@bloom-housing/shared-helpers@3.0.1-alpha.13...@bloom-housing/shared-helpers@3.0.1-alpha.14) (2021-11-22) + +**Note:** Version bump only for package @bloom-housing/shared-helpers + + + + + +## [3.0.1-alpha.13](https://github.com/bloom-housing/bloom/compare/@bloom-housing/shared-helpers@3.0.1-alpha.12...@bloom-housing/shared-helpers@3.0.1-alpha.13) (2021-11-22) + +**Note:** Version bump only for package @bloom-housing/shared-helpers + + + + + +## [3.0.1-alpha.12](https://github.com/bloom-housing/bloom/compare/@bloom-housing/shared-helpers@3.0.1-alpha.11...@bloom-housing/shared-helpers@3.0.1-alpha.12) (2021-11-22) + +**Note:** Version bump only for package @bloom-housing/shared-helpers + + + + + +## [3.0.1-alpha.11](https://github.com/bloom-housing/bloom/compare/@bloom-housing/shared-helpers@3.0.1-alpha.10...@bloom-housing/shared-helpers@3.0.1-alpha.11) (2021-11-22) + +**Note:** Version bump only for package @bloom-housing/shared-helpers + + + + + +## [3.0.1-alpha.10](https://github.com/bloom-housing/bloom/compare/@bloom-housing/shared-helpers@3.0.1-alpha.9...@bloom-housing/shared-helpers@3.0.1-alpha.10) (2021-11-17) + +**Note:** Version bump only for package @bloom-housing/shared-helpers + + + + + +## [3.0.1-alpha.9](https://github.com/bloom-housing/bloom/compare/@bloom-housing/shared-helpers@3.0.1-alpha.8...@bloom-housing/shared-helpers@3.0.1-alpha.9) (2021-11-16) + +**Note:** Version bump only for package @bloom-housing/shared-helpers + + + + + +## [3.0.1-alpha.8](https://github.com/bloom-housing/bloom/compare/@bloom-housing/shared-helpers@3.0.1-alpha.7...@bloom-housing/shared-helpers@3.0.1-alpha.8) (2021-11-15) + +**Note:** Version bump only for package @bloom-housing/shared-helpers + + + + + +## [3.0.1-alpha.7](https://github.com/bloom-housing/bloom/compare/@bloom-housing/shared-helpers@3.0.1-alpha.6...@bloom-housing/shared-helpers@3.0.1-alpha.7) (2021-11-15) + +**Note:** Version bump only for package @bloom-housing/shared-helpers + + + + + +## [3.0.1-alpha.6](https://github.com/bloom-housing/bloom/compare/@bloom-housing/shared-helpers@3.0.1-alpha.5...@bloom-housing/shared-helpers@3.0.1-alpha.6) (2021-11-15) + +**Note:** Version bump only for package @bloom-housing/shared-helpers + + + + + +## [3.0.1-alpha.5](https://github.com/bloom-housing/bloom/compare/@bloom-housing/shared-helpers@3.0.1-alpha.4...@bloom-housing/shared-helpers@3.0.1-alpha.5) (2021-11-12) + +**Note:** Version bump only for package @bloom-housing/shared-helpers + + + + + +## [3.0.1-alpha.4](https://github.com/bloom-housing/bloom/compare/@bloom-housing/shared-helpers@3.0.1-alpha.3...@bloom-housing/shared-helpers@3.0.1-alpha.4) (2021-11-12) + +**Note:** Version bump only for package @bloom-housing/shared-helpers + + + + + +## [3.0.1-alpha.3](https://github.com/bloom-housing/bloom/compare/@bloom-housing/shared-helpers@3.0.1-alpha.2...@bloom-housing/shared-helpers@3.0.1-alpha.3) (2021-11-11) + +**Note:** Version bump only for package @bloom-housing/shared-helpers + + + + + +## [3.0.1-alpha.2](https://github.com/bloom-housing/bloom/compare/@bloom-housing/shared-helpers@3.0.1-alpha.1...@bloom-housing/shared-helpers@3.0.1-alpha.2) (2021-11-10) + +**Note:** Version bump only for package @bloom-housing/shared-helpers + + + + + +## [3.0.1-alpha.1](https://github.com/bloom-housing/bloom/compare/@bloom-housing/shared-helpers@3.0.1-alpha.0...@bloom-housing/shared-helpers@3.0.1-alpha.1) (2021-11-09) + +**Note:** Version bump only for package @bloom-housing/shared-helpers + + + + + +## [3.0.1-alpha.0](https://github.com/bloom-housing/bloom/compare/@bloom-housing/shared-helpers@3.0.0...@bloom-housing/shared-helpers@3.0.1-alpha.0) (2021-11-09) + +**Note:** Version bump only for package @bloom-housing/shared-helpers + + + + + +# [3.0.0](https://github.com/seanmalbert/bloom/compare/@bloom-housing/shared-helpers@2.0.0...@bloom-housing/shared-helpers@3.0.0) (2021-11-05) + +**Note:** Version bump only for package @bloom-housing/shared-helpers + + + + + +# [2.0.0](https://github.com/seanmalbert/bloom/compare/@bloom-housing/shared-helpers@1.0.6-alpha.5...@bloom-housing/shared-helpers@2.0.0) (2021-11-02) + +**Note:** Version bump only for package @bloom-housing/shared-helpers + + + + + +## [1.0.6-alpha.5](https://github.com/bloom-housing/bloom/compare/@bloom-housing/shared-helpers@1.0.6-alpha.4...@bloom-housing/shared-helpers@1.0.6-alpha.5) (2021-10-22) + +**Note:** Version bump only for package @bloom-housing/shared-helpers + + + + + +## [1.0.6-alpha.4](https://github.com/bloom-housing/bloom/compare/@bloom-housing/shared-helpers@1.0.6-alpha.3...@bloom-housing/shared-helpers@1.0.6-alpha.4) (2021-10-22) + +**Note:** Version bump only for package @bloom-housing/shared-helpers + + + + + +## [1.0.6-alpha.3](https://github.com/bloom-housing/bloom/compare/@bloom-housing/shared-helpers@1.0.6-alpha.2...@bloom-housing/shared-helpers@1.0.6-alpha.3) (2021-10-22) + +**Note:** Version bump only for package @bloom-housing/shared-helpers + + + + + +## [1.0.6-alpha.2](https://github.com/bloom-housing/bloom/compare/@bloom-housing/shared-helpers@1.0.6-alpha.1...@bloom-housing/shared-helpers@1.0.6-alpha.2) (2021-10-21) + +**Note:** Version bump only for package @bloom-housing/shared-helpers + + + + + +## [1.0.6-alpha.1](https://github.com/bloom-housing/bloom/compare/@bloom-housing/shared-helpers@1.0.6-alpha.0...@bloom-housing/shared-helpers@1.0.6-alpha.1) (2021-10-19) + +**Note:** Version bump only for package @bloom-housing/shared-helpers + +## 1.0.6-alpha.0 (2021-10-19) + +### chore + +- Add new `shared-helpers` package ([#1911](https://github.com/bloom-housing/bloom/issues/1911)) ([6e5d91b](https://github.com/bloom-housing/bloom/commit/6e5d91be5ccafd3d4b5bc1a578f2246a5e7f905b)) + +### BREAKING CHANGES + +- Move form keys out of ui-components diff --git a/shared-helpers/README.md b/shared-helpers/README.md new file mode 100644 index 0000000000..9051ad9192 --- /dev/null +++ b/shared-helpers/README.md @@ -0,0 +1,15 @@ +# Bloom Shared Helpers + +This package is the home of types and functions intended for shared use between the Next.js sites within the Bloom monorepo. In certain instances there is also some commonality between the frontend plus the backend (not implemented currently). + +## CLI + +To run the test suite which is built with Jest run: + +``` +yarn test + +# or + +yarn test:coverage # generates local coverage reports, useful as you are writing tests +``` diff --git a/shared-helpers/__tests__/formKeys.test.ts b/shared-helpers/__tests__/formKeys.test.ts new file mode 100644 index 0000000000..85a80e6a59 --- /dev/null +++ b/shared-helpers/__tests__/formKeys.test.ts @@ -0,0 +1,55 @@ +import { cleanup } from "@testing-library/react" +import { fieldGroupObjectToArray, prependRoot } from "../src/formKeys" + +afterEach(cleanup) + +describe("formKeys helpers", () => { + it("prependRoot should prepend a string", () => { + const testArray = ["a", "b", "c"] + expect(prependRoot("rootKey", testArray)).toStrictEqual(["rootKey-a", "rootKey-b", "rootKey-c"]) + }) + + it("fieldGroupObjectToArray with only matching keys", () => { + const testObj = { + ["root-A"]: "A", + ["root-B"]: "B", + ["root-C"]: "C", + } + const expectedArray = ["A", "B", "C"] + expect(fieldGroupObjectToArray(testObj, "root")).toStrictEqual(expectedArray) + }) + + it("fieldGroupObjectToArray with only some matching keys", () => { + const testObj = { + testObj: {}, + ["root-A"]: "A", + ["other-B"]: "B", + ["root-C"]: "C", + ["other"]: "D", + } + const expectedArray = ["A", "C"] + expect(fieldGroupObjectToArray(testObj, "root")).toStrictEqual(expectedArray) + }) + + it("fieldGroupObjectToArray with subkeys", () => { + const testObj = { + ["root-A"]: "A", + ["root-B"]: "B", + ["root-C"]: "C", + ["root-D"]: "subKey", + } + const expectedArray = ["A", "B", "C", "D: subKey"] + expect(fieldGroupObjectToArray(testObj, "root")).toStrictEqual(expectedArray) + }) + + it("fieldGroupObjectToArray with subArrays", () => { + const testObj = { + ["root-A"]: "A", + ["root-B"]: "B", + ["root-C"]: "C", + ["root-D"]: ["1", "2"], + } + const expectedArray = ["A", "B", "C", "D: 1,2"] + expect(fieldGroupObjectToArray(testObj, "root")).toStrictEqual(expectedArray) + }) +}) diff --git a/shared-helpers/__tests__/occupancyFormatting.test.tsx b/shared-helpers/__tests__/occupancyFormatting.test.tsx new file mode 100644 index 0000000000..4fb812f1b1 --- /dev/null +++ b/shared-helpers/__tests__/occupancyFormatting.test.tsx @@ -0,0 +1,122 @@ +import React from "react" +import { cleanup } from "@testing-library/react" +import { occupancyTable } from "../src/occupancyFormatting" +import { Listing, UnitType, UnitGroup } from "@bloom-housing/backend-core/types" + +const unitTypeSRO = { name: "SRO", numBedrooms: 0 } as UnitType +const unitTypeStudio = { name: "studio", numBedrooms: 0 } as UnitType +const unitTypeOneBdrm = { name: "oneBdrm", numBedrooms: 1 } as UnitType +const unitTypeTwoBdrm = { name: "twoBdrm", numBedrooms: 2 } as UnitType +const unitTypeThreeBdrm = { name: "threeBdrm", numBedrooms: 3 } as UnitType +const unitTypeFourBdrm = { name: "fourBdrm", numBedrooms: 4 } as UnitType + +const unitGroups: Omit[] = [ + { + unitType: [unitTypeStudio, unitTypeOneBdrm], + minOccupancy: 1, + maxOccupancy: 2, + }, + { + unitType: [unitTypeOneBdrm], + minOccupancy: 1, + maxOccupancy: 3, + }, + { + unitType: [unitTypeTwoBdrm], + minOccupancy: 2, + maxOccupancy: 6, + }, + { + unitType: [unitTypeTwoBdrm], + minOccupancy: 2, + maxOccupancy: undefined, + }, + { + unitType: [unitTypeTwoBdrm], + minOccupancy: undefined, + maxOccupancy: 2, + }, + { + unitType: [unitTypeFourBdrm], + minOccupancy: 1, + maxOccupancy: undefined, + }, + { + unitType: [unitTypeSRO], + minOccupancy: undefined, + maxOccupancy: 1, + }, + { + unitType: [unitTypeTwoBdrm], + minOccupancy: 1, + maxOccupancy: 1, + }, + { + unitType: [unitTypeThreeBdrm], + minOccupancy: 3, + maxOccupancy: 3, + }, + { + unitType: [unitTypeFourBdrm], + minOccupancy: undefined, + maxOccupancy: undefined, + }, + { + unitType: [unitTypeTwoBdrm, unitTypeOneBdrm], + minOccupancy: 1, + maxOccupancy: 7, + }, +] + +const testListing: Listing = {} as Listing +testListing.unitGroups = unitGroups as UnitGroup[] +afterEach(cleanup) + +describe("occupancy formatting helpers", () => { + describe("occupancyTable", () => { + it("properly creates occupancy table", () => { + expect(occupancyTable(testListing)).toStrictEqual([ + { + occupancy: "1-2 people", + unitType: Studio, 1 BR, + }, + { + occupancy: "at most 1 person", + unitType: SRO, + }, + { + occupancy: "1-3 people", + unitType: 1 BR, + }, + { + occupancy: "1-7 people", + unitType: 1 BR, 2 BR, + }, + { + occupancy: "2-6 people", + unitType: 2 BR, + }, + { + occupancy: "at least 2 people", + unitType: 2 BR, + }, + { + occupancy: "at most 2 people", + unitType: 2 BR, + }, + { + occupancy: "1 person", + unitType: 2 BR, + }, + { + occupancy: "3 people", + unitType: 3 BR, + }, + { + occupancy: "at least 1 person", + unitType: 4 BR, + }, + ]) + }) + }) +}) diff --git a/shared-helpers/__tests__/pdfs.test.ts b/shared-helpers/__tests__/pdfs.test.ts new file mode 100644 index 0000000000..e9967b546b --- /dev/null +++ b/shared-helpers/__tests__/pdfs.test.ts @@ -0,0 +1,39 @@ +import { ListingEventType, ListingEvent } from "@bloom-housing/backend-core/types" +import { cleanup } from "@testing-library/react" +import { cloudinaryPdfFromId, pdfUrlFromListingEvents } from "../src/pdfs" + +afterEach(cleanup) + +describe("pdfs helpers", () => { + it("should format cloudinary url", () => { + expect(cloudinaryPdfFromId("1234", "exygy")).toBe( + `https://res.cloudinary.com/exygy/image/upload/1234.pdf` + ) + }) + it("should return correct pdf url for event if event type exists and file is cloudinary type", () => { + const listingEvents = [ + { type: ListingEventType.lotteryResults, file: { fileId: "1234", label: "cloudinaryPDF" } }, + { type: ListingEventType.openHouse, file: { fileId: "5678", label: "cloudinaryPDF" } }, + ] as ListingEvent[] + expect(pdfUrlFromListingEvents(listingEvents, ListingEventType.lotteryResults, "exygy")).toBe( + `https://res.cloudinary.com/exygy/image/upload/1234.pdf` + ) + }) + it("should return null if event type exists but is not cloudinary type", () => { + const listingEvents = [ + { type: ListingEventType.lotteryResults, file: { fileId: "1234" } }, + ] as ListingEvent[] + expect(pdfUrlFromListingEvents(listingEvents, ListingEventType.lotteryResults, "exygy")).toBe( + null + ) + }) + it("should return null if no event of type exists", () => { + const listingEvents = [ + { type: ListingEventType.lotteryResults }, + { type: ListingEventType.openHouse }, + ] as ListingEvent[] + expect(pdfUrlFromListingEvents(listingEvents, ListingEventType.publicLottery, "exygy")).toBe( + null + ) + }) +}) diff --git a/shared-helpers/__tests__/photos.test.ts b/shared-helpers/__tests__/photos.test.ts new file mode 100644 index 0000000000..a1caf8c688 --- /dev/null +++ b/shared-helpers/__tests__/photos.test.ts @@ -0,0 +1,60 @@ +import { Listing } from "@bloom-housing/backend-core/types" +import { cleanup } from "@testing-library/react" +import { cloudinaryUrlFromId, imageUrlFromListing } from "../src/photos" + +afterEach(cleanup) + +describe("photos helper", () => { + const OLD_ENV = process.env + + beforeEach(() => { + jest.resetModules() + process.env = { ...OLD_ENV } + }) + + afterAll(() => { + process.env = OLD_ENV + }) + + it("should return correct cloudinary url", () => { + process.env.CLOUDINARY_CLOUD_NAME = "exygy" + expect(cloudinaryUrlFromId("1234")).toBe( + `https://res.cloudinary.com/exygy/image/upload/w_400,c_limit,q_65/1234.jpg` + ) + }) + + it("should return correct cloudinary url from a listing with new image field", () => { + process.env.CLOUDINARY_CLOUD_NAME = "exygy" + + const testListing = { + images: [ + { + ordinal: 0, + image: { + fileId: "1234", + label: "cloudinaryBuilding", + }, + }, + ], + } as Listing + + expect(imageUrlFromListing(testListing)).toBe( + `https://res.cloudinary.com/exygy/image/upload/w_400,c_limit,q_65/1234.jpg` + ) + }) + + it("should return correct id when falling back to old field", () => { + process.env.CLOUDINARY_CLOUD_NAME = "exygy" + + const testListing = { + assets: [ + { + fileId: "5678", + label: "building", + }, + ], + } as Listing + + expect(imageUrlFromListing(testListing)).toBe("5678") + }) +}) diff --git a/shared-helpers/__tests__/stringFormatting.test.ts b/shared-helpers/__tests__/stringFormatting.test.ts new file mode 100644 index 0000000000..3217262a9e --- /dev/null +++ b/shared-helpers/__tests__/stringFormatting.test.ts @@ -0,0 +1,19 @@ +import { cleanup } from "@testing-library/react" +import { getTimeRangeString } from "../src/stringFormatting" + +afterEach(cleanup) + +describe("stringFormatting helpers", () => { + describe("getTimeRangeString", () => { + it("formats different parameters as a time range", () => { + expect(getTimeRangeString(new Date(2018, 8, 10, 10), new Date(2018, 8, 18, 11))).toBe( + "10:00am - 11:00am" + ) + }) + it("formats different parameters as one time", () => { + expect(getTimeRangeString(new Date(2018, 8, 10, 10), new Date(2018, 8, 18, 10))).toBe( + "10:00am" + ) + }) + }) +}) diff --git a/shared-helpers/__tests__/unitTypes.test.ts b/shared-helpers/__tests__/unitTypes.test.ts new file mode 100644 index 0000000000..0caab6878b --- /dev/null +++ b/shared-helpers/__tests__/unitTypes.test.ts @@ -0,0 +1,109 @@ +import { cleanup } from "@testing-library/react" +import { getUniqueUnitTypes, sortUnitTypes } from "../src/unitTypes" +import { UnitStatus } from "@bloom-housing/backend-core/types" + +afterEach(cleanup) + +describe("unit type: sortUnitTypes helper", () => { + it("should return empty array if empty array is passed in", () => { + expect(sortUnitTypes([])).toStrictEqual([]) + }) + it("should sort basic arrays", () => { + expect( + sortUnitTypes([ + { id: "SRO", name: "sro" }, + { id: "studio", name: "studio" }, + { id: "oneBdrm", name: "oneBdrm" }, + { id: "twoBdrm", name: "twoBdrm" }, + { id: "threeBdrm", name: "threeBdrm" }, + { id: "fourBdrm", name: "fourBdrm" }, + ]) + ).toStrictEqual([ + { id: "SRO", name: "sro" }, + { id: "studio", name: "studio" }, + { id: "oneBdrm", name: "oneBdrm" }, + { id: "twoBdrm", name: "twoBdrm" }, + { id: "threeBdrm", name: "threeBdrm" }, + { id: "fourBdrm", name: "fourBdrm" }, + ]) + expect( + sortUnitTypes([ + { id: "fourBdrm", name: "fourBdrm" }, + { id: "studio", name: "studio" }, + { id: "oneBdrm", name: "oneBdrm" }, + { id: "twoBdrm", name: "twoBdrm" }, + { id: "threeBdrm", name: "threeBdrm" }, + { id: "SRO", name: "sro" }, + ]) + ).toStrictEqual([ + { id: "SRO", name: "sro" }, + { id: "studio", name: "studio" }, + { id: "oneBdrm", name: "oneBdrm" }, + { id: "twoBdrm", name: "twoBdrm" }, + { id: "threeBdrm", name: "threeBdrm" }, + { id: "fourBdrm", name: "fourBdrm" }, + ]) + }) + it("should sort complex arrays", () => { + expect( + sortUnitTypes([ + { id: "oneBdrm", name: "oneBdrm" }, + { id: "studio", name: "studio" }, + { id: "threeBdrm", name: "threeBdrm" }, + { id: "oneBdrm", name: "oneBdrm" }, + { id: "twoBdrm", name: "twoBdrm" }, + { id: "fourBdrm", name: "fourBdrm" }, + { id: "SRO", name: "sro" }, + { id: "threeBdrm", name: "threeBdrm" }, + { id: "fourBdrm", name: "fourBdrm" }, + ]) + ).toStrictEqual([ + { id: "SRO", name: "sro" }, + { id: "studio", name: "studio" }, + { id: "oneBdrm", name: "oneBdrm" }, + { id: "oneBdrm", name: "oneBdrm" }, + { id: "twoBdrm", name: "twoBdrm" }, + { id: "threeBdrm", name: "threeBdrm" }, + { id: "threeBdrm", name: "threeBdrm" }, + { id: "fourBdrm", name: "fourBdrm" }, + { id: "fourBdrm", name: "fourBdrm" }, + ]) + }) +}) + +describe("unit type: getUniqueUnitTypes helper", () => { + it("should return empty array if empty array is passed in", () => { + expect(getUniqueUnitTypes([])).toStrictEqual([]) + }) + it("should return empty array if all elements are invalid", () => { + expect( + getUniqueUnitTypes([ + { status: UnitStatus["available"], id: "", createdAt: new Date(), updatedAt: new Date() }, + ]) + ).toStrictEqual([]) + }) + it("should return 1 element if 1 valid element is passed in", () => { + expect( + getUniqueUnitTypes([ + { + status: UnitStatus["available"], + id: "example id", + createdAt: new Date(), + updatedAt: new Date(), + unitType: { + id: "Test", + createdAt: new Date(), + updatedAt: new Date(), + numBedrooms: 2, + name: "Example Name", + }, + }, + ]) + ).toStrictEqual([ + { + id: "Test", + name: "Example Name", + }, + ]) + }) +}) diff --git a/shared-helpers/index.ts b/shared-helpers/index.ts new file mode 100644 index 0000000000..16b8dbe73b --- /dev/null +++ b/shared-helpers/index.ts @@ -0,0 +1,14 @@ +export * from "./src/blankApplication" +export * from "./src/catchNetworkError" +export * from "./src/formKeys" +export * from "./src/gtm" +export * from "./src/nextjs" +export * from "./src/occupancyFormatting" +export * from "./src/pdfs" +export * from "./src/photos" +export * from "./src/programHelpers" +export * from "./src/stringFormatting" +export * from "./src/unitTypes" +export * from "./src/minMaxFinder" +export * from "./src/formatRentRange" +export * from "./src/formatRange" diff --git a/shared-helpers/jest.config.js b/shared-helpers/jest.config.js new file mode 100644 index 0000000000..3e5d5a8e6b --- /dev/null +++ b/shared-helpers/jest.config.js @@ -0,0 +1,35 @@ +/*eslint no-undef: "error"*/ +/*eslint-env node*/ + +process.env.TZ = "UTC" + +module.exports = { + testRegex: ["/*.test.tsx$", "/*.test.ts$"], + collectCoverageFrom: ["**/*.ts", "!**/*.tsx"], + coverageReporters: ["lcov", "text"], + coverageDirectory: "test-coverage", + coverageThreshold: { + global: { + branches: 0, + functions: 0, + lines: 0, + statements: 0, + }, + }, + preset: "ts-jest", + globals: { + "ts-jest": { + tsConfig: "tsconfig.json", + }, + }, + moduleNameMapper: { + "\\.(scss|css|less)$": "identity-obj-proxy", + }, + rootDir: "..", + roots: ["/shared-helpers"], + transform: { + "^.+\\.[t|j]sx?$": "ts-jest", + }, + setupFiles: ["dotenv/config"], + setupFilesAfterEnv: ["/shared-helpers/.jest/setup-tests.js"], +} diff --git a/shared-helpers/package.json b/shared-helpers/package.json new file mode 100644 index 0000000000..f8d3ddbab5 --- /dev/null +++ b/shared-helpers/package.json @@ -0,0 +1,38 @@ +{ + "name": "@bloom-housing/shared-helpers", + "version": "4.2.0", + "description": "Shared helpers for Bloom affordable housing system", + "homepage": "https://github.com/bloom-housing/bloom/tree/master/shared-helpers", + "main": "index.js", + "license": "Apache-2.0", + "private": true, + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org" + }, + "scripts": { + "test": "jest -w 1", + "test:coverage": "jest -w 1 --coverage --watchAll=false", + "prettier": "prettier --write \"**/*.ts\"" + }, + "dependencies": { + "@bloom-housing/backend-core": "^4.2.0", + "@bloom-housing/ui-components": "^4.2.0" + }, + "devDependencies": { + "@bloom-housing/ui-components": "^3.0.1-alpha.15", + "@testing-library/jest-dom": "^5.11.9", + "@testing-library/react": "^11.2.5", + "@types/jest": "^26.0.14", + "@types/node-polyglot": "^2.4.1", + "@types/react-dom": "^16.9.5", + "@types/react-test-renderer": "^16.9.3", + "jest": "^26.5.3", + "react": "^17.0.2", + "react-dom": "^17.0.2", + "react-test-renderer": "^17.0.2", + "ts-jest": "^26.4.1", + "ts-loader": "^8.0.4", + "typescript": "^3.9.7" + } +} diff --git a/shared-helpers/src/blankApplication.ts b/shared-helpers/src/blankApplication.ts new file mode 100644 index 0000000000..c4f8ac89eb --- /dev/null +++ b/shared-helpers/src/blankApplication.ts @@ -0,0 +1,111 @@ +import { + ApplicationStatus, + ApplicationSubmissionType, + Language, + ApplicationPreference, + ApplicationProgram, +} from "@bloom-housing/backend-core/types" + +export const blankApplication = { + loaded: false, + autofilled: false, + completedSections: 0, + submissionType: ApplicationSubmissionType.electronical, + language: Language.en, + acceptedTerms: false, + status: ApplicationStatus.submitted, + applicant: { + orderId: undefined, + firstName: "", + middleName: "", + lastName: "", + birthMonth: "", + birthDay: "", + birthYear: "", + emailAddress: null, + noEmail: false, + phoneNumber: "", + phoneNumberType: "", + noPhone: false, + workInRegion: null, + address: { + street: "", + street2: "", + city: "", + state: "", + zipCode: "", + county: "", + latitude: null, + longitude: null, + }, + workAddress: { + street: "", + street2: "", + city: "", + state: "", + zipCode: "", + county: "", + latitude: null, + longitude: null, + }, + }, + additionalPhone: false, + additionalPhoneNumber: "", + additionalPhoneNumberType: "", + contactPreferences: [], + householdSize: 0, + housingStatus: "", + sendMailToMailingAddress: false, + mailingAddress: { + street: "", + street2: "", + city: "", + state: "", + zipCode: "", + }, + alternateAddress: { + street: "", + street2: "", + city: "", + state: "", + zipCode: "", + }, + alternateContact: { + type: "", + otherType: "", + firstName: "", + lastName: "", + agency: "", + phoneNumber: "", + emailAddress: null, + mailingAddress: { + street: "", + city: "", + state: "", + zipCode: "", + }, + }, + accessibility: { + mobility: null, + vision: null, + hearing: null, + }, + householdExpectingChanges: null, + householdStudent: null, + incomeVouchers: null, + income: null, + incomePeriod: null, + householdMembers: [], + preferredUnit: [], + demographics: { + ethnicity: "", + race: [], + gender: "", + sexualOrientation: "", + howDidYouHear: [], + }, + preferences: [] as ApplicationPreference[], + programs: [] as ApplicationProgram[], + confirmationCode: "", + id: "", +} diff --git a/shared-helpers/src/catchNetworkError.ts b/shared-helpers/src/catchNetworkError.ts new file mode 100644 index 0000000000..bd8ca706c9 --- /dev/null +++ b/shared-helpers/src/catchNetworkError.ts @@ -0,0 +1,94 @@ +import { useState } from "react" +import { t, AlertTypes } from "@bloom-housing/ui-components" +import axios, { AxiosError } from "axios" + +export type NetworkStatus = { + content: NetworkStatusContent + type?: NetworkStatusType + reset: NetworkErrorReset +} + +export type NetworkStatusType = AlertTypes + +export type NetworkStatusError = AxiosError + +export type NetworkStatusContent = { + title: string + description: string + error?: AxiosError +} | null + +export type NetworkErrorDetermineError = ( + status: number, + error: AxiosError, + mfaEnabled?: boolean +) => void + +export type NetworkErrorReset = () => void + +export enum NetworkErrorMessage { + PasswordOutdated = "passwordOutdated", + MfaUnauthorized = "mfaUnauthorized", +} + +/** + * This helper can be used in the catch part for each network request. It determines a proper title and message for AlertBox + AlertNotice components depending on error status code. + */ +export const useCatchNetworkError = () => { + const [networkError, setNetworkError] = useState(null) + + const check401Error = (message: string, error: AxiosError) => { + if (message === NetworkErrorMessage.PasswordOutdated) { + setNetworkError({ + title: t("authentication.signIn.passwordOutdated"), + description: `${t( + "authentication.signIn.changeYourPassword" + )} ${t("t.here")}`, + error, + }) + } else if (message === NetworkErrorMessage.MfaUnauthorized) { + setNetworkError({ + title: t("authentication.signIn.enterValidEmailAndPasswordAndMFA"), + description: t("authentication.signIn.afterFailedAttempts"), + error, + }) + } else { + setNetworkError({ + title: t("authentication.signIn.enterValidEmailAndPassword"), + description: t("authentication.signIn.afterFailedAttempts"), + error, + }) + } + } + + const determineNetworkError: NetworkErrorDetermineError = (status, error) => { + const responseMessage = axios.isAxiosError(error) ? error.response?.data.message : "" + + switch (status) { + case 401: + check401Error(responseMessage, error) + break + case 429: + setNetworkError({ + title: t("authentication.signIn.accountHasBeenLocked"), + description: t("authentication.signIn.youHaveToWait"), + error, + }) + break + default: + setNetworkError({ + title: t("errors.somethingWentWrong"), + description: t("authentication.signIn.errorGenericMessage"), + error, + }) + } + } + + const resetNetworkError: NetworkErrorReset = () => setNetworkError(null) + + return { + networkError, + determineNetworkError, + resetNetworkError, + } +} diff --git a/shared-helpers/src/formKeys.ts b/shared-helpers/src/formKeys.ts new file mode 100644 index 0000000000..042148d553 --- /dev/null +++ b/shared-helpers/src/formKeys.ts @@ -0,0 +1,257 @@ +export const stateKeys = [ + "", + "AL", + "AK", + "AZ", + "AR", + "CA", + "CO", + "CT", + "DE", + "DC", + "FL", + "GA", + "HI", + "ID", + "IL", + "IN", + "IA", + "KS", + "KY", + "LA", + "ME", + "MD", + "MA", + "MI", + "MN", + "MS", + "MO", + "MT", + "NE", + "NV", + "NH", + "NJ", + "NM", + "NY", + "NC", + "ND", + "OH", + "OK", + "OR", + "PA", + "RI", + "SC", + "SD", + "TN", + "TX", + "UT", + "VT", + "VA", + "WA", + "WV", + "WI", + "WY", +] + +export const contactPreferencesKeys = [ + { + id: "email", + }, + { + id: "phone", + }, + { + id: "letter", + }, + { + id: "text", + }, +] + +export const relationshipKeys = [ + "", + "spouse", + "registeredDomesticPartner", + "parent", + "child", + "sibling", + "cousin", + "aunt", + "uncle", + "nephew", + "niece", + "grandparent", + "greatGrandparent", + "inLaw", + "friend", + "other", +] + +export const altContactRelationshipKeys = [ + "familyMember", + "friend", + "caseManager", + "other", + "noContact", +] + +export const ethnicityKeys = ["hispanicLatino", "notHispanicLatino"] + +export const rootRaceKeys = [ + "americanIndianAlaskanNative", + "asian", + "blackAfricanAmerican", + "nativeHawaiianOtherPacificIslander", + "white", + "otherMultiracial", + "declineToRespond", +] + +export const asianKeys = [ + "asianIndian", + "chinese", + "filipino", + "japanese", + "korean", + "vietnamese", + "otherAsian", +] + +export const nativeHawaiianOtherPacificIslanderKeys = [ + "nativeHawaiian", + "guamanianOrChamorro", + "samoan", + "otherPacificIslander", +] + +export const genderKeys = [ + "female", + "male", + "genderqueerGenderNon-Binary", + "transFemale", + "transMale", + "notListed", +] + +export const sexualOrientation = [ + "bisexual", + "gayLesbianSameGenderLoving", + "questioningUnsure", + "straightHeterosexual", + "notListed", +] + +export const prependRoot = (root: string, subKeys: string[]) => { + return subKeys.map((key) => `${root}-${key}`) +} + +interface subCheckboxes { + [key: string]: string[] +} + +// Transform an object with keys that may be prepended with a string to an array of only the values with the string +export const fieldGroupObjectToArray = ( + formObject: { [key: string]: any }, + rootKey: string +): string[] => { + const modifiedArray: string[] = [] + const getValue = (elem: string) => { + const formSubKey = elem.substring(elem.indexOf("-") + 1) + return formSubKey === formObject[elem] ? formSubKey : `${formSubKey}: ${formObject[elem]}` + } + Object.keys(formObject) + .filter((formValue) => formValue.split("-")[0] === rootKey && formObject[formValue]) + .forEach((elem) => { + if (formObject[elem].isArray) { + formObject[elem].forEach(() => { + modifiedArray.push(getValue(elem)) + }) + } else { + modifiedArray.push(getValue(elem)) + } + }) + return modifiedArray +} + +export const raceKeys: subCheckboxes = { + americanIndianAlaskanNative: [], + asian: prependRoot("asian", asianKeys), + blackAfricanAmerican: [], + nativeHawaiianOtherPacificIslander: prependRoot( + "nativeHawaiianOtherPacificIslander", + nativeHawaiianOtherPacificIslanderKeys + ), + white: [], + otherMultiracial: [], + declineToRespond: [], +} + +export const howDidYouHear = [ + { + id: "jurisdictionWebsite", + }, + { + id: "developerWebsite", + }, + { + id: "flyer", + }, + { + id: "emailAlert", + }, + { + id: "friend", + }, + { + id: "housingCounselor", + }, + { + id: "radioAd", + }, + { + id: "busAd", + }, + { + id: "other", + }, +] + +export const phoneNumberKeys = ["work", "home", "cell"] + +export const preferredUnit = [ + { + id: "studio", + }, + { + id: "oneBedroom", + }, + { + id: "twoBedroom", + }, + { + id: "threeBedroom", + }, + { + id: "moreThanThreeBedroom", + }, +] + +export const bedroomKeys = ["SRO", "studio", "oneBdrm", "twoBdrm", "threeBdrm"] + +export const listingFeatures = { + elevator: "Elevator", + wheelchairRamp: "Wheelchair Ramp", + serviceAnimalsAllowed: "Service Animals Allowed", + accessibleParking: "Accessible Parking", + parkingOnSite: "Parking on Site", + inUnitWasherDryer: "In Unit Washer Dryer", + laundryInBuilding: "Laundry in Building", + barrierFreeEntrance: "Barrier Free Entrance", + rollInShower: "Laundry in Building", + grabBars: "Grab Bars", + heatingInUnit: "Heating in Unit", + acInUnit: "AC in Unit", + hearing: "Hearing", + mobility: "Mobility", + visual: "Visual", +} diff --git a/shared-helpers/src/formatRange.ts b/shared-helpers/src/formatRange.ts new file mode 100644 index 0000000000..8c100a92e4 --- /dev/null +++ b/shared-helpers/src/formatRange.ts @@ -0,0 +1,15 @@ +export function formatRange( + min: string | number, + max: string | number, + prefix: string, + postfix: string +): string { + if (!isDefined(min) && !isDefined(max)) return "" + if (min == max || !isDefined(max)) return `${prefix}${min}${postfix}` + if (!isDefined(min)) return `${prefix}${max}${postfix}` + return `${prefix}${min}${postfix} - ${prefix}${max}${postfix}` +} + +export function isDefined(item: number | string): boolean { + return item !== null && item !== undefined && item !== "" +} diff --git a/shared-helpers/src/formatRentRange.ts b/shared-helpers/src/formatRentRange.ts new file mode 100644 index 0000000000..2e89fe1561 --- /dev/null +++ b/shared-helpers/src/formatRentRange.ts @@ -0,0 +1,16 @@ +import { MinMax } from "@bloom-housing/backend-core/types" +import { formatRange } from "./formatRange" + +export function formatRentRange(rent: MinMax, percent: MinMax): string { + let toReturn = "" + if (rent) { + toReturn += formatRange(rent.min, rent.max, "$", "") + } + if (rent && percent) { + toReturn += ", " + } + if (percent) { + toReturn += formatRange(percent.min, percent.max, "", "%") + } + return toReturn +} diff --git a/shared-helpers/src/gtm.ts b/shared-helpers/src/gtm.ts new file mode 100644 index 0000000000..c1f8e86ee3 --- /dev/null +++ b/shared-helpers/src/gtm.ts @@ -0,0 +1,36 @@ +import { ListingReviewOrder } from "@bloom-housing/backend-core/types" + +declare global { + interface Window { + dataLayer: DataLayerArgsUnion[] + } +} + +export type PageView = { + event: string + pageTitle: string + status: string +} + +export type ListingList = PageView & { + numberOfListings: number + listingIds: string[] +} + +export type ListingDetail = PageView & { + listingStartDate: string + listingStatus: string + listingID: string + listingType: ListingReviewOrder + applicationDueDate: string + digitalApplication: boolean + paperApplication: boolean +} + +type DataLayerArgsUnion = PageView | ListingList | ListingDetail + +export function pushGtmEvent(args: T): void { + if (!window) return + window.dataLayer = window.dataLayer || [] + window.dataLayer.push(args) +} diff --git a/shared-helpers/src/minMaxFinder.ts b/shared-helpers/src/minMaxFinder.ts new file mode 100644 index 0000000000..b8e839c558 --- /dev/null +++ b/shared-helpers/src/minMaxFinder.ts @@ -0,0 +1,15 @@ +import { MinMax } from "@bloom-housing/backend-core/types" + +export function minMaxFinder(range: MinMax, value: number): MinMax { + if (range === undefined) { + return { + min: value, + max: value, + } + } else { + range.min = Math.min(range.min, value) + range.max = Math.max(range.max, value) + + return range + } +} diff --git a/shared-helpers/src/nextjs.ts b/shared-helpers/src/nextjs.ts new file mode 100644 index 0000000000..8e51c88b10 --- /dev/null +++ b/shared-helpers/src/nextjs.ts @@ -0,0 +1,7 @@ +import { useEffect, useState } from "react" + +export const OnClientSide = () => { + const [mounted, setMounted] = useState(false) + useEffect(() => setMounted(true), []) + return mounted +} diff --git a/shared-helpers/src/occupancyFormatting.tsx b/shared-helpers/src/occupancyFormatting.tsx new file mode 100644 index 0000000000..3235004740 --- /dev/null +++ b/shared-helpers/src/occupancyFormatting.tsx @@ -0,0 +1,48 @@ +import * as React from "react" +import { t } from "@bloom-housing/ui-components" +import { Listing, UnitType } from "@bloom-housing/backend-core/types" + +export const occupancyTable = (listing: Listing) => { + const getOccupancyString = (min?: number, max?: number) => { + if (!max && min) return min === 1 ? t("t.minPerson") : t("t.minPeople", { num: min }) + if (!min && max) return max === 1 ? t("t.maxPerson") : t("t.maxPeople", { num: max }) + if (min === max) return max === 1 ? t("t.onePerson") : t("t.numPeople", { num: max }) + return t("t.peopleRange", { min, max }) + } + + const getUnitTypeNameString = (unitType: UnitType) => { + return t("listings.unitTypes." + unitType.name) + } + + const getUnitTypeString = (unitTypes: UnitType[]) => { + const unitTypesString = unitTypes.reduce((acc, curr, index) => { + return index > 0 ? `${acc}, ${getUnitTypeNameString(curr)}` : getUnitTypeNameString(curr) + }, "") + + return {unitTypesString} + } + + const sortedUnitGroups = listing.unitGroups + ?.sort( + (a, b) => + a.unitType.sort((c, d) => c.numBedrooms - d.numBedrooms)[0].numBedrooms - + b.unitType.sort((e, f) => e.numBedrooms - f.numBedrooms)[0].numBedrooms + ) + .filter((unitGroup) => unitGroup.maxOccupancy || unitGroup.minOccupancy) + + const tableRows = sortedUnitGroups?.reduce<{ [key: string]: string | JSX.Element }[]>( + (acc, curr) => { + const unitTypeString = getUnitTypeString(curr.unitType) + const occupancyString = getOccupancyString(curr.minOccupancy, curr.maxOccupancy) + if (occupancyString) { + acc.push({ + unitType: unitTypeString, + occupancy: occupancyString, + }) + } + return acc + }, + [] + ) + return tableRows +} diff --git a/shared-helpers/src/pdfs.ts b/shared-helpers/src/pdfs.ts new file mode 100644 index 0000000000..8280db830e --- /dev/null +++ b/shared-helpers/src/pdfs.ts @@ -0,0 +1,19 @@ +import { ListingEvent, ListingEventType } from "@bloom-housing/backend-core/types" + +export const cloudinaryPdfFromId = (publicId: string, cloudName: string) => { + return `https://res.cloudinary.com/${cloudName}/image/upload/${publicId}.pdf` +} + +export const pdfUrlFromListingEvents = ( + events: ListingEvent[], + listingEventType: ListingEventType, + cloudName: string +) => { + const event = events.find((event) => event?.type === listingEventType) + if (event) { + return event.file?.label == "cloudinaryPDF" + ? cloudinaryPdfFromId(event.file.fileId, cloudName) + : event.url ?? null + } + return null +} diff --git a/shared-helpers/src/photos.ts b/shared-helpers/src/photos.ts new file mode 100644 index 0000000000..f5234e2d5f --- /dev/null +++ b/shared-helpers/src/photos.ts @@ -0,0 +1,23 @@ +import { Asset, Listing } from "@bloom-housing/backend-core/types" + +export const cloudinaryUrlFromId = (publicId: string, size = 400) => { + const cloudName = process.env.cloudinaryCloudName || process.env.CLOUDINARY_CLOUD_NAME + return `https://res.cloudinary.com/${cloudName}/image/upload/w_${size},c_limit,q_65/${publicId}.jpg` +} + +export const imageUrlFromListing = (listing: Listing, size = 400) => { + // Use the new `image` field + const imageAssets = + listing?.images?.length && listing.images[0].image ? [listing.images[0].image] : listing?.assets + + // Fallback to `assets` + const cloudinaryBuilding = imageAssets?.find( + (asset: Asset) => asset.label == "cloudinaryBuilding" + )?.fileId + if (cloudinaryBuilding) return cloudinaryUrlFromId(cloudinaryBuilding, size) + + return ( + imageAssets?.find((asset: Asset) => asset.label == "building")?.fileId || + "/images/detroitDefault.png" + ) +} diff --git a/shared-helpers/src/programHelpers.ts b/shared-helpers/src/programHelpers.ts new file mode 100644 index 0000000000..44facdca84 --- /dev/null +++ b/shared-helpers/src/programHelpers.ts @@ -0,0 +1,96 @@ +import { ApplicationProgram, Program, FormMetaDataType } from "@bloom-housing/backend-core/types" + +export const PROGRAMS_FORM_PATH = "application.programs" + +export const mapProgramToApi = (program: Program, data: Record) => { + if (Object.keys(data).length === 0) { + return { + key: "", + claimed: false, + options: [], + } + } + + const [key, value] = Object.entries(data)[0] + const options = [] + + if (program?.formMetadata?.type === FormMetaDataType.checkbox) { + value.forEach((option: string) => { + options.push({ + key: option, + checked: true, + extraData: [], + }) + }) + } else { + options.push({ + key: value, + checked: true, + extraData: [], + }) + program?.formMetadata?.options.forEach((option) => { + if (option.key !== value) { + options.push({ + key: option.key, + checked: false, + extraData: [], + }) + } + }) + } + + return { + key, + claimed: true, + options, + } +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export const mapProgramsToApi = (programs: Program[], data: Record) => { + if (Object.keys(data).length === 0) return [] + const savedPrograms = [] as ApplicationProgram[] + Object.entries(data).forEach(([key, value]) => { + const program = programs.find((item) => item.formMetadata?.key === key) + if (!program) return + + const mappedProgram = mapProgramToApi(program, { [key]: value }) + if (mappedProgram.key) { + savedPrograms.push(mappedProgram) + } + }) + return savedPrograms +} + +// used in the paper apps only +export const mapApiToProgramsPaperForm = (programs: ApplicationProgram[]) => { + const result = {} + + programs?.forEach((program) => { + const key = program.key + + const selectedOption = program.options.reduce((accum, item) => { + if (item.checked) { + return [...accum, item.key] + } + return accum + }, []) + if (program.claimed) { + Object.assign(result, { + [key]: selectedOption.length === 1 ? selectedOption[0] : selectedOption, + }) + } + }) + + return result +} + +export const getProgramOptionName = (key: string, metaKey: string) => { + return key === "preferNotToSay" + ? "t.preferNotToSay" + : `${PROGRAMS_FORM_PATH}.${metaKey}.${key}.label` +} + +export const getProgramOptionDescription = (key: string, metaKey: string) => { + return `${PROGRAMS_FORM_PATH}.${metaKey}.${key}.description` +} diff --git a/shared-helpers/src/stringFormatting.ts b/shared-helpers/src/stringFormatting.ts new file mode 100644 index 0000000000..d8c7b7db18 --- /dev/null +++ b/shared-helpers/src/stringFormatting.ts @@ -0,0 +1,7 @@ +import dayjs from "dayjs" + +export const getTimeRangeString = (start: Date, end: Date) => { + const startTime = dayjs(start).format("hh:mma") + const endTime = dayjs(end).format("hh:mma") + return startTime === endTime ? startTime : `${startTime} - ${endTime}` +} diff --git a/shared-helpers/src/unitTypes.ts b/shared-helpers/src/unitTypes.ts new file mode 100644 index 0000000000..6dae932335 --- /dev/null +++ b/shared-helpers/src/unitTypes.ts @@ -0,0 +1,42 @@ +import { Unit, UnitType } from "@bloom-housing/backend-core/types" + +type GetUnitTypeNamesReturn = { + id: string + name: string +} + +export const UnitTypeSort = ["SRO", "studio", "oneBdrm", "twoBdrm", "threeBdrm", "fourBdrm"] + +export const sortUnitTypes = (units: UnitType[] | GetUnitTypeNamesReturn[]) => { + if (!units) return [] + + return units.sort((a, b) => UnitTypeSort.indexOf(a.name) - UnitTypeSort.indexOf(b.name)) +} + +export const getUniqueUnitTypes = (units: Unit[]): GetUnitTypeNamesReturn[] => { + if (!units) return [] + + const unitTypes = units.reduce((acc, curr) => { + const { id, name } = curr.unitType || {} + + if (!id || !name) return acc + + const unitTypeExists = acc.some((item) => item.id === id) + + if (!unitTypeExists) { + acc.push({ + id, + name, + }) + } + + return acc + }, [] as GetUnitTypeNamesReturn[]) + + const sorted = sortUnitTypes(unitTypes) + + return sorted +} + +// It creates array of objects with the id property +export const createUnitTypeId = (unitIds: string[]) => unitIds?.map((id) => ({ id } ?? [])) diff --git a/shared-helpers/tsconfig.json b/shared-helpers/tsconfig.json new file mode 100644 index 0000000000..7b91fc750d --- /dev/null +++ b/shared-helpers/tsconfig.json @@ -0,0 +1,29 @@ +{ + "extends": "../tsconfig.json" /* TODO: is this worth connecting? */, + "compilerOptions": { + "outDir": "build/lib", + "module": "esnext", + "esModuleInterop": true, + "target": "es5", + "lib": ["es5", "es6", "es7", "es2017", "dom"], + "sourceMap": true, + "allowJs": true, + "jsx": "react", + "moduleResolution": "node", + "rootDirs": ["src"], + "baseUrl": "src", + "forceConsistentCasingInFileNames": true, + "noImplicitReturns": true, + "noImplicitThis": true, + "noImplicitAny": true, + "strictNullChecks": true, + "suppressImplicitAnyIndexErrors": true, + "noUnusedLocals": false, + "declaration": true, + "allowSyntheticDefaultImports": true, + "experimentalDecorators": true, + "emitDecoratorMetadata": true, + "resolveJsonModule": true + }, + "include": ["src/**/*", "__tests__/**/*"] +} diff --git a/sites/partners/.babelrc b/sites/partners/.babelrc new file mode 100644 index 0000000000..55f3f85dc3 --- /dev/null +++ b/sites/partners/.babelrc @@ -0,0 +1,4 @@ +{ + "presets": ["next/babel"], + "plugins": ["istanbul"] +} diff --git a/sites/partners/.env.template b/sites/partners/.env.template new file mode 100644 index 0000000000..414040d1fb --- /dev/null +++ b/sites/partners/.env.template @@ -0,0 +1,12 @@ +## == IMPORTANT REMINDER: ANY EDITS HERE MUST BE UPDATED IN ALL ENVIRONMENTS (incl. CI) == ## +BACKEND_API_BASE=http://localhost:3100 +BACKEND_PROXY_BASE= +LISTINGS_QUERY=/listings +NEXTJS_PORT=3001 +SHOW_DUPLICATES=FALSE +SHOW_LM_LINKS=TRUE +CLOUDINARY_CLOUD_NAME=exygy +CLOUDINARY_KEY='abcxyz' +CLOUDINARY_SIGNED_PRESET='test123' +MAPBOX_TOKEN= + diff --git a/sites/partners/.jest/setup-tests.js b/sites/partners/.jest/setup-tests.js new file mode 100644 index 0000000000..aaf2af2ea1 --- /dev/null +++ b/sites/partners/.jest/setup-tests.js @@ -0,0 +1 @@ +// Future home of additional Jest config diff --git a/sites/partners/CHANGELOG.md b/sites/partners/CHANGELOG.md new file mode 100644 index 0000000000..67b34450d5 --- /dev/null +++ b/sites/partners/CHANGELOG.md @@ -0,0 +1,2489 @@ +# Change Log + +All notable changes to this project will be documented in this file. +See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. + +# [4.2.0](https://github.com/seanmalbert/bloom/compare/@bloom-housing/partners@4.1.2...@bloom-housing/partners@4.2.0) (2022-04-06) + + +* 2022-04-05 release (#2627) ([485fb48](https://github.com/seanmalbert/bloom/commit/485fb48cfbad48bcabfef5e2e704025f608aee89)), closes [#2627](https://github.com/seanmalbert/bloom/issues/2627) [#2352](https://github.com/seanmalbert/bloom/issues/2352) [#2316](https://github.com/seanmalbert/bloom/issues/2316) [#2356](https://github.com/seanmalbert/bloom/issues/2356) [#2353](https://github.com/seanmalbert/bloom/issues/2353) [#2338](https://github.com/seanmalbert/bloom/issues/2338) [#2377](https://github.com/seanmalbert/bloom/issues/2377) [#2320](https://github.com/seanmalbert/bloom/issues/2320) [#2386](https://github.com/seanmalbert/bloom/issues/2386) [#2362](https://github.com/seanmalbert/bloom/issues/2362) [#2395](https://github.com/seanmalbert/bloom/issues/2395) [#2410](https://github.com/seanmalbert/bloom/issues/2410) [#2407](https://github.com/seanmalbert/bloom/issues/2407) [#2430](https://github.com/seanmalbert/bloom/issues/2430) [#2418](https://github.com/seanmalbert/bloom/issues/2418) [#2434](https://github.com/seanmalbert/bloom/issues/2434) [#2374](https://github.com/seanmalbert/bloom/issues/2374) [#2435](https://github.com/seanmalbert/bloom/issues/2435) [#2420](https://github.com/seanmalbert/bloom/issues/2420) [#2412](https://github.com/seanmalbert/bloom/issues/2412) [#2438](https://github.com/seanmalbert/bloom/issues/2438) [#2429](https://github.com/seanmalbert/bloom/issues/2429) [#2452](https://github.com/seanmalbert/bloom/issues/2452) [#2458](https://github.com/seanmalbert/bloom/issues/2458) [#2423](https://github.com/seanmalbert/bloom/issues/2423) [#2432](https://github.com/seanmalbert/bloom/issues/2432) [#2437](https://github.com/seanmalbert/bloom/issues/2437) [#2440](https://github.com/seanmalbert/bloom/issues/2440) [#2441](https://github.com/seanmalbert/bloom/issues/2441) [#2460](https://github.com/seanmalbert/bloom/issues/2460) [#2459](https://github.com/seanmalbert/bloom/issues/2459) [#2464](https://github.com/seanmalbert/bloom/issues/2464) [#2465](https://github.com/seanmalbert/bloom/issues/2465) [#2466](https://github.com/seanmalbert/bloom/issues/2466) [#2436](https://github.com/seanmalbert/bloom/issues/2436) [#2451](https://github.com/seanmalbert/bloom/issues/2451) [#2415](https://github.com/seanmalbert/bloom/issues/2415) [#2354](https://github.com/seanmalbert/bloom/issues/2354) [#2455](https://github.com/seanmalbert/bloom/issues/2455) [#2484](https://github.com/seanmalbert/bloom/issues/2484) [#2482](https://github.com/seanmalbert/bloom/issues/2482) [#2483](https://github.com/seanmalbert/bloom/issues/2483) [#2476](https://github.com/seanmalbert/bloom/issues/2476) [#2485](https://github.com/seanmalbert/bloom/issues/2485) [#2470](https://github.com/seanmalbert/bloom/issues/2470) [#2488](https://github.com/seanmalbert/bloom/issues/2488) [#2487](https://github.com/seanmalbert/bloom/issues/2487) [#2496](https://github.com/seanmalbert/bloom/issues/2496) [#2498](https://github.com/seanmalbert/bloom/issues/2498) [#2499](https://github.com/seanmalbert/bloom/issues/2499) [#2291](https://github.com/seanmalbert/bloom/issues/2291) [#2461](https://github.com/seanmalbert/bloom/issues/2461) [#2485](https://github.com/seanmalbert/bloom/issues/2485) [#2494](https://github.com/seanmalbert/bloom/issues/2494) [#2503](https://github.com/seanmalbert/bloom/issues/2503) [#2495](https://github.com/seanmalbert/bloom/issues/2495) [#2477](https://github.com/seanmalbert/bloom/issues/2477) [#2505](https://github.com/seanmalbert/bloom/issues/2505) [#2372](https://github.com/seanmalbert/bloom/issues/2372) [#2489](https://github.com/seanmalbert/bloom/issues/2489) [#2497](https://github.com/seanmalbert/bloom/issues/2497) [#2506](https://github.com/seanmalbert/bloom/issues/2506) [#2486](https://github.com/seanmalbert/bloom/issues/2486) +* 2022-04-04 release (#2614) ([fecab85](https://github.com/seanmalbert/bloom/commit/fecab85c748a55ab4aff5d591c8e0ac702254559)), closes [#2614](https://github.com/seanmalbert/bloom/issues/2614) [#2349](https://github.com/seanmalbert/bloom/issues/2349) [#2350](https://github.com/seanmalbert/bloom/issues/2350) [#2351](https://github.com/seanmalbert/bloom/issues/2351) [#2348](https://github.com/seanmalbert/bloom/issues/2348) [#2352](https://github.com/seanmalbert/bloom/issues/2352) [#2316](https://github.com/seanmalbert/bloom/issues/2316) [#2356](https://github.com/seanmalbert/bloom/issues/2356) [#2353](https://github.com/seanmalbert/bloom/issues/2353) [#2338](https://github.com/seanmalbert/bloom/issues/2338) [#2377](https://github.com/seanmalbert/bloom/issues/2377) [#2320](https://github.com/seanmalbert/bloom/issues/2320) [#2386](https://github.com/seanmalbert/bloom/issues/2386) [#2362](https://github.com/seanmalbert/bloom/issues/2362) [#2395](https://github.com/seanmalbert/bloom/issues/2395) [#2410](https://github.com/seanmalbert/bloom/issues/2410) [#2407](https://github.com/seanmalbert/bloom/issues/2407) [#2430](https://github.com/seanmalbert/bloom/issues/2430) [#2418](https://github.com/seanmalbert/bloom/issues/2418) [#2434](https://github.com/seanmalbert/bloom/issues/2434) [#2374](https://github.com/seanmalbert/bloom/issues/2374) [#2435](https://github.com/seanmalbert/bloom/issues/2435) [#2420](https://github.com/seanmalbert/bloom/issues/2420) [#2412](https://github.com/seanmalbert/bloom/issues/2412) [#2438](https://github.com/seanmalbert/bloom/issues/2438) [#2429](https://github.com/seanmalbert/bloom/issues/2429) [#2452](https://github.com/seanmalbert/bloom/issues/2452) [#2458](https://github.com/seanmalbert/bloom/issues/2458) [#2423](https://github.com/seanmalbert/bloom/issues/2423) [#2432](https://github.com/seanmalbert/bloom/issues/2432) [#2437](https://github.com/seanmalbert/bloom/issues/2437) [#2440](https://github.com/seanmalbert/bloom/issues/2440) [#2441](https://github.com/seanmalbert/bloom/issues/2441) [#2460](https://github.com/seanmalbert/bloom/issues/2460) [#2459](https://github.com/seanmalbert/bloom/issues/2459) [#2464](https://github.com/seanmalbert/bloom/issues/2464) [#2465](https://github.com/seanmalbert/bloom/issues/2465) [#2466](https://github.com/seanmalbert/bloom/issues/2466) [#2436](https://github.com/seanmalbert/bloom/issues/2436) [#2451](https://github.com/seanmalbert/bloom/issues/2451) [#2415](https://github.com/seanmalbert/bloom/issues/2415) [#2354](https://github.com/seanmalbert/bloom/issues/2354) [#2455](https://github.com/seanmalbert/bloom/issues/2455) [#2484](https://github.com/seanmalbert/bloom/issues/2484) [#2482](https://github.com/seanmalbert/bloom/issues/2482) [#2483](https://github.com/seanmalbert/bloom/issues/2483) [#2476](https://github.com/seanmalbert/bloom/issues/2476) [#2485](https://github.com/seanmalbert/bloom/issues/2485) [#2470](https://github.com/seanmalbert/bloom/issues/2470) [#2488](https://github.com/seanmalbert/bloom/issues/2488) [#2487](https://github.com/seanmalbert/bloom/issues/2487) [#2496](https://github.com/seanmalbert/bloom/issues/2496) [#2498](https://github.com/seanmalbert/bloom/issues/2498) [#2499](https://github.com/seanmalbert/bloom/issues/2499) [#2291](https://github.com/seanmalbert/bloom/issues/2291) [#2461](https://github.com/seanmalbert/bloom/issues/2461) [#2485](https://github.com/seanmalbert/bloom/issues/2485) [#2494](https://github.com/seanmalbert/bloom/issues/2494) [#2503](https://github.com/seanmalbert/bloom/issues/2503) [#2495](https://github.com/seanmalbert/bloom/issues/2495) [#2477](https://github.com/seanmalbert/bloom/issues/2477) [#2505](https://github.com/seanmalbert/bloom/issues/2505) [#2372](https://github.com/seanmalbert/bloom/issues/2372) [#2489](https://github.com/seanmalbert/bloom/issues/2489) [#2497](https://github.com/seanmalbert/bloom/issues/2497) [#2506](https://github.com/seanmalbert/bloom/issues/2506) [#2486](https://github.com/seanmalbert/bloom/issues/2486) + + +### BREAKING CHANGES + +* remove applicationDueTime field and consolidated into applicationDueDate + +* chore(release): version + + - @bloom-housing/backend-core@3.0.2-alpha.13 + - @bloom-housing/shared-helpers@4.0.1-alpha.21 + - @bloom-housing/partners@4.0.1-alpha.23 + - @bloom-housing/public@4.0.1-alpha.22 + - @bloom-housing/ui-components@4.0.1-alpha.21 +* consolidated all event section components in one new component, uptake will require removing the deprecated components and uptaking EventSection + +* chore(release): version + + - @bloom-housing/backend-core@3.0.2-alpha.38 + - @bloom-housing/shared-helpers@4.0.1-alpha.63 + - @bloom-housing/partners@4.0.1-alpha.67 + - @bloom-housing/public@4.0.1-alpha.66 + - @bloom-housing/ui-components@4.0.1-alpha.62 +* remove applicationDueTime field and consolidated into applicationDueDate + +* chore(release): version + + - @bloom-housing/backend-core@3.0.2-alpha.13 + - @bloom-housing/shared-helpers@4.0.1-alpha.21 + - @bloom-housing/partners@4.0.1-alpha.23 + - @bloom-housing/public@4.0.1-alpha.22 + - @bloom-housing/ui-components@4.0.1-alpha.21 +* consolidated all event section components in one new component, uptake will require removing the deprecated components and uptaking EventSection + +* chore(release): version + + - @bloom-housing/backend-core@3.0.2-alpha.38 + - @bloom-housing/shared-helpers@4.0.1-alpha.63 + - @bloom-housing/partners@4.0.1-alpha.67 + - @bloom-housing/public@4.0.1-alpha.66 + - @bloom-housing/ui-components@4.0.1-alpha.62 + + + + + +## [4.1.3-alpha.7](https://github.com/bloom-housing/bloom/compare/@bloom-housing/partners@4.1.3-alpha.6...@bloom-housing/partners@4.1.3-alpha.7) (2022-04-05) + + +### Bug Fixes + +* remove shared-helpers dependency from ui-components ([#2620](https://github.com/bloom-housing/bloom/issues/2620)) ([cd6ea54](https://github.com/bloom-housing/bloom/commit/cd6ea5450402a9b5d2a8681c403cbfcff6b6b1c9)) + + + + + +## [4.1.3-alpha.6](https://github.com/bloom-housing/bloom/compare/@bloom-housing/partners@4.1.3-alpha.5...@bloom-housing/partners@4.1.3-alpha.6) (2022-04-05) + +**Note:** Version bump only for package @bloom-housing/partners + + + + + +## [4.1.3-alpha.5](https://github.com/bloom-housing/bloom/compare/@bloom-housing/partners@4.1.3-alpha.4...@bloom-housing/partners@4.1.3-alpha.5) (2022-04-04) + +**Note:** Version bump only for package @bloom-housing/partners + + + + + +## [4.1.3-alpha.4](https://github.com/bloom-housing/bloom/compare/@bloom-housing/partners@4.1.3-alpha.3...@bloom-housing/partners@4.1.3-alpha.4) (2022-04-04) + +**Note:** Version bump only for package @bloom-housing/partners + + + + + +## [4.1.3-alpha.3](https://github.com/bloom-housing/bloom/compare/@bloom-housing/partners@4.1.3-alpha.2...@bloom-housing/partners@4.1.3-alpha.3) (2022-04-04) + +**Note:** Version bump only for package @bloom-housing/partners + + + + + +## [4.1.3-alpha.2](https://github.com/bloom-housing/bloom/compare/@bloom-housing/partners@4.1.3-alpha.1...@bloom-housing/partners@4.1.3-alpha.2) (2022-03-31) + + +### Bug Fixes + +* select programs working with single jurisdiction ([#2598](https://github.com/bloom-housing/bloom/issues/2598)) ([7fec414](https://github.com/bloom-housing/bloom/commit/7fec414c8ede55f16679f2e099f58965773cf5a3)) + + + + + +## [4.1.3-alpha.1](https://github.com/bloom-housing/bloom/compare/@bloom-housing/partners@4.1.3-alpha.0...@bloom-housing/partners@4.1.3-alpha.1) (2022-03-30) + + +### Bug Fixes + +* added margin bottom to button on mobile in unit modal ([e26b763](https://github.com/bloom-housing/bloom/commit/e26b763b4ec4024f1e90131ee30d5ecbbb8a9daf)) +* added margin bottom to second button in unit modal, only on mobile ([a94f5f2](https://github.com/bloom-housing/bloom/commit/a94f5f2ca5b6687baff5f29dc7294e150cc106f3)) + + + + + +## [4.1.3-alpha.0](https://github.com/bloom-housing/bloom/compare/@bloom-housing/partners@4.1.2-alpha.3...@bloom-housing/partners@4.1.3-alpha.0) (2022-03-30) + +**Note:** Version bump only for package @bloom-housing/partners + + + + + +## [4.1.2](https://github.com/seanmalbert/bloom/compare/@bloom-housing/partners@4.1.1...@bloom-housing/partners@4.1.2) (2022-03-29) + + + + + +## [4.1.2-alpha.3](https://github.com/bloom-housing/bloom/compare/@bloom-housing/partners@4.1.2-alpha.2...@bloom-housing/partners@4.1.2-alpha.3) (2022-03-29) + +**Note:** Version bump only for package @bloom-housing/partners + + + + + +## [4.1.2-alpha.2](https://github.com/bloom-housing/bloom/compare/@bloom-housing/partners@4.1.2-alpha.1...@bloom-housing/partners@4.1.2-alpha.2) (2022-03-29) + + +### Bug Fixes + +* partners user lisitngs all checkbox ([#2592](https://github.com/bloom-housing/bloom/issues/2592)) ([47fd4b3](https://github.com/bloom-housing/bloom/commit/47fd4b31dc710ef2ccc28473faefe5f047d614b4)) +* removed unused partner footer links ([#2590](https://github.com/bloom-housing/bloom/issues/2590)) ([318d42e](https://github.com/bloom-housing/bloom/commit/318d42e01f5374c7cd2d3e7b35a4bb44e3659c94)) + + + + + +## [4.1.2-alpha.1](https://github.com/bloom-housing/bloom/compare/@bloom-housing/partners@4.1.2-alpha.0...@bloom-housing/partners@4.1.2-alpha.1) (2022-03-28) + + +### Features + +* adds partners re-request confirmation ([#2574](https://github.com/bloom-housing/bloom/issues/2574)) ([235af78](https://github.com/bloom-housing/bloom/commit/235af781914e5c36104bb3862dd55152a16e6750)), closes [#2577](https://github.com/bloom-housing/bloom/issues/2577) + + + + + +## [4.1.2-alpha.0](https://github.com/bloom-housing/bloom/compare/@bloom-housing/partners@4.1.1-alpha.5...@bloom-housing/partners@4.1.2-alpha.0) (2022-03-28) + + +* 2022 03 28 sync master (#2593) ([580283d](https://github.com/bloom-housing/bloom/commit/580283da22246b7d39978e7dfa08016b2c0c3757)), closes [#2593](https://github.com/bloom-housing/bloom/issues/2593) [#2037](https://github.com/bloom-housing/bloom/issues/2037) [#2095](https://github.com/bloom-housing/bloom/issues/2095) [#2162](https://github.com/bloom-housing/bloom/issues/2162) [#2293](https://github.com/bloom-housing/bloom/issues/2293) [#2295](https://github.com/bloom-housing/bloom/issues/2295) [#2296](https://github.com/bloom-housing/bloom/issues/2296) [#2294](https://github.com/bloom-housing/bloom/issues/2294) [#2277](https://github.com/bloom-housing/bloom/issues/2277) [#2299](https://github.com/bloom-housing/bloom/issues/2299) [#2292](https://github.com/bloom-housing/bloom/issues/2292) [#2308](https://github.com/bloom-housing/bloom/issues/2308) [#2239](https://github.com/bloom-housing/bloom/issues/2239) [#2311](https://github.com/bloom-housing/bloom/issues/2311) [#2230](https://github.com/bloom-housing/bloom/issues/2230) [#2302](https://github.com/bloom-housing/bloom/issues/2302) [#2288](https://github.com/bloom-housing/bloom/issues/2288) [#2317](https://github.com/bloom-housing/bloom/issues/2317) [#2386](https://github.com/bloom-housing/bloom/issues/2386) [#2215](https://github.com/bloom-housing/bloom/issues/2215) [#2303](https://github.com/bloom-housing/bloom/issues/2303) [#2410](https://github.com/bloom-housing/bloom/issues/2410) [#2430](https://github.com/bloom-housing/bloom/issues/2430) [#2439](https://github.com/bloom-housing/bloom/issues/2439) [#2196](https://github.com/bloom-housing/bloom/issues/2196) [#2238](https://github.com/bloom-housing/bloom/issues/2238) [#2226](https://github.com/bloom-housing/bloom/issues/2226) [#2230](https://github.com/bloom-housing/bloom/issues/2230) [#2243](https://github.com/bloom-housing/bloom/issues/2243) [#2195](https://github.com/bloom-housing/bloom/issues/2195) [#2215](https://github.com/bloom-housing/bloom/issues/2215) [#2266](https://github.com/bloom-housing/bloom/issues/2266) [#2188](https://github.com/bloom-housing/bloom/issues/2188) [#2270](https://github.com/bloom-housing/bloom/issues/2270) [#2188](https://github.com/bloom-housing/bloom/issues/2188) [#2213](https://github.com/bloom-housing/bloom/issues/2213) [#2234](https://github.com/bloom-housing/bloom/issues/2234) [#1901](https://github.com/bloom-housing/bloom/issues/1901) [#2260](https://github.com/bloom-housing/bloom/issues/2260) [#1927](https://github.com/bloom-housing/bloom/issues/1927) [#1927](https://github.com/bloom-housing/bloom/issues/1927) [#1927](https://github.com/bloom-housing/bloom/issues/1927) [#1927](https://github.com/bloom-housing/bloom/issues/1927) [#1927](https://github.com/bloom-housing/bloom/issues/1927) [#1927](https://github.com/bloom-housing/bloom/issues/1927) [#1927](https://github.com/bloom-housing/bloom/issues/1927) [#1927](https://github.com/bloom-housing/bloom/issues/1927) [#1927](https://github.com/bloom-housing/bloom/issues/1927) [#1927](https://github.com/bloom-housing/bloom/issues/1927) [#1927](https://github.com/bloom-housing/bloom/issues/1927) [#1927](https://github.com/bloom-housing/bloom/issues/1927) [#1927](https://github.com/bloom-housing/bloom/issues/1927) [#2280](https://github.com/bloom-housing/bloom/issues/2280) [#2253](https://github.com/bloom-housing/bloom/issues/2253) [#2276](https://github.com/bloom-housing/bloom/issues/2276) [#2282](https://github.com/bloom-housing/bloom/issues/2282) [#2262](https://github.com/bloom-housing/bloom/issues/2262) [#2278](https://github.com/bloom-housing/bloom/issues/2278) [#2293](https://github.com/bloom-housing/bloom/issues/2293) [#2295](https://github.com/bloom-housing/bloom/issues/2295) [#2296](https://github.com/bloom-housing/bloom/issues/2296) [#2294](https://github.com/bloom-housing/bloom/issues/2294) [#2277](https://github.com/bloom-housing/bloom/issues/2277) [#2290](https://github.com/bloom-housing/bloom/issues/2290) [#2299](https://github.com/bloom-housing/bloom/issues/2299) [#2292](https://github.com/bloom-housing/bloom/issues/2292) [#2303](https://github.com/bloom-housing/bloom/issues/2303) [#2305](https://github.com/bloom-housing/bloom/issues/2305) [#2306](https://github.com/bloom-housing/bloom/issues/2306) [#2308](https://github.com/bloom-housing/bloom/issues/2308) [#2190](https://github.com/bloom-housing/bloom/issues/2190) [#2239](https://github.com/bloom-housing/bloom/issues/2239) [#2311](https://github.com/bloom-housing/bloom/issues/2311) [#2302](https://github.com/bloom-housing/bloom/issues/2302) [#2301](https://github.com/bloom-housing/bloom/issues/2301) [#1927](https://github.com/bloom-housing/bloom/issues/1927) [#2313](https://github.com/bloom-housing/bloom/issues/2313) [#2289](https://github.com/bloom-housing/bloom/issues/2289) [#2279](https://github.com/bloom-housing/bloom/issues/2279) [#2288](https://github.com/bloom-housing/bloom/issues/2288) [#2317](https://github.com/bloom-housing/bloom/issues/2317) [#2319](https://github.com/bloom-housing/bloom/issues/2319) [#2108](https://github.com/bloom-housing/bloom/issues/2108) [#2326](https://github.com/bloom-housing/bloom/issues/2326) [#2349](https://github.com/bloom-housing/bloom/issues/2349) [#2350](https://github.com/bloom-housing/bloom/issues/2350) [#2351](https://github.com/bloom-housing/bloom/issues/2351) [#2348](https://github.com/bloom-housing/bloom/issues/2348) [#2352](https://github.com/bloom-housing/bloom/issues/2352) [#2316](https://github.com/bloom-housing/bloom/issues/2316) [#2356](https://github.com/bloom-housing/bloom/issues/2356) [#2353](https://github.com/bloom-housing/bloom/issues/2353) [#2338](https://github.com/bloom-housing/bloom/issues/2338) [#2377](https://github.com/bloom-housing/bloom/issues/2377) [#2320](https://github.com/bloom-housing/bloom/issues/2320) [#2386](https://github.com/bloom-housing/bloom/issues/2386) [#2362](https://github.com/bloom-housing/bloom/issues/2362) [#2395](https://github.com/bloom-housing/bloom/issues/2395) [#2410](https://github.com/bloom-housing/bloom/issues/2410) [#2407](https://github.com/bloom-housing/bloom/issues/2407) [#2430](https://github.com/bloom-housing/bloom/issues/2430) [#2418](https://github.com/bloom-housing/bloom/issues/2418) [#2434](https://github.com/bloom-housing/bloom/issues/2434) [#2374](https://github.com/bloom-housing/bloom/issues/2374) [#2435](https://github.com/bloom-housing/bloom/issues/2435) [#2420](https://github.com/bloom-housing/bloom/issues/2420) [#2412](https://github.com/bloom-housing/bloom/issues/2412) [#2434](https://github.com/bloom-housing/bloom/issues/2434) [#2458](https://github.com/bloom-housing/bloom/issues/2458) [#2460](https://github.com/bloom-housing/bloom/issues/2460) [#2465](https://github.com/bloom-housing/bloom/issues/2465) [#2436](https://github.com/bloom-housing/bloom/issues/2436) [#2484](https://github.com/bloom-housing/bloom/issues/2484) [#2482](https://github.com/bloom-housing/bloom/issues/2482) [#2483](https://github.com/bloom-housing/bloom/issues/2483) [#2488](https://github.com/bloom-housing/bloom/issues/2488) [#2485](https://github.com/bloom-housing/bloom/issues/2485) [#2519](https://github.com/bloom-housing/bloom/issues/2519) [#2506](https://github.com/bloom-housing/bloom/issues/2506) [#2534](https://github.com/bloom-housing/bloom/issues/2534) [#2544](https://github.com/bloom-housing/bloom/issues/2544) [#2550](https://github.com/bloom-housing/bloom/issues/2550) [#2288](https://github.com/bloom-housing/bloom/issues/2288) [#2317](https://github.com/bloom-housing/bloom/issues/2317) [#2319](https://github.com/bloom-housing/bloom/issues/2319) [#2108](https://github.com/bloom-housing/bloom/issues/2108) [#2326](https://github.com/bloom-housing/bloom/issues/2326) [#2349](https://github.com/bloom-housing/bloom/issues/2349) [#2350](https://github.com/bloom-housing/bloom/issues/2350) [#2351](https://github.com/bloom-housing/bloom/issues/2351) [#2348](https://github.com/bloom-housing/bloom/issues/2348) [#2352](https://github.com/bloom-housing/bloom/issues/2352) [#2316](https://github.com/bloom-housing/bloom/issues/2316) [#2356](https://github.com/bloom-housing/bloom/issues/2356) [#2353](https://github.com/bloom-housing/bloom/issues/2353) [#2338](https://github.com/bloom-housing/bloom/issues/2338) [#2377](https://github.com/bloom-housing/bloom/issues/2377) [#2320](https://github.com/bloom-housing/bloom/issues/2320) [#2386](https://github.com/bloom-housing/bloom/issues/2386) [#2362](https://github.com/bloom-housing/bloom/issues/2362) [#2395](https://github.com/bloom-housing/bloom/issues/2395) [#2410](https://github.com/bloom-housing/bloom/issues/2410) [#2407](https://github.com/bloom-housing/bloom/issues/2407) [#2430](https://github.com/bloom-housing/bloom/issues/2430) [#2418](https://github.com/bloom-housing/bloom/issues/2418) [#2434](https://github.com/bloom-housing/bloom/issues/2434) [#2374](https://github.com/bloom-housing/bloom/issues/2374) [#2435](https://github.com/bloom-housing/bloom/issues/2435) [#2420](https://github.com/bloom-housing/bloom/issues/2420) [#2412](https://github.com/bloom-housing/bloom/issues/2412) [#2438](https://github.com/bloom-housing/bloom/issues/2438) [#2429](https://github.com/bloom-housing/bloom/issues/2429) [#2452](https://github.com/bloom-housing/bloom/issues/2452) [#2458](https://github.com/bloom-housing/bloom/issues/2458) [#2423](https://github.com/bloom-housing/bloom/issues/2423) [#2432](https://github.com/bloom-housing/bloom/issues/2432) [#2437](https://github.com/bloom-housing/bloom/issues/2437) [#2440](https://github.com/bloom-housing/bloom/issues/2440) [#2441](https://github.com/bloom-housing/bloom/issues/2441) [#2460](https://github.com/bloom-housing/bloom/issues/2460) [#2459](https://github.com/bloom-housing/bloom/issues/2459) [#2464](https://github.com/bloom-housing/bloom/issues/2464) [#2465](https://github.com/bloom-housing/bloom/issues/2465) [#2466](https://github.com/bloom-housing/bloom/issues/2466) [#2436](https://github.com/bloom-housing/bloom/issues/2436) [#2451](https://github.com/bloom-housing/bloom/issues/2451) [#2415](https://github.com/bloom-housing/bloom/issues/2415) [#2354](https://github.com/bloom-housing/bloom/issues/2354) [#2455](https://github.com/bloom-housing/bloom/issues/2455) [#2484](https://github.com/bloom-housing/bloom/issues/2484) [#2482](https://github.com/bloom-housing/bloom/issues/2482) [#2483](https://github.com/bloom-housing/bloom/issues/2483) [#2476](https://github.com/bloom-housing/bloom/issues/2476) [#2485](https://github.com/bloom-housing/bloom/issues/2485) [#2470](https://github.com/bloom-housing/bloom/issues/2470) [#2488](https://github.com/bloom-housing/bloom/issues/2488) [#2487](https://github.com/bloom-housing/bloom/issues/2487) [#2496](https://github.com/bloom-housing/bloom/issues/2496) [#2498](https://github.com/bloom-housing/bloom/issues/2498) [#2499](https://github.com/bloom-housing/bloom/issues/2499) [#2291](https://github.com/bloom-housing/bloom/issues/2291) [#2461](https://github.com/bloom-housing/bloom/issues/2461) [#2485](https://github.com/bloom-housing/bloom/issues/2485) [#2494](https://github.com/bloom-housing/bloom/issues/2494) [#2503](https://github.com/bloom-housing/bloom/issues/2503) [#2495](https://github.com/bloom-housing/bloom/issues/2495) [#2477](https://github.com/bloom-housing/bloom/issues/2477) [#2505](https://github.com/bloom-housing/bloom/issues/2505) [#2372](https://github.com/bloom-housing/bloom/issues/2372) [#2489](https://github.com/bloom-housing/bloom/issues/2489) [#2497](https://github.com/bloom-housing/bloom/issues/2497) [#2506](https://github.com/bloom-housing/bloom/issues/2506) [#2486](https://github.com/bloom-housing/bloom/issues/2486) + + +### BREAKING CHANGES + +* preferences model and relationships changed + +* feat: feat(backend): extend UserUpdateDto to support email change + +picked from dev 3e1fdbd0ea91d4773973d5c485a5ba61303db90a + +* fix: 2056/user account edit fix + +picked from dev a15618c0cb548ff5b2ae913b802c9e08bb673f30 + +* refactor: 2085/adds top level catchAll exception filter + +picked from dev aeaa63d1af1fa3d11671e169cb3bd23d356fface + +* feat: feat: Change unit number field type to text + +picked from dev f54be7c7ba6aac8e00fee610dc86584b60cc212d + +* feat(backend): improve application flagged set saving efficiency + +* fix: fix: updates address order + +picked from dev 252e014dcbd2e4c305384ed552135f5a8e4e4767 + +* fix: sets programs to optoinal and updates versions + +* chore: chore(deps): bump electron from 13.1.7 to 13.3.0 + +* chore: chore(deps): bump axios from 0.21.1 to 0.21.2 + +* fix: adds programs service + +* fix: fix lisitng e2e tests + +* fix: fix member tests + +* fix: adds jurisdictionId to useSWR path + +* fix: recalculate units available on listing update + +picked form dev f1a3dbce6478b16542ed61ab20de5dfb9b797262 + +* feat: feat(backend): make use of new application confirmation codes + +picked from dev 3c45c2904818200eed4568931d4cc352fd2f449e + +* revert: revert "chore(deps): bump axios from 0.21.1 to 0.21.2 + +picked from dev 2b83bc0393afc42eed542e326d5ef75502ce119c + +* fix: app submission w/ no due date + +picked from dev 4af1f5a8448f16d347b4a65ecb85fda4d6ed71fc + +* feat: adds new preferences, reserved community type + +* feat: adds bottom border to preferences + +* feat: updates preference string + +* fix: preference cleanup for avance + +* refactor: remove applicationAddress + +picked from dev bf10632a62bf2f14922948c046ea3352ed010f4f + +* feat: refactor and add public site application flow cypress tests + +picked from dev 9ec0e8d05f9570773110754e7fdaf49254d1eab8 + +* feat: better seed data for ami-charts + +picked from dev d8b1d4d185731a589c563a32bd592d01537785f3 + +* feat: adds listing management cypress tests to partner portal + +* fix: listings management keep empty strings, remove empty objects + +picked from dev c4b1e833ec128f457015ac7ffa421ee6047083d9 + +* feat: one month rent + +picked from dev 883b0d53030e1c4d54f2f75bd5e188bb1d255f64 + +* test: view.spec.ts test + +picked from dev 324446c90138d8fac50aba445f515009b5a58bfb + +* refactor: removes jsonpath + +picked from dev deb39acc005607ce3076942b1f49590d08afc10c + +* feat: adds jurisdictions to pref seeds + +picked from dev 9e47cec3b1acfe769207ccbb33c07019cd742e33 + +* feat: new demographics sub-race questions + +picked from dev 9ab892694c1ad2fa8890b411b3b32af68ade1fc3 + +* feat: updates email confirmation for lottery + +picked from dev 1a5e824c96d8e23674c32ea92688b9f7255528d3 + +* fix: add ariaHidden to Icon component + +picked from dev c7bb86aec6fd5ad386c7ca50087d0113b14503be + +* fix: add ariaLabel prop to Button component + +picked from dev 509ddc898ba44c05e26f8ed8c777f1ba456eeee5 + +* fix: change the yes/no radio text to be more descriptive + +picked from dev 0c46054574535523d6f217bb0677bbe732b8945f + +* fix: remove alameda reference in demographics + +picked from dev 7d5991cbf6dbe0b61f2b14d265e87ce3687f743d + +* chore: release version + +picked from dev fe82f25dc349877d974ae62d228fea0354978fb7 + +* feat: ami chart jurisdictionalized + +picked from dev 0a5cbc88a9d9e3c2ff716fe0f44ca6c48f5dcc50 + +* refactor: make backend a peer dependency in ui-components + +picked from dev 952aaa14a77e0960312ff0eeee51399d1d6af9f3 + +* feat: add a phone number column to the user_accounts table + +picked from dev 2647df9ab9888a525cc8a164d091dda6482c502a + +* chore: removes application program partners + +* chore: removes application program display + +* Revert "chore: removes application program display" + +This reverts commit 14825b4a6c9cd1a7235e32074e32af18a71b5c26. + +* Revert "chore: removes application program partners" + +This reverts commit d7aa38c777972a2e21d9f816441caa27f98d3f86. + +* chore: yarn.lock and backend-swagger + +* fix: removes Duplicate identifier fieldGroupObjectToArray + +* feat: skip preferences if not on listing + +* chore(release): version + +* fix: cannot save custom mailing, dropoff, or pickup address + +* chore(release): version + +* chore: converge on one axios version, remove peer dependency + +* chore(release): version + +* feat: simplify Waitlist component and use more flexible schema + +* chore(release): version + +* fix: lottery results uploads now save + +* chore(release): version + +* feat: add SRO unit type + +* chore(release): version + +* fix: paper application submission + +* chore(release): version + +* fix: choose-language context + +* chore(release): version + +* fix: applications/view hide prefs + +* chore(release): version + +* feat: overrides fallback to english, tagalog support + +* chore(release): version + +* fix: account translations + +* chore(release): version + +* fix: units with invalid ami chart + +* chore(release): version + +* fix: remove description for the partners programs + +* fix: fix modal styles on mobile + +* fix: visual improvement to programs form display + +* fix: submission tests not running +* sign-in pages have been updated +* moved some helpers from ui-components to shared-helpers +* remove applicationDueTime field and consolidated into applicationDueDate + +* chore(release): version + + - @bloom-housing/backend-core@3.0.2-alpha.13 + - @bloom-housing/shared-helpers@4.0.1-alpha.21 + - @bloom-housing/partners@4.0.1-alpha.23 + - @bloom-housing/public@4.0.1-alpha.22 + - @bloom-housing/ui-components@4.0.1-alpha.21 +* moved some helpers from ui-components to shared-helpers +* remove applicationDueTime field and consolidated into applicationDueDate + +* chore(release): version + + - @bloom-housing/backend-core@3.0.2-alpha.13 + - @bloom-housing/shared-helpers@4.0.1-alpha.21 + - @bloom-housing/partners@4.0.1-alpha.23 + - @bloom-housing/public@4.0.1-alpha.22 + - @bloom-housing/ui-components@4.0.1-alpha.21 +* consolidated all event section components in one new component, uptake will require removing the deprecated components and uptaking EventSection + +* chore(release): version + + - @bloom-housing/backend-core@3.0.2-alpha.38 + - @bloom-housing/shared-helpers@4.0.1-alpha.63 + - @bloom-housing/partners@4.0.1-alpha.67 + - @bloom-housing/public@4.0.1-alpha.66 + - @bloom-housing/ui-components@4.0.1-alpha.62 + + + + + +## [4.1.1](https://github.com/seanmalbert/bloom/compare/@bloom-housing/partners@4.1.0...@bloom-housing/partners@4.1.1) (2022-03-28) + + + + + +## [4.1.1-alpha.5](https://github.com/bloom-housing/bloom/compare/@bloom-housing/partners@4.1.1-alpha.4...@bloom-housing/partners@4.1.1-alpha.5) (2022-03-28) + +**Note:** Version bump only for package @bloom-housing/partners + + + + + +## [4.1.1-alpha.4](https://github.com/bloom-housing/bloom/compare/@bloom-housing/partners@4.1.1-alpha.3...@bloom-housing/partners@4.1.1-alpha.4) (2022-03-25) + +**Note:** Version bump only for package @bloom-housing/partners + + + + + +## [4.1.1-alpha.3](https://github.com/bloom-housing/bloom/compare/@bloom-housing/partners@4.1.1-alpha.2...@bloom-housing/partners@4.1.1-alpha.3) (2022-03-22) + +**Note:** Version bump only for package @bloom-housing/partners + + + + + +## [4.1.1-alpha.2](https://github.com/bloom-housing/bloom/compare/@bloom-housing/partners@4.1.1-alpha.1...@bloom-housing/partners@4.1.1-alpha.2) (2022-03-16) + +**Note:** Version bump only for package @bloom-housing/partners + + + + + +## [4.1.1-alpha.1](https://github.com/bloom-housing/bloom/compare/@bloom-housing/partners@4.1.1-alpha.0...@bloom-housing/partners@4.1.1-alpha.1) (2022-03-10) + +**Note:** Version bump only for package @bloom-housing/partners + + + + + +## [4.1.1-alpha.0](https://github.com/bloom-housing/bloom/compare/@bloom-housing/partners@4.0.1-alpha.87...@bloom-housing/partners@4.1.1-alpha.0) (2022-03-02) + +**Note:** Version bump only for package @bloom-housing/partners + + + + + +## [4.0.1](https://github.com/seanmalbert/bloom/compare/@bloom-housing/partners@4.0.1-alpha.87...@bloom-housing/partners@4.0.1) (2022-03-02) + +**Note:** Version bump only for package @bloom-housing/partners + + + + + +# [4.1.0](https://github.com/seanmalbert/bloom/compare/@bloom-housing/partners@4.0.3...@bloom-housing/partners@4.1.0) (2022-03-02) + + +* 2022-03-01 release (#2550) ([2f2264c](https://github.com/seanmalbert/bloom/commit/2f2264cffe41d0cc1ebb79ef5c894458694d9340)), closes [#2550](https://github.com/seanmalbert/bloom/issues/2550) [#2288](https://github.com/seanmalbert/bloom/issues/2288) [#2317](https://github.com/seanmalbert/bloom/issues/2317) [#2319](https://github.com/seanmalbert/bloom/issues/2319) [#2108](https://github.com/seanmalbert/bloom/issues/2108) [#2326](https://github.com/seanmalbert/bloom/issues/2326) [#2349](https://github.com/seanmalbert/bloom/issues/2349) [#2350](https://github.com/seanmalbert/bloom/issues/2350) [#2351](https://github.com/seanmalbert/bloom/issues/2351) [#2348](https://github.com/seanmalbert/bloom/issues/2348) [#2352](https://github.com/seanmalbert/bloom/issues/2352) [#2316](https://github.com/seanmalbert/bloom/issues/2316) [#2356](https://github.com/seanmalbert/bloom/issues/2356) [#2353](https://github.com/seanmalbert/bloom/issues/2353) [#2338](https://github.com/seanmalbert/bloom/issues/2338) [#2377](https://github.com/seanmalbert/bloom/issues/2377) [#2320](https://github.com/seanmalbert/bloom/issues/2320) [#2386](https://github.com/seanmalbert/bloom/issues/2386) [#2362](https://github.com/seanmalbert/bloom/issues/2362) [#2395](https://github.com/seanmalbert/bloom/issues/2395) [#2410](https://github.com/seanmalbert/bloom/issues/2410) [#2407](https://github.com/seanmalbert/bloom/issues/2407) [#2430](https://github.com/seanmalbert/bloom/issues/2430) [#2418](https://github.com/seanmalbert/bloom/issues/2418) [#2434](https://github.com/seanmalbert/bloom/issues/2434) [#2374](https://github.com/seanmalbert/bloom/issues/2374) [#2435](https://github.com/seanmalbert/bloom/issues/2435) [#2420](https://github.com/seanmalbert/bloom/issues/2420) [#2412](https://github.com/seanmalbert/bloom/issues/2412) [#2438](https://github.com/seanmalbert/bloom/issues/2438) [#2429](https://github.com/seanmalbert/bloom/issues/2429) [#2452](https://github.com/seanmalbert/bloom/issues/2452) [#2458](https://github.com/seanmalbert/bloom/issues/2458) [#2423](https://github.com/seanmalbert/bloom/issues/2423) [#2432](https://github.com/seanmalbert/bloom/issues/2432) [#2437](https://github.com/seanmalbert/bloom/issues/2437) [#2440](https://github.com/seanmalbert/bloom/issues/2440) [#2441](https://github.com/seanmalbert/bloom/issues/2441) [#2460](https://github.com/seanmalbert/bloom/issues/2460) [#2459](https://github.com/seanmalbert/bloom/issues/2459) [#2464](https://github.com/seanmalbert/bloom/issues/2464) [#2465](https://github.com/seanmalbert/bloom/issues/2465) [#2466](https://github.com/seanmalbert/bloom/issues/2466) [#2436](https://github.com/seanmalbert/bloom/issues/2436) [#2451](https://github.com/seanmalbert/bloom/issues/2451) [#2415](https://github.com/seanmalbert/bloom/issues/2415) [#2354](https://github.com/seanmalbert/bloom/issues/2354) [#2455](https://github.com/seanmalbert/bloom/issues/2455) [#2484](https://github.com/seanmalbert/bloom/issues/2484) [#2482](https://github.com/seanmalbert/bloom/issues/2482) [#2483](https://github.com/seanmalbert/bloom/issues/2483) [#2476](https://github.com/seanmalbert/bloom/issues/2476) [#2485](https://github.com/seanmalbert/bloom/issues/2485) [#2470](https://github.com/seanmalbert/bloom/issues/2470) [#2488](https://github.com/seanmalbert/bloom/issues/2488) [#2487](https://github.com/seanmalbert/bloom/issues/2487) [#2496](https://github.com/seanmalbert/bloom/issues/2496) [#2498](https://github.com/seanmalbert/bloom/issues/2498) [#2499](https://github.com/seanmalbert/bloom/issues/2499) [#2291](https://github.com/seanmalbert/bloom/issues/2291) [#2461](https://github.com/seanmalbert/bloom/issues/2461) [#2485](https://github.com/seanmalbert/bloom/issues/2485) [#2494](https://github.com/seanmalbert/bloom/issues/2494) [#2503](https://github.com/seanmalbert/bloom/issues/2503) [#2495](https://github.com/seanmalbert/bloom/issues/2495) [#2477](https://github.com/seanmalbert/bloom/issues/2477) [#2505](https://github.com/seanmalbert/bloom/issues/2505) [#2372](https://github.com/seanmalbert/bloom/issues/2372) [#2489](https://github.com/seanmalbert/bloom/issues/2489) [#2497](https://github.com/seanmalbert/bloom/issues/2497) [#2506](https://github.com/seanmalbert/bloom/issues/2506) [#2486](https://github.com/seanmalbert/bloom/issues/2486) + + +### BREAKING CHANGES + +* moved some helpers from ui-components to shared-helpers +* remove applicationDueTime field and consolidated into applicationDueDate + +* chore(release): version + + - @bloom-housing/backend-core@3.0.2-alpha.13 + - @bloom-housing/shared-helpers@4.0.1-alpha.21 + - @bloom-housing/partners@4.0.1-alpha.23 + - @bloom-housing/public@4.0.1-alpha.22 + - @bloom-housing/ui-components@4.0.1-alpha.21 +* consolidated all event section components in one new component, uptake will require removing the deprecated components and uptaking EventSection + +* chore(release): version + + - @bloom-housing/backend-core@3.0.2-alpha.38 + - @bloom-housing/shared-helpers@4.0.1-alpha.63 + - @bloom-housing/partners@4.0.1-alpha.67 + - @bloom-housing/public@4.0.1-alpha.66 + - @bloom-housing/ui-components@4.0.1-alpha.62 + + + + + +## [4.0.1-alpha.87](https://github.com/bloom-housing/bloom/compare/@bloom-housing/partners@4.0.1-alpha.86...@bloom-housing/partners@4.0.1-alpha.87) (2022-02-28) + + +### Features + +* updates to mfa styling ([#2532](https://github.com/bloom-housing/bloom/issues/2532)) ([7654efc](https://github.com/bloom-housing/bloom/commit/7654efc8a7c5cba0f7436fda62b886f646fe8a03)) + + + + + +## [4.0.1-alpha.86](https://github.com/bloom-housing/bloom/compare/@bloom-housing/partners@4.0.1-alpha.85...@bloom-housing/partners@4.0.1-alpha.86) (2022-02-28) + +**Note:** Version bump only for package @bloom-housing/partners + + + + + +## [4.0.1-alpha.85](https://github.com/bloom-housing/bloom/compare/@bloom-housing/partners@4.0.1-alpha.84...@bloom-housing/partners@4.0.1-alpha.85) (2022-02-26) + +**Note:** Version bump only for package @bloom-housing/partners + + + + + +## [4.0.3](https://github.com/seanmalbert/bloom/compare/@bloom-housing/partners@4.0.2...@bloom-housing/partners@4.0.3) (2022-02-25) + +## Features + +* overrides partner app website trans ([#2534](https://github.com/seanmalbert/bloom/issues/2534)) ([16c7a4e](https://github.com/seanmalbert/bloom/commit/16c7a4eb8f5ae05dbea9380702c2150a922ca3f0)) + + + + + +## [4.0.1-alpha.84](https://github.com/bloom-housing/bloom/compare/@bloom-housing/partners@4.0.1-alpha.83...@bloom-housing/partners@4.0.1-alpha.84) (2022-02-25) + +**Note:** Version bump only for package @bloom-housing/partners + + + + + +## [4.0.1-alpha.83](https://github.com/bloom-housing/bloom/compare/@bloom-housing/partners@4.0.1-alpha.82...@bloom-housing/partners@4.0.1-alpha.83) (2022-02-25) + +**Note:** Version bump only for package @bloom-housing/partners + + + + + +## [4.0.1-alpha.82](https://github.com/bloom-housing/bloom/compare/@bloom-housing/partners@4.0.1-alpha.81...@bloom-housing/partners@4.0.1-alpha.82) (2022-02-24) + + +### Features + +* overrides partner app website trans ([#2534](https://github.com/bloom-housing/bloom/issues/2534)) ([9e09b0b](https://github.com/bloom-housing/bloom/commit/9e09b0bbb3e394c92dcce18bb0cba74db03c49fa)) + + + + + +## [4.0.1-alpha.81](https://github.com/bloom-housing/bloom/compare/@bloom-housing/partners@4.0.1-alpha.80...@bloom-housing/partners@4.0.1-alpha.81) (2022-02-22) + + +### Bug Fixes + +* purge listing detail with wildcard ([4fd2137](https://github.com/bloom-housing/bloom/commit/4fd21374c2dc213dfe1b8dde004d41895126c1d6)) + + + + + +## [4.0.1-alpha.80](https://github.com/bloom-housing/bloom/compare/@bloom-housing/partners@4.0.1-alpha.79...@bloom-housing/partners@4.0.1-alpha.80) (2022-02-22) + + +### Features + +* updates cache clear to separate individual and lists ([#2529](https://github.com/bloom-housing/bloom/issues/2529)) ([1521191](https://github.com/bloom-housing/bloom/commit/15211918b8bf0741ff6a25265b1bf3a60d5678b2)) + + + + + +## [4.0.1-alpha.79](https://github.com/bloom-housing/bloom/compare/@bloom-housing/partners@4.0.1-alpha.78...@bloom-housing/partners@4.0.1-alpha.79) (2022-02-22) + +**Note:** Version bump only for package @bloom-housing/partners + + + + + +## [4.0.1-alpha.78](https://github.com/bloom-housing/bloom/compare/@bloom-housing/partners@4.0.1-alpha.77...@bloom-housing/partners@4.0.1-alpha.78) (2022-02-18) + +**Note:** Version bump only for package @bloom-housing/partners + + + + + +## [4.0.1-alpha.77](https://github.com/bloom-housing/bloom/compare/@bloom-housing/partners@4.0.1-alpha.76...@bloom-housing/partners@4.0.1-alpha.77) (2022-02-17) + +**Note:** Version bump only for package @bloom-housing/partners + + + + + +## [4.0.1-alpha.76](https://github.com/bloom-housing/bloom/compare/@bloom-housing/partners@4.0.1-alpha.75...@bloom-housing/partners@4.0.1-alpha.76) (2022-02-17) + +**Note:** Version bump only for package @bloom-housing/partners + + + + + +## [4.0.1-alpha.75](https://github.com/bloom-housing/bloom/compare/@bloom-housing/partners@4.0.1-alpha.74...@bloom-housing/partners@4.0.1-alpha.75) (2022-02-17) + +**Note:** Version bump only for package @bloom-housing/partners + + + + + +## [4.0.1-alpha.74](https://github.com/bloom-housing/bloom/compare/@bloom-housing/partners@4.0.1-alpha.73...@bloom-housing/partners@4.0.1-alpha.74) (2022-02-16) + +**Note:** Version bump only for package @bloom-housing/partners + + + + + +## [4.0.1-alpha.73](https://github.com/bloom-housing/bloom/compare/@bloom-housing/partners@4.0.1-alpha.72...@bloom-housing/partners@4.0.1-alpha.73) (2022-02-16) + +**Note:** Version bump only for package @bloom-housing/partners + + + + + +## [4.0.1-alpha.72](https://github.com/bloom-housing/bloom/compare/@bloom-housing/partners@4.0.1-alpha.71...@bloom-housing/partners@4.0.1-alpha.72) (2022-02-16) + +**Note:** Version bump only for package @bloom-housing/partners + + + + + +## [4.0.1-alpha.71](https://github.com/bloom-housing/bloom/compare/@bloom-housing/partners@4.0.1-alpha.70...@bloom-housing/partners@4.0.1-alpha.71) (2022-02-16) + +**Note:** Version bump only for package @bloom-housing/partners + + + + + +## [4.0.1-alpha.70](https://github.com/bloom-housing/bloom/compare/@bloom-housing/partners@4.0.1-alpha.69...@bloom-housing/partners@4.0.1-alpha.70) (2022-02-16) + +**Note:** Version bump only for package @bloom-housing/partners + + + + + +## [4.0.1-alpha.69](https://github.com/bloom-housing/bloom/compare/@bloom-housing/partners@4.0.1-alpha.68...@bloom-housing/partners@4.0.1-alpha.69) (2022-02-16) + +**Note:** Version bump only for package @bloom-housing/partners + + + + + +## [4.0.1-alpha.68](https://github.com/bloom-housing/bloom/compare/@bloom-housing/partners@4.0.1-alpha.67...@bloom-housing/partners@4.0.1-alpha.68) (2022-02-15) + + +### Features + +* **backend:** make listing image an array ([#2477](https://github.com/bloom-housing/bloom/issues/2477)) ([cab9800](https://github.com/bloom-housing/bloom/commit/cab98003e640c880be2218fa42321eadeec35e9c)) + + + + + +## [4.0.1-alpha.67](https://github.com/bloom-housing/bloom/compare/@bloom-housing/partners@4.0.1-alpha.66...@bloom-housing/partners@4.0.1-alpha.67) (2022-02-15) + +**Note:** Version bump only for package @bloom-housing/partners + + + + + +## [4.0.1-alpha.66](https://github.com/bloom-housing/bloom/compare/@bloom-housing/partners@4.0.1-alpha.65...@bloom-housing/partners@4.0.1-alpha.66) (2022-02-15) + +**Note:** Version bump only for package @bloom-housing/partners + + + + + +## [4.0.1-alpha.65](https://github.com/bloom-housing/bloom/compare/@bloom-housing/partners@4.0.1-alpha.64...@bloom-housing/partners@4.0.1-alpha.65) (2022-02-15) + +**Note:** Version bump only for package @bloom-housing/partners + + + + + +## [4.0.1-alpha.64](https://github.com/bloom-housing/bloom/compare/@bloom-housing/partners@4.0.1-alpha.63...@bloom-housing/partners@4.0.1-alpha.64) (2022-02-15) + + +### Features + +* **backend:** add partners portal users multi factor authentication ([#2291](https://github.com/bloom-housing/bloom/issues/2291)) ([5b10098](https://github.com/bloom-housing/bloom/commit/5b10098d8668f9f42c60e90236db16d6cc517793)), closes [#2461](https://github.com/bloom-housing/bloom/issues/2461) [#2485](https://github.com/bloom-housing/bloom/issues/2485) + + + + + +## [4.0.1-alpha.63](https://github.com/bloom-housing/bloom/compare/@bloom-housing/partners@4.0.1-alpha.62...@bloom-housing/partners@4.0.1-alpha.63) (2022-02-14) + +**Note:** Version bump only for package @bloom-housing/partners + + + + + +## [4.0.1-alpha.62](https://github.com/bloom-housing/bloom/compare/@bloom-housing/partners@4.0.1-alpha.61...@bloom-housing/partners@4.0.1-alpha.62) (2022-02-14) + +**Note:** Version bump only for package @bloom-housing/partners + + + + + +## [4.0.1-alpha.61](https://github.com/bloom-housing/bloom/compare/@bloom-housing/partners@4.0.1-alpha.60...@bloom-housing/partners@4.0.1-alpha.61) (2022-02-12) + +**Note:** Version bump only for package @bloom-housing/partners + + + + + +## [4.0.1-alpha.60](https://github.com/bloom-housing/bloom/compare/@bloom-housing/partners@4.0.1-alpha.59...@bloom-housing/partners@4.0.1-alpha.60) (2022-02-10) + +**Note:** Version bump only for package @bloom-housing/partners + + + + + +## [4.0.1-alpha.59](https://github.com/bloom-housing/bloom/compare/@bloom-housing/partners@4.0.1-alpha.58...@bloom-housing/partners@4.0.1-alpha.59) (2022-02-10) + +**Note:** Version bump only for package @bloom-housing/partners + + + + + +## [4.0.1-alpha.58](https://github.com/bloom-housing/bloom/compare/@bloom-housing/partners@4.0.1-alpha.57...@bloom-housing/partners@4.0.1-alpha.58) (2022-02-10) + +**Note:** Version bump only for package @bloom-housing/partners + + + + + +## [4.0.1-alpha.57](https://github.com/bloom-housing/bloom/compare/@bloom-housing/partners@4.0.1-alpha.56...@bloom-housing/partners@4.0.1-alpha.57) (2022-02-10) + +**Note:** Version bump only for package @bloom-housing/partners + + + + + +## [4.0.2](https://github.com/seanmalbert/bloom/compare/@bloom-housing/partners@4.0.1...@bloom-housing/partners@4.0.2) (2022-02-09) + + + + + +## [4.0.1-alpha.56](https://github.com/bloom-housing/bloom/compare/@bloom-housing/partners@4.0.1-alpha.55...@bloom-housing/partners@4.0.1-alpha.56) (2022-02-09) + +**Note:** Version bump only for package @bloom-housing/partners + + + + + +## [4.0.1-alpha.55](https://github.com/bloom-housing/bloom/compare/@bloom-housing/partners@4.0.1-alpha.54...@bloom-housing/partners@4.0.1-alpha.55) (2022-02-09) + +**Note:** Version bump only for package @bloom-housing/partners + + + + + +## [4.0.1-alpha.54](https://github.com/bloom-housing/bloom/compare/@bloom-housing/partners@4.0.1-alpha.53...@bloom-housing/partners@4.0.1-alpha.54) (2022-02-09) + +**Note:** Version bump only for package @bloom-housing/partners + + + + + +## [4.0.1-alpha.53](https://github.com/bloom-housing/bloom/compare/@bloom-housing/partners@4.0.1-alpha.52...@bloom-housing/partners@4.0.1-alpha.53) (2022-02-09) + +**Note:** Version bump only for package @bloom-housing/partners + + + + + +## [4.0.1-alpha.52](https://github.com/bloom-housing/bloom/compare/@bloom-housing/partners@4.0.1-alpha.51...@bloom-housing/partners@4.0.1-alpha.52) (2022-02-09) + + +### Bug Fixes + +* cannot remove some fields in listings management ([#2455](https://github.com/bloom-housing/bloom/issues/2455)) ([acd9b51](https://github.com/bloom-housing/bloom/commit/acd9b51bb49581b4728b445d56c5c0a3c43e2777)) + + + + + +## [4.0.1-alpha.51](https://github.com/bloom-housing/bloom/compare/@bloom-housing/partners@4.0.1-alpha.50...@bloom-housing/partners@4.0.1-alpha.51) (2022-02-08) + +**Note:** Version bump only for package @bloom-housing/partners + + + + + +## [4.0.1-alpha.50](https://github.com/bloom-housing/bloom/compare/@bloom-housing/partners@4.0.1-alpha.49...@bloom-housing/partners@4.0.1-alpha.50) (2022-02-07) + +**Note:** Version bump only for package @bloom-housing/partners + + + + + +## [4.0.1](https://github.com/seanmalbert/bloom/compare/@bloom-housing/partners@3.0.1...@bloom-housing/partners@4.0.1) (2022-02-03) + +## Bug Fixes + +* ami charts without all households ([#2430](https://github.com/seanmalbert/bloom/issues/2430)) ([5e18eba](https://github.com/seanmalbert/bloom/commit/5e18eba1d24bff038b192477b72d9d3f1f05a39d)) + + +* 2022-01-27 release (#2439) ([860f6af](https://github.com/seanmalbert/bloom/commit/860f6af6204903e4dcddf671d7ba54f3ec04f121)), closes [#2439](https://github.com/seanmalbert/bloom/issues/2439) [#2196](https://github.com/seanmalbert/bloom/issues/2196) [#2238](https://github.com/seanmalbert/bloom/issues/2238) [#2226](https://github.com/seanmalbert/bloom/issues/2226) [#2230](https://github.com/seanmalbert/bloom/issues/2230) [#2243](https://github.com/seanmalbert/bloom/issues/2243) [#2195](https://github.com/seanmalbert/bloom/issues/2195) [#2215](https://github.com/seanmalbert/bloom/issues/2215) [#2266](https://github.com/seanmalbert/bloom/issues/2266) [#2188](https://github.com/seanmalbert/bloom/issues/2188) [#2270](https://github.com/seanmalbert/bloom/issues/2270) [#2188](https://github.com/seanmalbert/bloom/issues/2188) [#2213](https://github.com/seanmalbert/bloom/issues/2213) [#2234](https://github.com/seanmalbert/bloom/issues/2234) [#1901](https://github.com/seanmalbert/bloom/issues/1901) [#2260](https://github.com/seanmalbert/bloom/issues/2260) [#1927](https://github.com/seanmalbert/bloom/issues/1927) [#1927](https://github.com/seanmalbert/bloom/issues/1927) [#1927](https://github.com/seanmalbert/bloom/issues/1927) [#1927](https://github.com/seanmalbert/bloom/issues/1927) [#1927](https://github.com/seanmalbert/bloom/issues/1927) [#1927](https://github.com/seanmalbert/bloom/issues/1927) [#1927](https://github.com/seanmalbert/bloom/issues/1927) [#1927](https://github.com/seanmalbert/bloom/issues/1927) [#1927](https://github.com/seanmalbert/bloom/issues/1927) [#1927](https://github.com/seanmalbert/bloom/issues/1927) [#1927](https://github.com/seanmalbert/bloom/issues/1927) [#1927](https://github.com/seanmalbert/bloom/issues/1927) [#1927](https://github.com/seanmalbert/bloom/issues/1927) [#2280](https://github.com/seanmalbert/bloom/issues/2280) [#2253](https://github.com/seanmalbert/bloom/issues/2253) [#2276](https://github.com/seanmalbert/bloom/issues/2276) [#2282](https://github.com/seanmalbert/bloom/issues/2282) [#2262](https://github.com/seanmalbert/bloom/issues/2262) [#2278](https://github.com/seanmalbert/bloom/issues/2278) [#2293](https://github.com/seanmalbert/bloom/issues/2293) [#2295](https://github.com/seanmalbert/bloom/issues/2295) [#2296](https://github.com/seanmalbert/bloom/issues/2296) [#2294](https://github.com/seanmalbert/bloom/issues/2294) [#2277](https://github.com/seanmalbert/bloom/issues/2277) [#2290](https://github.com/seanmalbert/bloom/issues/2290) [#2299](https://github.com/seanmalbert/bloom/issues/2299) [#2292](https://github.com/seanmalbert/bloom/issues/2292) [#2303](https://github.com/seanmalbert/bloom/issues/2303) [#2305](https://github.com/seanmalbert/bloom/issues/2305) [#2306](https://github.com/seanmalbert/bloom/issues/2306) [#2308](https://github.com/seanmalbert/bloom/issues/2308) [#2190](https://github.com/seanmalbert/bloom/issues/2190) [#2239](https://github.com/seanmalbert/bloom/issues/2239) [#2311](https://github.com/seanmalbert/bloom/issues/2311) [#2302](https://github.com/seanmalbert/bloom/issues/2302) [#2301](https://github.com/seanmalbert/bloom/issues/2301) [#1927](https://github.com/seanmalbert/bloom/issues/1927) [#2313](https://github.com/seanmalbert/bloom/issues/2313) [#2289](https://github.com/seanmalbert/bloom/issues/2289) [#2279](https://github.com/seanmalbert/bloom/issues/2279) [#2288](https://github.com/seanmalbert/bloom/issues/2288) [#2317](https://github.com/seanmalbert/bloom/issues/2317) [#2319](https://github.com/seanmalbert/bloom/issues/2319) [#2108](https://github.com/seanmalbert/bloom/issues/2108) [#2326](https://github.com/seanmalbert/bloom/issues/2326) [#2349](https://github.com/seanmalbert/bloom/issues/2349) [#2350](https://github.com/seanmalbert/bloom/issues/2350) [#2351](https://github.com/seanmalbert/bloom/issues/2351) [#2348](https://github.com/seanmalbert/bloom/issues/2348) [#2352](https://github.com/seanmalbert/bloom/issues/2352) [#2316](https://github.com/seanmalbert/bloom/issues/2316) [#2356](https://github.com/seanmalbert/bloom/issues/2356) [#2353](https://github.com/seanmalbert/bloom/issues/2353) [#2338](https://github.com/seanmalbert/bloom/issues/2338) [#2377](https://github.com/seanmalbert/bloom/issues/2377) [#2320](https://github.com/seanmalbert/bloom/issues/2320) [#2386](https://github.com/seanmalbert/bloom/issues/2386) [#2362](https://github.com/seanmalbert/bloom/issues/2362) [#2395](https://github.com/seanmalbert/bloom/issues/2395) [#2410](https://github.com/seanmalbert/bloom/issues/2410) [#2407](https://github.com/seanmalbert/bloom/issues/2407) [#2430](https://github.com/seanmalbert/bloom/issues/2430) [#2418](https://github.com/seanmalbert/bloom/issues/2418) [#2434](https://github.com/seanmalbert/bloom/issues/2434) [#2374](https://github.com/seanmalbert/bloom/issues/2374) [#2435](https://github.com/seanmalbert/bloom/issues/2435) [#2420](https://github.com/seanmalbert/bloom/issues/2420) [#2412](https://github.com/seanmalbert/bloom/issues/2412) [#2434](https://github.com/seanmalbert/bloom/issues/2434) + + +### BREAKING CHANGES + +* sign-in pages have been updated +* moved some helpers from ui-components to shared-helpers +* remove applicationDueTime field and consolidated into applicationDueDate + +* chore(release): version + + - @bloom-housing/backend-core@3.0.2-alpha.13 + - @bloom-housing/shared-helpers@4.0.1-alpha.21 + - @bloom-housing/partners@4.0.1-alpha.23 + - @bloom-housing/public@4.0.1-alpha.22 + - @bloom-housing/ui-components@4.0.1-alpha.21 + + + + + +## [4.0.1-alpha.49](https://github.com/bloom-housing/bloom/compare/@bloom-housing/partners@4.0.1-alpha.48...@bloom-housing/partners@4.0.1-alpha.49) (2022-02-02) + + +### Bug Fixes + +* unit accordion radio button not showing default value ([#2451](https://github.com/bloom-housing/bloom/issues/2451)) ([4ed8103](https://github.com/bloom-housing/bloom/commit/4ed81039b9130d0433b11df2bdabc495ce2b9f24)) + + + + + +## [4.0.1-alpha.48](https://github.com/bloom-housing/bloom/compare/@bloom-housing/partners@4.0.1-alpha.47...@bloom-housing/partners@4.0.1-alpha.48) (2022-02-02) + +**Note:** Version bump only for package @bloom-housing/partners + + + + + +## [4.0.1-alpha.47](https://github.com/bloom-housing/bloom/compare/@bloom-housing/partners@4.0.1-alpha.46...@bloom-housing/partners@4.0.1-alpha.47) (2022-02-02) + +**Note:** Version bump only for package @bloom-housing/partners + + + + + +## [4.0.1-alpha.46](https://github.com/bloom-housing/bloom/compare/@bloom-housing/partners@4.0.1-alpha.45...@bloom-housing/partners@4.0.1-alpha.46) (2022-02-02) + +**Note:** Version bump only for package @bloom-housing/partners + + + + + +## [4.0.1-alpha.45](https://github.com/bloom-housing/bloom/compare/@bloom-housing/partners@4.0.1-alpha.44...@bloom-housing/partners@4.0.1-alpha.45) (2022-02-01) + +**Note:** Version bump only for package @bloom-housing/partners + + + + + +## [4.0.1-alpha.44](https://github.com/bloom-housing/bloom/compare/@bloom-housing/partners@4.0.1-alpha.43...@bloom-housing/partners@4.0.1-alpha.44) (2022-02-01) + +**Note:** Version bump only for package @bloom-housing/partners + + + + + +## [4.0.1-alpha.43](https://github.com/bloom-housing/bloom/compare/@bloom-housing/partners@4.0.1-alpha.42...@bloom-housing/partners@4.0.1-alpha.43) (2022-02-01) + +**Note:** Version bump only for package @bloom-housing/partners + + + + + +## [4.0.1-alpha.42](https://github.com/bloom-housing/bloom/compare/@bloom-housing/partners@4.0.1-alpha.41...@bloom-housing/partners@4.0.1-alpha.42) (2022-02-01) + +**Note:** Version bump only for package @bloom-housing/partners + + + + + +## [4.0.1-alpha.41](https://github.com/bloom-housing/bloom/compare/@bloom-housing/partners@4.0.1-alpha.40...@bloom-housing/partners@4.0.1-alpha.41) (2022-02-01) + +**Note:** Version bump only for package @bloom-housing/partners + + + + + +## [4.0.1-alpha.40](https://github.com/bloom-housing/bloom/compare/@bloom-housing/partners@4.0.1-alpha.39...@bloom-housing/partners@4.0.1-alpha.40) (2022-02-01) + + +### Features + +* partners terms page ([#2440](https://github.com/bloom-housing/bloom/issues/2440)) ([63105bc](https://github.com/bloom-housing/bloom/commit/63105bcedfe371a4a9995e25b1e5aba67d06ad0c)) + + + + + +## [4.0.1-alpha.39](https://github.com/bloom-housing/bloom/compare/@bloom-housing/partners@4.0.1-alpha.38...@bloom-housing/partners@4.0.1-alpha.39) (2022-02-01) + +**Note:** Version bump only for package @bloom-housing/partners + + + + + +## [4.0.1-alpha.38](https://github.com/bloom-housing/bloom/compare/@bloom-housing/partners@4.0.1-alpha.37...@bloom-housing/partners@4.0.1-alpha.38) (2022-02-01) + + +### Features + +* **backend:** add publishedAt and closedAt to listing entity ([#2432](https://github.com/bloom-housing/bloom/issues/2432)) ([f3b0f86](https://github.com/bloom-housing/bloom/commit/f3b0f864a6d5d2ad3d886e828743454c3e8fca71)) + + + + + +## [4.0.1-alpha.37](https://github.com/bloom-housing/bloom/compare/@bloom-housing/partners@4.0.1-alpha.36...@bloom-housing/partners@4.0.1-alpha.37) (2022-02-01) + +**Note:** Version bump only for package @bloom-housing/partners + + + + + +## [4.0.1-alpha.36](https://github.com/bloom-housing/bloom/compare/@bloom-housing/partners@4.0.1-alpha.35...@bloom-housing/partners@4.0.1-alpha.36) (2022-01-31) + +**Note:** Version bump only for package @bloom-housing/partners + + + + + +## [4.0.1-alpha.35](https://github.com/bloom-housing/bloom/compare/@bloom-housing/partners@4.0.1-alpha.34...@bloom-housing/partners@4.0.1-alpha.35) (2022-01-31) + +**Note:** Version bump only for package @bloom-housing/partners + + + + + +## [4.0.1-alpha.34](https://github.com/bloom-housing/bloom/compare/@bloom-housing/partners@4.0.1-alpha.33...@bloom-housing/partners@4.0.1-alpha.34) (2022-01-27) + + +### Features + +* outdated password messaging updates ([b14e19d](https://github.com/bloom-housing/bloom/commit/b14e19d43099af2ba721d8aaaeeb2be886d05111)) + + + + + +## [4.0.1-alpha.33](https://github.com/bloom-housing/bloom/compare/@bloom-housing/partners@4.0.1-alpha.32...@bloom-housing/partners@4.0.1-alpha.33) (2022-01-26) + +**Note:** Version bump only for package @bloom-housing/partners + + + + + +## [4.0.1-alpha.32](https://github.com/bloom-housing/bloom/compare/@bloom-housing/partners@4.0.1-alpha.31...@bloom-housing/partners@4.0.1-alpha.32) (2022-01-26) + +**Note:** Version bump only for package @bloom-housing/partners + + + + + +## [4.0.1-alpha.31](https://github.com/bloom-housing/bloom/compare/@bloom-housing/partners@4.0.1-alpha.30...@bloom-housing/partners@4.0.1-alpha.31) (2022-01-26) + +**Note:** Version bump only for package @bloom-housing/partners + + + + + +## [4.0.1-alpha.30](https://github.com/bloom-housing/bloom/compare/@bloom-housing/partners@4.0.1-alpha.29...@bloom-housing/partners@4.0.1-alpha.30) (2022-01-24) + +**Note:** Version bump only for package @bloom-housing/partners + + + + + +## [4.0.1-alpha.29](https://github.com/bloom-housing/bloom/compare/@bloom-housing/partners@4.0.1-alpha.28...@bloom-housing/partners@4.0.1-alpha.29) (2022-01-24) + + +### Bug Fixes + +* ami charts without all households ([#2430](https://github.com/bloom-housing/bloom/issues/2430)) ([92dfbad](https://github.com/bloom-housing/bloom/commit/92dfbad32c90d84ee1ec3a3468c084cb110aa8be)) + + + + + +## [4.0.1-alpha.28](https://github.com/bloom-housing/bloom/compare/@bloom-housing/partners@4.0.1-alpha.27...@bloom-housing/partners@4.0.1-alpha.28) (2022-01-21) + +**Note:** Version bump only for package @bloom-housing/partners + + + + + +## [4.0.1-alpha.27](https://github.com/bloom-housing/bloom/compare/@bloom-housing/partners@4.0.1-alpha.26...@bloom-housing/partners@4.0.1-alpha.27) (2022-01-21) + +**Note:** Version bump only for package @bloom-housing/partners + + + + + +## [4.0.1-alpha.26](https://github.com/bloom-housing/bloom/compare/@bloom-housing/partners@4.0.1-alpha.25...@bloom-housing/partners@4.0.1-alpha.26) (2022-01-20) + +**Note:** Version bump only for package @bloom-housing/partners + + + + + +## [4.0.1-alpha.25](https://github.com/bloom-housing/bloom/compare/@bloom-housing/partners@4.0.1-alpha.24...@bloom-housing/partners@4.0.1-alpha.25) (2022-01-14) + +**Note:** Version bump only for package @bloom-housing/partners + + + + + +## [3.0.1](https://github.com/seanmalbert/bloom/compare/@bloom-housing/partners@3.0.1-alpha.34...@bloom-housing/partners@3.0.1) (2022-01-13) + + +### Bug Fixes + +* adds jurisdictionId to useSWR path ([c7d6adb](https://github.com/seanmalbert/bloom/commit/c7d6adba109aa50f3c1556c89c0ec714fd4c6e50)) +* cannot save custom mailing, dropoff, or pickup address ([edcb068](https://github.com/seanmalbert/bloom/commit/edcb068ca23411e0a34f1dc2ff4c77ab489ac0fc)) +* listings management keep empty strings, remove empty objects ([3aba274](https://github.com/seanmalbert/bloom/commit/3aba274a751cdb2db55b65ade1cda5d1689ca681)) +* lottery results uploads now save ([8c9dd0f](https://github.com/seanmalbert/bloom/commit/8c9dd0f043dd3835f12bc8f087b9a5519cbfd4f8)) +* paper application submission ([384b86b](https://github.com/seanmalbert/bloom/commit/384b86b624392012b56039dc4a289393f24653f5)) +* remove description for the partners programs ([d4478b8](https://github.com/seanmalbert/bloom/commit/d4478b8eaf68efdf4b23c55f15656e82a907dbc4)) +* removes Duplicate identifier fieldGroupObjectToArray ([a3a2f43](https://github.com/seanmalbert/bloom/commit/a3a2f434606628e4ad141250c401405ced10cdf4)) +* retnal assistance eror message ([09f583b](https://github.com/seanmalbert/bloom/commit/09f583be137336c92f7077beb1f1fbab2b82aefb)) +* updates lastName on application save ([a977ffd](https://github.com/seanmalbert/bloom/commit/a977ffd4b81fbf09122c51ccf066d0a3f3f6544c)) +* versioning issues ([#2311](https://github.com/seanmalbert/bloom/issues/2311)) ([c274a29](https://github.com/seanmalbert/bloom/commit/c274a2985061b389c2cae6386137a4caacd7f7c0)) + + +* Release 11 11 21 (#2162) ([4847469](https://github.com/seanmalbert/bloom/commit/484746982e440c1c1c87c85089d86cd5968f1cae)), closes [#2162](https://github.com/seanmalbert/bloom/issues/2162) + + + +### Features + +* adds listing management cypress tests to partner portal ([2e37eec](https://github.com/seanmalbert/bloom/commit/2e37eecf6344f6e25422a24ad7f4563fee4564de)) +* adds updating open listing modal ([#2288](https://github.com/seanmalbert/bloom/issues/2288)) ([d184326](https://github.com/seanmalbert/bloom/commit/d18432610a55a5e54f567ff6157bb863ed61cb21)) +* ami chart jurisdictionalized ([b2e2537](https://github.com/seanmalbert/bloom/commit/b2e2537818d92ff41ea51fbbeb23d9d7e8c1cf52)) +* filter partner users ([3dd8f9b](https://github.com/seanmalbert/bloom/commit/3dd8f9b3cc1f9f90916d49b7136d5f1f73df5291)) +* new demographics sub-race questions ([910df6a](https://github.com/seanmalbert/bloom/commit/910df6ad3985980becdc2798076ed5dfeeb310b5)) +* one month rent ([319743d](https://github.com/seanmalbert/bloom/commit/319743d23268f5b55e129c0878510edb4204b668)) +* overrides fallback to english, tagalog support ([b79fd10](https://github.com/seanmalbert/bloom/commit/b79fd1018619f618bd9be8e870d35c1180b81dfb)) +* postmark date time fields partners ([#2239](https://github.com/seanmalbert/bloom/issues/2239)) ([cf20b88](https://github.com/seanmalbert/bloom/commit/cf20b88cb613b815c641cad34a38908e22722a4a)) +* simplify Waitlist component and use more flexible schema ([aa8e006](https://github.com/seanmalbert/bloom/commit/aa8e00616d886e8d57316b2362d35c0c550007c6)) + + +### Reverts + +* Revert "chore(release): version" ([47a2c67](https://github.com/seanmalbert/bloom/commit/47a2c67af5c7c41f360fafc6c5386476866ea403)) +* Revert "chore: removes application program partners" ([91e22d8](https://github.com/seanmalbert/bloom/commit/91e22d891104e8d4fc024d709a6a14cec1400733)) +* Revert "chore: removes application program display" ([740cf00](https://github.com/seanmalbert/bloom/commit/740cf00dc3a729eed037d56a8dfc5988decd2651)) + + + +### BREAKING CHANGES + +* preferences model and relationships changed + +* feat: feat(backend): extend UserUpdateDto to support email change + +picked from dev 3e1fdbd0ea91d4773973d5c485a5ba61303db90a + +* fix: 2056/user account edit fix + +picked from dev a15618c0cb548ff5b2ae913b802c9e08bb673f30 + +* refactor: 2085/adds top level catchAll exception filter + +picked from dev aeaa63d1af1fa3d11671e169cb3bd23d356fface + +* feat: feat: Change unit number field type to text + +picked from dev f54be7c7ba6aac8e00fee610dc86584b60cc212d + +* feat(backend): improve application flagged set saving efficiency + +* fix: fix: updates address order + +picked from dev 252e014dcbd2e4c305384ed552135f5a8e4e4767 + +* fix: sets programs to optoinal and updates versions + +* chore: chore(deps): bump electron from 13.1.7 to 13.3.0 + +* chore: chore(deps): bump axios from 0.21.1 to 0.21.2 + +* fix: adds programs service + +* fix: fix lisitng e2e tests + +* fix: fix member tests + + + + + +## [4.0.1-alpha.24](https://github.com/bloom-housing/bloom/compare/@bloom-housing/partners@4.0.1-alpha.23...@bloom-housing/partners@4.0.1-alpha.24) (2022-01-13) + + +### Bug Fixes + +* partners render issue ([#2395](https://github.com/bloom-housing/bloom/issues/2395)) ([7fb108d](https://github.com/bloom-housing/bloom/commit/7fb108d744fcafd6b9df42706d2a2f58fbc30f0a)) + + + + + +## [4.0.1-alpha.23](https://github.com/bloom-housing/bloom/compare/@bloom-housing/partners@4.0.1-alpha.22...@bloom-housing/partners@4.0.1-alpha.23) (2022-01-13) + + +### Bug Fixes + +* dates showing as invalid in send by mail section ([#2362](https://github.com/bloom-housing/bloom/issues/2362)) ([3567388](https://github.com/bloom-housing/bloom/commit/35673882d87e2b524b2c94d1fb7b40c9d777f0a3)) + + +### BREAKING CHANGES + +* remove applicationDueTime field and consolidated into applicationDueDate + + + + + +## [4.0.1-alpha.22](https://github.com/bloom-housing/bloom/compare/@bloom-housing/partners@4.0.1-alpha.21...@bloom-housing/partners@4.0.1-alpha.22) (2022-01-13) + +**Note:** Version bump only for package @bloom-housing/partners + + + + + +## [4.0.1-alpha.21](https://github.com/bloom-housing/bloom/compare/@bloom-housing/partners@4.0.1-alpha.20...@bloom-housing/partners@4.0.1-alpha.21) (2022-01-11) + + +### Bug Fixes + +* open house events can now be edited and work cross-browser ([#2320](https://github.com/bloom-housing/bloom/issues/2320)) ([4af6efd](https://github.com/bloom-housing/bloom/commit/4af6efdd29787a93faf1a314073e2e201584214f)) + + + + + +## [4.0.1-alpha.20](https://github.com/bloom-housing/bloom/compare/@bloom-housing/partners@4.0.1-alpha.19...@bloom-housing/partners@4.0.1-alpha.20) (2022-01-11) + + +### Bug Fixes + +* add test id ([d4b0ed2](https://github.com/bloom-housing/bloom/commit/d4b0ed2426b7f8aced3b2dd44baf2a438410838d)) +* update naming ([b9c645c](https://github.com/bloom-housing/bloom/commit/b9c645cd1460567f9c35a54c7fd93bc5957d593e)) +* use drag n drop ([a354904](https://github.com/bloom-housing/bloom/commit/a3549045d4f0da64692318f84f0336f1287ad48a)) + + + + + +## [4.0.1-alpha.19](https://github.com/bloom-housing/bloom/compare/@bloom-housing/partners@4.0.1-alpha.18...@bloom-housing/partners@4.0.1-alpha.19) (2022-01-08) + + +### Bug Fixes + +* ensure dayjs parsing strings will work as expected ([eb44939](https://github.com/bloom-housing/bloom/commit/eb449395ebea3a3b4b58eb217df1e1313c722a0d)) + + + + + +## [4.0.1-alpha.18](https://github.com/bloom-housing/bloom/compare/@bloom-housing/partners@4.0.1-alpha.17...@bloom-housing/partners@4.0.1-alpha.18) (2022-01-07) + +**Note:** Version bump only for package @bloom-housing/partners + + + + + +## [4.0.1-alpha.17](https://github.com/bloom-housing/bloom/compare/@bloom-housing/partners@4.0.1-alpha.16...@bloom-housing/partners@4.0.1-alpha.17) (2022-01-07) + +**Note:** Version bump only for package @bloom-housing/partners + + + + + +## [4.0.1-alpha.16](https://github.com/bloom-housing/bloom/compare/@bloom-housing/partners@4.0.1-alpha.15...@bloom-housing/partners@4.0.1-alpha.16) (2022-01-07) + +**Note:** Version bump only for package @bloom-housing/partners + + + + + +## [4.0.1-alpha.15](https://github.com/bloom-housing/bloom/compare/@bloom-housing/partners@4.0.1-alpha.14...@bloom-housing/partners@4.0.1-alpha.15) (2022-01-04) + + +### Bug Fixes + +* applications drop off address ([d73cdf6](https://github.com/bloom-housing/bloom/commit/d73cdf69fa550bf178a7f433ca9a1bbe2ce678a2)) +* helper imports of form types ([e58ed71](https://github.com/bloom-housing/bloom/commit/e58ed71c703a53a7ab4284e6d7e2c1857cb8ed7b)) +* jest tests in partners now allows jsx ([3fef534](https://github.com/bloom-housing/bloom/commit/3fef534925f8fee6a63a2a99def692b59df83bdd)) + + + + + +## [4.0.1-alpha.14](https://github.com/bloom-housing/bloom/compare/@bloom-housing/partners@4.0.1-alpha.13...@bloom-housing/partners@4.0.1-alpha.14) (2022-01-04) + +**Note:** Version bump only for package @bloom-housing/partners + + + + + +## [4.0.1-alpha.13](https://github.com/bloom-housing/bloom/compare/@bloom-housing/partners@4.0.1-alpha.12...@bloom-housing/partners@4.0.1-alpha.13) (2022-01-04) + +**Note:** Version bump only for package @bloom-housing/partners + + + + + +## [4.0.1-alpha.12](https://github.com/bloom-housing/bloom/compare/@bloom-housing/partners@4.0.1-alpha.11...@bloom-housing/partners@4.0.1-alpha.12) (2022-01-04) + +**Note:** Version bump only for package @bloom-housing/partners + + + + + +## [4.0.1-alpha.11](https://github.com/bloom-housing/bloom/compare/@bloom-housing/partners@4.0.1-alpha.10...@bloom-housing/partners@4.0.1-alpha.11) (2022-01-03) + +**Note:** Version bump only for package @bloom-housing/partners + + + + + +## [4.0.1-alpha.10](https://github.com/bloom-housing/bloom/compare/@bloom-housing/partners@4.0.1-alpha.9...@bloom-housing/partners@4.0.1-alpha.10) (2022-01-03) + +**Note:** Version bump only for package @bloom-housing/partners + + + + + +## [4.0.1-alpha.9](https://github.com/bloom-housing/bloom/compare/@bloom-housing/partners@4.0.1-alpha.8...@bloom-housing/partners@4.0.1-alpha.9) (2022-01-03) + + +### Bug Fixes + +* cypress coverage configs ([eec74ee](https://github.com/bloom-housing/bloom/commit/eec74eef138f6af275ae3cfe16262ed215b16907)) + + + + + +## [4.0.1-alpha.8](https://github.com/bloom-housing/bloom/compare/@bloom-housing/partners@4.0.1-alpha.7...@bloom-housing/partners@4.0.1-alpha.8) (2022-01-03) + +**Note:** Version bump only for package @bloom-housing/partners + + + + + +## [4.0.1-alpha.7](https://github.com/bloom-housing/bloom/compare/@bloom-housing/partners@4.0.0...@bloom-housing/partners@4.0.1-alpha.7) (2022-01-03) + + +### Bug Fixes + +* bump version ([#2349](https://github.com/bloom-housing/bloom/issues/2349)) ([b9e3ba1](https://github.com/bloom-housing/bloom/commit/b9e3ba10aebd6534090f8be231a9ea77b3c929b6)) +* bump version ([#2350](https://github.com/bloom-housing/bloom/issues/2350)) ([05863f5](https://github.com/bloom-housing/bloom/commit/05863f55f3939bea4387bd7cf4eb1f34df106124)) + + +* 2227/lock login attempts frontend (#2260) ([281ea43](https://github.com/bloom-housing/bloom/commit/281ea435e618a73a73f233a7a494f961fbac8fa2)), closes [#2260](https://github.com/bloom-housing/bloom/issues/2260) [#1927](https://github.com/bloom-housing/bloom/issues/1927) [#1927](https://github.com/bloom-housing/bloom/issues/1927) [#1927](https://github.com/bloom-housing/bloom/issues/1927) [#1927](https://github.com/bloom-housing/bloom/issues/1927) [#1927](https://github.com/bloom-housing/bloom/issues/1927) [#1927](https://github.com/bloom-housing/bloom/issues/1927) [#1927](https://github.com/bloom-housing/bloom/issues/1927) [#1927](https://github.com/bloom-housing/bloom/issues/1927) [#1927](https://github.com/bloom-housing/bloom/issues/1927) [#1927](https://github.com/bloom-housing/bloom/issues/1927) [#1927](https://github.com/bloom-housing/bloom/issues/1927) [#1927](https://github.com/bloom-housing/bloom/issues/1927) [#1927](https://github.com/bloom-housing/bloom/issues/1927) + + +### Features + +* **backend:** add user password expiration ([107c2f0](https://github.com/bloom-housing/bloom/commit/107c2f06e2f8367b52cb7cc8f00e6d9aef751fe0)) +* password reset message ([0cba6e6](https://github.com/bloom-housing/bloom/commit/0cba6e62b45622a430612672daef5c97c1e6b140)) + + +### BREAKING CHANGES + +* sign-in pages have been updated + + + + + +## [4.0.1-alpha.6](https://github.com/bloom-housing/bloom/compare/@bloom-housing/partners@4.0.0...@bloom-housing/partners@4.0.1-alpha.6) (2022-01-03) + + +### Bug Fixes + +* bump version ([#2349](https://github.com/bloom-housing/bloom/issues/2349)) ([b9e3ba1](https://github.com/bloom-housing/bloom/commit/b9e3ba10aebd6534090f8be231a9ea77b3c929b6)) +* bump version ([#2350](https://github.com/bloom-housing/bloom/issues/2350)) ([05863f5](https://github.com/bloom-housing/bloom/commit/05863f55f3939bea4387bd7cf4eb1f34df106124)) + + +* 2227/lock login attempts frontend (#2260) ([281ea43](https://github.com/bloom-housing/bloom/commit/281ea435e618a73a73f233a7a494f961fbac8fa2)), closes [#2260](https://github.com/bloom-housing/bloom/issues/2260) [#1927](https://github.com/bloom-housing/bloom/issues/1927) [#1927](https://github.com/bloom-housing/bloom/issues/1927) [#1927](https://github.com/bloom-housing/bloom/issues/1927) [#1927](https://github.com/bloom-housing/bloom/issues/1927) [#1927](https://github.com/bloom-housing/bloom/issues/1927) [#1927](https://github.com/bloom-housing/bloom/issues/1927) [#1927](https://github.com/bloom-housing/bloom/issues/1927) [#1927](https://github.com/bloom-housing/bloom/issues/1927) [#1927](https://github.com/bloom-housing/bloom/issues/1927) [#1927](https://github.com/bloom-housing/bloom/issues/1927) [#1927](https://github.com/bloom-housing/bloom/issues/1927) [#1927](https://github.com/bloom-housing/bloom/issues/1927) [#1927](https://github.com/bloom-housing/bloom/issues/1927) + + +### Features + +* **backend:** add user password expiration ([107c2f0](https://github.com/bloom-housing/bloom/commit/107c2f06e2f8367b52cb7cc8f00e6d9aef751fe0)) +* password reset message ([0cba6e6](https://github.com/bloom-housing/bloom/commit/0cba6e62b45622a430612672daef5c97c1e6b140)) + + +### BREAKING CHANGES + +* sign-in pages have been updated + + + + + +## [4.0.1-alpha.1](https://github.com/seanmalbert/bloom/compare/@bloom-housing/partners@4.0.0...@bloom-housing/partners@4.0.1-alpha.1) (2021-12-23) + + +* 2227/lock login attempts frontend (#2260) ([281ea43](https://github.com/seanmalbert/bloom/commit/281ea435e618a73a73f233a7a494f961fbac8fa2)), closes [#2260](https://github.com/seanmalbert/bloom/issues/2260) [#1927](https://github.com/seanmalbert/bloom/issues/1927) [#1927](https://github.com/seanmalbert/bloom/issues/1927) [#1927](https://github.com/seanmalbert/bloom/issues/1927) [#1927](https://github.com/seanmalbert/bloom/issues/1927) [#1927](https://github.com/seanmalbert/bloom/issues/1927) [#1927](https://github.com/seanmalbert/bloom/issues/1927) [#1927](https://github.com/seanmalbert/bloom/issues/1927) [#1927](https://github.com/seanmalbert/bloom/issues/1927) [#1927](https://github.com/seanmalbert/bloom/issues/1927) [#1927](https://github.com/seanmalbert/bloom/issues/1927) [#1927](https://github.com/seanmalbert/bloom/issues/1927) [#1927](https://github.com/seanmalbert/bloom/issues/1927) [#1927](https://github.com/seanmalbert/bloom/issues/1927) + + +### BREAKING CHANGES + +* sign-in pages have been updated + + + + + +## [4.0.1-alpha.0](https://github.com/seanmalbert/bloom/compare/@bloom-housing/partners@4.0.0...@bloom-housing/partners@4.0.1-alpha.0) (2021-12-23) + + +* 2227/lock login attempts frontend (#2260) ([281ea43](https://github.com/seanmalbert/bloom/commit/281ea435e618a73a73f233a7a494f961fbac8fa2)), closes [#2260](https://github.com/seanmalbert/bloom/issues/2260) [#1927](https://github.com/seanmalbert/bloom/issues/1927) [#1927](https://github.com/seanmalbert/bloom/issues/1927) [#1927](https://github.com/seanmalbert/bloom/issues/1927) [#1927](https://github.com/seanmalbert/bloom/issues/1927) [#1927](https://github.com/seanmalbert/bloom/issues/1927) [#1927](https://github.com/seanmalbert/bloom/issues/1927) [#1927](https://github.com/seanmalbert/bloom/issues/1927) [#1927](https://github.com/seanmalbert/bloom/issues/1927) [#1927](https://github.com/seanmalbert/bloom/issues/1927) [#1927](https://github.com/seanmalbert/bloom/issues/1927) [#1927](https://github.com/seanmalbert/bloom/issues/1927) [#1927](https://github.com/seanmalbert/bloom/issues/1927) [#1927](https://github.com/seanmalbert/bloom/issues/1927) + + +### BREAKING CHANGES + +* sign-in pages have been updated + + + + + +# [4.0.0](https://github.com/seanmalbert/bloom/compare/@bloom-housing/partners@3.0.1-alpha.65...@bloom-housing/partners@4.0.0) (2021-12-22) + + +### Bug Fixes + +* partners shared-helpers version ([50f4a06](https://github.com/seanmalbert/bloom/commit/50f4a0658761b8675064a98f316b2dd35b0d3fe0)) + + +### Code Refactoring + +* removing helpers from ui-components that are backend dependent ([#2108](https://github.com/seanmalbert/bloom/issues/2108)) ([1d0c1f3](https://github.com/seanmalbert/bloom/commit/1d0c1f340781a3ba76c89462d8bee954dd40b889)) + + +### Features + +* adds updating open listing modal ([#2288](https://github.com/seanmalbert/bloom/issues/2288)) ([4f6945f](https://github.com/seanmalbert/bloom/commit/4f6945f04d797fad1b3140bcdc74b134ea42810a)) + + +### BREAKING CHANGES + +* moved some helpers from ui-components to shared-helpers + + + + + +## [3.0.1-alpha.65](https://github.com/bloom-housing/bloom/compare/@bloom-housing/partners@3.0.1-alpha.64...@bloom-housing/partners@3.0.1-alpha.65) (2021-12-15) + +**Note:** Version bump only for package @bloom-housing/partners + + + + + +## [3.0.1-alpha.64](https://github.com/bloom-housing/bloom/compare/@bloom-housing/partners@3.0.1-alpha.63...@bloom-housing/partners@3.0.1-alpha.64) (2021-12-15) + + +### Features + +* filter partner users ([63566f2](https://github.com/bloom-housing/bloom/commit/63566f206b154031a143b649b986aaecd5181313)) + + + + + +## [3.0.1-alpha.63](https://github.com/bloom-housing/bloom/compare/@bloom-housing/partners@3.0.1-alpha.62...@bloom-housing/partners@3.0.1-alpha.63) (2021-12-15) + +**Note:** Version bump only for package @bloom-housing/partners + + + + + +## [3.0.1-alpha.62](https://github.com/bloom-housing/bloom/compare/@bloom-housing/partners@3.0.1-alpha.61...@bloom-housing/partners@3.0.1-alpha.62) (2021-12-15) + +**Note:** Version bump only for package @bloom-housing/partners + + + + + +## [3.0.1-alpha.61](https://github.com/bloom-housing/bloom/compare/@bloom-housing/partners@3.0.1-alpha.60...@bloom-housing/partners@3.0.1-alpha.61) (2021-12-14) + +**Note:** Version bump only for package @bloom-housing/partners + + + + + +## [3.0.1-alpha.60](https://github.com/bloom-housing/bloom/compare/@bloom-housing/partners@3.0.1-alpha.59...@bloom-housing/partners@3.0.1-alpha.60) (2021-12-14) + +**Note:** Version bump only for package @bloom-housing/partners + + + + + +## [3.0.1-alpha.59](https://github.com/bloom-housing/bloom/compare/@bloom-housing/partners@3.0.1-alpha.58...@bloom-housing/partners@3.0.1-alpha.59) (2021-12-13) + +**Note:** Version bump only for package @bloom-housing/partners + + + + + +## [3.0.1-alpha.58](https://github.com/bloom-housing/bloom/compare/@bloom-housing/partners@3.0.1-alpha.56...@bloom-housing/partners@3.0.1-alpha.58) (2021-12-13) + + +### Bug Fixes + +* versioning issues ([#2311](https://github.com/bloom-housing/bloom/issues/2311)) ([0b1d143](https://github.com/bloom-housing/bloom/commit/0b1d143ab8b17add9d52533560f28d7a1f6dfd3d)) + + + + + +## [3.0.1-alpha.56](https://github.com/bloom-housing/bloom/compare/@bloom-housing/partners@3.0.1-alpha.55...@bloom-housing/partners@3.0.1-alpha.56) (2021-12-10) + +**Note:** Version bump only for package @bloom-housing/partners + + + + + +## [3.0.1-alpha.55](https://github.com/bloom-housing/bloom/compare/@bloom-housing/partners@3.0.1-alpha.54...@bloom-housing/partners@3.0.1-alpha.55) (2021-12-09) + +**Note:** Version bump only for package @bloom-housing/partners + + + + + +## [3.0.1-alpha.54](https://github.com/bloom-housing/bloom/compare/@bloom-housing/partners@3.0.1-alpha.53...@bloom-housing/partners@3.0.1-alpha.54) (2021-12-09) + +**Note:** Version bump only for package @bloom-housing/partners + + + + + +## [3.0.1-alpha.53](https://github.com/bloom-housing/bloom/compare/@bloom-housing/partners@3.0.1-alpha.52...@bloom-housing/partners@3.0.1-alpha.53) (2021-12-09) + +**Note:** Version bump only for package @bloom-housing/partners + + + + + +## [3.0.1-alpha.52](https://github.com/bloom-housing/bloom/compare/@bloom-housing/partners@3.0.1-alpha.51...@bloom-housing/partners@3.0.1-alpha.52) (2021-12-09) + +**Note:** Version bump only for package @bloom-housing/partners + + + + + +## [3.0.1-alpha.51](https://github.com/bloom-housing/bloom/compare/@bloom-housing/partners@3.0.1-alpha.50...@bloom-housing/partners@3.0.1-alpha.51) (2021-12-08) + +**Note:** Version bump only for package @bloom-housing/partners + + + + + +## [3.0.1-alpha.50](https://github.com/bloom-housing/bloom/compare/@bloom-housing/partners@3.0.1-alpha.49...@bloom-housing/partners@3.0.1-alpha.50) (2021-12-07) + +**Note:** Version bump only for package @bloom-housing/partners + + + + + +## [3.0.1-alpha.49](https://github.com/bloom-housing/bloom/compare/@bloom-housing/partners@3.0.1-alpha.48...@bloom-housing/partners@3.0.1-alpha.49) (2021-12-07) + + +### Features + +* overrides fallback to english, tagalog support ([#2262](https://github.com/bloom-housing/bloom/issues/2262)) ([679ab9b](https://github.com/bloom-housing/bloom/commit/679ab9b1816d5934f48f02ca5f5696952ef88ae7)) + + + + + +## [3.0.1-alpha.48](https://github.com/bloom-housing/bloom/compare/@bloom-housing/partners@3.0.1-alpha.47...@bloom-housing/partners@3.0.1-alpha.48) (2021-12-07) + +**Note:** Version bump only for package @bloom-housing/partners + + + + + +## [3.0.1-alpha.47](https://github.com/bloom-housing/bloom/compare/@bloom-housing/partners@3.0.1-alpha.46...@bloom-housing/partners@3.0.1-alpha.47) (2021-12-06) + + +### Bug Fixes + +* Remove description for the partners programs ([#2234](https://github.com/bloom-housing/bloom/issues/2234)) ([2bbbeb5](https://github.com/bloom-housing/bloom/commit/2bbbeb52868d8f4b5ee6723018fa34619073017b)), closes [#1901](https://github.com/bloom-housing/bloom/issues/1901) + + + + + +## [3.0.1-alpha.46](https://github.com/bloom-housing/bloom/compare/@bloom-housing/partners@3.0.1-alpha.45...@bloom-housing/partners@3.0.1-alpha.46) (2021-12-06) + +**Note:** Version bump only for package @bloom-housing/partners + + + + + +## [3.0.1-alpha.45](https://github.com/bloom-housing/bloom/compare/@bloom-housing/partners@3.0.1-alpha.44...@bloom-housing/partners@3.0.1-alpha.45) (2021-12-03) + +**Note:** Version bump only for package @bloom-housing/partners + + + + + +## [3.0.1-alpha.44](https://github.com/bloom-housing/bloom/compare/@bloom-housing/partners@3.0.1-alpha.43...@bloom-housing/partners@3.0.1-alpha.44) (2021-12-03) + +**Note:** Version bump only for package @bloom-housing/partners + + + + + +## [3.0.1-alpha.43](https://github.com/bloom-housing/bloom/compare/@bloom-housing/partners@3.0.1-alpha.42...@bloom-housing/partners@3.0.1-alpha.43) (2021-12-03) + +**Note:** Version bump only for package @bloom-housing/partners + + + + + +## [3.0.1-alpha.42](https://github.com/bloom-housing/bloom/compare/@bloom-housing/partners@3.0.1-alpha.41...@bloom-housing/partners@3.0.1-alpha.42) (2021-12-01) + +**Note:** Version bump only for package @bloom-housing/partners + + + + + +## [3.0.1-alpha.41](https://github.com/bloom-housing/bloom/compare/@bloom-housing/partners@3.0.1-alpha.40...@bloom-housing/partners@3.0.1-alpha.41) (2021-12-01) + +**Note:** Version bump only for package @bloom-housing/partners + + + + + +## [3.0.1-alpha.40](https://github.com/bloom-housing/bloom/compare/@bloom-housing/partners@3.0.1-alpha.39...@bloom-housing/partners@3.0.1-alpha.40) (2021-12-01) + +**Note:** Version bump only for package @bloom-housing/partners + + + + + +## [3.0.1-alpha.39](https://github.com/bloom-housing/bloom/compare/@bloom-housing/partners@3.0.1-alpha.38...@bloom-housing/partners@3.0.1-alpha.39) (2021-11-30) + + +### Bug Fixes + +* lottery results uploads now save ([#2226](https://github.com/bloom-housing/bloom/issues/2226)) ([8964bba](https://github.com/bloom-housing/bloom/commit/8964bba2deddbd077a049649c26f6fe8b576ed2f)) + + + + + +## [3.0.1-alpha.38](https://github.com/bloom-housing/bloom/compare/@bloom-housing/partners@3.0.1-alpha.37...@bloom-housing/partners@3.0.1-alpha.38) (2021-11-30) + + +### Bug Fixes + +* **backend:** nginx with heroku configuration ([#2196](https://github.com/bloom-housing/bloom/issues/2196)) ([a1e2630](https://github.com/bloom-housing/bloom/commit/a1e26303bdd660b9ac267da55dc8d09661216f1c)) + + + + + +## [3.0.1-alpha.37](https://github.com/bloom-housing/bloom/compare/@bloom-housing/partners@3.0.1-alpha.36...@bloom-housing/partners@3.0.1-alpha.37) (2021-11-29) + + +### Bug Fixes + +* feedback on the waitlist data and display ([9432542](https://github.com/bloom-housing/bloom/commit/9432542efd9ba2e4bf8dd7195895e75f5d2e0623)) + + +### Features + +* simplify Waitlist component and use more flexible schema ([96df149](https://github.com/bloom-housing/bloom/commit/96df1496f377ddfa6f0e6c016c84954b6a43ff4a)) + + + + + +## [3.0.1-alpha.36](https://github.com/bloom-housing/bloom/compare/@bloom-housing/partners@3.0.1-alpha.35...@bloom-housing/partners@3.0.1-alpha.36) (2021-11-29) + +**Note:** Version bump only for package @bloom-housing/partners + + + + + +## [3.0.1-alpha.35](https://github.com/bloom-housing/bloom/compare/@bloom-housing/partners@3.0.1-alpha.34...@bloom-housing/partners@3.0.1-alpha.35) (2021-11-29) + + +### Bug Fixes + +* cannot save custom mailing, dropoff, or pickup address ([#2207](https://github.com/bloom-housing/bloom/issues/2207)) ([96484b5](https://github.com/bloom-housing/bloom/commit/96484b5676ecb000e492851ee12766ba9e6cd86f)) + + + + + +## [3.0.1-alpha.34](https://github.com/bloom-housing/bloom/compare/@bloom-housing/partners@3.0.1-alpha.33...@bloom-housing/partners@3.0.1-alpha.34) (2021-11-23) + +**Note:** Version bump only for package @bloom-housing/partners + + + + + +## [3.0.1-alpha.33](https://github.com/bloom-housing/bloom/compare/@bloom-housing/partners@3.0.1-alpha.32...@bloom-housing/partners@3.0.1-alpha.33) (2021-11-23) + +**Note:** Version bump only for package @bloom-housing/partners + + + + + +## [3.0.1-alpha.32](https://github.com/bloom-housing/bloom/compare/@bloom-housing/partners@3.0.1-alpha.31...@bloom-housing/partners@3.0.1-alpha.32) (2021-11-23) + +**Note:** Version bump only for package @bloom-housing/partners + + + + + +## [3.0.1-alpha.31](https://github.com/bloom-housing/bloom/compare/@bloom-housing/partners@3.0.1-alpha.30...@bloom-housing/partners@3.0.1-alpha.31) (2021-11-23) + +**Note:** Version bump only for package @bloom-housing/partners + + + + + +## [3.0.1-alpha.30](https://github.com/bloom-housing/bloom/compare/@bloom-housing/partners@3.0.1-alpha.29...@bloom-housing/partners@3.0.1-alpha.30) (2021-11-23) + +**Note:** Version bump only for package @bloom-housing/partners + + + + + +## [3.0.1-alpha.29](https://github.com/bloom-housing/bloom/compare/@bloom-housing/partners@3.0.1-alpha.28...@bloom-housing/partners@3.0.1-alpha.29) (2021-11-23) + + +### Features + +* new demographics sub-race questions ([#2109](https://github.com/bloom-housing/bloom/issues/2109)) ([9ab8926](https://github.com/bloom-housing/bloom/commit/9ab892694c1ad2fa8890b411b3b32af68ade1fc3)) + + + + + +## [3.0.1-alpha.28](https://github.com/bloom-housing/bloom/compare/@bloom-housing/partners@3.0.1-alpha.27...@bloom-housing/partners@3.0.1-alpha.28) (2021-11-22) + +**Note:** Version bump only for package @bloom-housing/partners + + + + + +## [3.0.1-alpha.27](https://github.com/bloom-housing/bloom/compare/@bloom-housing/partners@3.0.1-alpha.26...@bloom-housing/partners@3.0.1-alpha.27) (2021-11-22) + +**Note:** Version bump only for package @bloom-housing/partners + + + + + +## [3.0.1-alpha.26](https://github.com/bloom-housing/bloom/compare/@bloom-housing/partners@3.0.1-alpha.25...@bloom-housing/partners@3.0.1-alpha.26) (2021-11-22) + +**Note:** Version bump only for package @bloom-housing/partners + + + + + +## [3.0.1-alpha.25](https://github.com/bloom-housing/bloom/compare/@bloom-housing/partners@3.0.1-alpha.24...@bloom-housing/partners@3.0.1-alpha.25) (2021-11-22) + +**Note:** Version bump only for package @bloom-housing/partners + + + + + +## [3.0.1-alpha.24](https://github.com/bloom-housing/bloom/compare/@bloom-housing/partners@3.0.1-alpha.23...@bloom-housing/partners@3.0.1-alpha.24) (2021-11-17) + +**Note:** Version bump only for package @bloom-housing/partners + + + + + +## [3.0.1-alpha.23](https://github.com/bloom-housing/bloom/compare/@bloom-housing/partners@3.0.1-alpha.22...@bloom-housing/partners@3.0.1-alpha.23) (2021-11-16) + +**Note:** Version bump only for package @bloom-housing/partners + + + + + +## [3.0.1-alpha.22](https://github.com/bloom-housing/bloom/compare/@bloom-housing/partners@3.0.1-alpha.21...@bloom-housing/partners@3.0.1-alpha.22) (2021-11-16) + +**Note:** Version bump only for package @bloom-housing/partners + + + + + +## [3.0.1-alpha.21](https://github.com/bloom-housing/bloom/compare/@bloom-housing/partners@3.0.1-alpha.20...@bloom-housing/partners@3.0.1-alpha.21) (2021-11-16) + +**Note:** Version bump only for package @bloom-housing/partners + + + + + +## [3.0.1-alpha.20](https://github.com/bloom-housing/bloom/compare/@bloom-housing/partners@3.0.1-alpha.19...@bloom-housing/partners@3.0.1-alpha.20) (2021-11-16) + +**Note:** Version bump only for package @bloom-housing/partners + + + + + +## [3.0.1-alpha.19](https://github.com/bloom-housing/bloom/compare/@bloom-housing/partners@3.0.1-alpha.18...@bloom-housing/partners@3.0.1-alpha.19) (2021-11-15) + +**Note:** Version bump only for package @bloom-housing/partners + + + + + +## [3.0.1-alpha.18](https://github.com/bloom-housing/bloom/compare/@bloom-housing/partners@3.0.1-alpha.17...@bloom-housing/partners@3.0.1-alpha.18) (2021-11-15) + +**Note:** Version bump only for package @bloom-housing/partners + + + + + +## [3.0.1-alpha.17](https://github.com/bloom-housing/bloom/compare/@bloom-housing/partners@3.0.1-alpha.16...@bloom-housing/partners@3.0.1-alpha.17) (2021-11-15) + +**Note:** Version bump only for package @bloom-housing/partners + + + + + +## [3.0.1-alpha.16](https://github.com/bloom-housing/bloom/compare/@bloom-housing/partners@3.0.1-alpha.15...@bloom-housing/partners@3.0.1-alpha.16) (2021-11-12) + +**Note:** Version bump only for package @bloom-housing/partners + + + + + +## [3.0.1-alpha.15](https://github.com/bloom-housing/bloom/compare/@bloom-housing/partners@3.0.1-alpha.14...@bloom-housing/partners@3.0.1-alpha.15) (2021-11-12) + +**Note:** Version bump only for package @bloom-housing/partners + + + + + +## [3.0.1-alpha.14](https://github.com/bloom-housing/bloom/compare/@bloom-housing/partners@3.0.1-alpha.13...@bloom-housing/partners@3.0.1-alpha.14) (2021-11-12) + +**Note:** Version bump only for package @bloom-housing/partners + + + + + +## [3.0.1-alpha.13](https://github.com/bloom-housing/bloom/compare/@bloom-housing/partners@3.0.1-alpha.12...@bloom-housing/partners@3.0.1-alpha.13) (2021-11-11) + +**Note:** Version bump only for package @bloom-housing/partners + + + + + +## [3.0.1-alpha.12](https://github.com/bloom-housing/bloom/compare/@bloom-housing/partners@3.0.1-alpha.11...@bloom-housing/partners@3.0.1-alpha.12) (2021-11-11) + + +### Bug Fixes + +* fixes program, preference, ami-chart de-dupe ([#2169](https://github.com/bloom-housing/bloom/issues/2169)) ([3530757](https://github.com/bloom-housing/bloom/commit/35307575bd78f4a0ceee03ae21f07a61e9018bba)) + + + + + +## [3.0.1-alpha.11](https://github.com/bloom-housing/bloom/compare/@bloom-housing/partners@3.0.1-alpha.10...@bloom-housing/partners@3.0.1-alpha.11) (2021-11-10) + +**Note:** Version bump only for package @bloom-housing/partners + + + + + +## [3.0.1-alpha.10](https://github.com/bloom-housing/bloom/compare/@bloom-housing/partners@3.0.1-alpha.9...@bloom-housing/partners@3.0.1-alpha.10) (2021-11-09) + +**Note:** Version bump only for package @bloom-housing/partners + + + + + +## [3.0.1-alpha.9](https://github.com/bloom-housing/bloom/compare/@bloom-housing/partners@3.0.1-alpha.8...@bloom-housing/partners@3.0.1-alpha.9) (2021-11-09) + +**Note:** Version bump only for package @bloom-housing/partners + + + + + +## [3.0.1-alpha.8](https://github.com/bloom-housing/bloom/compare/@bloom-housing/partners@3.0.1-alpha.7...@bloom-housing/partners@3.0.1-alpha.8) (2021-11-09) + +**Note:** Version bump only for package @bloom-housing/partners + + + + + +## [3.0.1-alpha.7](https://github.com/bloom-housing/bloom/compare/@bloom-housing/partners@3.0.1-alpha.6...@bloom-housing/partners@3.0.1-alpha.7) (2021-11-09) + +**Note:** Version bump only for package @bloom-housing/partners + + + + + +## [3.0.1-alpha.6](https://github.com/bloom-housing/bloom/compare/@bloom-housing/partners@3.0.1-alpha.5...@bloom-housing/partners@3.0.1-alpha.6) (2021-11-09) + +**Note:** Version bump only for package @bloom-housing/partners + + + + + +## [3.0.1-alpha.5](https://github.com/bloom-housing/bloom/compare/@bloom-housing/partners@3.0.1-alpha.4...@bloom-housing/partners@3.0.1-alpha.5) (2021-11-09) + + +### Features + +* Change unit number field type to text ([#2136](https://github.com/bloom-housing/bloom/issues/2136)) ([f54be7c](https://github.com/bloom-housing/bloom/commit/f54be7c7ba6aac8e00fee610dc86584b60cc212d)) + + + + + +## [3.0.1-alpha.4](https://github.com/bloom-housing/bloom/compare/@bloom-housing/partners@3.0.1-alpha.3...@bloom-housing/partners@3.0.1-alpha.4) (2021-11-09) + +**Note:** Version bump only for package @bloom-housing/partners + + + + + +## [3.0.1-alpha.3](https://github.com/bloom-housing/bloom/compare/@bloom-housing/partners@3.0.1-alpha.2...@bloom-housing/partners@3.0.1-alpha.3) (2021-11-08) + + +### Features + +* add Programs section to listings management ([#2093](https://github.com/bloom-housing/bloom/issues/2093)) ([9bd1fe1](https://github.com/bloom-housing/bloom/commit/9bd1fe1033dee0fb7e73756254474471bc304f5e)) + + + + + +## [3.0.1-alpha.2](https://github.com/bloom-housing/bloom/compare/@bloom-housing/partners@3.0.1-alpha.1...@bloom-housing/partners@3.0.1-alpha.2) (2021-11-08) + +**Note:** Version bump only for package @bloom-housing/partners + + + + + +## [3.0.1-alpha.1](https://github.com/bloom-housing/bloom/compare/@bloom-housing/partners@3.0.1-alpha.0...@bloom-housing/partners@3.0.1-alpha.1) (2021-11-08) + +**Note:** Version bump only for package @bloom-housing/partners + + + + + +## [3.0.1-alpha.0](https://github.com/bloom-housing/bloom/compare/@bloom-housing/partners@3.0.0...@bloom-housing/partners@3.0.1-alpha.0) (2021-11-05) + + +* 1837/preferences cleanup 3 (#2144) ([3ce6d5e](https://github.com/bloom-housing/bloom/commit/3ce6d5eb5aac49431ec5bf4912dbfcbe9077d84e)), closes [#2144](https://github.com/bloom-housing/bloom/issues/2144) + + +### BREAKING CHANGES + +* Preferences are now M-N relation with a listing and have an intermediate table with ordinal number + +* refactor(backend): preferences deduplication + +So far each listing referenced it's own unique Preferences. This change introduces Many to Many +relationship between Preference and Listing entity and forces sharing Preferences between listings. + +* feat(backend): extend preferences migration with moving existing relations to a new intermediate tab + +* feat(backend): add Preference - Jurisdiction ManyToMany relation + +* feat: adapt frontend to backend changes + +* fix(backend): typeORM preferences select statement + +* fix(backend): connect preferences with jurisdictions in seeds, fix pref filter validator + +* fix(backend): fix missing import in preferences-filter-params.ts + +* refactor: rebase issue + +* feat: uptake jurisdictional preferences + +* fix: fixup tests + +* fix: application preferences ignore page, always separate + +* Remove page from src/migration/1633359409242-add-listing-preferences-intermediate-relation.ts + +* fix: preference fetching and ordering/pages + +* Fix code style issues with Prettier + +* fix(backend): query User__leasingAgentInListings__jurisdiction_User__leasingAgentIn specified more + +* fix: perferences cypress tests + +Co-authored-by: Michal Plebanski +Co-authored-by: Emily Jablonski +Co-authored-by: Lint Action + + + + + +# [3.0.0](https://github.com/seanmalbert/bloom/compare/@bloom-housing/partners@2.0.1-alpha.9...@bloom-housing/partners@3.0.0) (2021-11-05) + +**Note:** Version bump only for package @bloom-housing/partners + + + + + +## [2.0.1-alpha.9](https://github.com/bloom-housing/bloom/compare/@bloom-housing/partners@2.0.1-alpha.8...@bloom-housing/partners@2.0.1-alpha.9) (2021-11-05) + +**Note:** Version bump only for package @bloom-housing/partners + + + + + +## [2.0.1-alpha.8](https://github.com/bloom-housing/bloom/compare/@bloom-housing/partners@2.0.1-alpha.7...@bloom-housing/partners@2.0.1-alpha.8) (2021-11-04) + + +### Reverts + +* Revert "refactor: listing preferences and adds jurisdictional filtering" ([41f72c0](https://github.com/bloom-housing/bloom/commit/41f72c0db49cf94d7930f5cfc88f6ee9d6040986)) + + + + + +## [2.0.1-alpha.7](https://github.com/bloom-housing/bloom/compare/@bloom-housing/partners@2.0.1-alpha.6...@bloom-housing/partners@2.0.1-alpha.7) (2021-11-04) + +**Note:** Version bump only for package @bloom-housing/partners + + + + + +## [2.0.1-alpha.6](https://github.com/bloom-housing/bloom/compare/@bloom-housing/partners@2.0.1-alpha.5...@bloom-housing/partners@2.0.1-alpha.6) (2021-11-04) + + +### Features + +* Updates application confirmation numbers ([#2072](https://github.com/bloom-housing/bloom/issues/2072)) ([75cd67b](https://github.com/bloom-housing/bloom/commit/75cd67bcb62280936bdeeaee8c9b7b2583a1339d)) + + + + + +## [2.0.1-alpha.5](https://github.com/bloom-housing/bloom/compare/@bloom-housing/partners@2.0.1-alpha.4...@bloom-housing/partners@2.0.1-alpha.5) (2021-11-03) + +**Note:** Version bump only for package @bloom-housing/partners + + + + + +## [2.0.1-alpha.4](https://github.com/bloom-housing/bloom/compare/@bloom-housing/partners@2.0.1-alpha.3...@bloom-housing/partners@2.0.1-alpha.4) (2021-11-03) + + +### Bug Fixes + +* don't send email confirmation on paper app submission ([#2110](https://github.com/bloom-housing/bloom/issues/2110)) ([7f83b70](https://github.com/bloom-housing/bloom/commit/7f83b70327049245ecfba04ae3aea4e967929b2a)) + + + + + +## [2.0.1-alpha.3](https://github.com/bloom-housing/bloom/compare/@bloom-housing/partners@2.0.1-alpha.2...@bloom-housing/partners@2.0.1-alpha.3) (2021-11-03) + +**Note:** Version bump only for package @bloom-housing/partners + + + + + +## [2.0.1-alpha.2](https://github.com/bloom-housing/bloom/compare/@bloom-housing/partners@2.0.1-alpha.1...@bloom-housing/partners@2.0.1-alpha.2) (2021-11-02) + + +### Features + +* two new common app questions - Household Changes and Household Student ([#2070](https://github.com/bloom-housing/bloom/issues/2070)) ([42a752e](https://github.com/bloom-housing/bloom/commit/42a752ec073c0f5b65374c7a68da1e34b0b1c949)) + + + + + +## [2.0.1-alpha.1](https://github.com/bloom-housing/bloom/compare/@bloom-housing/partners@2.0.1-alpha.0...@bloom-housing/partners@2.0.1-alpha.1) (2021-11-02) + +**Note:** Version bump only for package @bloom-housing/partners + + + + + +## [2.0.1-alpha.0](https://github.com/bloom-housing/bloom/compare/@bloom-housing/partners@2.0.0...@bloom-housing/partners@2.0.1-alpha.0) (2021-11-02) + + +### Bug Fixes + +* Updates lastName on application save ([aff87ec](https://github.com/bloom-housing/bloom/commit/aff87ec99ad2fbd4a1f9a6853157ea7770f85a56)) + + + + + +# [2.0.0](https://github.com/seanmalbert/bloom/compare/@bloom-housing/partners@2.0.0-pre-tailwind.26...@bloom-housing/partners@2.0.0) (2021-11-02) + +**Note:** Version bump only for package @bloom-housing/partners + + + + + +# [2.0.0-pre-tailwind.26](https://github.com/bloom-housing/bloom/compare/@bloom-housing/partners@2.0.0-pre-tailwind.25...@bloom-housing/partners@2.0.0-pre-tailwind.26) (2021-11-02) + + +### Code Refactoring + +* listing preferences and adds jurisdictional filtering ([9f661b4](https://github.com/bloom-housing/bloom/commit/9f661b43921ec939bd1bf5709c934ad6f56dd859)) + + +### BREAKING CHANGES + +* updates preference relationship with listings + + + + + +# [2.0.0-pre-tailwind.25](https://github.com/bloom-housing/bloom/compare/@bloom-housing/partners@2.0.0-pre-tailwind.24...@bloom-housing/partners@2.0.0-pre-tailwind.25) (2021-11-01) + + +### Bug Fixes + +* reverts preferences to re-add as breaking/major bump ([4f7d893](https://github.com/bloom-housing/bloom/commit/4f7d89327361b3b28b368c23cfd24e6e8123a0a8)) + + + + + +# [2.0.0-pre-tailwind.24](https://github.com/bloom-housing/bloom/compare/@bloom-housing/partners@2.0.0-pre-tailwind.23...@bloom-housing/partners@2.0.0-pre-tailwind.24) (2021-10-30) + +**Note:** Version bump only for package @bloom-housing/partners + + + + + +# [2.0.0-pre-tailwind.23](https://github.com/bloom-housing/bloom/compare/@bloom-housing/partners@2.0.0-pre-tailwind.22...@bloom-housing/partners@2.0.0-pre-tailwind.23) (2021-10-30) + + +* Preferences cleanup (#1947) ([7329a58](https://github.com/bloom-housing/bloom/commit/7329a58cc9242faf647459e46de1e3cff3fe9c9d)), closes [#1947](https://github.com/bloom-housing/bloom/issues/1947) + + +### BREAKING CHANGES + +* Preferences are now M-N relation with a listing and have an intermediate table with ordinal number + +* refactor(backend): preferences deduplication + +So far each listing referenced it's own unique Preferences. This change introduces Many to Many +relationship between Preference and Listing entity and forces sharing Preferences between listings. + +* feat(backend): extend preferences migration with moving existing relations to a new intermediate tab + +* feat(backend): add Preference - Jurisdiction ManyToMany relation + +* feat: adapt frontend to backend changes + +* fix(backend): typeORM preferences select statement + +* fix(backend): connect preferences with jurisdictions in seeds, fix pref filter validator + +* fix(backend): fix missing import in preferences-filter-params.ts + +* refactor: rebase issue + +* feat: uptake jurisdictional preferences + +* fix: fixup tests + +* fix: application preferences ignore page, always separate + +* Remove page from src/migration/1633359409242-add-listing-preferences-intermediate-relation.ts + +* fix: preference fetching and ordering/pages + +* Fix code style issues with Prettier + +* fix(backend): query User__leasingAgentInListings__jurisdiction_User__leasingAgentIn specified more + +* fix: perferences cypress tests + +Co-authored-by: Emily Jablonski +Co-authored-by: Sean Albert +Co-authored-by: Lint Action + + + + + +# [2.0.0-pre-tailwind.22](https://github.com/bloom-housing/bloom/compare/@bloom-housing/partners@2.0.0-pre-tailwind.21...@bloom-housing/partners@2.0.0-pre-tailwind.22) (2021-10-29) + +**Note:** Version bump only for package @bloom-housing/partners + + + + + +# [2.0.0-pre-tailwind.21](https://github.com/bloom-housing/bloom/compare/@bloom-housing/partners@2.0.0-pre-tailwind.20...@bloom-housing/partners@2.0.0-pre-tailwind.21) (2021-10-29) + +**Note:** Version bump only for package @bloom-housing/partners + + + + + +# [2.0.0-pre-tailwind.20](https://github.com/bloom-housing/bloom/compare/@bloom-housing/partners@2.0.0-pre-tailwind.19...@bloom-housing/partners@2.0.0-pre-tailwind.20) (2021-10-29) + +**Note:** Version bump only for package @bloom-housing/partners + + + + + +# [2.0.0-pre-tailwind.19](https://github.com/bloom-housing/bloom/compare/@bloom-housing/partners@2.0.0-pre-tailwind.18...@bloom-housing/partners@2.0.0-pre-tailwind.19) (2021-10-29) + +**Note:** Version bump only for package @bloom-housing/partners + + + + + +# [2.0.0-pre-tailwind.18](https://github.com/bloom-housing/bloom/compare/@bloom-housing/partners@2.0.0-pre-tailwind.17...@bloom-housing/partners@2.0.0-pre-tailwind.18) (2021-10-29) + +**Note:** Version bump only for package @bloom-housing/partners + + + + + +# [2.0.0-pre-tailwind.17](https://github.com/bloom-housing/bloom/compare/@bloom-housing/partners@2.0.0-pre-tailwind.16...@bloom-housing/partners@2.0.0-pre-tailwind.17) (2021-10-29) + +**Note:** Version bump only for package @bloom-housing/partners + + + + + +# [2.0.0-pre-tailwind.16](https://github.com/bloom-housing/bloom/compare/@bloom-housing/partners@2.0.0-pre-tailwind.15...@bloom-housing/partners@2.0.0-pre-tailwind.16) (2021-10-28) + +**Note:** Version bump only for package @bloom-housing/partners + + + + + +# [2.0.0-pre-tailwind.15](https://github.com/bloom-housing/bloom/compare/@bloom-housing/partners@2.0.0-pre-tailwind.14...@bloom-housing/partners@2.0.0-pre-tailwind.15) (2021-10-28) + + +### Bug Fixes + +* in listings management keep empty strings, remove empty objects ([#2064](https://github.com/bloom-housing/bloom/issues/2064)) ([c4b1e83](https://github.com/bloom-housing/bloom/commit/c4b1e833ec128f457015ac7ffa421ee6047083d9)) + + + + + +# [2.0.0-pre-tailwind.14](https://github.com/bloom-housing/bloom/compare/@bloom-housing/partners@2.0.0-pre-tailwind.13...@bloom-housing/partners@2.0.0-pre-tailwind.14) (2021-10-27) + +**Note:** Version bump only for package @bloom-housing/partners + + + + + +# [2.0.0-pre-tailwind.13](https://github.com/bloom-housing/bloom/compare/@bloom-housing/partners@2.0.0-pre-tailwind.12...@bloom-housing/partners@2.0.0-pre-tailwind.13) (2021-10-26) + + +### Bug Fixes + +* Incorrect listing status ([#2015](https://github.com/bloom-housing/bloom/issues/2015)) ([48aa14e](https://github.com/bloom-housing/bloom/commit/48aa14eb522cb8e4d0a25fdeadcc392b30d7f1a9)) + + + + + +# [2.0.0-pre-tailwind.12](https://github.com/bloom-housing/bloom/compare/@bloom-housing/partners@2.0.0-pre-tailwind.11...@bloom-housing/partners@2.0.0-pre-tailwind.12) (2021-10-25) + + +### Bug Fixes + +* duplicate unit during Copy & New and Save & New ([#1963](https://github.com/bloom-housing/bloom/issues/1963)) ([d597a3f](https://github.com/bloom-housing/bloom/commit/d597a3f57ed4c489804e10e3b6bac99e5f9bedcc)) + + + + + +# [2.0.0-pre-tailwind.11](https://github.com/bloom-housing/bloom/compare/@bloom-housing/partners@2.0.0-pre-tailwind.10...@bloom-housing/partners@2.0.0-pre-tailwind.11) (2021-10-25) + +**Note:** Version bump only for package @bloom-housing/partners + + + + + +# [2.0.0-pre-tailwind.10](https://github.com/bloom-housing/bloom/compare/@bloom-housing/partners@2.0.0-pre-tailwind.9...@bloom-housing/partners@2.0.0-pre-tailwind.10) (2021-10-22) + +**Note:** Version bump only for package @bloom-housing/partners + + + + + +# [2.0.0-pre-tailwind.9](https://github.com/bloom-housing/bloom/compare/@bloom-housing/partners@2.0.0-pre-tailwind.8...@bloom-housing/partners@2.0.0-pre-tailwind.9) (2021-10-22) + + +### Bug Fixes + +* makes listing programs optional ([fbe7134](https://github.com/bloom-housing/bloom/commit/fbe7134348e59e3fdb86663cfdca7648655e7b4b)) + + + + + +# [2.0.0-pre-tailwind.8](https://github.com/bloom-housing/bloom/compare/@bloom-housing/partners@2.0.0-pre-tailwind.7...@bloom-housing/partners@2.0.0-pre-tailwind.8) (2021-10-22) + +**Note:** Version bump only for package @bloom-housing/partners + + + + + +# [2.0.0-pre-tailwind.7](https://github.com/bloom-housing/bloom/compare/@bloom-housing/partners@2.0.0-pre-tailwind.6...@bloom-housing/partners@2.0.0-pre-tailwind.7) (2021-10-22) + +**Note:** Version bump only for package @bloom-housing/partners + + + + + +# [2.0.0-pre-tailwind.6](https://github.com/bloom-housing/bloom/compare/@bloom-housing/partners@2.0.0-pre-tailwind.5...@bloom-housing/partners@2.0.0-pre-tailwind.6) (2021-10-22) + +**Note:** Version bump only for package @bloom-housing/partners + + + + + +# [2.0.0-pre-tailwind.5](https://github.com/bloom-housing/bloom/compare/@bloom-housing/partners@2.0.0-pre-tailwind.4...@bloom-housing/partners@2.0.0-pre-tailwind.5) (2021-10-22) + + +### Bug Fixes + +* do not show login required on forgot password page ([6578dda](https://github.com/bloom-housing/bloom/commit/6578dda1db68b9d63058900ae7e847f7b7021912)) + + + + + +# [2.0.0-pre-tailwind.4](https://github.com/bloom-housing/bloom/compare/@bloom-housing/partners@2.0.0-pre-tailwind.3...@bloom-housing/partners@2.0.0-pre-tailwind.4) (2021-10-22) + +**Note:** Version bump only for package @bloom-housing/partners + + + + + +# [2.0.0-pre-tailwind.3](https://github.com/bloom-housing/bloom/compare/@bloom-housing/partners@2.0.0-pre-tailwind.2...@bloom-housing/partners@2.0.0-pre-tailwind.3) (2021-10-21) + +**Note:** Version bump only for package @bloom-housing/partners + + + + + +# [2.0.0-pre-tailwind.2](https://github.com/bloom-housing/bloom/compare/@bloom-housing/partners@2.0.0-pre-tailwind.1...@bloom-housing/partners@2.0.0-pre-tailwind.2) (2021-10-21) + +**Note:** Version bump only for package @bloom-housing/partners + + + + + +# [2.0.0-pre-tailwind.1](https://github.com/bloom-housing/bloom/compare/@bloom-housing/partners@2.0.0-pre-tailwind.0...@bloom-housing/partners@2.0.0-pre-tailwind.1) (2021-10-19) + +### Bug Fixes + +- Modals no longer prevent scroll after being closed ([#1962](https://github.com/bloom-housing/bloom/issues/1962)) ([667d5d3](https://github.com/bloom-housing/bloom/commit/667d5d3234c9a463c947c99d8c47acb9ac963e95)) +- Remove shared-helpers dependency from ui-components ([#2032](https://github.com/bloom-housing/bloom/issues/2032)) ([dba201f](https://github.com/bloom-housing/bloom/commit/dba201fa62523c59fc160addab793a7eac20609f)) + +# 2.0.0-pre-tailwind.0 (2021-10-19) + +### Bug Fixes + +- Adds applicationDueDate and Time to removeEmptyFields keysToIgnore ([f024427](https://github.com/bloom-housing/bloom/commit/f024427d4cd2039429e0d9e5db67a50011b5356e)) +- Ami chart values were not appearing after save and new ([#1952](https://github.com/bloom-housing/bloom/issues/1952)) ([cb65340](https://github.com/bloom-housing/bloom/commit/cb653409c8d2c403e1fbfa6ea71415b98af21455)) +- **backend:** unitCreate and UnitUpdateDto now require only IdDto for… ([#1956](https://github.com/bloom-housing/bloom/issues/1956)) ([43dcfbe](https://github.com/bloom-housing/bloom/commit/43dcfbe7493bdd654d7b898ed9650804a016065c)), closes [#1897](https://github.com/bloom-housing/bloom/issues/1897) +- Aan remove application pick up and drop off addresses ([#1954](https://github.com/bloom-housing/bloom/issues/1954)) ([68238ce](https://github.com/bloom-housing/bloom/commit/68238ce87968345d4a8b1a0308a1a70295174675)) +- Improved UX for the Building Selection Criteria drawer ([#1994](https://github.com/bloom-housing/bloom/issues/1994)) ([4bd8b09](https://github.com/bloom-housing/bloom/commit/4bd8b09456b54584c3731bcca64019dc231d0c55)) +- Removes 150 char limit on textarea fields ([6eb7036](https://github.com/bloom-housing/bloom/commit/6eb70364409c5910aa9b8277b37a8214c2a94358)) +- Removes duplicate unitStatusOptions from UnitForm ([d3e71c5](https://github.com/bloom-housing/bloom/commit/d3e71c5dcc40b154f211b16ad3a1d1abac05ebae)) +- Reponsive TW grid classes, nested overlays ([#1881](https://github.com/bloom-housing/bloom/issues/1881)) ([620ed1f](https://github.com/bloom-housing/bloom/commit/620ed1fbbf0466336a53ea233cdb0c3984eeda15)) +- Typo in the paper applications table ([#1965](https://github.com/bloom-housing/bloom/issues/1965)) ([a342772](https://github.com/bloom-housing/bloom/commit/a3427723cbaeb3282abbaa78ae61a69262b5d71c)) +- Visual QA on SiteHeader ([#2010](https://github.com/bloom-housing/bloom/issues/2010)) ([ce86277](https://github.com/bloom-housing/bloom/commit/ce86277d451d83630ba79e89dfb8ad9c4b69bdae)) + +### chore + +- Add new `shared-helpers` package ([#1911](https://github.com/bloom-housing/bloom/issues/1911)) ([6e5d91b](https://github.com/bloom-housing/bloom/commit/6e5d91be5ccafd3d4b5bc1a578f2246a5e7f905b)) + +### Code Refactoring + +- Update textarea character limits ([#1906](https://github.com/bloom-housing/bloom/issues/1906)) ([96d362f](https://github.com/bloom-housing/bloom/commit/96d362f0e8740d255f298ef7505f4933982e270d)), closes [#1890](https://github.com/bloom-housing/bloom/issues/1890) + +### Features + +- Listings management draft and publish validation backend & frontend ([#1850](https://github.com/bloom-housing/bloom/issues/1850)) ([ef67997](https://github.com/bloom-housing/bloom/commit/ef67997a056c6f1f758d2fa67bf877d4a3d897ab)) +- Required labels on listings management fields ([#1924](https://github.com/bloom-housing/bloom/issues/1924)) ([0a2e2da](https://github.com/bloom-housing/bloom/commit/0a2e2da473938c510afbb7cd1ddcd2287813a972)) +- Show confirmation modal when publishing listings ([#1847](https://github.com/bloom-housing/bloom/issues/1847)) ([2de8062](https://github.com/bloom-housing/bloom/commit/2de80625ee9569f41f57debf04e2030829b6c969)), closes [#1772](https://github.com/bloom-housing/bloom/issues/1772) [#1772](https://github.com/bloom-housing/bloom/issues/1772) +- Support PDF uploads or webpage links for building selection criteria ([#1893](https://github.com/bloom-housing/bloom/issues/1893)) ([8514b43](https://github.com/bloom-housing/bloom/commit/8514b43ba337d33cb877ff468bf780ff47fdc772)) + +### Performance Improvements + +- Separates css imports and disables local purge ([#1883](https://github.com/bloom-housing/bloom/issues/1883)) ([668968e](https://github.com/bloom-housing/bloom/commit/668968e45072e9a5121af3cf32d0d8307c671907)), closes [#1882](https://github.com/bloom-housing/bloom/issues/1882) + +### Reverts + +- Revert "latest dev (#1999)" ([73a2789](https://github.com/bloom-housing/bloom/commit/73a2789d8f133f2d788e2399faa42b374d74ab15)), closes [#1999](https://github.com/bloom-housing/bloom/issues/1999) + +### BREAKING CHANGES + +- **backend:** POST/PUT /listings interface change +- Manually add totalFlagged until fixed +- Moves form keys out of ui-components +- Default limit on Textarea is 1000 now diff --git a/sites/partners/README.md b/sites/partners/README.md new file mode 100644 index 0000000000..881e1ede46 --- /dev/null +++ b/sites/partners/README.md @@ -0,0 +1,31 @@ +# Bloom Partners Application + +This is the reference implementation of our partners web app, providing the UI for all of the administrative functions for affordable housing partners using Bloom-based systems (managing applications, and soon publishing listings). Partners include housing developers, property managers, cities, and counties. + +## Getting Started + +All from within `sites/partners`: + +- `yarn install` to install dependencies +- Copy the `.env.template` to `.env` and edit variables appropriate to your local environment +- `yarn dev:all` will start up the backend at port 3100 and the partners app at port 3001 + +## Tests + +For our partnres application, our tests currently consistent of a Cypress integration test suite. We are looking to add React Testing Library unit tests soon. + +To run the Cypress suite, with the application running, run `yarn test` from within `sites/partners` and when the test runner in a Chrome browser opens, click on whichever suite you want to run. + +## Environment Variables + +| Name | Description | Default | Type | +| ------------------------ | --------------------------------------------------------------------------------------------------------------------------------------- | --------------------- | ------- | +| BACKEND_API_BASE | URL pointing to a working NestJS bloom server (no trailing slash) | http://localhost:3100 | string | +| NEXTJS_PORT | Defines port number the server will listen to for incoming connections | 3001 | number | +| LISTINGS_QUERY | Value specifying what path to use to fetch listings at build time for static serving (?) | /listings | string | +| SHOW_DUPLICATES | Toggles the duplicate application feature on/off | false | boolean | +| SHOW_LM_LINKS | Toggles the listings management button on/off | true | boolean | +| MAPBOX_TOKEN | Access token used for interacting with maps. See more documentation [here](https://docs.mapbox.com/help/getting-started/access-tokens/) | Available internally | string | +| CLOUDINARY_CLOUD_NAME | Used for features that upload files/images | exygy | string | +| CLOUDINARY_KEY | Used for features that upload files/images, access token | Available internally | string | +| CLOUDINARY_SIGNED_PRESET | Used for features that upload files/images, access token | Available internally | string | diff --git a/sites/partners/__tests__/AdditionalMetadataFormatter.test.ts b/sites/partners/__tests__/AdditionalMetadataFormatter.test.ts new file mode 100644 index 0000000000..d336fce724 --- /dev/null +++ b/sites/partners/__tests__/AdditionalMetadataFormatter.test.ts @@ -0,0 +1,52 @@ +import { Preference, ReservedCommunityType } from "@bloom-housing/backend-core/types" +import { LatitudeLongitude } from "@bloom-housing/ui-components" +import AdditionalMetadataFormatter from "../src/listings/PaperListingForm/formatters/AdditionalMetadataFormatter" +import { FormListing, FormMetadata } from "../src/listings/PaperListingForm/formTypes" + +const latLong: LatitudeLongitude = { + latitude: 37.36537, + longitude: -121.91071, +} + +const formatData = (data, metadata) => { + return new AdditionalMetadataFormatter({ ...data }, metadata).format().data +} + +const fixtureData = { reservedCommunityType: { id: "12345" } } as FormListing + +describe("AdditionalMetadataFormatter", () => { + it("should format preferences", () => { + const metadata = { + latLong, + preferences: [ + { + title: "Preference 1", + }, + { + title: "Preference 2", + }, + ], + programs: [], + } as FormMetadata + + expect(formatData(fixtureData, metadata).listingPreferences).toEqual([ + { preference: { title: "Preference 1" }, ordinal: 1 }, + { preference: { title: "Preference 2" }, ordinal: 2 }, + ]) + }) + + it("should format buildingAddress", () => { + const address = { street: "123 Anywhere St.", city: "Anytown", state: "CA" } + const data = { + ...fixtureData, + buildingAddress: address, + } + const metadata = { + preferences: [], + programs: [], + latLong, + } + + expect(formatData(data, metadata).buildingAddress).toEqual({ ...address, ...latLong }) + }) +}) diff --git a/sites/partners/__tests__/BooleansFormatter.test.ts b/sites/partners/__tests__/BooleansFormatter.test.ts new file mode 100644 index 0000000000..e0663cd545 --- /dev/null +++ b/sites/partners/__tests__/BooleansFormatter.test.ts @@ -0,0 +1,43 @@ +import { ListingApplicationAddressType } from "@bloom-housing/backend-core/types" +import { YesNoAnswer } from "../src/applications/PaperApplicationForm/FormTypes" +import BooleansFormatter from "../src/listings/PaperListingForm/formatters/BooleansFormatter" +import { + AnotherAddressEnum, + FormListing, + FormMetadata, +} from "../src/listings/PaperListingForm/formTypes" + +// test helpers +const metadata = {} as FormMetadata +const formatData = (data) => { + return new BooleansFormatter({ ...data }, metadata).format().data +} + +describe("BooleansFormatter", () => { + it("should format applicationDropOffAddressType", () => { + const data = {} as FormListing + + expect(formatData(data).applicationDropOffAddressType).toBeNull() + + data.canApplicationsBeDroppedOff = YesNoAnswer.Yes + data.whereApplicationsDroppedOff = ListingApplicationAddressType.leasingAgent + expect(formatData(data).applicationDropOffAddressType).toEqual( + ListingApplicationAddressType.leasingAgent + ) + + data.whereApplicationsDroppedOff = AnotherAddressEnum.anotherAddress + expect(formatData(data).applicationDropOffAddressType).toBeNull() + }) + + it("should format digitalApplication", () => { + const data = {} as FormListing + + expect(formatData(data).digitalApplication).toBeNull() + + data.digitalApplicationChoice = YesNoAnswer.Yes + expect(formatData(data).digitalApplication).toBe(true) + + data.digitalApplicationChoice = YesNoAnswer.No + expect(formatData(data).digitalApplication).toBe(false) + }) +}) diff --git a/sites/partners/__tests__/DatesFormatter.test.ts b/sites/partners/__tests__/DatesFormatter.test.ts new file mode 100644 index 0000000000..df60c1a6e2 --- /dev/null +++ b/sites/partners/__tests__/DatesFormatter.test.ts @@ -0,0 +1,49 @@ +import { TimeFieldPeriod } from "@bloom-housing/ui-components" +import { createDate, createTime } from "../lib/helpers" +import { YesNoAnswer } from "../src/applications/PaperApplicationForm/FormTypes" +import DatesFormatter from "../src/listings/PaperListingForm/formatters/DatesFormatter" +import { FormMetadata } from "../src/listings/PaperListingForm/formTypes" + +// test helpers +const metadata = {} as FormMetadata +const formatData = (data) => { + return new DatesFormatter({ ...data }, metadata).format().data +} +const dueDate = { + year: "2021", + month: "10", + day: "20", +} +const dueTime = { + hours: "10", + minutes: "30", + period: "am" as TimeFieldPeriod, +} + +describe("DatesFormatter", () => { + it("should format applicationDueDate and Time", () => { + const data = { + applicationDueDateField: dueDate, + applicationDueTimeField: dueTime, + } + const applicationDueDate = formatData(data).applicationDueDate + expect(applicationDueDate).toEqual(createTime(applicationDueDate, dueTime)) + expect(formatData(data).applicationDueDate).toEqual(createTime(applicationDueDate, dueTime)) + }) + + it("should format postmarkedApplicationsReceivedByDate", () => { + let data = {} + + expect(formatData(data).postmarkedApplicationsReceivedByDate).toBeNull() + + data = { + postmarkByDateDateField: dueDate, + postmarkByDateTimeField: dueTime, + arePostmarksConsidered: YesNoAnswer.Yes, + } + + expect(formatData(data).postmarkedApplicationsReceivedByDate.toISOString()).toEqual( + "2021-10-20T10:30:00.000Z" + ) + }) +}) diff --git a/sites/partners/__tests__/JurisdictionFormatter.test.ts b/sites/partners/__tests__/JurisdictionFormatter.test.ts new file mode 100644 index 0000000000..0fa41e4770 --- /dev/null +++ b/sites/partners/__tests__/JurisdictionFormatter.test.ts @@ -0,0 +1,25 @@ +import JurisdictionFormatter from "../src/listings/PaperListingForm/formatters/JurisdictionFormatter" +import { FormListing, FormMetadata } from "../src/listings/PaperListingForm/formTypes" + +const metadata = { + profile: { + jurisdictions: [{ name: "Alameda" }], + }, +} as FormMetadata + +describe("JurisdictionFormatter", () => { + it("should pull from profile when data is blank", () => { + const data = {} as FormListing + + const formatter = new JurisdictionFormatter(data, metadata).format() + expect(formatter.data.jurisdiction).toEqual({ name: "Alameda" }) + }) + it("should use data when present and ignore profile", () => { + const data = { + jurisdiction: { name: "San Jose" }, + } as FormListing + + const formatter = new JurisdictionFormatter(data, metadata).format() + expect(formatter.data.jurisdiction).toEqual({ name: "San Jose" }) + }) +}) diff --git a/sites/partners/__tests__/WaitlistFormatter.test.ts b/sites/partners/__tests__/WaitlistFormatter.test.ts new file mode 100644 index 0000000000..ced80a5623 --- /dev/null +++ b/sites/partners/__tests__/WaitlistFormatter.test.ts @@ -0,0 +1,63 @@ +import { YesNoAnswer } from "../src/applications/PaperApplicationForm/FormTypes" +import WaitlistFormatter from "../src/listings/PaperListingForm/formatters/WaitlistFormatter" +import { FormListing, FormMetadata } from "../src/listings/PaperListingForm/formTypes" + +const metadata = {} as FormMetadata +const formatData = (data) => { + return new WaitlistFormatter({ ...data }, metadata).format().data +} + +describe("WaitlistFormatter", () => { + it("should format waitlistCurrentSize", () => { + const data = {} as FormListing + expect(formatData(data).waitlistCurrentSize).toBeNull() + + data.waitlistOpenQuestion = YesNoAnswer.Yes + expect(formatData(data).waitlistCurrentSize).toBeNull() + + data.waitlistCurrentSize = 10 + expect(formatData(data).waitlistCurrentSize).toEqual(10) + + data.waitlistOpenQuestion = YesNoAnswer.No + expect(formatData(data).waitlistCurrentSize).toBeNull() + }) + + it("should format waitlistMaxSize", () => { + const data = {} as FormListing + expect(formatData(data).waitlistMaxSize).toBeNull() + + data.waitlistOpenQuestion = YesNoAnswer.Yes + expect(formatData(data).waitlistMaxSize).toBeNull() + + data.waitlistMaxSize = 20 + expect(formatData(data).waitlistMaxSize).toEqual(20) + + data.waitlistOpenQuestion = YesNoAnswer.No + expect(formatData(data).waitlistMaxSize).toBeNull() + }) + + it("should format waitlistOpenSpots", () => { + const data = {} as FormListing + expect(formatData(data).waitlistOpenSpots).toBeNull() + + data.waitlistOpenQuestion = YesNoAnswer.Yes + expect(formatData(data).waitlistOpenSpots).toBeNull() + + data.waitlistOpenSpots = 15 + expect(formatData(data).waitlistOpenSpots).toEqual(15) + + data.waitlistOpenQuestion = YesNoAnswer.No + expect(formatData(data).waitlistOpenSpots).toBeNull() + }) + + it("should format isWaitlistOpen", () => { + const data = {} as FormListing + expect(formatData(data).isWaitlistOpen).toBeNull() + + data.waitlistOpenQuestion = YesNoAnswer.Yes + expect(formatData(data).isWaitlistOpen).toEqual(true) + + data.waitlistOpenQuestion = YesNoAnswer.No + expect(formatData(data).isWaitlistOpen).toEqual(false) + }) +}) diff --git a/sites/partners/__tests__/formTypes.test.ts b/sites/partners/__tests__/formTypes.test.ts new file mode 100644 index 0000000000..51ee3955a4 --- /dev/null +++ b/sites/partners/__tests__/formTypes.test.ts @@ -0,0 +1,5 @@ +import * as formTypes from "../src/listings/PaperListingForm/formTypes" + +test("formTypes work", () => { + expect(formTypes.addressTypes.anotherAddress).toStrictEqual("anotherAddress") +}) diff --git a/sites/partners/cypress.json b/sites/partners/cypress.json new file mode 100644 index 0000000000..6db57c71b7 --- /dev/null +++ b/sites/partners/cypress.json @@ -0,0 +1,11 @@ +{ + "baseUrl": "http://localhost:3001", + "defaultCommandTimeout": 10000, + "projectId": "bloom-partners-reference", + "numTestsKeptInMemory": 0, + "env": { + "codeCoverage": { + "url": "/api/__coverage__" + } + } +} diff --git a/sites/partners/cypress/fixtures/alternateContactOnlyData.json b/sites/partners/cypress/fixtures/alternateContactOnlyData.json new file mode 100644 index 0000000000..0e61eb93c7 --- /dev/null +++ b/sites/partners/cypress/fixtures/alternateContactOnlyData.json @@ -0,0 +1,76 @@ +{ + "language": "n/a", + "applicant.firstName": "n/a", + "applicant.middleName": "n/a", + "applicant.lastName": "n/a", + "dateOfBirth.birthMonth": "n/a", + "dateOfBirth.birthDay": "n/a", + "dateOfBirth.birthYear": "n/a", + "applicant.emailAddress": "n/a", + "phoneNumber": "n/a", + "applicant.phoneNumberType": "n/a", + "additionalPhoneNumber": "n/a", + "additionalPhoneNumberType": "n/a", + "applicant.workInRegion": "n/a", + "applicant.address.street": "n/a", + "applicant.address.street2": "n/a", + "applicant.address.city": "n/a", + "applicant.address.state": "n/a", + "applicant.address.stateCode": "n/a", + "applicant.address.zipCode": "n/a", + "alternateContact.firstName": "Alternate Contact", + "alternateContact.lastName": "Test", + "alternateContact.agency": "alt contact agency", + "alternateContact.emailAddress": "alt@Contact.com", + "alternateContact.phoneNumber": "5202457893", + "alternateContact.type": "Friend", + "alternateContact.mailingAddress.street": "245 e west street", + "alternateContact.mailingAddress.street2": "n/a", + "alternateContact.mailingAddress.city": "blorg", + "alternateContact.mailingAddress.state": "Nevada", + "alternateContact.mailingAddress.stateCode": "NV", + "alternateContact.mailingAddress.zipCode": "85748", + "householdExpectingChanges": "No", + "householdStudent": "No", + "incomePeriod": "n/a", + "incomeMonth": "n/a", + "incomeVouchers": "No", + "demographics.ethnicity": "n/a", + "acceptedTerms": "Yes", + "firstName": "n/a", + "middleName": "n/a", + "lastName": "n/a", + "dob-field-month": "n/a", + "dob-field-day": "n/a", + "dob-field-year": "n/a", + "relationship": "n/a", + "sameAddress": "n/a", + "workInRegion": "n/a", + "submittedBy": "n/a", + "dateOfBirth": "n/a", + "formattedPhoneNumber": "n/a", + "formattedAdditionalPhoneNumber": "n/a", + "alternateContact.formattedPhoneNumber": "(520) 245-7893", + "householdMemberDoB": "n/a", + "householdMemberName": "n/a", + "preferredUnitSize": "n/a", + "formattedMonthlyIncome": "n/a", + "preferredContact": "n/a", + "preferredUnit": "oneBdrm", + "submittedDate": "n/a", + "timeDate": "n/a", + "workAddress.streetAddress": "n/a", + "workAddress.street2": "n/a", + "workAddress.city": "n/a", + "workAddress.state": "n/a", + "workAddress.zipCode": "n/a", + "adaPriorities": "No", + "annualIncome": "n/a", + "applicationType": "Paper", + "mailingAddress.street": "n/a", + "mailingAddress.street2": "n/a", + "mailingAddress.city": "n/a", + "mailingAddress.zipCode": "n/a", + "mailingAddress.state": "n/a", + "mailingAddress.stateCode": "n/a" +} diff --git a/sites/partners/cypress/fixtures/applicantOnlyData.json b/sites/partners/cypress/fixtures/applicantOnlyData.json new file mode 100644 index 0000000000..9ed74656b7 --- /dev/null +++ b/sites/partners/cypress/fixtures/applicantOnlyData.json @@ -0,0 +1,76 @@ +{ + "language": "English", + "applicant.firstName": "Applicant", + "applicant.middleName": "Only", + "applicant.lastName": "Test", + "dateOfBirth.birthMonth": "10", + "dateOfBirth.birthDay": "5", + "dateOfBirth.birthYear": "2000", + "applicant.emailAddress": "Applicant@only.test", + "phoneNumber": "5202588811", + "applicant.phoneNumberType": "Home", + "additionalPhoneNumber": "n/a", + "additionalPhoneNumberType": "n/a", + "applicant.workInRegion": "Yes", + "applicant.address.street": "123 e street avenue", + "applicant.address.street2": "n/a", + "applicant.address.city": "pheonix", + "applicant.address.state": "Arizona", + "applicant.address.stateCode": "AZ", + "applicant.address.zipCode": "85745", + "alternateContact.firstName": "n/a", + "alternateContact.lastName": "n/a", + "alternateContact.agency": "n/a", + "alternateContact.emailAddress": "n/a", + "alternateContact.phoneNumber": "n/a", + "alternateContact.type": "n/a", + "alternateContact.mailingAddress.street": "n/a", + "alternateContact.mailingAddress.street2": "n/a", + "alternateContact.mailingAddress.city": "n/a", + "alternateContact.mailingAddress.state": "n/a", + "alternateContact.mailingAddress.stateCode": "n/a", + "alternateContact.mailingAddress.zipCode": "n/a", + "householdExpectingChanges": "No", + "householdStudent": "No", + "incomePeriod": "n/a", + "incomeMonth": "n/a", + "incomeVouchers": "No", + "demographics.ethnicity": "n/a", + "acceptedTerms": "Yes", + "firstName": "n/a", + "middleName": "n/a", + "lastName": "n/a", + "dob-field-month": "n/a", + "dob-field-day": "n/a", + "dob-field-year": "n/a", + "relationship": "n/a", + "sameAddress": "n/a", + "workInRegion": "n/a", + "submittedBy": "Applicant Test", + "dateOfBirth": "10/5/2000", + "formattedPhoneNumber": "(520) 258-8811", + "formattedAdditionalPhoneNumber": "n/a", + "alternateContact.formattedPhoneNumber": "n/a", + "householdMemberDoB": "n/a", + "householdMemberName": "n/a", + "preferredUnitSize": "n/a", + "formattedMonthlyIncome": "n/a", + "preferredContact": "Email", + "preferredUnit": "oneBdrm", + "submittedDate": "n/a", + "timeDate": "n/a", + "workAddress.streetAddress": "n/a", + "workAddress.street2": "n/a", + "workAddress.city": "n/a", + "workAddress.state": "n/a", + "workAddress.zipCode": "n/a", + "adaPriorities": "No", + "annualIncome": "n/a", + "applicationType": "Paper", + "mailingAddress.street": "123 e street avenue", + "mailingAddress.street2": "n/a", + "mailingAddress.city": "pheonix", + "mailingAddress.zipCode": "85745", + "mailingAddress.state": "Arizona", + "mailingAddress.stateCode": "AZ" +} diff --git a/sites/partners/cypress/fixtures/application.json b/sites/partners/cypress/fixtures/application.json new file mode 100644 index 0000000000..bf5bd9a356 --- /dev/null +++ b/sites/partners/cypress/fixtures/application.json @@ -0,0 +1,76 @@ +{ + "language": "English", + "applicant.firstName": "First Name", + "applicant.middleName": "Middle Name", + "applicant.lastName": "Last Name", + "dateOfBirth.birthMonth": 12, + "dateOfBirth.birthDay": 17, + "dateOfBirth.birthYear": 1993, + "applicant.emailAddress": "testEmail@exygy.com", + "phoneNumber": "5202458811", + "applicant.phoneNumberType": "Cell", + "additionalPhoneNumber": "5202587847", + "additionalPhoneNumberType": "Home", + "applicant.workInRegion": "No", + "applicant.address.street": "2325 w example trail", + "applicant.address.street2": "test", + "applicant.address.city": "San Francisco", + "applicant.address.state": "California", + "applicant.address.stateCode": "CA", + "applicant.address.zipCode": "85755", + "alternateContact.firstName": "Alt First Name", + "alternateContact.lastName": "Alt Last Name", + "alternateContact.agency": "Last Name", + "alternateContact.emailAddress": "altEmail@exygy.com", + "alternateContact.phoneNumber": "5202458811", + "alternateContact.type": "Friend", + "alternateContact.mailingAddress.street": "198 e solution drive", + "alternateContact.mailingAddress.street2": "Test 2", + "alternateContact.mailingAddress.city": "San Francisco 2", + "alternateContact.mailingAddress.state": "California", + "alternateContact.mailingAddress.stateCode": "CA", + "alternateContact.mailingAddress.zipCode": "85748", + "householdExpectingChanges": "No", + "householdStudent": "No", + "incomePeriod": "Month", + "incomeMonth": "5000", + "incomeVouchers": "No", + "demographics.ethnicity": "Not Hispanic / Latino", + "acceptedTerms": "Yes", + "firstName": "HouseHold First Name", + "middleName": "HouseHold Middle Name", + "lastName": "HouseHold Last Name", + "dob-field-month": "1", + "dob-field-day": "2", + "dob-field-year": "2000", + "relationship": "Friend", + "sameAddress": "Yes", + "workInRegion": "No", + "submittedBy": "First Name Last Name", + "dateOfBirth": "12/17/1993", + "formattedPhoneNumber": "(520) 245-8811", + "formattedAdditionalPhoneNumber": "(520) 258-7847", + "alternateContact.formattedPhoneNumber": "(520) 245-8811", + "householdMemberDoB": "1/2/2000", + "householdMemberName": "HouseHold First Name HouseHold Middle Name HouseHold Last Name", + "preferredUnitSize": "1 Bedroom", + "formattedMonthlyIncome": "$5,000", + "preferredContact": "Email", + "preferredUnit": "oneBdrm", + "submittedDate": "n/a", + "timeDate": "n/a", + "workAddress.streetAddress": "n/a", + "workAddress.street2": "n/a", + "workAddress.city": "n/a", + "workAddress.state": "n/a", + "workAddress.zipCode": "n/a", + "adaPriorities": "No", + "annualIncome": "n/a", + "applicationType": "Paper", + "mailingAddress.street": "2325 w example trail", + "mailingAddress.street2": "test", + "mailingAddress.city": "San Francisco", + "mailingAddress.state": "California", + "mailingAddress.stateCode": "CA", + "mailingAddress.zipCode": "85755" +} diff --git a/sites/partners/cypress/fixtures/cypress-automated-image-upload-071e2ab9-5a52-4f34-85f0-e41f696f4b96.jpeg b/sites/partners/cypress/fixtures/cypress-automated-image-upload-071e2ab9-5a52-4f34-85f0-e41f696f4b96.jpeg new file mode 100644 index 0000000000..8e36785ad8 Binary files /dev/null and b/sites/partners/cypress/fixtures/cypress-automated-image-upload-071e2ab9-5a52-4f34-85f0-e41f696f4b96.jpeg differ diff --git a/sites/partners/cypress/fixtures/demographicsOnlyData.json b/sites/partners/cypress/fixtures/demographicsOnlyData.json new file mode 100644 index 0000000000..5fdee71374 --- /dev/null +++ b/sites/partners/cypress/fixtures/demographicsOnlyData.json @@ -0,0 +1,76 @@ +{ + "language": "n/a", + "applicant.firstName": "n/a", + "applicant.middleName": "n/a", + "applicant.lastName": "n/a", + "dateOfBirth.birthMonth": "n/a", + "dateOfBirth.birthDay": "n/a", + "dateOfBirth.birthYear": "n/a", + "applicant.emailAddress": "n/a", + "phoneNumber": "n/a", + "applicant.phoneNumberType": "n/a", + "additionalPhoneNumber": "n/a", + "additionalPhoneNumberType": "n/a", + "applicant.workInRegion": "n/a", + "applicant.address.street": "n/a", + "applicant.address.street2": "n/a", + "applicant.address.city": "n/a", + "applicant.address.state": "n/a", + "applicant.address.stateCode": "n/a", + "applicant.address.zipCode": "n/a", + "alternateContact.firstName": "n/a", + "alternateContact.lastName": "n/a", + "alternateContact.agency": "n/a", + "alternateContact.emailAddress": "n/a", + "alternateContact.phoneNumber": "n/a", + "alternateContact.type": "n/a", + "alternateContact.mailingAddress.street": "n/a", + "alternateContact.mailingAddress.street2": "n/a", + "alternateContact.mailingAddress.city": "n/a", + "alternateContact.mailingAddress.state": "n/a", + "alternateContact.mailingAddress.stateCode": "n/a", + "alternateContact.mailingAddress.zipCode": "n/a", + "householdExpectingChanges": "No", + "householdStudent": "No", + "incomePeriod": "n/a", + "incomeMonth": "n/a", + "incomeVouchers": "No", + "demographics.ethnicity": "Not Hispanic / Latino", + "acceptedTerms": "Yes", + "firstName": "n/a", + "middleName": "n/a", + "lastName": "n/a", + "dob-field-month": "n/a", + "dob-field-day": "n/a", + "dob-field-year": "n/a", + "relationship": "n/a", + "sameAddress": "n/a", + "workInRegion": "n/a", + "submittedBy": "n/a", + "dateOfBirth": "n/a", + "formattedPhoneNumber": "n/a", + "formattedAdditionalPhoneNumber": "n/a", + "alternateContact.formattedPhoneNumber": "n/a", + "householdMemberDoB": "n/a", + "householdMemberName": "n/a", + "preferredUnitSize": "n/a", + "formattedMonthlyIncome": "n/a", + "preferredContact": "n/a", + "preferredUnit": "oneBdrm", + "submittedDate": "n/a", + "timeDate": "n/a", + "workAddress.streetAddress": "n/a", + "workAddress.street2": "n/a", + "workAddress.city": "n/a", + "workAddress.state": "n/a", + "workAddress.zipCode": "n/a", + "adaPriorities": "No", + "annualIncome": "n/a", + "applicationType": "Paper", + "mailingAddress.street": "n/a", + "mailingAddress.street2": "n/a", + "mailingAddress.city": "n/a", + "mailingAddress.zipCode": "n/a", + "mailingAddress.state": "n/a", + "mailingAddress.stateCode": "n/a" +} diff --git a/sites/partners/cypress/fixtures/emptyApplication.json b/sites/partners/cypress/fixtures/emptyApplication.json new file mode 100644 index 0000000000..825f431a7c --- /dev/null +++ b/sites/partners/cypress/fixtures/emptyApplication.json @@ -0,0 +1,76 @@ +{ + "language": "n/a", + "applicant.firstName": "n/a", + "applicant.middleName": "n/a", + "applicant.lastName": "n/a", + "dateOfBirth.birthMonth": "n/a", + "dateOfBirth.birthDay": "n/a", + "dateOfBirth.birthYear": "n/a", + "applicant.emailAddress": "n/a", + "phoneNumber": "n/a", + "applicant.phoneNumberType": "n/a", + "additionalPhoneNumber": "n/a", + "additionalPhoneNumberType": "n/a", + "applicant.workInRegion": "n/a", + "applicant.address.street": "n/a", + "applicant.address.street2": "n/a", + "applicant.address.city": "n/a", + "applicant.address.state": "n/a", + "applicant.address.stateCode": "n/a", + "applicant.address.zipCode": "n/a", + "alternateContact.firstName": "n/a", + "alternateContact.lastName": "n/a", + "alternateContact.agency": "n/a", + "alternateContact.emailAddress": "n/a", + "alternateContact.phoneNumber": "n/a", + "alternateContact.type": "n/a", + "alternateContact.mailingAddress.street": "n/a", + "alternateContact.mailingAddress.street2": "n/a", + "alternateContact.mailingAddress.city": "n/a", + "alternateContact.mailingAddress.state": "n/a", + "alternateContact.mailingAddress.stateCode": "n/a", + "alternateContact.mailingAddress.zipCode": "n/a", + "householdExpectingChanges": "No", + "householdStudent": "No", + "incomePeriod": "n/a", + "incomeMonth": "n/a", + "incomeVouchers": "No", + "demographics.ethnicity": "n/a", + "acceptedTerms": "Yes", + "firstName": "n/a", + "middleName": "n/a", + "lastName": "n/a", + "dob-field-month": "n/a", + "dob-field-day": "n/a", + "dob-field-year": "n/a", + "relationship": "n/a", + "sameAddress": "n/a", + "workInRegion": "n/a", + "submittedBy": "n/a", + "dateOfBirth": "n/a", + "formattedPhoneNumber": "n/a", + "formattedAdditionalPhoneNumber": "n/a", + "alternateContact.formattedPhoneNumber": "n/a", + "householdMemberDoB": "n/a", + "householdMemberName": "n/a", + "preferredUnitSize": "n/a", + "formattedMonthlyIncome": "n/a", + "preferredContact": "n/a", + "preferredUnit": "oneBdrm", + "submittedDate": "n/a", + "timeDate": "n/a", + "workAddress.streetAddress": "n/a", + "workAddress.street2": "n/a", + "workAddress.city": "n/a", + "workAddress.state": "n/a", + "workAddress.zipCode": "n/a", + "adaPriorities": "No", + "annualIncome": "n/a", + "applicationType": "Paper", + "mailingAddress.street": "n/a", + "mailingAddress.street2": "n/a", + "mailingAddress.city": "n/a", + "mailingAddress.zipCode": "n/a", + "mailingAddress.state": "n/a", + "mailingAddress.stateCode": "n/a" +} diff --git a/sites/partners/cypress/fixtures/householdDetailsOnlyData.json b/sites/partners/cypress/fixtures/householdDetailsOnlyData.json new file mode 100644 index 0000000000..fae1b742ab --- /dev/null +++ b/sites/partners/cypress/fixtures/householdDetailsOnlyData.json @@ -0,0 +1,76 @@ +{ + "language": "n/a", + "applicant.firstName": "n/a", + "applicant.middleName": "n/a", + "applicant.lastName": "n/a", + "dateOfBirth.birthMonth": "n/a", + "dateOfBirth.birthDay": "n/a", + "dateOfBirth.birthYear": "n/a", + "applicant.emailAddress": "n/a", + "phoneNumber": "n/a", + "applicant.phoneNumberType": "n/a", + "additionalPhoneNumber": "n/a", + "additionalPhoneNumberType": "n/a", + "applicant.workInRegion": "n/a", + "applicant.address.street": "n/a", + "applicant.address.street2": "n/a", + "applicant.address.city": "n/a", + "applicant.address.state": "n/a", + "applicant.address.stateCode": "n/a", + "applicant.address.zipCode": "n/a", + "alternateContact.firstName": "n/a", + "alternateContact.lastName": "n/a", + "alternateContact.agency": "n/a", + "alternateContact.emailAddress": "n/a", + "alternateContact.phoneNumber": "n/a", + "alternateContact.type": "n/a", + "alternateContact.mailingAddress.street": "n/a", + "alternateContact.mailingAddress.street2": "n/a", + "alternateContact.mailingAddress.city": "n/a", + "alternateContact.mailingAddress.state": "n/a", + "alternateContact.mailingAddress.stateCode": "n/a", + "alternateContact.mailingAddress.zipCode": "n/a", + "householdExpectingChanges": "Yes", + "householdStudent": "Yes", + "incomePeriod": "n/a", + "incomeMonth": "n/a", + "incomeVouchers": "No", + "demographics.ethnicity": "n/a", + "acceptedTerms": "Yes", + "firstName": "n/a", + "middleName": "n/a", + "lastName": "n/a", + "dob-field-month": "n/a", + "dob-field-day": "n/a", + "dob-field-year": "n/a", + "relationship": "n/a", + "sameAddress": "n/a", + "workInRegion": "n/a", + "submittedBy": "n/a", + "dateOfBirth": "n/a", + "formattedPhoneNumber": "n/a", + "formattedAdditionalPhoneNumber": "n/a", + "alternateContact.formattedPhoneNumber": "n/a", + "householdMemberDoB": "n/a", + "householdMemberName": "n/a", + "preferredUnitSize": "1 Bedroom", + "formattedMonthlyIncome": "n/a", + "preferredContact": "n/a", + "preferredUnit": "oneBdrm", + "submittedDate": "n/a", + "timeDate": "n/a", + "workAddress.streetAddress": "n/a", + "workAddress.street2": "n/a", + "workAddress.city": "n/a", + "workAddress.state": "n/a", + "workAddress.zipCode": "n/a", + "adaPriorities": "No", + "annualIncome": "n/a", + "applicationType": "Paper", + "mailingAddress.street": "n/a", + "mailingAddress.street2": "n/a", + "mailingAddress.city": "n/a", + "mailingAddress.zipCode": "n/a", + "mailingAddress.state": "n/a", + "mailingAddress.stateCode": "n/a" +} diff --git a/sites/partners/cypress/fixtures/householdIncomeOnlyData.json b/sites/partners/cypress/fixtures/householdIncomeOnlyData.json new file mode 100644 index 0000000000..6068fcee77 --- /dev/null +++ b/sites/partners/cypress/fixtures/householdIncomeOnlyData.json @@ -0,0 +1,76 @@ +{ + "language": "n/a", + "applicant.firstName": "n/a", + "applicant.middleName": "n/a", + "applicant.lastName": "n/a", + "dateOfBirth.birthMonth": "n/a", + "dateOfBirth.birthDay": "n/a", + "dateOfBirth.birthYear": "n/a", + "applicant.emailAddress": "n/a", + "phoneNumber": "n/a", + "applicant.phoneNumberType": "n/a", + "additionalPhoneNumber": "n/a", + "additionalPhoneNumberType": "n/a", + "applicant.workInRegion": "n/a", + "applicant.address.street": "n/a", + "applicant.address.street2": "n/a", + "applicant.address.city": "n/a", + "applicant.address.state": "n/a", + "applicant.address.stateCode": "n/a", + "applicant.address.zipCode": "n/a", + "alternateContact.firstName": "n/a", + "alternateContact.lastName": "n/a", + "alternateContact.agency": "n/a", + "alternateContact.emailAddress": "n/a", + "alternateContact.phoneNumber": "n/a", + "alternateContact.type": "n/a", + "alternateContact.mailingAddress.street": "n/a", + "alternateContact.mailingAddress.street2": "n/a", + "alternateContact.mailingAddress.city": "n/a", + "alternateContact.mailingAddress.state": "n/a", + "alternateContact.mailingAddress.stateCode": "n/a", + "alternateContact.mailingAddress.zipCode": "n/a", + "householdExpectingChanges": "No", + "householdStudent": "No", + "incomePeriod": "Month", + "incomeMonth": "6000", + "incomeVouchers": "Yes", + "demographics.ethnicity": "n/a", + "acceptedTerms": "Yes", + "firstName": "n/a", + "middleName": "n/a", + "lastName": "n/a", + "dob-field-month": "n/a", + "dob-field-day": "n/a", + "dob-field-year": "n/a", + "relationship": "n/a", + "sameAddress": "n/a", + "workInRegion": "n/a", + "submittedBy": "n/a", + "dateOfBirth": "n/a", + "formattedPhoneNumber": "n/a", + "formattedAdditionalPhoneNumber": "n/a", + "alternateContact.formattedPhoneNumber": "n/a", + "householdMemberDoB": "n/a", + "householdMemberName": "n/a", + "preferredUnitSize": "n/a", + "formattedMonthlyIncome": "$6,000", + "preferredContact": "n/a", + "preferredUnit": "oneBdrm", + "submittedDate": "n/a", + "timeDate": "n/a", + "workAddress.streetAddress": "n/a", + "workAddress.street2": "n/a", + "workAddress.city": "n/a", + "workAddress.state": "n/a", + "workAddress.zipCode": "n/a", + "adaPriorities": "No", + "annualIncome": "n/a", + "applicationType": "Paper", + "mailingAddress.street": "n/a", + "mailingAddress.street2": "n/a", + "mailingAddress.city": "n/a", + "mailingAddress.zipCode": "n/a", + "mailingAddress.state": "n/a", + "mailingAddress.stateCode": "n/a" +} diff --git a/sites/partners/cypress/fixtures/householdMemberOnlyData.json b/sites/partners/cypress/fixtures/householdMemberOnlyData.json new file mode 100644 index 0000000000..818e388730 --- /dev/null +++ b/sites/partners/cypress/fixtures/householdMemberOnlyData.json @@ -0,0 +1,76 @@ +{ + "language": "n/a", + "applicant.firstName": "n/a", + "applicant.middleName": "n/a", + "applicant.lastName": "n/a", + "dateOfBirth.birthMonth": "n/a", + "dateOfBirth.birthDay": "n/a", + "dateOfBirth.birthYear": "n/a", + "applicant.emailAddress": "n/a", + "phoneNumber": "n/a", + "applicant.phoneNumberType": "n/a", + "additionalPhoneNumber": "n/a", + "additionalPhoneNumberType": "n/a", + "applicant.workInRegion": "n/a", + "applicant.address.street": "n/a", + "applicant.address.street2": "n/a", + "applicant.address.city": "n/a", + "applicant.address.state": "n/a", + "applicant.address.stateCode": "n/a", + "applicant.address.zipCode": "n/a", + "alternateContact.firstName": "n/a", + "alternateContact.lastName": "n/a", + "alternateContact.agency": "n/a", + "alternateContact.emailAddress": "n/a", + "alternateContact.phoneNumber": "n/a", + "alternateContact.type": "n/a", + "alternateContact.mailingAddress.street": "n/a", + "alternateContact.mailingAddress.street2": "n/a", + "alternateContact.mailingAddress.city": "n/a", + "alternateContact.mailingAddress.state": "n/a", + "alternateContact.mailingAddress.stateCode": "n/a", + "alternateContact.mailingAddress.zipCode": "n/a", + "householdExpectingChanges": "No", + "householdStudent": "No", + "incomePeriod": "n/a", + "incomeMonth": "n/a", + "incomeVouchers": "No", + "demographics.ethnicity": "n/a", + "acceptedTerms": "Yes", + "firstName": "household", + "middleName": "member", + "lastName": "test", + "dob-field-month": "4", + "dob-field-day": "18", + "dob-field-year": "1999", + "relationship": "Friend", + "sameAddress": "Yes", + "workInRegion": "No", + "submittedBy": "n/a", + "dateOfBirth": "n/a", + "formattedPhoneNumber": "n/a", + "formattedAdditionalPhoneNumber": "n/a", + "alternateContact.formattedPhoneNumber": "n/a", + "householdMemberDoB": "4/18/1999", + "householdMemberName": "household member test", + "preferredUnitSize": "n/a", + "formattedMonthlyIncome": "n/a", + "preferredContact": "n/a", + "preferredUnit": "oneBdrm", + "submittedDate": "n/a", + "timeDate": "n/a", + "workAddress.streetAddress": "n/a", + "workAddress.street2": "n/a", + "workAddress.city": "n/a", + "workAddress.state": "n/a", + "workAddress.zipCode": "n/a", + "adaPriorities": "No", + "annualIncome": "n/a", + "applicationType": "Paper", + "mailingAddress.street": "n/a", + "mailingAddress.street2": "n/a", + "mailingAddress.city": "n/a", + "mailingAddress.zipCode": "n/a", + "mailingAddress.state": "n/a", + "mailingAddress.stateCode": "n/a" +} diff --git a/sites/partners/cypress/fixtures/listing.json b/sites/partners/cypress/fixtures/listing.json new file mode 100644 index 0000000000..8963ed25fa --- /dev/null +++ b/sites/partners/cypress/fixtures/listing.json @@ -0,0 +1,62 @@ +{ + "jurisdiction.id": "San Jose", + "name": "Basic Test Listing", + "developer": "Basic Test Developer", + "buildingAddress.street": "548 Market St. #59930", + "region": "Basic Test Region", + "neighborhood": "Basic Test Neighborhood", + "buildingAddress.city": "San Francisco", + "buildingAddress.state": "California", + "buildingAddress.zipCode": "94104", + "yearBuilt": "2021", + "reservedCommunityType.id": "Special Needs", + "reservedCommunityDescription": "Basic Test Description", + "number": "2", + "unitType.id": "One Bedroom", + "numBathrooms": "2", + "floor": "2", + "sqFeet": "300", + "minOccupancy": "2", + "maxOccupancy": "2", + "monthlyIncomeMin": "900", + "monthlyRent": "1000", + "priorityType.id": "Visual", + "applicationFee": "4", + "depositMin": "2", + "depositMax": "100", + "costsNotIncluded": "Internet", + "amenities": "Basic Amenity Info", + "accessibility": "Basic Accessibility Info", + "unitAmenities": "Basic Unit Amenity Info", + "smokingPolicy": "No Thanks", + "petPolicy": "Pets welcome. Please send in pictures, they aren't required, we just like pictures", + "servicesOffered": "Basic Services", + "creditHistory": "Basic Credit History", + "rentalHistory": "Basic Rental History", + "criminalBackground": "Basic Criminal Background", + "buildingSelectionCriteriaURL": "https://www.exygy.com", + "requiredDocuments": "Basic Required Documents", + "programRules": "Basic program rules", + "specialNotes": "basic special notes", + "leasingAgentName": "Basic Agent Name", + "leasingAgentEmail": "basicAgent@email.com", + "leasingAgentPhone": "520-245-8811", + "leasingAgentTitle": "Basic Agent Title", + "leasingAgentOfficeHours": "Basic Agent Office Hours", + "leasingAgentAddress.street": "548 Market St.", + "leasingAgentAddress.street2": "#59930", + "leasingAgentAddress.city": "San Francisco", + "leasingAgentAddress.zipCode": "94104", + "leasingAgentAddress.state": "California", + "additionalApplicationSubmissionNotes": "Basic Additional Application Submission Notes", + "date.month": "10", + "date.day": "04", + "date.year": "2022", + "label": "Basic Label", + "url": "https://www.exygy.com", + "startTime.hours": "10", + "startTime.minutes": "04", + "endTime.hours": "11", + "endTime.minutes": "05", + "note": "Basic Note" +} diff --git a/sites/partners/cypress/fixtures/listingImage.json b/sites/partners/cypress/fixtures/listingImage.json new file mode 100644 index 0000000000..6d04fb471f --- /dev/null +++ b/sites/partners/cypress/fixtures/listingImage.json @@ -0,0 +1,3 @@ +{ + "filePath": "cypress/fixtures/cypress-automated-image-upload-071e2ab9-5a52-4f34-85f0-e41f696f4b96.jpeg" +} diff --git a/sites/partners/cypress/fixtures/mfaUser.json b/sites/partners/cypress/fixtures/mfaUser.json new file mode 100644 index 0000000000..759500ac45 --- /dev/null +++ b/sites/partners/cypress/fixtures/mfaUser.json @@ -0,0 +1,5 @@ +{ + "email": "mfaUser@bloom.com", + "password": "abcdef12", + "mfaCode": "123456" +} diff --git a/sites/partners/cypress/fixtures/partialApplicationA.json b/sites/partners/cypress/fixtures/partialApplicationA.json new file mode 100644 index 0000000000..2570aa2203 --- /dev/null +++ b/sites/partners/cypress/fixtures/partialApplicationA.json @@ -0,0 +1,77 @@ +{ + "language": "n/a", + "applicant.firstName": "n/a", + "applicant.middleName": "n/a", + "applicant.lastName": "n/a", + "dateOfBirth.birthMonth": "n/a", + "dateOfBirth.birthDay": "n/a", + "dateOfBirth.birthYear": "n/a", + "applicant.emailAddress": "n/a", + "phoneNumber": "n/a", + "applicant.phoneNumberType": "n/a", + "additionalPhoneNumber": "n/a", + "additionalPhoneNumberType": "n/a", + "applicant.workInRegion": "n/a", + "applicant.address.street": "n/a", + "applicant.address.street2": "n/a", + "applicant.address.city": "n/a", + "applicant.address.state": "n/a", + "applicant.address.stateCode": "n/a", + "applicant.address.zipCode": "n/a", + "alternateContact.firstName": "n/a", + "alternateContact.lastName": "n/a", + "alternateContact.agency": "n/a", + "alternateContact.emailAddress": "n/a", + "alternateContact.phoneNumber": "n/a", + "alternateContact.type": "n/a", + "alternateContact.mailingAddress.street": "n/a", + "alternateContact.mailingAddress.street2": "n/a", + "alternateContact.mailingAddress.city": "n/a", + "alternateContact.mailingAddress.state": "n/a", + "alternateContact.mailingAddress.stateCode": "n/a", + "alternateContact.mailingAddress.zipCode": "n/a", + "householdExpectingChanges": "No", + "householdStudent": "No", + "incomePeriod": "Year", + "incomeMonth": "n/a", + "incomeVouchers": "No", + "demographics.ethnicity": "n/a", + "acceptedTerms": "Yes", + "firstName": "n/a", + "middleName": "n/a", + "lastName": "n/a", + "dob-field-month": "n/a", + "dob-field-day": "n/a", + "dob-field-year": "n/a", + "relationship": "n/a", + "sameAddress": "n/a", + "workInRegion": "n/a", + "submittedBy": "n/a", + "dateOfBirth": "n/a", + "formattedPhoneNumber": "n/a", + "formattedAdditionalPhoneNumber": "n/a", + "alternateContact.formattedPhoneNumber": "n/a", + "householdMemberDoB": "n/a", + "householdMemberName": "n/a", + "preferredUnitSize": "n/a", + "formattedMonthlyIncome": "n/a", + "preferredContact": "n/a", + "preferredUnit": "oneBdrm", + "submittedDate": "n/a", + "timeDate": "n/a", + "workAddress.streetAddress": "n/a", + "workAddress.street2": "n/a", + "workAddress.city": "n/a", + "workAddress.state": "n/a", + "workAddress.zipCode": "n/a", + "adaPriorities": "No", + "annualIncome": "$60,000", + "applicationType": "Paper", + "incomeYear": "60000", + "mailingAddress.street": "123 e delivery street", + "mailingAddress.street2": "23", + "mailingAddress.city": "Tucson", + "mailingAddress.zipCode": "85748", + "mailingAddress.state": "Arizona", + "mailingAddress.stateCode": "AZ" +} diff --git a/sites/partners/cypress/fixtures/user.json b/sites/partners/cypress/fixtures/user.json new file mode 100644 index 0000000000..7908ad362a --- /dev/null +++ b/sites/partners/cypress/fixtures/user.json @@ -0,0 +1,9 @@ +{ + "email": "admin@example.com", + "emailConfirmation": "admin@example.com", + "password": "abcdef", + "passwordConfirmation": "abcdef", + "firstName": "Test", + "lastName": "User", + "dob": "1980-01-01" +} diff --git a/sites/partners/cypress/integration/application.spec.ts b/sites/partners/cypress/integration/application.spec.ts new file mode 100644 index 0000000000..4ed6cf55a2 --- /dev/null +++ b/sites/partners/cypress/integration/application.spec.ts @@ -0,0 +1,23 @@ +describe("Application Management Tests", () => { + before(() => { + cy.login() + }) + + after(() => { + cy.signOut() + }) + + it("Application grid should display correct number of results", () => { + cy.visit("/") + cy.get(`[col-id="status"]`).eq(1).contains("Accepting Applications").click() + cy.getByID("lbTotalPages").contains("20") + cy.get(".applications-table") + .first() + .find(".ag-center-cols-container") + .first() + .find(".ag-row") + .should((elems) => { + expect(elems).to.have.length(8) + }) + }) +}) diff --git a/sites/partners/cypress/integration/listing.spec.ts b/sites/partners/cypress/integration/listing.spec.ts new file mode 100644 index 0000000000..b7b460d674 --- /dev/null +++ b/sites/partners/cypress/integration/listing.spec.ts @@ -0,0 +1,248 @@ +describe("Listing Management Tests", () => { + before(() => { + cy.login() + }) + + after(() => { + cy.signOut() + }) + + it("full listing publish", () => { + cy.visit("/") + cy.get("a > .button").contains("Add Listing").click() + cy.contains("New Listing") + cy.fixture("listing").then((listing) => { + cy.getByID("jurisdiction.id").select(listing["jurisdiction.id"]) + cy.get("#name").type(listing["name"]) + cy.get("#developer").type(listing["developer"]) + cy.getByID("addPhotoButton").contains("Add Photo").click() + cy.get(`[data-test-id="dropzone-input"]`).attachFile( + "cypress-automated-image-upload-071e2ab9-5a52-4f34-85f0-e41f696f4b96.jpeg", + { + subjectType: "drag-n-drop", + } + ) + cy.get(`[data-test-id="listing-photo-uploaded"]`).contains("Save").click() + cy.getByID("buildingAddress.street").type(listing["buildingAddress.street"]) + cy.getByID("neighborhood").type(listing["neighborhood"]) + cy.getByID("buildingAddress.city").type(listing["buildingAddress.city"]) + cy.getByID("buildingAddress.state").select(listing["buildingAddress.state"]) + cy.getByID("buildingAddress.zipCode").type(listing["buildingAddress.zipCode"]) + cy.getByID("yearBuilt").type(listing["yearBuilt"]) + cy.get(".addressPopup").contains(listing["buildingAddress.street"]) + cy.getByID("reservedCommunityType.id").select(listing["reservedCommunityType.id"]) + cy.getByID("reservedCommunityDescription").type(listing["reservedCommunityDescription"]) + cy.getByTestId("unit-types").check() + cy.get("#addUnitsButton").contains("Add Unit").click() + cy.getByID("number").type(listing["number"]) + cy.getByID("unitType.id").select(listing["unitType.id"]) + cy.getByID("numBathrooms").select(listing["numBathrooms"]) + cy.getByID("floor").select(listing["floor"]) + cy.getByID("sqFeet").type(listing["sqFeet"]) + cy.getByID("minOccupancy").select(listing["minOccupancy"]) + cy.getByID("maxOccupancy").select(listing["maxOccupancy"]) + cy.get("#fixed").check() + cy.getByID("monthlyIncomeMin").type(listing["monthlyIncomeMin"]) + cy.getByID("monthlyRent").type(listing["monthlyRent"]) + cy.getByID("priorityType.id").select(listing["priorityType.id"]) + cy.get(".mt-6 > .is-primary").contains("Save & Exit").click() + cy.get(".drawer__content > .is-primary").contains("Save").click() + cy.getByID("applicationFee").type(listing["applicationFee"]) + cy.getByID("depositMin").type(listing["depositMin"]) + cy.getByID("depositMax").type(listing["depositMax"]) + cy.getByID("costsNotIncluded").type(listing["costsNotIncluded"]) + cy.getByID("applicationFee").type(listing["applicationFee"]) + cy.getByID("depositMin").type(listing["depositMin"]) + cy.getByID("depositMax").type(listing["depositMax"]) + cy.getByID("costsNotIncluded").type(listing["costsNotIncluded"]) + cy.getByID("amenities").type(listing["amenities"]) + cy.getByID("accessibility").type(listing["accessibility"]) + cy.getByID("unitAmenities").type(listing["unitAmenities"]) + cy.getByID("smokingPolicy").type(listing["smokingPolicy"]) + cy.getByID("petPolicy").type(listing["petPolicy"]) + cy.getByID("servicesOffered").type(listing["servicesOffered"]) + cy.getByID("creditHistory").type(listing["creditHistory"]) + cy.getByID("rentalHistory").type(listing["rentalHistory"]) + cy.getByID("criminalBackground").type(listing["criminalBackground"]) + cy.get("#addBuildingSelectionCriteriaButton") + .contains("Add Building Selection Criteria") + .click() + cy.get("#criteriaAttachTypeURL").check() + cy.getByID("buildingSelectionCriteriaURL").type(listing["buildingSelectionCriteriaURL"]) + cy.get(".p-4 > .is-primary").contains("Save").click() + cy.getByID("requiredDocuments").type(listing["requiredDocuments"]) + cy.getByID("programRules").type(listing["programRules"]) + cy.getByID("specialNotes").type(listing["specialNotes"]) + cy.get(".text-right > .button").contains("Application Process").click() + cy.get("#reviewOrderFCFS").check() + cy.get("#dueDateQuestionNo").check() + cy.get("#waitlistOpenNo").check() + cy.getByID("leasingAgentName").type(listing["leasingAgentName"]) + cy.getByID("leasingAgentEmail").type(listing["leasingAgentEmail"]) + cy.getByID("leasingAgentPhone").type(listing["leasingAgentPhone"]) + cy.getByID("leasingAgentTitle").type(listing["leasingAgentTitle"]) + cy.getByID("leasingAgentOfficeHours").type(listing["leasingAgentOfficeHours"]) + cy.get("#digitalApplicationChoiceYes").check() + cy.get("#commonDigitalApplicationChoiceYes").check() + cy.get("#paperApplicationNo").check() + cy.get("#referralOpportunityNo").check() + + cy.getByID("leasingAgentAddress.street").type(listing["leasingAgentAddress.street"]) + cy.getByID("leasingAgentAddress.street2").type(listing["leasingAgentAddress.street2"]) + cy.getByID("leasingAgentAddress.city").type(listing["leasingAgentAddress.city"]) + cy.getByID("leasingAgentAddress.zipCode").type(listing["leasingAgentAddress.zipCode"]) + cy.getByID("leasingAgentAddress.state").select(listing["leasingAgentAddress.state"]) + + cy.get("#applicationsMailedInYes").check() + cy.get("#mailInAnotherAddress").check() + cy.getByTestId("mailing-address-street").type(listing["leasingAgentAddress.street"]) + cy.getByTestId("mailing-address-street2").type(listing["leasingAgentAddress.street2"]) + cy.getByTestId("mailing-address-city").type(listing["leasingAgentAddress.city"]) + cy.getByTestId("mailing-address-zip").type(listing["leasingAgentAddress.zipCode"]) + cy.getByTestId("mailing-address-state").select(listing["leasingAgentAddress.state"]) + + cy.get("#applicationsPickedUpNo").check() + cy.get("#applicationsDroppedOffNo").check() + cy.get("#postmarksConsideredYes").check() + cy.getByTestId("postmark-date-field-month").type("12") + cy.getByTestId("postmark-date-field-day").type("17") + cy.getByTestId("postmark-date-field-year").type("2022") + cy.getByTestId("postmark-time-field-hours").type("5") + cy.getByTestId("postmark-time-field-minutes").type("45") + cy.getByTestId("postmark-time-field-period").select("PM") + cy.getByID("additionalApplicationSubmissionNotes").type( + listing["additionalApplicationSubmissionNotes"] + ) + + cy.get("#addOpenHouseButton").contains("Add Open House").click() + + cy.getByID("date.month").type(listing["date.month"]) + cy.getByID("date.day").type(listing["date.day"]) + cy.getByID("date.year").type(listing["date.year"]) + cy.getByID("label").type(listing["label"]) + cy.getByID("url").type(listing["url"]) + cy.getByID("startTime.hours").type(listing["startTime.hours"]) + cy.getByID("startTime.minutes").type(listing["startTime.minutes"]) + cy.getByID("endTime.hours").type(listing["endTime.hours"]) + cy.getByID("endTime.minutes").type(listing["endTime.minutes"]) + cy.getByID("note").type(listing["note"]) + cy.getByID("startTime.period").select("AM") + cy.getByID("endTime.period").select("PM") + cy.get("form > .button").contains("Save").click() + cy.get("#publishButton").contains("Publish").click() + + cy.get("#publishButtonConfirm").contains("Publish").click() + cy.get(".page-header__title > .font-semibold").contains(listing["name"]) + }) + }) + + // TODO: make this not dependent on the previous test + it("verify details page", () => { + cy.fixture("listing").then((listing) => { + cy.getByID("jurisdiction.name").contains(listing["jurisdiction.id"]) + cy.get("#name").contains(listing["name"]) + cy.get("#developer").contains(listing["developer"]) + cy.get('[data-label="File Name"]').contains( + "cypress-automated-image-upload-071e2ab9-5a52-4f34-85f0-e41f696f4b96" + ) + cy.getByID("buildingAddress.street").contains(listing["buildingAddress.street"]) + cy.get("#region").contains(listing.region) + cy.get("#neighborhood").contains(listing.neighborhood) + cy.get("#neighborhood").contains(listing.neighborhood) + cy.getByID("buildingAddress.city").contains(listing["buildingAddress.city"]) + cy.getByID("buildingAddress.state").contains("CA") + cy.getByID("buildingAddress.zipCode").contains(listing["buildingAddress.zipCode"]) + cy.get("#yearBuilt").contains(listing["yearBuilt"]) + cy.get("#longitude").contains("-122.40078") + cy.get("#latitude").contains("37.79006") + cy.get("#reservedCommunityType").contains(listing["reservedCommunityType.id"]) + cy.get("#reservedCommunityDescription").contains(listing["reservedCommunityDescription"]) + cy.getByTestId("unit-types-or-individual").contains("Unit Types") + cy.get("#unitTable").contains(listing["number"]) + cy.get("#unitTable").contains(listing["monthlyRent"]) + cy.get("#unitTable").contains(listing["sqFeet"]) + cy.get("#unitTable").contains(listing["priorityType.id"]) + cy.get("#unitTable").contains("Available") + cy.get("#applicationFee").contains(listing["applicationFee"]) + cy.get("#applicationFee").contains(listing["applicationFee"]) + cy.get("#applicationFee").contains(listing["applicationFee"]) + cy.get("#depositMin").contains(listing["depositMin"]) + cy.get("#depositMax").contains(listing["depositMax"]) + cy.get("#costsNotIncluded").contains(listing["costsNotIncluded"]) + cy.get("#amenities").contains(listing["amenities"]) + cy.get("#unitAmenities").contains(listing["unitAmenities"]) + cy.get("#accessibility").contains(listing["accessibility"]) + cy.get("#smokingPolicy").contains(listing["smokingPolicy"]) + cy.get("#petPolicy").contains(listing["petPolicy"]) + cy.get("#servicesOffered").contains(listing["servicesOffered"]) + cy.get("#creditHistory").contains(listing["creditHistory"]) + cy.get("#rentalHistory").contains(listing["rentalHistory"]) + cy.get("#criminalBackground").contains(listing["criminalBackground"]) + cy.get("#rentalAssistance").contains( + "The property is subsidized by the Section 8 Project-Based Voucher Program. As a result, Housing Choice Vouchers, Section 8 and other valid rental assistance programs are not accepted by this property." + ) + cy.get("#buildingSelectionCriteriaTable").contains(listing["buildingSelectionCriteriaURL"]) + cy.get("#requiredDocuments").contains(listing["requiredDocuments"]) + cy.get("#programRules").contains(listing["programRules"]) + cy.get("#specialNotes").contains(listing["specialNotes"]) + cy.get("#reviewOrderQuestion").contains("First come first serve") + cy.get("#dueDateQuestion").contains("No") + cy.getByID("waitlist.openQuestion").contains("No") + cy.get("#whatToExpect").contains( + "Applicants will be contacted by the property agent in rank order until vacancies are filled. All of the information that you have provided will be verified and your eligibility confirmed. Your application will be removed from the waitlist if you have made any fraudulent statements. If we cannot verify a housing preference that you have claimed, you will not receive the preference but will not be otherwise penalized. Should your application be chosen, be prepared to fill out a more detailed application and provide required supporting documents." + ) + cy.get("#leasingAgentName").contains(listing["leasingAgentName"]) + cy.get("#leasingAgentEmail").contains(listing["leasingAgentEmail"].toLowerCase()) + cy.get("#leasingAgentPhone").contains("(520) 245-8811") + cy.get("#leasingAgentOfficeHours").contains(listing["leasingAgentOfficeHours"]) + cy.get("#leasingAgentTitle").contains(listing["leasingAgentTitle"]) + cy.get("#digitalApplication").contains("Yes") + cy.getByID("digitalMethod.type").contains("Yes") + cy.get("#paperApplication").contains("No") + cy.get("#referralOpportunity").contains("No") + cy.getByID("leasingAgentAddress.street").contains(listing["leasingAgentAddress.street"]) + cy.getByID("leasingAgentAddress.street2").contains(listing["leasingAgentAddress.street2"]) + cy.getByID("leasingAgentAddress.city").contains(listing["leasingAgentAddress.city"]) + cy.getByID("leasingAgentAddress.state").contains("CA") + cy.getByID("leasingAgentAddress.zipCode").contains(listing["leasingAgentAddress.zipCode"]) + cy.getByID("applicationPickupQuestion").contains("No") + cy.getByID("applicationMailingSection").contains("Yes") + cy.getByTestId("applicationMailingAddress.street").contains( + listing["leasingAgentAddress.street"] + ) + cy.getByTestId("applicationMailingAddress.street2").contains( + listing["leasingAgentAddress.street2"] + ) + cy.getByTestId("applicationMailingAddress.city").contains(listing["leasingAgentAddress.city"]) + cy.getByTestId("applicationMailingAddress.zipCode").contains( + listing["leasingAgentAddress.zipCode"] + ) + cy.getByTestId("applicationMailingAddress.state").contains("CA") + cy.get("#applicationDropOffQuestion").contains("No") + cy.get("#postmarksConsideredQuestion").contains("Yes") + cy.getByTestId("postmark-date").contains("12") + cy.getByTestId("postmark-date").contains("17") + cy.getByTestId("postmark-date").contains("2022") + cy.getByTestId("postmark-time").contains("5") + cy.getByTestId("postmark-time").contains("45") + cy.getByTestId("postmark-time").contains("PM") + cy.get("#additionalApplicationSubmissionNotes").contains( + listing["additionalApplicationSubmissionNotes"] + ) + cy.getByID("openhouseHeader").contains("10/04/2022") + cy.getByID("openhouseHeader").contains("10:04 AM") + cy.getByID("openhouseHeader").contains("11:05 PM") + }) + }) + + // TODO: make this not dependent on the previous test + it("verify open listing warning happens", () => { + cy.fixture("listing").then((listing) => { + cy.getByTestId("listingEditButton").contains("Edit").click() + cy.getByTestId("nameField").type(" (Edited)") + cy.getByTestId("saveAndExitButton").contains("Save & Exit").click() + cy.getByTestId("listingIsAlreadyLiveButton").contains("Save").click() + cy.getByTestId("page-header-text").should("have.text", `${listing["name"]} (Edited)`) + }) + }) +}) diff --git a/sites/partners/cypress/integration/mfa.spec.ts b/sites/partners/cypress/integration/mfa.spec.ts new file mode 100644 index 0000000000..3062d09956 --- /dev/null +++ b/sites/partners/cypress/integration/mfa.spec.ts @@ -0,0 +1,13 @@ +describe("Log in using MFA Tests", () => { + it("should log in using mfa pathway", () => { + cy.intercept("POST", "/auth/request-mfa-code", { + statusCode: 201, + body: { + email: "mfauser@bloom.com", + yazeedTest: "yest", + }, + }) + cy.loginWithMfa() + cy.signOut() + }) +}) diff --git a/sites/partners/cypress/integration/paperApplication.spec.ts b/sites/partners/cypress/integration/paperApplication.spec.ts new file mode 100644 index 0000000000..a55cb6775a --- /dev/null +++ b/sites/partners/cypress/integration/paperApplication.spec.ts @@ -0,0 +1,143 @@ +describe("Paper Application Tests", () => { + before(() => { + cy.login() + }) + + after(() => { + cy.signOut() + }) + + beforeEach(() => { + cy.visit("/") + cy.getByTestId("listing-status-cell").eq(1).click() + cy.getByTestId("addApplicationButton").contains("Add Application").click() + }) + + it("fill paper application form completely", () => { + cy.fixture("application").then((application) => { + cy.fillPrimaryApplicant(application) + cy.fillAlternateContact(application) + cy.fillHouseholdMember(application) + cy.fillHouseholdDetails(application) + cy.fillHouseholdIncome(application) + cy.fillDemographics(application) + cy.fillTerms(application, true) + cy.verifyApplicationData(application) + cy.verifyPrimaryApplicant(application) + cy.verifyAlternateContact(application) + cy.verifyHouseholdMembers(application) + cy.verifyHouseholdDetails(application) + cy.verifyHouseholdIncome(application) + cy.verifyTerms(application) + }) + }) + + it("submit with no data", () => { + cy.fixture("emptyApplication").then((application) => { + cy.fillTerms(application, true) + cy.verifyApplicationData(application) + cy.verifyPrimaryApplicant(application) + cy.verifyAlternateContact(application) + cy.verifyHouseholdDetails(application) + cy.verifyHouseholdIncome(application) + cy.verifyTerms(application) + }) + }) + + it("submit different data", () => { + cy.fixture("partialApplicationA").then((application) => { + cy.fillMailingAddress(application) + cy.fillHouseholdIncome(application, ["incomeMonth"]) + cy.fillTerms(application, true) + cy.verifyApplicationData(application) + cy.verifyPrimaryApplicant(application) + cy.verifyAlternateContact(application) + cy.verifyHouseholdDetails(application) + cy.verifyHouseholdIncome(application) + cy.verifyTerms(application) + }) + }) + + it("fill only applicant data", () => { + cy.fixture("applicantOnlyData").then((application) => { + cy.fillPrimaryApplicant(application, [ + "application.additionalPhoneNumber", + "application.additionalPhoneNumberType", + "application.applicant.address.street2", + ]) + cy.fillTerms(application, true) + cy.verifyApplicationData(application) + cy.verifyPrimaryApplicant(application) + cy.verifyAlternateContact(application) + cy.verifyHouseholdDetails(application) + cy.verifyHouseholdIncome(application) + cy.verifyTerms(application) + }) + }) + + it("fill only alternate contact data", () => { + cy.fixture("alternateContactOnlyData").then((application) => { + cy.fillAlternateContact(application, ["alternateContact.mailingAddress.street2"]) + cy.fillTerms(application, true) + cy.verifyApplicationData(application) + cy.verifyPrimaryApplicant(application) + cy.verifyAlternateContact(application) + cy.verifyHouseholdDetails(application) + cy.verifyHouseholdIncome(application) + cy.verifyTerms(application) + }) + }) + + it("fill only household member data", () => { + cy.fixture("householdMemberOnlyData").then((application) => { + cy.fillHouseholdMember(application, []) + cy.fillTerms(application, true) + cy.verifyApplicationData(application) + cy.verifyPrimaryApplicant(application) + cy.verifyAlternateContact(application) + cy.verifyHouseholdMembers(application) + cy.verifyHouseholdDetails(application) + cy.verifyHouseholdIncome(application) + cy.verifyTerms(application) + }) + }) + + it("fill only household detail data", () => { + cy.fixture("householdDetailsOnlyData").then((application) => { + cy.fillHouseholdDetails(application, []) + cy.fillTerms(application, true) + cy.verifyApplicationData(application) + cy.verifyPrimaryApplicant(application) + cy.verifyAlternateContact(application) + cy.verifyHouseholdDetails(application) + cy.verifyHouseholdIncome(application) + cy.verifyTerms(application) + }) + }) + + it("fill only household income data", () => { + cy.fixture("householdIncomeOnlyData").then((application) => { + cy.fillHouseholdIncome(application, []) + cy.fillTerms(application, true) + cy.verifyApplicationData(application) + cy.verifyPrimaryApplicant(application) + cy.verifyAlternateContact(application) + cy.verifyHouseholdDetails(application) + cy.verifyHouseholdIncome(application) + cy.verifyTerms(application) + }) + }) + + it("fill only demographic data", () => { + cy.fixture("demographicsOnlyData").then((application) => { + cy.fillDemographics(application, []) + cy.fillTerms(application, true) + cy.verifyApplicationData(application) + cy.verifyPrimaryApplicant(application) + cy.verifyAlternateContact(application) + cy.verifyHouseholdDetails(application) + cy.verifyHouseholdIncome(application) + cy.verifyTerms(application) + }) + }) +}) diff --git a/sites/partners/cypress/plugins/index.js b/sites/partners/cypress/plugins/index.js new file mode 100644 index 0000000000..12e655088d --- /dev/null +++ b/sites/partners/cypress/plugins/index.js @@ -0,0 +1,23 @@ +/*eslint-env node*/ + +// *********************************************************** +// This example plugins/index.js can be used to load plugins +// +// You can change the location of this file or turn off loading +// the plugins file with the 'pluginsFile' configuration option. +// +// You can read more here: +// https://on.cypress.io/plugins-guide +// *********************************************************** + +// This function is called when a project is opened or re-opened (e.g. due to +// the project's config changing) + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +module.exports = (on, config) => { + // eslint-disable-next-line @typescript-eslint/no-var-requires + require("@cypress/code-coverage/task")(on, config) + // `on` is used to hook into various events Cypress emits + // `config` is the resolved Cypress config + return config +} diff --git a/sites/partners/cypress/support/commands.js b/sites/partners/cypress/support/commands.js new file mode 100644 index 0000000000..3db69e89c2 --- /dev/null +++ b/sites/partners/cypress/support/commands.js @@ -0,0 +1,374 @@ +/* eslint-disable no-undef */ + +// *********************************************** +// This example commands.js shows you how to +// create various custom commands and overwrite +// existing commands. +// +// For more comprehensive examples of custom +// commands please read more here: +// https://on.cypress.io/custom-commands +// *********************************************** +// +// +// -- This is a parent command -- +// Cypress.Commands.add("login", (email, password) => { ... }) +// +// +// -- This is a child command -- +// Cypress.Commands.add("drag", { prevSubject: 'element'}, (subject, options) => { ... }) +// +// +// -- This is a dual command -- +// Cypress.Commands.add("dismiss", { prevSubject: 'optional'}, (subject, options) => { ... }) +// +// +// -- This is will overwrite an existing command -- +// Cypress.Commands.overwrite("visit", (originalFn, url, options) => { ... }) +import "cypress-file-upload" + +Cypress.Commands.add("getByID", (id, ...args) => { + return cy.get(`#${CSS.escape(id)}`, ...args) +}) + +Cypress.Commands.add("getByTestId", (testId) => { + return cy.get(`[data-test-id="${testId}"]`) +}) + +Cypress.Commands.add("login", () => { + cy.visit("/") + cy.fixture("user").then((user) => { + cy.get("input#email").type(user.email) + cy.get("input#password").type(user.password) + cy.get(".button").contains("Sign in").click() + cy.contains("Listings") + }) +}) + +Cypress.Commands.add("loginWithMfa", () => { + cy.visit("/") + cy.fixture("mfaUser").then((user) => { + cy.get("input#email").type(user.email) + cy.get("input#password").type(user.password) + cy.get(".button").contains("Sign in").click() + cy.getByTestId("verify-by-email").click() + cy.getByTestId("sign-in-mfa-code-field").type(user.mfaCode) + cy.getByTestId("verify-and-sign-in").click() + cy.contains("Listings") + }) +}) + +Cypress.Commands.add("signOut", () => { + cy.get("button").contains("Sign out").click() + cy.get("input#email") +}) + +Cypress.Commands.add("verifyAlertBox", () => { + cy.get(".status-aside__buttons > .grid-section > .grid-section__inner > :nth-child(1) > .button") + .contains("Publish") + .click() + cy.get("[data-testid=footer] > .grid-section > .grid-section__inner > :nth-child(1) > .button") + .contains("Publish") + .click() + cy.get(".alert-box").contains( + "Please resolve any errors before saving or publishing your listing." + ) +}) + +const processSet = (application, set, fieldsToSkip, command) => { + if (set.length) { + set.forEach(({ id, fieldKey }) => { + if (!fieldsToSkip.includes(id)) { + if (command === "type") { + cy.getByID(id).type(application[fieldKey]) + } else if (command === "select") { + cy.getByID(id).select(application[fieldKey]) + } else if (command === "click") { + cy.getByID(fieldKey).click() + } + } + }) + } +} + +const fillFields = (application, fieldsToType, fieldsToSelect, fieldsToClick, fieldsToSkip) => { + processSet(application, fieldsToType, fieldsToSkip, "type") + processSet(application, fieldsToSelect, fieldsToSkip, "select") + processSet(application, fieldsToClick, fieldsToSkip, "click") +} + +Cypress.Commands.add("fillPrimaryApplicant", (application, fieldsToSkip = []) => { + const fieldsToType = [ + { id: "application.applicant.firstName", fieldKey: "applicant.firstName" }, + { id: "application.applicant.middleName", fieldKey: "applicant.middleName" }, + { id: "application.applicant.lastName", fieldKey: "applicant.lastName" }, + { id: "dateOfBirth.birthMonth", fieldKey: "dateOfBirth.birthMonth" }, + { id: "dateOfBirth.birthDay", fieldKey: "dateOfBirth.birthDay" }, + { id: "dateOfBirth.birthYear", fieldKey: "dateOfBirth.birthYear" }, + { id: "application.applicant.emailAddress", fieldKey: "applicant.emailAddress" }, + { id: "phoneNumber", fieldKey: "phoneNumber" }, + { id: "application.additionalPhoneNumber", fieldKey: "additionalPhoneNumber" }, + { id: "application.applicant.address.street", fieldKey: "applicant.address.street" }, + { id: "application.applicant.address.street2", fieldKey: "applicant.address.street2" }, + { id: "application.applicant.address.city", fieldKey: "applicant.address.city" }, + { id: "application.applicant.address.zipCode", fieldKey: "applicant.address.zipCode" }, + ] + + const fieldsToSelect = [ + { id: "application.language", fieldKey: "language" }, + { id: "application.applicant.phoneNumberType", fieldKey: "applicant.phoneNumberType" }, + { id: "application.additionalPhoneNumberType", fieldKey: "additionalPhoneNumberType" }, + { id: "application.applicant.address.state", fieldKey: "applicant.address.state" }, + ] + + const fieldsToClick = [ + { + id: "application.applicant.workInRegion", + fieldKey: `application.applicant.workInRegion${application["applicant.workInRegion"]}`, + }, + { id: "email", fieldKey: "email" }, + ] + + fillFields(application, fieldsToType, fieldsToSelect, fieldsToClick, fieldsToSkip) +}) + +Cypress.Commands.add("fillAlternateContact", (application, fieldsToSkip = []) => { + const fieldsToType = [ + { id: "application.alternateContact.firstName", fieldKey: "alternateContact.firstName" }, + { id: "application.alternateContact.lastName", fieldKey: "alternateContact.lastName" }, + { id: "application.alternateContact.agency", fieldKey: "alternateContact.agency" }, + { id: "application.alternateContact.emailAddress", fieldKey: "alternateContact.emailAddress" }, + { id: "application.alternateContact.phoneNumber", fieldKey: "alternateContact.phoneNumber" }, + { + id: "application.alternateContact.mailingAddress.street", + fieldKey: "alternateContact.mailingAddress.street", + }, + { + id: "application.alternateContact.mailingAddress.street2", + fieldKey: "alternateContact.mailingAddress.street2", + }, + { + id: "application.alternateContact.mailingAddress.city", + fieldKey: "alternateContact.mailingAddress.city", + }, + { + id: "application.alternateContact.mailingAddress.zipCode", + fieldKey: "alternateContact.mailingAddress.zipCode", + }, + ] + + const fieldsToSelect = [ + { id: "application.alternateContact.type", fieldKey: "alternateContact.type" }, + { + id: "application.alternateContact.mailingAddress.state", + fieldKey: "alternateContact.mailingAddress.state", + }, + ] + + fillFields(application, fieldsToType, fieldsToSelect, [], fieldsToSkip) +}) + +Cypress.Commands.add("fillHouseholdMember", (application, fieldsToSkip = []) => { + cy.getByTestId("addHouseholdMemberButton").click() + + const fieldsToType = [ + { id: "firstName", fieldKey: "firstName" }, + { id: "middleName", fieldKey: "middleName" }, + { id: "lastName", fieldKey: "lastName" }, + ] + + const fieldsToSelect = [{ id: "relationship", fieldKey: "relationship" }] + + const fieldsToClick = [ + { + id: "sameAddress", + fieldKey: `sameAddress${application["sameAddress"]}`, + }, + { id: "workInRegion", fieldKey: `workInRegion${application["workInRegion"]}` }, + ] + + fillFields(application, fieldsToType, fieldsToSelect, fieldsToClick, fieldsToSkip) + + if (!fieldsToSkip.includes("dob-field-month")) { + cy.getByTestId("dob-field-month").eq(1).type(application["dob-field-month"]) + } + if (!fieldsToSkip.includes("dob-field-day")) { + cy.getByTestId("dob-field-day").eq(1).type(application["dob-field-day"]) + } + if (!fieldsToSkip.includes("dob-field-year")) { + cy.getByTestId("dob-field-year").eq(1).type(application["dob-field-year"]) + } + + cy.getByTestId("submitAddMemberForm").click() +}) + +Cypress.Commands.add("fillHouseholdDetails", (application, fieldsToSkip = []) => { + const fieldsToClick = [ + { + id: "application.householdExpectingChanges", + fieldKey: `application.householdExpectingChanges${application["householdExpectingChanges"]}`, + }, + { + id: "application.householdStudent", + fieldKey: `application.householdStudent${application["householdStudent"]}`, + }, + ] + fillFields(application, [], [], fieldsToClick, fieldsToSkip) + cy.getByTestId(`preferredUnit.${application["preferredUnit"]}`).click() +}) + +Cypress.Commands.add("fillHouseholdIncome", (application, fieldsToSkip = []) => { + if (!fieldsToSkip.includes("application.incomePeriod")) { + cy.getByID(`application.incomePeriod${application["incomePeriod"]}`).click() + } + if (!fieldsToSkip.includes("incomeMonth")) { + cy.getByID("incomeMonth").type(application["incomeMonth"]) + } else if (!fieldsToSkip.includes("incomeYear")) { + cy.getByID("incomeYear").type(application["incomeYear"]) + } + if (!fieldsToSkip.includes("application.incomeVouchers")) { + cy.getByID("application.incomeVouchers").select(application["incomeVouchers"]) + } +}) + +Cypress.Commands.add("fillDemographics", (application, fieldsToSkip = []) => { + if (!fieldsToSkip.includes("application.demographics.ethnicity")) { + cy.getByID("application.demographics.ethnicity").select(application["demographics.ethnicity"]) + } + if (!fieldsToSkip.includes("americanIndianAlaskanNative")) { + cy.getByID("americanIndianAlaskanNative").click() + } + if (!fieldsToSkip.includes("jurisdictionWebsite")) { + cy.getByID("jurisdictionWebsite").click() + } +}) + +Cypress.Commands.add("fillMailingAddress", (application, fieldsToSkip = []) => { + cy.getByID("application.sendMailToMailingAddress").click() + + const fieldsToType = [ + { id: "application.mailingAddress.street", fieldKey: "mailingAddress.street" }, + { id: "application.mailingAddress.street2", fieldKey: "mailingAddress.street2" }, + { id: "application.mailingAddress.city", fieldKey: "mailingAddress.city" }, + { id: "application.mailingAddress.zipCode", fieldKey: "mailingAddress.zipCode" }, + ] + + const fieldsToSelect = [ + { id: "application.mailingAddress.state", fieldKey: "mailingAddress.state" }, + ] + + fillFields(application, fieldsToType, fieldsToSelect, [], fieldsToSkip) +}) + +Cypress.Commands.add("fillTerms", (application, submit) => { + cy.getByID(`application.acceptedTerms${application["acceptedTerms"]}`).click() + if (submit) { + cy.getByTestId("submitApplicationButton").click() + } +}) + +const verifyHelper = (application, listOfFields, fieldsToSkip) => { + const fields = listOfFields.filter(({ id }) => !fieldsToSkip.includes(id)) + fields.forEach(({ id, fieldKey }) => { + cy.getByTestId(id).contains(application[fieldKey]).should("have.text", application[fieldKey]) + }) +} + +Cypress.Commands.add("verifyApplicationData", (application, fieldsToSkip = []) => { + cy.getByTestId("number").should("not.be.empty") + cy.getByTestId("totalSize").should("not.be.empty") + const fields = [ + { id: "type", fieldKey: "applicationType" }, + { id: "submittedDate", fieldKey: "submittedDate" }, + { id: "timeDate", fieldKey: "timeDate" }, + { id: "language", fieldKey: "language" }, + { id: "submittedBy", fieldKey: "submittedBy" }, + ] + verifyHelper(application, fields, fieldsToSkip) +}) + +Cypress.Commands.add("verifyPrimaryApplicant", (application, fieldsToSkip = []) => { + cy.getByTestId("emailAddress").contains(application["applicant.emailAddress"].toLowerCase()) + const fields = [ + { id: "firstName", fieldKey: "applicant.firstName" }, + { id: "middleName", fieldKey: "applicant.middleName" }, + { id: "lastName", fieldKey: "applicant.lastName" }, + { id: "dateOfBirth", fieldKey: "dateOfBirth" }, + { id: "phoneNumber", fieldKey: "formattedPhoneNumber" }, + { id: "additionalPhoneNumber", fieldKey: "formattedAdditionalPhoneNumber" }, + { id: "preferredContact", fieldKey: "preferredContact" }, + { id: "workInRegion", fieldKey: "applicant.workInRegion" }, + { id: "residenceAddress.streetAddress", fieldKey: "applicant.address.street" }, + { id: "residenceAddress.street2", fieldKey: "applicant.address.street2" }, + { id: "residenceAddress.city", fieldKey: "applicant.address.city" }, + { id: "residenceAddress.state", fieldKey: "applicant.address.stateCode" }, + { id: "residenceAddress.zipCode", fieldKey: "applicant.address.zipCode" }, + { id: "mailingAddress.streetAddress", fieldKey: "mailingAddress.street" }, + { id: "mailingAddress.street2", fieldKey: "mailingAddress.street2" }, + { id: "mailingAddress.city", fieldKey: "mailingAddress.city" }, + { id: "mailingAddress.state", fieldKey: "mailingAddress.stateCode" }, + { id: "mailingAddress.zipCode", fieldKey: "mailingAddress.zipCode" }, + { id: "workAddress.streetAddress", fieldKey: "workAddress.streetAddress" }, + { id: "workAddress.street2", fieldKey: "workAddress.street2" }, + { id: "workAddress.city", fieldKey: "workAddress.city" }, + { id: "workAddress.state", fieldKey: "workAddress.state" }, + { id: "workAddress.zipCode", fieldKey: "workAddress.zipCode" }, + ] + verifyHelper(application, fields, fieldsToSkip) +}) + +Cypress.Commands.add("verifyAlternateContact", (application, fieldsToSkip = []) => { + cy.getByTestId("alternateContact.emailAddress").contains( + application["alternateContact.emailAddress"].toLowerCase() + ) + const fields = [ + { id: "alternateContact.firstName", fieldKey: "alternateContact.firstName" }, + { id: "alternateContact.lastName", fieldKey: "alternateContact.lastName" }, + { id: "relationship", fieldKey: "alternateContact.type" }, + { id: "alternateContact.agency", fieldKey: "alternateContact.agency" }, + { id: "alternateContact.phoneNumber", fieldKey: "alternateContact.formattedPhoneNumber" }, + { id: "alternateContact.streetAddress", fieldKey: "alternateContact.mailingAddress.street" }, + { id: "alternateContact.street2", fieldKey: "alternateContact.mailingAddress.street2" }, + { id: "alternateContact.city", fieldKey: "alternateContact.mailingAddress.city" }, + { id: "alternateContact.state", fieldKey: "alternateContact.mailingAddress.stateCode" }, + { id: "alternateContact.zipCode", fieldKey: "alternateContact.mailingAddress.zipCode" }, + ] + verifyHelper(application, fields, fieldsToSkip) +}) + +Cypress.Commands.add("verifyHouseholdMembers", (application, fieldsToSkip = []) => { + ;[ + { id: `[data-label="Name"]`, fieldKey: "householdMemberName" }, + { id: `[data-label="Date of Birth"]`, fieldKey: "householdMemberDoB" }, + { id: `[data-label="Relationship"]`, fieldKey: "relationship" }, + { id: `[data-label="Same Residence"]`, fieldKey: "sameAddress" }, + { id: `[data-label="Work in Region"]`, fieldKey: "workInRegion" }, + ] + .filter(({ id }) => !fieldsToSkip.includes(id)) + .forEach(({ id, fieldKey }) => { + cy.get(id).contains(application[fieldKey]) + }) +}) + +Cypress.Commands.add("verifyHouseholdDetails", (application, fieldsToSkip = []) => { + const fields = [ + { id: "preferredUnitSizes", fieldKey: "preferredUnitSize" }, + { id: "adaPriorities", fieldKey: "adaPriorities" }, + { id: "expectingChanges", fieldKey: "householdExpectingChanges" }, + { id: "householdStudent", fieldKey: "householdStudent" }, + ] + verifyHelper(application, fields, fieldsToSkip) +}) + +Cypress.Commands.add("verifyHouseholdIncome", (application, fieldsToSkip = []) => { + const fields = [ + { id: "annualIncome", fieldKey: "annualIncome" }, + { id: "monthlyIncome", fieldKey: "formattedMonthlyIncome" }, + { id: "vouchers", fieldKey: "incomeVouchers" }, + ] + verifyHelper(application, fields, fieldsToSkip) +}) + +Cypress.Commands.add("verifyTerms", (application) => { + cy.getByTestId("signatureOnTerms").contains(application["acceptedTerms"]) +}) diff --git a/sites/partners/cypress/support/helpers.ts b/sites/partners/cypress/support/helpers.ts new file mode 100644 index 0000000000..c8305a8daa --- /dev/null +++ b/sites/partners/cypress/support/helpers.ts @@ -0,0 +1,2 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +export const listingsUrl = "http://localhost:3100/listings?limit=all" diff --git a/sites/partners/cypress/support/index.d.ts b/sites/partners/cypress/support/index.d.ts new file mode 100644 index 0000000000..2db6558fbe --- /dev/null +++ b/sites/partners/cypress/support/index.d.ts @@ -0,0 +1,36 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +type attachFileSubjectArgs = { + subjectType: string +} + +declare namespace Cypress { + interface Chainable { + /** + * Custom command to select DOM element by data-cy attribute. + * @example cy.dataCy('greeting') + */ + getByID(value: string): Chainable + getByTestId(value: string): Chainable + login(): Chainable + loginWithMfa(): Chainable + attachFile(command: string, optionalProcessingConfig: attachFileSubjectArgs): Chainable + verifyAlertBox(): Chainable + fillPrimaryApplicant(value: Record, fieldsToSkip?: string[]): Chainable + fillAlternateContact(value: Record, fieldsToSkip?: string[]): Chainable + fillHouseholdMember(value: Record, fieldsToSkip?: string[]): Chainable + fillHouseholdDetails(value: Record, fieldsToSkip?: string[]): Chainable + fillHouseholdIncome(value: Record, fieldsToSkip?: string[]): Chainable + fillDemographics(value: Record, fieldsToSkip?: string[]): Chainable + fillTerms(value: Record, submit: boolean): Chainable + verifyApplicationData(value: Record, fieldsToSkip?: string[]): Chainable + verifyPrimaryApplicant(value: Record, fieldsToSkip?: string[]): Chainable + verifyAlternateContact(value: Record, fieldsToSkip?: string[]): Chainable + verifyHouseholdMembers(value: Record, fieldsToSkip?: string[]): Chainable + verifyHouseholdDetails(value: Record, fieldsToSkip?: string[]): Chainable + verifyHouseholdIncome(value: Record, fieldsToSkip?: string[]): Chainable + verifyTerms(value: Record, fieldsToSkip?: string[]): Chainable + fillMailingAddress(value: Record, fieldsToSkip?: string[]): Chainable + signOut(): Chainable + } +} +/* eslint-enable @typescript-eslint/no-unused-vars */ diff --git a/sites/partners/cypress/support/index.js b/sites/partners/cypress/support/index.js new file mode 100644 index 0000000000..5b1090d1ed --- /dev/null +++ b/sites/partners/cypress/support/index.js @@ -0,0 +1,20 @@ +// *********************************************************** +// This example support/index.js is processed and +// loaded automatically before your test files. +// +// This is a great place to put global configuration and +// behavior that modifies Cypress. +// +// You can change the location of this file or turn off +// automatically serving support files with the +// 'supportFile' configuration option. +// +// You can read more here: +// https://on.cypress.io/configuration +// *********************************************************** +import "@cypress/code-coverage/support" +// Import commands.js using ES2015 syntax: +import "./commands" + +// Alternatively you can use CommonJS syntax: +// require('./commands') diff --git a/sites/partners/cypress/tsconfig.json b/sites/partners/cypress/tsconfig.json new file mode 100644 index 0000000000..1492c473ce --- /dev/null +++ b/sites/partners/cypress/tsconfig.json @@ -0,0 +1,9 @@ +{ + "compilerOptions": { + "strict": true, + "target": "es5", + "lib": ["es5", "dom"], + "types": ["cypress", "cypress-file-upload"] + }, + "include": ["**/*.ts"] +} diff --git a/sites/partners/jest.config.js b/sites/partners/jest.config.js new file mode 100644 index 0000000000..e869f7f812 --- /dev/null +++ b/sites/partners/jest.config.js @@ -0,0 +1,35 @@ +/*eslint no-undef: "error"*/ +/*eslint-env node*/ + +process.env.TZ = "UTC" + +module.exports = { + testRegex: ["/*.test.tsx$", "/*.test.ts$"], + collectCoverageFrom: ["**/*.ts", "!**/*.tsx"], + coverageReporters: ["lcov", "text"], + coverageDirectory: "test-coverage", + coverageThreshold: { + global: { + branches: 0, + functions: 0, + lines: 0, + statements: 0, + }, + }, + preset: "ts-jest", + globals: { + "ts-jest": { + tsConfig: "tsconfig.test.json", + }, + }, + rootDir: "../..", + roots: ["/sites/partners"], + transform: { + "^.+\\.[t|j]sx?$": "ts-jest", + }, + setupFiles: ["dotenv/config"], + setupFilesAfterEnv: ["/sites/partners/.jest/setup-tests.js"], + moduleNameMapper: { + "\\.(scss|css|less)$": "identity-obj-proxy", + }, +} diff --git a/sites/partners/layouts/forms.tsx b/sites/partners/layouts/forms.tsx new file mode 100644 index 0000000000..bb7c3b17e6 --- /dev/null +++ b/sites/partners/layouts/forms.tsx @@ -0,0 +1,15 @@ +import Layout from "." + +const Forms = (props) => { + return ( + +
+
+ {props.children} +
+
+
+ ) +} + +export default Forms diff --git a/sites/partners/layouts/index.tsx b/sites/partners/layouts/index.tsx new file mode 100644 index 0000000000..c950f0aeb9 --- /dev/null +++ b/sites/partners/layouts/index.tsx @@ -0,0 +1,92 @@ +import React, { useContext } from "react" +import Head from "next/head" +import { useRouter } from "next/router" +import { + LocalizedLink, + SiteHeader, + SiteFooter, + FooterNav, + FooterSection, + t, + AuthContext, + MenuLink, + setSiteAlertMessage, +} from "@bloom-housing/ui-components" + +const Layout = (props) => { + const { profile, signOut } = useContext(AuthContext) + const router = useRouter() + const currentYear = new Date().getFullYear() + const menuLinks: MenuLink[] = [] + if (profile) { + menuLinks.push({ + title: t("nav.listings"), + href: "/", + }) + } + if (profile?.roles?.isAdmin) { + menuLinks.push({ + title: t("nav.users"), + href: "/users", + }) + } + if (profile) { + menuLinks.push({ + title: t("nav.signOut"), + onClick: async () => { + setSiteAlertMessage(t(`authentication.signOut.success`), "notice") + await router.push("/sign-in") + signOut() + }, + }) + } + return ( +
+
+ + {t("nav.siteTitlePartners")} + + + + +
{props.children}
+ + + + City of Detroit logo + + +

+ {t("footer.header")} +
+ + {t("footer.headerLink")} + +

+
+ + + {t("pageTitle.privacy")} + {" "} + + {t("pageTitle.disclaimer")} + + +
+
+
+ ) +} + +export default Layout diff --git a/sites/partners/lib/formatApplicationData.ts b/sites/partners/lib/formatApplicationData.ts new file mode 100644 index 0000000000..8f1225d38e --- /dev/null +++ b/sites/partners/lib/formatApplicationData.ts @@ -0,0 +1,359 @@ +import { + ApplicationUpdate, + ApplicantUpdate, + Language, + IncomePeriod, + ApplicationSubmissionType, + ApplicationStatus, + AddressUpdate, + HouseholdMember, + Program, +} from "@bloom-housing/backend-core/types" + +import { + TimeFieldPeriod, + mapPreferencesToApi, + mapApiToPreferencesForm, +} from "@bloom-housing/ui-components" +import { + fieldGroupObjectToArray, + mapProgramsToApi, + mapApiToProgramsPaperForm, +} from "@bloom-housing/shared-helpers" +import { + FormTypes, + YesNoAnswer, + ApplicationTypes, + Address, +} from "../src/applications/PaperApplicationForm/FormTypes" +import dayjs from "dayjs" +import utc from "dayjs/plugin/utc" +dayjs.extend(utc) +import customParseFormat from "dayjs/plugin/customParseFormat" +dayjs.extend(customParseFormat) + +/* + Some of fields are optional, not active, so it occurs 'undefined' as value. + This function eliminates those fields and parse to a proper format. +*/ + +const getAddress = (condition: boolean, addressData?: Address): AddressUpdate => { + const blankAddress: AddressUpdate = { + street: "", + street2: "", + city: "", + state: "", + zipCode: "", + } + + return condition ? (addressData as AddressUpdate) : blankAddress +} + +const getBooleanValue = (applicationField: YesNoAnswer) => { + return applicationField === null ? null : applicationField === YesNoAnswer.Yes ? true : false +} + +const getYesNoValue = (applicationField: boolean) => { + return applicationField === null ? null : applicationField ? YesNoAnswer.Yes : YesNoAnswer.No +} + +const mapEmptyStringToNull = (value: string) => (value === "" ? null : value) + +interface FormData extends FormTypes { + householdMembers: HouseholdMember[] + submissionType: ApplicationSubmissionType +} + +type mapFormToApiProps = { + data: FormData + listingId: string + editMode: boolean + programs: Program[] +} + +/* + Format data which comes from react-hook-form into correct API format. +*/ + +export const mapFormToApi = ({ data, listingId, editMode, programs }: mapFormToApiProps) => { + const language: Language | null = data.application?.language ? data.application?.language : null + + const submissionDate: Date | null = (() => { + const TIME_24H_FORMAT = "MM/DD/YYYY HH:mm:ss" + + // rename default (wrong property names) + const { day: submissionDay, month: submissionMonth, year: submissionYear } = + data.dateSubmitted || {} + const { hours, minutes = 0, seconds = 0, period } = data?.timeSubmitted || {} + + if (!submissionDay || !submissionMonth || !submissionYear) return null + + const dateString = dayjs( + `${submissionMonth}/${submissionDay}/${submissionYear} ${hours}:${minutes}:${seconds} ${period}`, + "MM/DD/YYYY hh:mm:ss A" + ).format(TIME_24H_FORMAT) + + const formattedDate = dayjs(dateString, TIME_24H_FORMAT).utc(true).toDate() + + return formattedDate + })() + + // create applicant + const applicant = ((): ApplicantUpdate => { + const phoneNumber: string | null = data?.phoneNumber || null + const { applicant: applicantData } = data.application + const phoneNumberType: string | null = applicantData.phoneNumberType || null + const noEmail = !applicantData.emailAddress + const noPhone = !phoneNumber + const workInRegion: string | null = applicantData?.workInRegion || null + const emailAddress: string | null = applicantData?.emailAddress || null + + applicantData.firstName = mapEmptyStringToNull(applicantData.firstName) + applicantData.lastName = mapEmptyStringToNull(applicantData.lastName) + + const workAddress = getAddress( + applicantData?.workInRegion === YesNoAnswer.Yes, + applicantData?.workAddress + ) + + return { + ...applicantData, + ...data.dateOfBirth, + emailAddress, + workInRegion, + workAddress, + phoneNumber, + phoneNumberType, + noEmail, + noPhone, + } + })() + + const preferences = mapPreferencesToApi(data) + const programsForm = data.application.programs + ? Object.entries(data.application.programs).reduce((acc, curr) => { + if (curr[1]) { + Object.assign(acc, { [curr[0]]: curr[1] }) + } + return acc + }, {}) + : {} + + const programsData = mapProgramsToApi(programs, programsForm) + + // additional phone + const { + additionalPhoneNumber: additionalPhoneNumberData, + additionalPhoneNumberType: additionalPhoneNumberTypeData, + mailingAddress: mailingAddressData, + additionalPhoneNumber, + contactPreferences, + sendMailToMailingAddress, + accessibility, + } = data.application + + const additionalPhone = !additionalPhoneNumberData + const additionalPhoneNumberType = additionalPhoneNumberTypeData + ? additionalPhoneNumberTypeData + : null + + const demographics = { + ...data.application.demographics, + race: fieldGroupObjectToArray(data, "race"), + } + + const mailingAddress = getAddress(sendMailToMailingAddress, mailingAddressData) + + const alternateContact = data.application.alternateContact + + // send null instead of empty string + alternateContact.emailAddress = alternateContact.emailAddress || null + + // pass blank address, not used for now everywhere + const alternateAddress = getAddress(false, null) + + const { incomeMonth, incomeYear, householdMembers } = data + + const incomePeriod: IncomePeriod | null = data.application?.incomePeriod || null + + const income = incomePeriod === IncomePeriod.perMonth ? incomeMonth : incomeYear || null + const incomeVouchers = getBooleanValue(data.application.incomeVouchers) + const acceptedTerms = getBooleanValue(data.application.acceptedTerms) + const householdExpectingChanges = getBooleanValue(data.application.householdExpectingChanges) + const householdStudent = getBooleanValue(data.application.householdStudent) + + const submissionType = editMode ? data.submissionType : ApplicationSubmissionType.paper + const status = ApplicationStatus.submitted + + const listing = { + id: listingId, + } + + // we need to add primary applicant + const householdSize = householdMembers.length + 1 || 1 + let preferredUnit: Record<"id", string>[] = [] + + if (data.application?.preferredUnit) { + if (Array.isArray(data.application?.preferredUnit)) { + preferredUnit = data.application.preferredUnit.map((id) => ({ id })) + } else { + preferredUnit = [{ id: data.application.preferredUnit }] + } + } + + const result: ApplicationUpdate = { + submissionDate, + language, + applicant, + additionalPhone, + additionalPhoneNumber, + additionalPhoneNumberType, + contactPreferences, + sendMailToMailingAddress, + mailingAddress, + alternateContact, + accessibility, + householdExpectingChanges, + householdStudent, + preferences, + programs: programsData, + income, + incomePeriod, + incomeVouchers, + demographics, + acceptedTerms, + submissionType, + status, + listing, + preferredUnit, + alternateAddress, + householdMembers, + householdSize, + } + + return result +} + +/* + Format data which comes from the API into correct react-hook-form format. +*/ + +export const mapApiToForm = (applicationData: ApplicationUpdate) => { + const submissionDate = applicationData.submissionDate + ? dayjs(new Date(applicationData.submissionDate)).utc() + : null + + const dateOfBirth = (() => { + const { birthDay, birthMonth, birthYear } = applicationData.applicant + + return { + birthDay, + birthMonth, + birthYear, + } + })() + + const incomePeriod = applicationData.incomePeriod + const incomeMonth = incomePeriod === "perMonth" ? applicationData.income : null + const incomeYear = incomePeriod === "perYear" ? applicationData.income : null + + const timeSubmitted = (() => { + if (!submissionDate) return + + const hours = submissionDate.format("hh") + const minutes = submissionDate.format("mm") + const seconds = submissionDate.format("ss") + const period = submissionDate.format("A").toLowerCase() as TimeFieldPeriod + + return { + hours, + minutes, + seconds, + period, + } + })() + + const dateSubmitted = (() => { + if (!submissionDate) return null + + const month = submissionDate.format("MM") + const day = submissionDate.format("DD") + const year = submissionDate.format("YYYY") + + return { + month, + day, + year, + } + })() + + const phoneNumber = applicationData.applicant.phoneNumber + + const preferences = mapApiToPreferencesForm(applicationData.preferences) + const programs = mapApiToProgramsPaperForm(applicationData.programs) + + const application: ApplicationTypes = (() => { + const { + language, + contactPreferences, + sendMailToMailingAddress, + mailingAddress, + accessibility, + incomePeriod, + demographics, + additionalPhoneNumber, + additionalPhoneNumberType, + alternateContact, + } = applicationData + + const incomeVouchers = getYesNoValue(applicationData.incomeVouchers) + const acceptedTerms = getYesNoValue(applicationData.acceptedTerms) + const householdExpectingChanges = getYesNoValue(applicationData.householdExpectingChanges) + const householdStudent = getYesNoValue(applicationData.householdStudent) + + const workInRegion = applicationData.applicant.workInRegion as YesNoAnswer + + const applicant = { + ...applicationData.applicant, + workInRegion, + } + + const preferredUnit = applicationData?.preferredUnit?.map((unit) => unit.id) + + const result = { + applicant, + language, + phoneNumber, + additionalPhoneNumber, + additionalPhoneNumberType, + preferences, + contactPreferences, + sendMailToMailingAddress, + mailingAddress, + preferredUnit, + accessibility, + householdExpectingChanges, + householdStudent, + incomePeriod, + incomeVouchers, + demographics, + acceptedTerms, + alternateContact, + programs, + } + + return result + })() + + const values: FormTypes = { + dateOfBirth, + dateSubmitted, + timeSubmitted, + phoneNumber, + incomeMonth, + incomeYear, + application, + } + + return values +} diff --git a/sites/partners/lib/helpers.ts b/sites/partners/lib/helpers.ts new file mode 100644 index 0000000000..4a1c0d15c3 --- /dev/null +++ b/sites/partners/lib/helpers.ts @@ -0,0 +1,320 @@ +import { SetStateAction } from "react" +import { t, CloudinaryUpload, TimeFieldPeriod } from "@bloom-housing/ui-components" +import { cloudinaryUrlFromId } from "@bloom-housing/shared-helpers" +import dayjs from "dayjs" +import utc from "dayjs/plugin/utc" +dayjs.extend(utc) +import customParseFormat from "dayjs/plugin/customParseFormat" +dayjs.extend(customParseFormat) + +import { + AmiChart, + ApplicationSubmissionType, + AssetsService, + ListingEventType, + ListingEvent, + IncomePeriod, +} from "@bloom-housing/backend-core/types" +import { TempUnit, FormListing } from "../src/listings/PaperListingForm/formTypes" +import { FieldError } from "react-hook-form" + +type DateTimePST = { + hour: string + minute: string + second: string + dayPeriod: string + year: string + day: string + month: string +} + +interface FormOption { + label: string + value: string +} + +export interface FormOptions { + [key: string]: FormOption[] +} + +export const emailRegex = /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/ + +export const convertDataToPst = (dateObj: Date, type: ApplicationSubmissionType) => { + if (!dateObj) { + return { + date: t("t.n/a"), + time: t("t.n/a"), + } + } + + if (type === ApplicationSubmissionType.electronical) { + // convert date and time to PST (electronical applications) + const ptFormat = new Intl.DateTimeFormat("en-US", { + timeZone: "America/Los_Angeles", + hour: "numeric", + minute: "numeric", + second: "numeric", + year: "numeric", + day: "numeric", + month: "numeric", + }) + + const originalDate = new Date(dateObj) + const ptDateParts = ptFormat.formatToParts(originalDate) + const timeValues = ptDateParts.reduce((acc, curr) => { + Object.assign(acc, { + [curr.type]: curr.value, + }) + return acc + }, {} as DateTimePST) + + const { month, day, year, hour, minute, second, dayPeriod } = timeValues + + const date = `${month}/${day}/${year}` + const time = `${hour}:${minute}:${second} ${dayPeriod} PT` + + return { + date, + time, + } + } + + if (type === ApplicationSubmissionType.paper) { + const dayjsDate = dayjs(dateObj) + + const date = dayjsDate.utc().format("MM/DD/YYYY") + const time = dayjsDate.utc().format("hh:mm:ss A") + + return { + date, + time, + } + } +} + +export const toNumberOrNull = (obj: string | number | undefined): number => { + return obj ? Number(obj) : null +} + +export const toNumberOrUndefined = (obj: string | number | undefined): number => { + return obj ? Number(obj) : undefined +} + +export const stringToNumberOrOne = (str: string | number | undefined): number => { + return str ? Number(str) : 1 +} + +export const stringToBoolean = (str: string | boolean | undefined): boolean => { + return str === true || str === "true" || str === "yes" +} + +export const booleanToString = (bool: boolean): string => { + return bool === true ? "true" : "false" +} + +export const getRentType = (unit: TempUnit): string | null => { + return unit?.monthlyIncomeMin && unit?.monthlyRent + ? "fixed" + : unit?.monthlyRentAsPercentOfIncome + ? "percentage" + : null +} + +export const getAmiChartId = (chart: AmiChart | string | undefined): string | null => { + if (chart === undefined) { + return null + } + return chart instanceof Object ? chart.id : chart +} + +export const isNullOrUndefined = (value: unknown): boolean => { + return value === null || value === undefined +} + +export const getLotteryEvent = (listing: FormListing): ListingEvent | undefined => { + const lotteryEvents = listing?.events.filter( + (event) => event.type === ListingEventType.publicLottery + ) + return lotteryEvents && lotteryEvents.length && lotteryEvents[0].startTime + ? lotteryEvents[0] + : null +} + +export function arrayToFormOptions( + arr: T[], + label: string, + value: string, + translateLabel?: string, + addEmpty = false +): FormOption[] { + const options = arr.map((val: T) => ({ + label: translateLabel ? t(`${translateLabel}.${val[label]}`) : val[label], + value: val[value], + })) + if (addEmpty) { + options.unshift({ label: "", value: "" }) + } + + return options +} + +/** + * Create Date object with date and time which comes from the TimeField component + */ +export const createTime = ( + date: Date, + formTime: { hours: string; minutes: string; period: TimeFieldPeriod } +) => { + if (!formTime?.hours || !formTime.minutes || !date) return null + + let formattedHours = parseInt(formTime.hours) + if (formTime.period === "am" && formattedHours === 12) { + formattedHours = 0 + } + if (formTime.period === "pm" && formattedHours !== 12) { + formattedHours = formattedHours + 12 + } + + return dayjs(date).hour(formattedHours).minute(parseInt(formTime.minutes)).toDate() +} + +/** + * Create Date object depending on DateField component + */ +export const createDate = (formDate: { year: string; month: string; day: string }) => { + if (!formDate || !formDate?.year || !formDate?.month || !formDate?.day) return null + + return dayjs(`${formDate.year}-${formDate.month}-${formDate.day}`, "YYYY-MM-DD").toDate() +} + +interface FileUploaderParams { + file: File + setCloudinaryData: (data: SetStateAction<{ id: string; url: string }>) => void + setProgressValue: (value: SetStateAction) => void +} + +/** + * Accept a file from the Dropzone component along with data and progress state + * setters. It will then handle obtaining a signature from the backend and + * uploading the file to Cloudinary, setting progress along the way and the + * id/url of the file when the upload is complete. + */ +export const cloudinaryFileUploader = async ({ + file, + setCloudinaryData, + setProgressValue, +}: FileUploaderParams) => { + const cloudName = process.env.cloudinaryCloudName + const uploadPreset = process.env.cloudinarySignedPreset + + setProgressValue(1) + + const timestamp = Math.round(new Date().getTime() / 1000) + const tag = "browser_upload" + + const assetsService = new AssetsService() + const params = { + timestamp, + tags: tag, + upload_preset: uploadPreset, + } + + const resp = await assetsService.createPresignedUploadMetadata({ + body: { parametersToSign: params }, + }) + const signature = resp.signature + + setProgressValue(3) + + void CloudinaryUpload({ + signature, + apiKey: process.env.cloudinaryKey, + timestamp, + file, + onUploadProgress: (progress) => { + setProgressValue(progress) + }, + cloudName, + uploadPreset, + tag, + }).then((response) => { + setProgressValue(100) + setCloudinaryData({ + id: response.data.public_id, + url: cloudinaryUrlFromId(response.data.public_id), + }) + }) +} + +export function formatIncome(value: number, currentType: IncomePeriod, returnType: IncomePeriod) { + const usd = new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + minimumFractionDigits: 0, + maximumFractionDigits: 0, + }) + + if (returnType === "perMonth") { + const monthIncomeNumber = currentType === "perYear" ? value / 12 : value + return usd.format(monthIncomeNumber) + } else { + const yearIncomeNumber = currentType === "perMonth" ? value * 12 : value + return usd.format(yearIncomeNumber) + } +} + +export const isObject = (obj: any, key: string) => { + return ( + obj[key] && + typeof obj[key] === "object" && + !Array.isArray(obj[key]) && + !(Object.prototype.toString.call(obj[key]) === "[object Date]") + ) +} + +/** + * + * @param obj - Any object + * + * End result is an object with these rules for fields: + * No empty objects - removed + * No objects that only have fields with null / empty strings - removed + * No null/undefined fields - removed + * No empty strings - set to null but still included + * Arrays / non-empty strings / Date objects - no changes + */ +export const removeEmptyObjects = (obj: any, nested?: boolean) => { + Object.keys(obj).forEach((key) => { + if (isObject(obj, key)) { + if (Object.keys(obj[key]).length === 0) { + delete obj[key] + } else { + removeEmptyObjects(obj[key], true) + } + } + if (isObject(obj, key) && Object.keys(obj[key]).length === 0) { + delete obj[key] + } + if (obj[key] === null || obj[key] === undefined) { + if (nested) { + delete obj[key] + } + } + + if (obj[key] === "") { + if (nested) { + delete obj[key] + } else { + obj[key] = null + } + } + }) +} + +export const fieldHasError = (errorObj: FieldError) => { + return errorObj !== undefined +} + +export const fieldMessage = (errorObj: FieldError) => { + return errorObj?.message +} diff --git a/sites/partners/lib/hooks.ts b/sites/partners/lib/hooks.ts new file mode 100644 index 0000000000..7884ea104f --- /dev/null +++ b/sites/partners/lib/hooks.ts @@ -0,0 +1,375 @@ +import { useContext } from "react" +import useSWR, { mutate } from "swr" +import qs from "qs" + +import { AuthContext } from "@bloom-housing/ui-components" +import { + EnumApplicationsApiExtraModelOrder, + EnumApplicationsApiExtraModelOrderBy, + EnumListingFilterParamsComparison, + EnumPreferencesFilterParamsComparison, + EnumProgramsFilterParamsComparison, + EnumUserFilterParamsComparison, +} from "@bloom-housing/backend-core/types" + +interface PaginationProps { + page?: number + limit: number | "all" +} + +interface UseSingleApplicationDataProps extends PaginationProps { + listingId: string +} + +type UseUserListProps = PaginationProps + +type UseListingsDataProps = PaginationProps & { + listingIds?: string[] +} + +export function useSingleListingData(listingId: string) { + const { listingsService } = useContext(AuthContext) + const fetcher = () => listingsService.retrieve({ id: listingId }) + + const { data, error } = useSWR(`${process.env.backendApiBase}/listings/${listingId}`, fetcher) + + return { + listingDto: data, + listingLoading: !error && !data, + listingError: error, + } +} + +export function useListingsData({ page, limit, listingIds }: UseListingsDataProps) { + const params = { + page, + limit, + view: "base", + } + + // filter if logged user is an agent + if (listingIds !== undefined) { + Object.assign(params, { + filter: [ + { + $comparison: EnumListingFilterParamsComparison["IN"], + id: listingIds.join(","), + }, + ], + }) + } + + const { listingsService } = useContext(AuthContext) + const fetcher = () => listingsService.list(params) + + const paramsString = qs.stringify(params) + const { data, error } = useSWR(`${process.env.backendApiBase}/listings?${paramsString}`, fetcher) + + return { + listingDtos: data, + listingsLoading: !error && !data, + listingsError: error, + } +} + +export function useApplicationsData( + pageIndex: number, + limit = 10, + listingId: string, + search: string, + orderBy?: EnumApplicationsApiExtraModelOrderBy, + order?: EnumApplicationsApiExtraModelOrder +) { + const { applicationsService } = useContext(AuthContext) + + const queryParams = new URLSearchParams() + queryParams.append("listingId", listingId) + queryParams.append("page", pageIndex.toString()) + queryParams.append("limit", limit.toString()) + + if (search) { + queryParams.append("search", search) + } + + if (orderBy) { + queryParams.append("orderBy", search) + queryParams.append("order", order ?? EnumApplicationsApiExtraModelOrder.ASC) + } + + const endpoint = `${process.env.backendApiBase}/applications?${queryParams.toString()}` + + const params = { + listingId, + page: pageIndex, + limit, + } + + if (search) { + Object.assign(params, { search }) + } + + if (orderBy) { + Object.assign(params, { orderBy, order: order ?? "ASC" }) + } + + const fetcher = () => applicationsService.list(params) + const { data, error } = useSWR(endpoint, fetcher) + + return { + appsData: data, + appsLoading: !error && !data, + appsError: error, + } +} + +export function useSingleApplicationData(applicationId: string) { + const { applicationsService } = useContext(AuthContext) + const backendSingleApplicationsEndpointUrl = `${process.env.backendApiBase}/applications/${applicationId}` + + const fetcher = () => applicationsService.retrieve({ id: applicationId }) + const { data, error } = useSWR(backendSingleApplicationsEndpointUrl, fetcher) + + return { + application: data, + applicationLoading: !error && !data, + applicationError: error, + } +} + +export function useFlaggedApplicationsList({ + listingId, + page, + limit, +}: UseSingleApplicationDataProps) { + const { applicationFlaggedSetsService } = useContext(AuthContext) + + const params = { + listingId, + page, + } + + const queryParams = new URLSearchParams() + queryParams.append("listingId", listingId) + queryParams.append("page", page.toString()) + + if (typeof limit === "number") { + queryParams.append("limit", limit.toString()) + Object.assign(params, limit) + } + + const endpoint = `${process.env.backendApiBase}/applicationFlaggedSets?${queryParams.toString()}` + + const fetcher = () => applicationFlaggedSetsService.list(params) + + const { data, error } = useSWR(endpoint, fetcher) + + return { + data, + error, + } +} + +export function useSingleFlaggedApplication(afsId: string) { + const { applicationFlaggedSetsService } = useContext(AuthContext) + + const endpoint = `${process.env.backendApiBase}/applicationFlaggedSets/${afsId}` + const fetcher = () => + applicationFlaggedSetsService.retrieve({ + afsId, + }) + + const { data, error } = useSWR(endpoint, fetcher) + + const revalidate = () => mutate(endpoint) + + return { + revalidate, + data, + error, + } +} + +export function useSingleAmiChartData(amiChartId: string) { + const { amiChartsService } = useContext(AuthContext) + const fetcher = () => amiChartsService.retrieve({ amiChartId }) + + const { data, error } = useSWR(`${process.env.backendApiBase}/amiCharts/${amiChartId}`, fetcher) + + return { + data, + error, + } +} + +export function useAmiChartList(jurisdiction: string) { + const { amiChartsService } = useContext(AuthContext) + const fetcher = () => amiChartsService.list({ jurisdictionId: jurisdiction }) + + const { data, error } = useSWR(`${process.env.backendApiBase}/amiCharts/${jurisdiction}`, fetcher) + + return { + data, + loading: !error && !data, + error, + } +} + +export function useSingleAmiChart(amiChartId: string) { + const { amiChartsService } = useContext(AuthContext) + const fetcher = () => amiChartsService.retrieve({ amiChartId }) + + const { data, error } = useSWR(`${process.env.backendApiBase}/amiCharts/${amiChartId}`, fetcher) + + return { + data, + loading: !error && !data, + error, + } +} + +export function useUnitPriorityList() { + const { unitPriorityService } = useContext(AuthContext) + const fetcher = () => unitPriorityService.list() + + const { data, error } = useSWR( + `${process.env.backendApiBase}/unitAccessibilityPriorityTypes`, + fetcher + ) + + return { + data, + loading: !error && !data, + error, + } +} + +export function useUnitTypeList() { + const { unitTypesService } = useContext(AuthContext) + const fetcher = () => unitTypesService.list() + + const { data, error } = useSWR(`${process.env.backendApiBase}/unitTypes`, fetcher) + + return { + data, + loading: !error && !data, + error, + } +} + +export function usePreferenceList() { + const { preferencesService } = useContext(AuthContext) + const fetcher = () => preferencesService.list() + + const { data, error } = useSWR(`${process.env.backendApiBase}/preferences`, fetcher) + + return { + data, + loading: !error && !data, + error, + } +} + +export function useJurisdictionalPreferenceList(jurisdictionId: string) { + const { preferencesService } = useContext(AuthContext) + const fetcher = () => + preferencesService.list({ + filter: [ + { + $comparison: EnumPreferencesFilterParamsComparison["="], + jurisdiction: jurisdictionId, + }, + ], + }) + + const { data, error } = useSWR( + `${process.env.backendApiBase}/preferences/${jurisdictionId}`, + fetcher + ) + + return { + data, + loading: !error && !data, + error, + } +} + +export function useProgramList() { + const { programsService } = useContext(AuthContext) + const fetcher = () => programsService.list() + + const { data, error } = useSWR(`${process.env.backendApiBase}/programs`, fetcher) + + return { + data, + loading: !error && !data, + error, + } +} + +export function useJurisdictionalProgramList(jurisdictionId: string) { + const { programsService } = useContext(AuthContext) + const fetcher = () => + programsService.list({ + filter: [ + { + $comparison: EnumProgramsFilterParamsComparison["="], + jurisdiction: jurisdictionId, + }, + ], + }) + + const { data, error } = useSWR( + `${process.env.backendApiBase}/programs/${jurisdictionId}`, + fetcher + ) + + return { + data, + loading: !error && !data, + error, + } +} + +export function useReservedCommunityTypeList() { + const { reservedCommunityTypeService } = useContext(AuthContext) + const fetcher = () => reservedCommunityTypeService.list() + + const { data, error } = useSWR(`${process.env.backendApiBase}/reservedCommunityTypes`, fetcher) + + return { + data, + loading: !error && !data, + error, + } +} + +export function useUserList({ page, limit }: UseUserListProps) { + const queryParams = new URLSearchParams() + queryParams.append("page", page.toString()) + queryParams.append("limit", limit.toString()) + + const { userService } = useContext(AuthContext) + + const fetcher = () => + userService.list({ + page, + limit, + filter: [ + { + isPortalUser: true, + $comparison: EnumUserFilterParamsComparison["NA"], + }, + ], + }) + + const { data, error } = useSWR( + `${process.env.backendApiBase}/user/list?${queryParams.toString()}`, + fetcher + ) + + return { + data, + loading: !error && !data, + error, + } +} diff --git a/sites/partners/lib/signInHelpers.ts b/sites/partners/lib/signInHelpers.ts new file mode 100644 index 0000000000..0fccdfb031 --- /dev/null +++ b/sites/partners/lib/signInHelpers.ts @@ -0,0 +1,104 @@ +import { EnumRequestMfaCodeMfaType } from "@bloom-housing/backend-core/types" + +export enum EnumRenderStep { + emailAndPassword = "email and password", + mfaType = "mfa type", + phoneNumber = "phone number if missing", + enterCode = "enter mfa code", +} + +export const onSubmitEmailAndPassword = ( + setEmail, + setPassword, + setRenderStep, + determineNetworkError, + login, + router, + resetNetworkError +) => async (data: { email: string; password: string }) => { + const { email, password } = data + try { + await login(email, password) + await router.push("/") + } catch (error) { + if (error?.response?.data?.name === "mfaCodeIsMissing") { + setEmail(email) + setPassword(password) + resetNetworkError() + setRenderStep(EnumRenderStep.mfaType) + } else { + const { status } = error.response || {} + determineNetworkError(status, error) + } + } +} + +export const onSubmitMfaType = ( + email, + password, + setMfaType, + setRenderStep, + requestMfaCode, + determineNetworkError, + setAllowPhoneNumberEdit, + setPhoneNumber, + resetNetworkError +) => async (data: { mfaType: EnumRequestMfaCodeMfaType }) => { + const { mfaType: incomingMfaType } = data + try { + const res = await requestMfaCode(email, password, incomingMfaType) + if (!res.phoneNumberVerified && incomingMfaType === EnumRequestMfaCodeMfaType.sms) { + setAllowPhoneNumberEdit(true) + setPhoneNumber(res.phoneNumber) + } + setMfaType(incomingMfaType) + resetNetworkError() + setRenderStep(EnumRenderStep.enterCode) + } catch (error) { + if (error?.response?.data?.name === "phoneNumberMissing") { + setMfaType(incomingMfaType) + setRenderStep(EnumRenderStep.phoneNumber) + } else { + const { status } = error.response || {} + determineNetworkError(status, error) + } + } +} + +export const onSubmitMfaCodeWithPhone = ( + email, + password, + mfaType, + setRenderStep, + requestMfaCode, + setAllowPhoneNumberEdit, + setPhoneNumber, + resetNetworkError +) => async (data: { phoneNumber: string }) => { + const { phoneNumber } = data + await requestMfaCode(email, password, mfaType, phoneNumber) + resetNetworkError() + setRenderStep(EnumRenderStep.enterCode) + setAllowPhoneNumberEdit(true) + setPhoneNumber(phoneNumber) +} + +export const onSubmitMfaCode = ( + email, + password, + determineNetworkError, + login, + router, + mfaType, + resetNetworkError +) => async (data: { mfaCode: string }) => { + const { mfaCode } = data + try { + await login(email, password, mfaCode, mfaType) + resetNetworkError() + await router.push("/") + } catch (error) { + const { status } = error.response || {} + determineNetworkError(status, error, true) + } +} diff --git a/sites/partners/netlify.toml b/sites/partners/netlify.toml new file mode 100644 index 0000000000..48d26efb59 --- /dev/null +++ b/sites/partners/netlify.toml @@ -0,0 +1,13 @@ +[build] + +command = "yarn run build" +ignore = "/bin/false" + +[build.environment] + +NODE_VERSION = "14.17.6" +YARN_VERSION = "1.22.4" +NEXT_TELEMETRY_DISABLED = "1" + +# reminder: URLs and fragments should *not* have a trailing / +LISTINGS_QUERY = "/listings" \ No newline at end of file diff --git a/sites/partners/next-env.d.ts b/sites/partners/next-env.d.ts new file mode 100644 index 0000000000..9bc3dd46b9 --- /dev/null +++ b/sites/partners/next-env.d.ts @@ -0,0 +1,6 @@ +/// +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/basic-features/typescript for more information. diff --git a/sites/partners/next.config.js b/sites/partners/next.config.js new file mode 100644 index 0000000000..7c569b43a5 --- /dev/null +++ b/sites/partners/next.config.js @@ -0,0 +1,66 @@ +/* eslint-env node */ +/* eslint-disable @typescript-eslint/no-var-requires */ + +const withTM = require("next-transpile-modules")([ + "@bloom-housing/shared-helpers", + "@bloom-housing/ui-components", + "@bloom-housing/backend-core", +]) +const withBundleAnalyzer = require("@next/bundle-analyzer")({ + enabled: process.env.ANALYZE === "true", +}) + +if (process.env.NODE_ENV !== "production") { + require("dotenv").config() +} + +// Set up app-wide constants +let BACKEND_API_BASE = "http://localhost:3100" +if (process.env.INCOMING_HOOK_BODY && process.env.INCOMING_HOOK_BODY.startsWith("http")) { + // This is a value that can get set via a Netlify webhook for branch deploys + BACKEND_API_BASE = decodeURIComponent(process.env.INCOMING_HOOK_BODY) +} else if (process.env.BACKEND_API_BASE) { + BACKEND_API_BASE = process.env.BACKEND_API_BASE +} +const LISTINGS_QUERY = process.env.LISTINGS_QUERY || "/listings" +console.log(`Using ${BACKEND_API_BASE}${LISTINGS_QUERY} for the listing service.`) + +const BACKEND_PROXY_BASE = process.env.BACKEND_PROXY_BASE + +const MAPBOX_TOKEN = process.env.MAPBOX_TOKEN +// Load the Tailwind theme and set up SASS vars +const bloomTheme = require("./tailwind.config.js") +const tailwindVars = require("@bloom-housing/ui-components/tailwind.tosass.js")(bloomTheme) + +// Tell webpack to compile the ui components package +// https://www.npmjs.com/package/next-transpile-modules +module.exports = withBundleAnalyzer( + withTM({ + target: "serverless", + env: { + backendApiBase: BACKEND_API_BASE, + backendProxyBase: BACKEND_PROXY_BASE, + listingServiceUrl: BACKEND_API_BASE + LISTINGS_QUERY, + idleTimeout: process.env.IDLE_TIMEOUT, + showDuplicates: process.env.SHOW_DUPLICATES === "TRUE", + cloudinaryCloudName: process.env.CLOUDINARY_CLOUD_NAME, + cloudinaryKey: process.env.CLOUDINARY_KEY, + cloudinarySignedPreset: process.env.CLOUDINARY_SIGNED_PRESET, + mapBoxToken: MAPBOX_TOKEN, + }, + i18n: { + locales: process.env.LANGUAGES ? process.env.LANGUAGES.split(",") : ["en"], + defaultLocale: "en", + }, + sassOptions: { + additionalData: tailwindVars, + }, + webpack: (config) => { + config.module.rules.push({ + test: /\.md$/, + type: "asset/source", + }) + return config + }, + }) +) diff --git a/sites/partners/package.json b/sites/partners/package.json new file mode 100644 index 0000000000..6de89cbc55 --- /dev/null +++ b/sites/partners/package.json @@ -0,0 +1,76 @@ +{ + "name": "@bloom-housing/partners", + "version": "4.2.0", + "author": "Sean Albert ", + "description": "Partners app reference implementation for the Bloom affordable housing system", + "main": "index.js", + "license": "Apache-2.0", + "private": true, + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org" + }, + "scripts": { + "dev": "NODE_OPTIONS='--inspect=9231' next -p ${NEXTJS_PORT:-3001}", + "build": "next build", + "test": "concurrently \"yarn dev\" \"cypress open\"", + "test:unit": "jest -w 1", + "test:headless": "concurrently \"yarn dev\" \"cypress run\"", + "test:coverage": "yarn nyc report --reporter=text-summary --check-coverage", + "export": "next export", + "start": "next start -p ${NEXTJS_PORT:-3001}", + "dev:listings": "cd ../../backend/core && yarn dev", + "dev:server-wait": "wait-on \"http-get://localhost:${PORT:-3100}/listings\" && yarn dev", + "dev:all": "concurrently \"yarn dev:listings\" \"yarn dev:server-wait\"", + "dev:all-cypress": "concurrently \"yarn dev:listings\" \"yarn dev:server-wait-cypress\"", + "dev:server-wait-cypress": "wait-on \"http-get://localhost:${PORT:-3100}/listings\" --httpTimeout 60000 --tcpTimeout 1500 -v --interval 15000 && yarn build && yarn start" + }, + "dependencies": { + "@bloom-housing/backend-core": "^4.2.0", + "@bloom-housing/shared-helpers": "^4.2.0", + "@bloom-housing/ui-components": "^4.2.0", + "@mapbox/mapbox-sdk": "^0.13.0", + "@zeit/next-sass": "^1.0.1", + "ag-grid-community": "^26.0.0", + "ag-grid-react": "^26.0.0", + "@mdx-js/loader": "1.6.18", + "@next/mdx": "^10.1.0", + "axios": "^0.21.1", + "dayjs": "^1.10.7", + "dotenv": "^8.2.0", + "electron": "^13.1.7", + "nanoid": "^3.1.12", + "next": "^11.1.1", + "next-plugin-custom-babel-config": "^1.0.2", + "node-polyglot": "^2.4.0", + "node-sass": "^7.0.0", + "qs": "^6.10.1", + "react": "^17.0.2", + "react-dom": "^17.0.2", + "react-hook-form": "^6.15.5", + "swr": "^1.0.1", + "tailwindcss": "npm:@tailwindcss/postcss7-compat@2.2.10" + }, + "devDependencies": { + "@babel/core": "^7.11.6", + "@babel/preset-env": "^7.11.5", + "@cypress/code-coverage": "^3.9.12", + "@cypress/webpack-preprocessor": "^5.4.6", + "@next/bundle-analyzer": "^10.1.0", + "@types/mapbox__mapbox-sdk": "^0.13.2", + "@types/node": "^12.12.67", + "@types/react": "^16.9.52", + "babel-loader": "^8.1.0", + "concurrently": "^5.3.0", + "cypress": "^9.4.1", + "cypress-file-upload": "^5.0.8", + "jest": "^26.5.3", + "js-levenshtein": "^1.1.6", + "next-transpile-modules": "^8.0.0", + "nyc": "^15.1.0", + "postcss": "^8.3.6", + "sass-loader": "^10.0.3", + "typescript": "^3.9.7", + "webpack": "^4.44.2" + } +} diff --git a/sites/partners/page_content/locale_overrides/ar.json b/sites/partners/page_content/locale_overrides/ar.json new file mode 100644 index 0000000000..c596087408 --- /dev/null +++ b/sites/partners/page_content/locale_overrides/ar.json @@ -0,0 +1,11 @@ +{ + "nav": { + "siteTitlePartners": "بوابة ديترويت بارتنر" + }, + "footer": { + "copyright": "مدينة ديترويت © 2021 • جميع الحقوق محفوظة", + "header": "ديترويت هوم كونيكت هو مشروع تابع لـ", + "headerUrl": "هتبص://ديترويتم.جوف/دبرتمنتص/حسينجندرفتلزشندبرتمنة", + "headerLink": "مدينة ديترويت - قسم الإسكان والتنشيط" + } +} diff --git a/sites/partners/page_content/locale_overrides/bn.json b/sites/partners/page_content/locale_overrides/bn.json new file mode 100644 index 0000000000..269029b1a3 --- /dev/null +++ b/sites/partners/page_content/locale_overrides/bn.json @@ -0,0 +1,11 @@ +{ + "nav": { + "siteTitlePartners": "ডেট্রয়েট পার্টনার পোর্টাল" + }, + "footer": { + "copyright": "ডেট্রয়েট শহর © 2021 • সর্বস্বত্ব সংরক্ষিত", + "header": "ডেট্রয়েট হোম কানেক্ট একটি প্রকল্প", + "headerUrl": "হটপস://ডেট্রয়টমি.গভ/ডিপার্টমেন্টস/হাউসিং-এন্ড-রেভিটালিজটিও-ডিপার্টমেন্ট", + "headerLink": "ডেট্রয়েট শহর - আবাসন ও পুনরুজ্জীবন বিভাগ" + } +} diff --git a/sites/partners/page_content/locale_overrides/es.json b/sites/partners/page_content/locale_overrides/es.json new file mode 100644 index 0000000000..70c6eded31 --- /dev/null +++ b/sites/partners/page_content/locale_overrides/es.json @@ -0,0 +1,11 @@ +{ + "nav": { + "siteTitlePartners": "Portal de socios de Detroit" + }, + "footer": { + "copyright": "Ciudad de Detroit © 2021 • Todos los derechos reservados", + "header": "Detroit Home Connect es un proyecto del", + "headerUrl": "https://detroitmi.gov/departments/housing-and-revitalization-department", + "headerLink": "Ciudad de Detroit - Departamento de Vivienda y Revitalización" + } +} diff --git a/sites/partners/page_content/locale_overrides/general.json b/sites/partners/page_content/locale_overrides/general.json new file mode 100644 index 0000000000..546a38b10c --- /dev/null +++ b/sites/partners/page_content/locale_overrides/general.json @@ -0,0 +1,350 @@ +{ + "application.add.addHouseholdMember": "Add Household Member", + "application.add.applicationAddError": "You’ll need to resolve any errors before moving on.", + "application.add.applicationSubmitted": "Application Submitted", + "application.add.applicationUpdated": "Application Updated", + "application.add.dateSubmitted": "Date Submitted", + "application.add.demographicsInformation": "Demographic Information", + "application.add.ethnicity": "Ethnicity", + "application.add.hearing": "Hearing Impairments", + "application.add.howDidYouHearAboutUs": "How did you hear about us?", + "application.add.incomePeriod": "Income Period", + "application.add.languageSubmittedIn": "Language Submitted In", + "application.add.mobility": "Mobility Impairments", + "application.add.race": "Race", + "application.add.sameAddressAsPrimary": "Same Address as Primary", + "application.add.sameResidence": "Same Residence", + "application.add.saveAndExit": "Save & exit", + "application.add.timeSubmitted": "Time Submitted", + "application.add.vision": "Vision Impairments", + "application.add.workInRegion": "Work in the region?", + "application.deleteApplicationDescription": "Deleting this application means you will lose all the information you've entered.", + "application.deleteMemberDescription": "Do you really want to delete this member?", + "application.deleteThisApplication": "Delete this application?", + "application.deleteThisMember": "Delete this member?", + "application.details.adaPriorities": "ADA Priorities Selected", + "application.details.agency": "Agency if Applicable", + "application.details.annualIncome": "Annual Income", + "application.details.applicationData": "Application Data", + "application.details.applicationStatus.draft": "Draft", + "application.details.applicationStatus.removed": "Removed", + "application.details.applicationStatus.submitted": "Submitted", + "application.details.householdIncome": "Declared Household Income", + "application.details.householdMemberDetails": "Household Member Details", + "application.details.householdSize": "Household Size", + "application.details.language": "Application Language", + "application.details.monthlyIncome": "Monthly Income", + "application.details.number": "Application Number", + "application.details.preferences": "Application Preferences", + "application.details.preferredContact": "Preferred Contact", + "application.details.preferredUnitSizes": "Preferred Unit Sizes", + "application.details.programs": "Application Programs", + "application.details.residenceAddress": "Residence Address", + "application.details.signatureOnTerms": "Signature on Terms of Agreement", + "application.details.submissionType.electronical": "Electronic", + "application.details.submissionType.paper": "Paper", + "application.details.submittedBy": "Submitted By", + "application.details.submittedDate": "Application Submitted Date", + "application.details.timeDate": "Application Submitted Time", + "application.details.totalSize": "Total Household Size", + "application.details.type": "Application Submission Type", + "application.details.vouchers": "Housing Voucher or Subsidy", + "application.details.workInRegion": "Work in Region", + "applications.newApplication": "New Application", + "applications.table.additionalPhoneType": "Additional Phone Type", + "applications.table.altContactAgency": "Alt Contact Agency", + "applications.table.altContactCity": "Alt Contact City", + "applications.table.altContactEmail": "Alt Contact Email", + "applications.table.altContactFirstName": "Alt Contact First Name", + "applications.table.altContactLastName": "Alt Contact Last Name", + "applications.table.altContactPhone": "Alt Contact Phone", + "applications.table.altContactRelationship": "Alt Contact Relationship", + "applications.table.altContactState": "Alt Contact State", + "applications.table.altContactStreetAddress": "Alt Contact Street Address", + "applications.table.altContactZip": "Alt Contact Zip", + "applications.table.applicationSubmissionDate": "Application Submission Date", + "applications.table.applicationType": "Application Type", + "applications.table.declaredAnnualIncome": "Declared Annual Income", + "applications.table.declaredMonthlyIncome": "Declared Monthly Income", + "applications.table.householdDob": "Household DOB", + "applications.table.mailingCity": "Mailing City", + "applications.table.mailingState": "Mailing State", + "applications.table.mailingStreet": "Mailing Street Address", + "applications.table.mailingZip": "Mailing Zip", + "applications.table.phoneType": "Phone Type", + "applications.table.preferenceClaimed": "Preference Claimed", + "applications.table.primaryDob": "Primary DOB", + "applications.table.requestAda": "Request ADA", + "applications.table.residenceCity": "Residence City", + "applications.table.residenceState": "Residence State", + "applications.table.residenceStreet": "Residence Street Address", + "applications.table.residenceZip": "Residence Zip", + "applications.table.subsidyOrVoucher": "Subsidy or Voucher", + "applications.table.workCity": "Work City", + "applications.table.workState": "Work State", + "applications.table.workStreet": "Work Street Address", + "applications.table.workZip": "Work Zip", + "applications.totalApplications": "Total Applications", + "applications.totalSets": "Total Sets", + "authentication.createAccount.errors.tokenMissing": "Wrong token provided.", + "authentication.createAccount.firstName": "First Name", + "authentication.createAccount.lastName": "Last Name", + "errors.alert.emailConflict": "That email is already in use", + "errors.unauthorized.message": "Uh oh, you are not allowed to access this page.", + "errors.unauthorized.title": "Unauthorized", + "errors.urlError": "Please enter a valid url", + "errors.partialAddress": "Cannot enter a partial address", + "errors.minGreaterThanMaxOccupancyError": "Min Occupancy must be less than or equal to Max Occupancy", + "errors.maxLessThanMinOccupancyError": "Max Occupancy must be greater than or equal to Min Occupancy", + "errors.minGreaterThanMaxSqFeetError": "Min must be less than or equal to Max Square Footage", + "errors.maxLessThanMinSqFeetError": "Max must be greater than or equal to Min Square Footage", + "errors.minGreaterThanMaxFloorError": "Min must be less than or equal to Max Floor", + "errors.maxLessThanMinFloorError": "Max must be greater than or equal to Min Floor", + "errors.minGreaterThanMaxBathroomError": "Min must be less than or equal to Max Number of Bathrooms", + "errors.maxLessThanMinBathroomError": "Max must be greater than or equal to Min Number of Bathrooms", + "errors.totalCountLessThanTotalAvailableError": "Group Quantity must be greater than or equal to Vacancies", + "errors.totalAvailableGreaterThanTotalCountError": "Vacancies must be less than or equal to Group Quantity", + "flags.flaggedSet": "Flagged Set", + "flags.markedAsDuplicate": "%{quantity} applications marked as duplicate", + "flags.resolveFlag": "Resolve Flag", + "flags.ruleName": "Rule Name", + "footer.header": "Detroit Home Connect is a project of the", + "footer.headerUrl": "https://detroitmi.gov/departments/housing-and-revitalization-department", + "footer.headerLink": "City of Detroit", + "leasingAgent.name": "Leasing Agent Name", + "leasingAgent.namePlaceholder": "Full Name", + "leasingAgent.officeHoursPlaceholder": "ex: 9:00am - 5:00pm, Monday to Friday", + "leasingAgent.title": "Leasing Agent Title", + "listings.actions.close": "Close", + "listings.actions.draft": "Save as Draft", + "listings.actions.postResults": "Post Results", + "listings.actions.preview": "Preview", + "listings.actions.previewLotteryResults": "Preview Lottery Results", + "listings.actions.publish": "Publish", + "listings.actions.resultsPosted": "Results Posted", + "listings.actions.unpublish": "Unpublish", + "listings.active": "Accepting Applications", + "listings.activePreferences": "Active Preferences", + "listings.addBuildingSelectionCriteria": "Add Building Selection Criteria", + "listings.addBuildingSelectionCriteriaSubtitle": "How will you specify the building selection criteria?", + "listings.addListing": "Add Listing", + "listings.addPaperApplication": "Add Paper Application", + "listings.addPhoto": "Add Photo", + "listings.addPreference": "Add Preference", + "listings.addPreferences": "Add Preferences", + "listings.additionalApplicationSubmissionNotes": "Additional Application Submission Notes", + "listings.amiOverrideTitle": "Override for household size of %{householdSize}", + "listings.applicationDropOffQuestion": "Can applications be dropped off?", + "listings.applicationDueTime": "Application Due Time", + "listings.applicationPickupQuestion": "Can applications be picked up?", + "listings.atAnotherAddress": "At another address", + "listings.atLeasingAgentAddress": "At the leasing agent address", + "listings.availableUnits": "Available Units", + "listings.closeThisListing": "Do you really want to close this listing?", + "listings.customOnlineApplicationUrl": "Custom Online Application URL", + "listings.depositMax": "Deposit Max", + "listings.depositMin": "Deposit Min", + "listings.details.createdDate": "Date Created", + "listings.details.id": "Listing ID", + "listings.details.listingData": "Listing Data", + "listings.details.updatedDate": "Date Updated", + "listings.developer": "Housing Developer", + "listings.dropOffAddress": "Drop Off Address", + "listings.dueDateQuestion": "Is there an application due date?", + "listings.editPreferences": "Edit Preferences", + "listings.events.deleteConf": "Do you really want to delete this event?", + "listings.events.deleteThisEvent": "Delete this event", + "listings.events.openHouseNotes": "Open House Notes", + "listings.fieldError": "Please resolve any errors before saving or publishing your listing.", + "listings.firstComeFirstServe": "First come first serve", + "listings.isDigitalApplication": "Is there a digital application?", + "listings.isPaperApplication": "Is there a paper application?", + "listings.isReferralOpportunity": "Is there a referral opportunity?", + "listings.latitude": "Latitude", + "listings.leasingAgentAddress": "Leasing Agent Address", + "listings.listingIsAlreadyLive": "This listing is already live. Updates will affect the applicant experience on the housing portal.", + "listings.listingName": "Listing Name", + "listings.listingStatus.active": "Open", + "listings.listingStatus.closed": "Closed", + "listings.listingStatus.pending": "Draft", + "listings.listingStatusText": "Listing Status", + "listings.listingSubmitted": "Listing Submitted", + "listings.longitude": "Longitude", + "listings.lotteryDateNotes": "Lottery Date Notes", + "listings.lotteryDateQuestion": "When will the lottery be run?", + "listings.lotteryEndTime": "Lottery End Time", + "listings.lotteryStartTime": "Lottery Start Time", + "listings.lotteryTitle": "Lottery", + "listings.mapPinAutomaticDescription": "Map pin position is based on the address provided", + "listings.mapPinCustomDescription": "Drag the pin to update the marker location", + "listings.mapPinPosition": "Map Pin Position", + "listings.mapPreview": "Map Preview", + "listings.mapPreviewNoAddress": "Enter an address to preview the map", + "listings.maxAnnualIncome": "Maximum Annual Income", + "listings.newListing": "New Listing", + "listings.pdfHelperText": "Select PDF file", + "listings.pickupAddress": "Pickup Address", + "listings.postmarksConsideredQuestion": "Are postmarks considered?", + "listings.publishThisListing": "Publishing will push the listing live on the public site.", + "listings.receivedByDate": "Received by Date", + "listings.receivedByTime": "Received by Time", + "listings.recommended": "Recommended", + "listings.referralContactPhone": "Referral Contact Phone", + "listings.referralSummary": "Referral Summary", + "listings.requiredToPublish": "Required to publish", + "listings.reservedCommunityDescription": "Reserved Community Description", + "listings.reviewOrderQuestion": "How is the application review order determined?", + "listings.sections.addOpenHouse": "Add Open House", + "listings.sections.additionalDetails": "Additional Details", + "listings.sections.additionalDetailsSubtitle": "Are there any other required documents and selection criteria?", + "listings.sections.additionalEligibilitySubtext": "Let applicants know any other rules of the building.", + "listings.sections.additionalFeesSubtitle": "Tell us about any other fees required by the applicant.", + "listings.sections.applicationAddressSubtitle": "In the event of paper applications, where do you want applications dropped off or mailed?", + "listings.sections.applicationAddressTitle": "Application Address", + "listings.sections.applicationDatesSubtitle": "Tell us about important dates related to this listing.", + "listings.sections.applicationDatesTitle": "Application Dates", + "listings.sections.applicationTypesSubtitle": "Configure the online application and upload paper application forms.", + "listings.sections.applicationTypesTitle": "Application Types", + "listings.sections.buildingAddress": "Building Address", + "listings.sections.buildingDetailsSubtitle": "Tell us where the building is located.", + "listings.sections.buildingDetailsTitle": "Building Details", + "listings.sections.buildingFeaturesSubtitle": "Provide details about any amenities and unit details.", + "listings.sections.buildingFeaturesTitle": "Building Features", + "listings.sections.communityType": "Community Type", + "listings.sections.communityTypeSubtitle": "Are there any requirements that applicants need to meet?", + "listings.sections.costsNotIncluded": "Costs Not Included", + "listings.sections.depositHelperText": "Deposit Helper Text", + "listings.sections.housingPreferencesSubtext": "Tell us about any preferences that will be used to rank qualifying applicants.", + "listings.sections.introSubtitle": "Let's get started with some basic information about your listing.", + "listings.sections.introTitle": "Listing Intro", + "listings.sections.leasingAgentSubtitle": "Provide details about the leasing agent who will be managing the application process.", + "listings.sections.leasingAgentTitle": "Leasing Agent", + "listings.sections.lotteryResultsHelperText": "Upload Results", + "listings.sections.openHouse": "Open House", + "listings.sections.photoSubtitle": "Upload an image for the listing that will be used as a preview.", + "listings.sections.photoTitle": "Listing Photo", + "listings.sections.photoHelperText": "Select JPEG or PNG files", + "listings.sections.rankingsResultsSubtitle": "Provide details about what happens to applications once they are submitted.", + "listings.sections.rankingsResultsTitle": "Rankings & Results", + "listings.selectJurisdiction": "You must first select a jurisdiction", + "listings.selectPreferences": "Select Preferences", + "listings.streetAddressOrPOBox": "Street Address or PO Box", + "listings.totalListings": "Total Listings", + "listings.unit.%incomeRent": "Percentage of Income Rent", + "listings.unit.accessibilityPriorityType": "Accessibility Priority Type", + "listings.unit.add": "Add Unit", + "listings.unit.ami": "AMI", + "listings.unit.amiChart": "AMI Chart", + "listings.unit.amiPercentage": "Percentage of AMI", + "listings.unit.delete": "Delete this Unit", + "listings.unit.deleteConf": "Do you really want to delete this unit?", + "listings.unit.details": "Details", + "listings.unit.eligibility": "Eligibility", + "listings.unit.fixed": "Fixed amount", + "listings.unit.floor": "Unit Floor", + "listings.unit.individualUnits": "Individual Units", + "listings.unit.maxOccupancy": "Max Occupancy", + "listings.unit.minOccupancy": "Minimum Occupancy", + "listings.unit.monthlyRent": "Monthly Rent", + "listings.unit.numBathrooms": "Number of Bathrooms", + "listings.unit.number": "Unit #", + "listings.unit.percentage": "% of income", + "listings.unit.priorityType": "ADA", + "listings.unit.rent": "Rent", + "listings.unit.rentType": "How is Rent Determined?", + "listings.unit.sqft": "SQ FT", + "listings.unit.squareFootage": "Square Footage", + "listings.unit.status": "Status", + "listings.unit.statusOptions.available": "Available", + "listings.unit.statusOptions.occupied": "Occupied", + "listings.unit.statusOptions.unavailable": "Unavailable", + "listings.unit.statusOptions.unknown": "Unknown", + "listings.unit.title": "Unit", + "listings.unit.type": "Unit Type", + "listings.unit.typeOptions.SRO": "SRO", + "listings.unit.typeOptions.fourBdrm": "Four Bedroom", + "listings.unit.typeOptions.oneBdrm": "One Bedroom", + "listings.unit.typeOptions.studio": "Studio", + "listings.unit.typeOptions.threeBdrm": "Three Bedroom", + "listings.unit.typeOptions.twoBdrm": "Two Bedroom", + "listings.unit.unitCopied": "Unit Copied", + "listings.unit.unitNumber": "Unit Number", + "listings.unit.unitSaved": "Unit Saved", + "listings.unit.unitStatus": "Unit Status", + "listings.unit.unitTypes": "Unit Types", + "listings.unitTypesOrIndividual": "Do you want to show unit types or individual units?", + "listings.units": "Listing Units", + "listings.unitsDescription": "Select the building units that are available through the listing.", + "listings.usingCommonDigitalApplication": "Are you using the common digital application?", + "listings.waitlist.currentSizeQuestion": "How many people are on the current list?", + "listings.waitlist.maxSize": "Maximum Waitlist Size", + "listings.waitlist.maxSizeQuestion": "What is the maximum size of the waitlist?", + "listings.waitlist.openQuestion": "Do you want to show a waitlist?", + "listings.waitlist.openSize": "Number of Openings", + "listings.waitlist.openSizeQuestion": "How many spots are open on the list?", + "listings.whatToExpectLabel": "Tell the applicant what to expect from the process", + "listings.whenApplicationsClose": "When applications close to the public", + "listings.whereDropOffQuestion": "Where are applications dropped off?", + "listings.wherePickupQuestion": "Where are applications picked up?", + "listings.yearBuilt": "Year Built", + "nav.applications": "Applications", + "nav.flags": "Flags", + "nav.siteTitlePartners": "Detroit Partner Portal", + "nav.users": "Users", + "t.addNotes": "Add notes", + "t.areYouSure": "Are you sure?", + "t.automatic": "Automatic", + "t.copy": "Make a Copy", + "t.custom": "Custom", + "t.date": "Date", + "t.descriptionTitle": "Description", + "t.done": "Done", + "t.draft": "Draft", + "t.end": "End", + "t.endTime": "End Time", + "t.enterAmount": "Enter amount", + "t.export": "Export", + "t.fileName": "File Name", + "t.filter": "Filter", + "t.invite": "Invite", + "t.jurisdiction": "Jurisdiction", + "t.label": "Label", + "t.language": "Language", + "t.link": "Link", + "t.listing": "Listings", + "t.notes": "Notes", + "t.optional": "Optional", + "t.order": "Order", + "t.otherRelationShip": "Other Relationship", + "t.post": "Post", + "t.preview": "Preview", + "t.role": "Role", + "t.save": "Save", + "t.saveExit": "Save & Exit", + "t.saveNew": "Save & New", + "t.saved": "Saved", + "t.secondPhone": "Second Phone", + "t.startTime": "Start Time", + "t.submitNew": "Submit & New", + "t.uploadFile": "Upload File", + "t.url": "URL", + "t.view": "View", + "users.addPassword": "Add a Password", + "users.addUser": "Add User", + "users.administrator": "Administrator", + "users.allListings": "All listings", + "users.confirmAccount": "Confirm Account", + "users.confirmed": "Confirmed", + "users.doYouWantDeleteUser": "Do you really want to delete this user?", + "users.editUser": "Edit User", + "users.makeNote": "When creating your password make sure you make note of it so you remember it in the future.", + "users.needUniquePassword": "You'll need to add a unique password in order to confirm your account.", + "users.partner": "Partner", + "users.requestResend": "Request Resend", + "users.requestResendDescription": "Your token expired. You will need to have a new confirmation link sent to you.", + "users.requestResendExplanation": "Please enter your email, and we'll send you a new confirmation link", + "users.resendInvite": "Resend Invite", + "users.totalUsers": "total users", + "users.unconfirmed": "Unconfirmed", + "users.userDetails": "User Details" +} diff --git a/sites/partners/pages/_app.tsx b/sites/partners/pages/_app.tsx new file mode 100644 index 0000000000..ed632dcf52 --- /dev/null +++ b/sites/partners/pages/_app.tsx @@ -0,0 +1,78 @@ +import React, { useMemo } from "react" +import { SWRConfig } from "swr" +import type { AppProps } from "next/app" + +import "@bloom-housing/ui-components/src/global/css-imports.scss" +import "@bloom-housing/ui-components/src/global/app-css.scss" +import { + addTranslation, + ConfigProvider, + AuthProvider, + RequireLogin, + NavigationContext, + GenericRouter, +} from "@bloom-housing/ui-components" + +// TODO: Make these not-global +import "ag-grid-community/dist/styles/ag-grid.css" +import "ag-grid-community/dist/styles/ag-theme-alpine.css" + +import LinkComponent from "../src/LinkComponent" +import { translations, overrideTranslations } from "../src/translations" + +// Note: import overrides.scss last so that it overrides styles defined in imports above +import "../styles/overrides.scss" + +const signInMessage = "Login is required to view this page." + +function BloomApp({ Component, router, pageProps }: AppProps) { + const { locale } = router + const skipLoginRoutes = ["/forgot-password", "/reset-password", "/users/confirm"] + + useMemo(() => { + addTranslation(translations.general, true) + if (locale && locale !== "en" && translations[locale]) { + addTranslation(translations[locale]) + } + addTranslation(overrideTranslations.en) + if (overrideTranslations[locale]) { + addTranslation(overrideTranslations[locale]) + } + }, [locale]) + + return ( + { + const { status } = error.response || {} + if (status === 403) { + window.location.href = "/unauthorized" + } + }, + }} + > + + + + +
+ {typeof window === "undefined" ? null : } +
+
+
+
+
+
+ ) +} + +export default BloomApp diff --git a/sites/partners/pages/_error.tsx b/sites/partners/pages/_error.tsx new file mode 100644 index 0000000000..97af22a522 --- /dev/null +++ b/sites/partners/pages/_error.tsx @@ -0,0 +1,23 @@ +import Layout from "../layouts" +import Head from "next/head" +import { Hero, MarkdownSection, t } from "@bloom-housing/ui-components" + +const Error = () => { + const pageTitle = t("errors.notFound.title") + + return ( + + + {pageTitle} + + + {t("errors.notFound.message")} + +
+ An error has occurred. +
+
+ ) +} + +export default Error diff --git a/sites/partners/pages/api/coverage.ts b/sites/partners/pages/api/coverage.ts new file mode 100644 index 0000000000..54d64f3ebc --- /dev/null +++ b/sites/partners/pages/api/coverage.ts @@ -0,0 +1,8 @@ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const globalAny: any = global + +export default (req, res) => { + res.status(200).json({ + coverage: globalAny.__coverage__ || null, + }) +} diff --git a/sites/partners/pages/application/[id]/edit.tsx b/sites/partners/pages/application/[id]/edit.tsx new file mode 100644 index 0000000000..588487ee4e --- /dev/null +++ b/sites/partners/pages/application/[id]/edit.tsx @@ -0,0 +1,50 @@ +import React from "react" +import Head from "next/head" +import { useRouter } from "next/router" +import { PageHeader, t } from "@bloom-housing/ui-components" +import Layout from "../../../layouts" +import PaperApplicationForm from "../../../src/applications/PaperApplicationForm/PaperApplicationForm" +import { useSingleApplicationData } from "../../../lib/hooks" +import { ApplicationContext } from "../../../src/applications/ApplicationContext" + +const NewApplication = () => { + const router = useRouter() + const applicationId = router.query.id as string + + const { application } = useSingleApplicationData(applicationId) + + if (!application) return false + + return ( + + + + {t("nav.siteTitlePartners")} + + + +

+ {t("t.edit")}: {application.applicant.firstName} {application.applicant.lastName} +

+ +

+ {application.confirmationCode || application.id} +

+ + } + className={"md:pt-16"} + /> + + +
+
+ ) +} + +export default NewApplication diff --git a/sites/partners/pages/application/[id]/index.tsx b/sites/partners/pages/application/[id]/index.tsx new file mode 100644 index 0000000000..7529395283 --- /dev/null +++ b/sites/partners/pages/application/[id]/index.tsx @@ -0,0 +1,171 @@ +import React, { useMemo, useState, useContext } from "react" +import { useRouter } from "next/router" +import Head from "next/head" +import { + AppearanceStyleType, + PageHeader, + t, + Tag, + Button, + AuthContext, + AlertBox, + SiteAlert, +} from "@bloom-housing/ui-components" +import { useSingleApplicationData } from "../../../lib/hooks" + +import Layout from "../../../layouts" +import { ApplicationStatus } from "@bloom-housing/backend-core/types" +import { + DetailsMemberDrawer, + MembersDrawer, +} from "../../../src/applications/PaperApplicationDetails/DetailsMemberDrawer" + +import { ApplicationContext } from "../../../src/applications/ApplicationContext" +import { DetailsApplicationData } from "../../../src/applications/PaperApplicationDetails/sections/DetailsApplicationData" +import { DetailsPrimaryApplicant } from "../../../src/applications/PaperApplicationDetails/sections/DetailsPrimaryApplicant" +import { DetailsAlternateContact } from "../../../src/applications/PaperApplicationDetails/sections/DetailsAlternateContact" +import { DetailsHouseholdMembers } from "../../../src/applications/PaperApplicationDetails/sections/DetailsHouseholdMembers" +import { DetailsHouseholdDetails } from "../../../src/applications/PaperApplicationDetails/sections/DetailsHouseholdDetails" +import { DetailsPreferences } from "../../../src/applications/PaperApplicationDetails/sections/DetailsPreferences" +import { DetailsPrograms } from "../../../src/applications/PaperApplicationDetails/sections/DetailsPrograms" +import { DetailsHouseholdIncome } from "../../../src/applications/PaperApplicationDetails/sections/DetailsHouseholdIncome" +import { DetailsTerms } from "../../../src/applications/PaperApplicationDetails/sections/DetailsTerms" +import { Aside } from "../../../src/applications/Aside" + +export default function ApplicationsList() { + const router = useRouter() + const applicationId = router.query.id as string + const { application } = useSingleApplicationData(applicationId) + + const { applicationsService } = useContext(AuthContext) + const [errorAlert, setErrorAlert] = useState(false) + + const [membersDrawer, setMembersDrawer] = useState(null) + + async function deleteApplication() { + try { + await applicationsService.delete({ id: applicationId }) + void router.push(`/listings/${application?.listing?.id}/applications`) + } catch (err) { + setErrorAlert(true) + } + } + + const applicationStatus = useMemo(() => { + switch (application?.status) { + case ApplicationStatus.submitted: + return ( + + {t(`application.details.applicationStatus.submitted`)} + + ) + case ApplicationStatus.removed: + return ( + + {t(`application.details.applicationStatus.removed`)} + + ) + default: + return ( + + {t(`application.details.applicationStatus.draft`)} + + ) + } + }, [application]) + + if (!application) return null + + return ( + + + + {t("nav.siteTitlePartners")} + + + +

+ {application.applicant.firstName} {application.applicant.lastName} +

+ +

+ {application.confirmationCode || application.id} +

+ + } + > +
+ +
+
+
+
+ + +
{applicationStatus}
+
+
+ +
+
+ {errorAlert && ( + setErrorAlert(false)} + closeable + type="alert" + > + {t("authentication.signIn.errorGenericMessage")} + + )} + +
+
+ + + + + + + + + + + + + + + + + +
+ +
+
+
+
+
+
+ + +
+ ) +} diff --git a/sites/partners/pages/forgot-password.tsx b/sites/partners/pages/forgot-password.tsx new file mode 100644 index 0000000000..37d00ce9b2 --- /dev/null +++ b/sites/partners/pages/forgot-password.tsx @@ -0,0 +1,51 @@ +import React, { useContext } from "react" +import { useRouter } from "next/router" +import { useForm } from "react-hook-form" +import { + AuthContext, + t, + setSiteAlertMessage, + FormForgotPassword, +} from "@bloom-housing/ui-components" +import FormsLayout from "../layouts/forms" +import { useCatchNetworkError } from "@bloom-housing/shared-helpers" + +const ForgotPassword = () => { + const router = useRouter() + const { forgotPassword } = useContext(AuthContext) + + /* Form Handler */ + // This is causing a linting issue with unbound-method, see open issue as of 10/21/2020: + // https://github.com/react-hook-form/react-hook-form/issues/2887 + // eslint-disable-next-line @typescript-eslint/unbound-method + const { register, handleSubmit, errors } = useForm() + const { networkError, determineNetworkError, resetNetworkError } = useCatchNetworkError() + + const onSubmit = async (data: { email: string }) => { + const { email } = data + + try { + await forgotPassword(email) + setSiteAlertMessage(t(`authentication.forgotPassword.success`), "success") + await router.push("/") + } catch (error) { + const { status } = error.response || {} + determineNetworkError(status, error) + } + } + + return ( + + + + ) +} + +export { ForgotPassword as default, ForgotPassword } diff --git a/sites/partners/pages/index.tsx b/sites/partners/pages/index.tsx new file mode 100644 index 0000000000..d9b9ff55da --- /dev/null +++ b/sites/partners/pages/index.tsx @@ -0,0 +1,203 @@ +import React, { useMemo, useContext, useState } from "react" +import Head from "next/head" +import { + PageHeader, + t, + AuthContext, + Button, + LocalizedLink, + AgPagination, + AG_PER_PAGE_OPTIONS, + LoadingOverlay, +} from "@bloom-housing/ui-components" +import { AgGridReact } from "ag-grid-react" +import { GridOptions } from "ag-grid-community" + +import { useListingsData } from "../lib/hooks" +import Layout from "../layouts" +import { MetaTags } from "../src/MetaTags" +import { ListingStatus } from "@bloom-housing/backend-core/types" +class formatLinkCell { + link: HTMLAnchorElement + + init(params) { + this.link = document.createElement("a") + this.link.classList.add("text-blue-700") + this.link.setAttribute("href", `/listings/${params.data.id}/applications`) + this.link.innerText = params.valueFormatted || params.value + } + + getGui() { + return this.link + } +} + +class ApplicationsLink extends formatLinkCell { + init(params) { + super.init(params) + this.link.setAttribute("href", `/listings/${params.data.id}/applications`) + this.link.setAttribute("data-test-id", "listing-status-cell") + } +} + +class ListingsLink extends formatLinkCell { + init(params) { + super.init(params) + this.link.setAttribute("href", `/listings/${params.data.id}`) + } +} + +export default function ListingsList() { + const { profile } = useContext(AuthContext) + const isAdmin = profile.roles?.isAdmin || false + + /* Pagination */ + const [itemsPerPage, setItemsPerPage] = useState(AG_PER_PAGE_OPTIONS[0]) + const [currentPage, setCurrentPage] = useState(1) + + const metaDescription = t("pageDescription.welcome", { regionName: t("region.name") }) + const metaImage = "" // TODO: replace with hero image + + class formatWaitlistStatus { + text: HTMLSpanElement + + init({ data }) { + const isWaitlistOpen = data.waitlistCurrentSize < data.waitlistMaxSize + + this.text = document.createElement("span") + this.text.innerHTML = isWaitlistOpen ? t("t.yes") : t("t.no") + } + + getGui() { + return this.text + } + } + + const gridOptions: GridOptions = { + components: { + ApplicationsLink, + formatLinkCell, + formatWaitlistStatus, + ListingsLink, + }, + } + + const columnDefs = useMemo(() => { + const columns = [ + { + headerName: t("listings.listingName"), + field: "name", + sortable: false, + filter: false, + resizable: true, + cellRenderer: "ListingsLink", + }, + { + headerName: t("listings.buildingAddress"), + field: "buildingAddress.street", + sortable: false, + filter: false, + resizable: true, + flex: 1, + valueFormatter: ({ value }) => (value ? value : t("t.none")), + }, + { + headerName: t("listings.listingStatusText"), + field: "status", + sortable: true, + filter: false, + resizable: true, + flex: 1, + valueFormatter: ({ value }) => { + switch (value) { + case ListingStatus.active: + return t("t.public") + case ListingStatus.pending: + return t("t.draft") + case ListingStatus.closed: + return t("t.closed") + default: + return "" + } + }, + }, + { + headerName: t("listings.verified"), + field: "isVerified", + sortable: true, + filter: false, + resizable: true, + valueFormatter: ({ value }) => (value ? t("t.yes") : t("t.no")), + }, + ] + return columns + }, []) + + const { listingDtos, listingsLoading } = useListingsData({ + page: currentPage, + limit: itemsPerPage, + listingIds: !isAdmin + ? profile?.leasingAgentInListings?.map((listing) => listing.id) + : undefined, + }) + + return ( + + + {t("nav.siteTitlePartners")} + + + +
+
+
+
+
+
+ {isAdmin && ( + + + + )} +
+
+ +
+ + + + + setCurrentPage(1)} + includeBorder={true} + /> +
+
+
+
+
+ ) +} diff --git a/sites/partners/pages/listings/[id]/applications/add.tsx b/sites/partners/pages/listings/[id]/applications/add.tsx new file mode 100644 index 0000000000..9424c80d17 --- /dev/null +++ b/sites/partners/pages/listings/[id]/applications/add.tsx @@ -0,0 +1,29 @@ +import React from "react" +import Head from "next/head" +import { PageHeader, SiteAlert, t } from "@bloom-housing/ui-components" +import Layout from "../../../../layouts" +import PaperApplicationForm from "../../../../src/applications/PaperApplicationForm/PaperApplicationForm" +import { useRouter } from "next/router" + +const NewApplication = () => { + const router = useRouter() + const listingId = router.query.id as string + + return ( + + + {t("nav.siteTitlePartners")} + + + +
+ +
+
+ + +
+ ) +} + +export default NewApplication diff --git a/sites/partners/pages/listings/[id]/applications/index.tsx b/sites/partners/pages/listings/[id]/applications/index.tsx new file mode 100644 index 0000000000..63af89f450 --- /dev/null +++ b/sites/partners/pages/listings/[id]/applications/index.tsx @@ -0,0 +1,311 @@ +import React, { useState, useEffect, useRef, useMemo, useContext, useCallback } from "react" +import { useRouter } from "next/router" +import dayjs from "dayjs" +import Head from "next/head" +import { + Field, + t, + Button, + debounce, + LocalizedLink, + AuthContext, + SiteAlert, + setSiteAlertMessage, + AgPagination, + AG_PER_PAGE_OPTIONS, +} from "@bloom-housing/ui-components" +import { + useApplicationsData, + useSingleListingData, + useFlaggedApplicationsList, +} from "../../../../lib/hooks" +import { ApplicationSecondaryNav } from "../../../../src/applications/ApplicationSecondaryNav" +import Layout from "../../../../layouts" +import { useForm } from "react-hook-form" +import { AgGridReact } from "ag-grid-react" +import { getColDefs } from "../../../../src/applications/ApplicationsColDefs" +import { GridOptions, ColumnApi, ColumnState } from "ag-grid-community" +import { + EnumApplicationsApiExtraModelOrder, + EnumApplicationsApiExtraModelOrderBy, +} from "@bloom-housing/backend-core/types" + +type ApplicationsListSortOptions = { + orderBy: EnumApplicationsApiExtraModelOrderBy + order: EnumApplicationsApiExtraModelOrder +} + +const ApplicationsList = () => { + const COLUMN_STATE_KEY = "column-state" + + const { applicationsService } = useContext(AuthContext) + const router = useRouter() + // eslint-disable-next-line @typescript-eslint/unbound-method + const { register, watch } = useForm() + + const [gridColumnApi, setGridColumnApi] = useState(null) + + /* Filter input */ + const filterField = watch("filter-input", "") + const [delayedFilterValue, setDelayedFilterValue] = useState(filterField) + + /* Pagination */ + const [itemsPerPage, setItemsPerPage] = useState(AG_PER_PAGE_OPTIONS[0]) + const [currentPage, setCurrentPage] = useState(1) + + /* OrderBy columns */ + const [sortOptions, setSortOptions] = useState({ + orderBy: null, + order: null, + }) + + const listingId = router.query.id as string + const { appsData } = useApplicationsData( + currentPage, + itemsPerPage, + listingId, + delayedFilterValue, + sortOptions.orderBy, + sortOptions.order + ) + const { listingDto } = useSingleListingData(listingId) + const countyCode = listingDto?.countyCode + const listingName = listingDto?.name + + const { data: flaggedApps } = useFlaggedApplicationsList({ + listingId, + page: 1, + limit: 1, + }) + + /* CSV export */ + const [csvExportLoading, setCsvExportLoading] = useState(false) + const [csvExportError, setCsvExportError] = useState(false) + + const fetchFilteredResults = (value: string) => { + setDelayedFilterValue(value) + } + + // Load a table state on initial render & pagination change (because the new data comes from the API) + useEffect(() => { + const savedColumnState = sessionStorage.getItem(COLUMN_STATE_KEY) + + if (gridColumnApi && savedColumnState) { + const parsedState: ColumnState[] = JSON.parse(savedColumnState) + + gridColumnApi.applyColumnState({ + state: parsedState, + applyOrder: true, + }) + } + }, [gridColumnApi, currentPage]) + + function saveColumnState(api: ColumnApi) { + const columnState = api.getColumnState() + const columnStateJSON = JSON.stringify(columnState) + sessionStorage.setItem(COLUMN_STATE_KEY, columnStateJSON) + } + + function onGridReady(params) { + setGridColumnApi(params.columnApi) + } + + const debounceFilter = useRef(debounce((value: string) => fetchFilteredResults(value), 1000)) + + // reset page to 1 when user change limit + useEffect(() => { + setCurrentPage(1) + }, [itemsPerPage]) + + // fetch filtered data + useEffect(() => { + setCurrentPage(1) + debounceFilter.current(filterField) + }, [filterField]) + + const applications = appsData?.items || [] + const appsMeta = appsData?.meta + + const onExport = async () => { + setCsvExportError(false) + setCsvExportLoading(true) + + try { + const content = await applicationsService.listAsCsv({ + listingId, + }) + + const now = new Date() + const dateString = dayjs(now).format("YYYY-MM-DD_HH:mm:ss") + + const blob = new Blob([content], { type: "text/csv" }) + const fileLink = document.createElement("a") + fileLink.setAttribute("download", `applications-${listingId}-${dateString}.csv`) + fileLink.href = URL.createObjectURL(blob) + + fileLink.click() + } catch (err) { + setCsvExportError(true) + setSiteAlertMessage(err.response.data.error, "alert") + } + + setCsvExportLoading(false) + } + + // ag grid settings + class formatLinkCell { + linkWithId: HTMLSpanElement + + init(params) { + const applicationId = params.data.id + + this.linkWithId = document.createElement("button") + this.linkWithId.classList.add("text-blue-700") + this.linkWithId.innerText = params.value + + this.linkWithId.addEventListener("click", function () { + void saveColumnState(params.columnApi) + void router.push(`/application/${applicationId}`) + }) + } + + getGui() { + return this.linkWithId + } + } + + // update table items order on sort change + const initialLoadOnSort = useRef(false) + const onSortChange = useCallback((columns: ColumnState[]) => { + // prevent multiple fetch on initial render + if (!initialLoadOnSort.current) { + initialLoadOnSort.current = true + return + } + + const sortedBy = columns.find((col) => col.sort) + const { colId, sort } = sortedBy || {} + + const allowedSortColIds: string[] = Object.values(EnumApplicationsApiExtraModelOrderBy) + + if (allowedSortColIds.includes(colId)) { + const name = EnumApplicationsApiExtraModelOrderBy[colId] + + setSortOptions({ + orderBy: name, + order: sort.toUpperCase() as EnumApplicationsApiExtraModelOrder, + }) + } + }, []) + + const gridOptions: GridOptions = { + onSortChanged: (params) => { + saveColumnState(params.columnApi) + onSortChange(params.columnApi.getColumnState()) + }, + onColumnMoved: (params) => saveColumnState(params.columnApi), + components: { + formatLinkCell: formatLinkCell, + }, + } + + const defaultColDef = { + resizable: true, + maxWidth: 300, + } + + // get the highest value from householdSize and limit to 6 + const maxHouseholdSize = useMemo(() => { + let max = 1 + + appsData?.items.forEach((item) => { + if (item.householdSize > max) { + max = item.householdSize + } + }) + + return max < 6 ? max : 6 + }, [appsData]) + + const columnDefs = useMemo(() => { + return getColDefs(maxHouseholdSize, countyCode) + }, [maxHouseholdSize, countyCode]) + + if (!applications) return null + + return ( + + + {t("nav.siteTitlePartners")} + + + + {csvExportError && ( +
+ +
+ )} +
+ +
+
+
+
+
+ +
+ +
+ + + + + +
+
+ +
+ + + +
+
+
+
+
+ ) +} + +export default ApplicationsList diff --git a/sites/partners/pages/listings/[id]/edit.tsx b/sites/partners/pages/listings/[id]/edit.tsx new file mode 100644 index 0000000000..c24607a405 --- /dev/null +++ b/sites/partners/pages/listings/[id]/edit.tsx @@ -0,0 +1,75 @@ +import React from "react" +import Head from "next/head" +import axios from "axios" +import { PageHeader, t } from "@bloom-housing/ui-components" +import { Listing } from "@bloom-housing/backend-core/types" +import Layout from "../../../layouts" +import PaperListingForm from "../../../src/listings/PaperListingForm" +import { ListingContext } from "../../../src/listings/ListingContext" +import { MetaTags } from "../../../src/MetaTags" +import ListingGuard from "../../../src/ListingGuard" + +const EditListing = (props: { listing: Listing }) => { + const metaDescription = "" + const metaImage = "" // TODO: replace with hero image + + const { listing } = props + + if (!listing) return false + + /** + * purposely leaving out the assets fallback, so when this gets to production + * a user can easily see the old asset on the detail, but not here, so we can upload it properly (this should only apply to older listings) + */ + if (listing.images.length === 0) { + listing.images = [{ ordinal: 0, image: { fileId: "", label: "" } }] + } + + return ( + + + + + {t("nav.siteTitlePartners")} + + + + + +

+ {t("t.edit")}: {listing.name} +

+ +

{listing.id}

+ + } + className={"md:pt-16"} + /> + + +
+
+
+ ) +} + +export async function getServerSideProps(context: { params: Record }) { + let response + + try { + response = await axios.get(`${process.env.backendApiBase}/listings/${context.params.id}`) + } catch (e) { + console.log("e = ", e) + return { notFound: true } + } + + return { props: { listing: response.data } } +} + +export default EditListing diff --git a/sites/partners/pages/listings/[id]/flags/[flagId]/index.tsx b/sites/partners/pages/listings/[id]/flags/[flagId]/index.tsx new file mode 100644 index 0000000000..2ab40336cc --- /dev/null +++ b/sites/partners/pages/listings/[id]/flags/[flagId]/index.tsx @@ -0,0 +1,211 @@ +import React, { useMemo, useState, useCallback, useContext, useEffect } from "react" +import Head from "next/head" +import { useRouter } from "next/router" +import { AgGridReact } from "ag-grid-react" +import { GridApi, RowNode, GridOptions } from "ag-grid-community" + +import Layout from "../../../../../layouts/" +import { + t, + Button, + PageHeader, + AlertBox, + AppearanceStyleType, + useMutate, + AuthContext, + StatusBar, +} from "@bloom-housing/ui-components" +import { useSingleFlaggedApplication } from "../../../../../lib/hooks" +import { getCols } from "../../../../../src/flags/applicationsCols" +import { + EnumApplicationFlaggedSetStatus, + ApplicationFlaggedSet, +} from "@bloom-housing/backend-core/types" + +const Flag = () => { + const { applicationFlaggedSetsService } = useContext(AuthContext) + + const router = useRouter() + const flagsetId = router.query.flagId as string + const listingId = router.query.id as string + + const [gridApi, setGridApi] = useState(null) + const [selectedRows, setSelectedRows] = useState([]) + + const columns = useMemo(() => getCols(), []) + + const { data, revalidate } = useSingleFlaggedApplication(flagsetId) + + const { mutate, reset, isSuccess, isLoading, isError } = useMutate() + + const gridOptions: GridOptions = { + getRowNodeId: (data) => data.id, + } + + /* It selects all flagged rows on init and update (revalidate). */ + const selectFlaggedApps = useCallback(() => { + if (!data) return + + const duplicateIds = data.applications + .filter((item) => item.markedAsDuplicate) + .map((item) => item.id) + + gridApi.forEachNode((row) => { + if (duplicateIds.includes(row.id)) { + gridApi.selectNode(row, true) + } + }) + }, [data, gridApi]) + + useEffect(() => { + if (!gridApi) return + + selectFlaggedApps() + }, [data, gridApi, selectFlaggedApps]) + + const onGridReady = (params) => { + setGridApi(params.api) + } + + const onSelectionChanged = () => { + const selected = gridApi.getSelectedNodes() + setSelectedRows(selected) + } + + const deselectAll = useCallback(() => { + gridApi.deselectAll() + }, [gridApi]) + + const resolveFlag = useCallback(() => { + const applicationIds = selectedRows?.map((item) => ({ id: item.data.id })) || [] + + void reset() + + void mutate(() => + applicationFlaggedSetsService.resolve({ + body: { + afsId: flagsetId, + applications: applicationIds, + }, + }) + ).then(() => { + deselectAll() + void revalidate() + }) + }, [ + mutate, + reset, + revalidate, + deselectAll, + selectedRows, + applicationFlaggedSetsService, + flagsetId, + ]) + + if (!data) return null + + return ( + + + {t("nav.siteTitlePartners")} + + + +

{data.rule}

+ + } + /> + +
+ router.push(`/listings/${listingId}/flags`)} + > + {t("t.back")} + + } + tagStyle={ + data.status === EnumApplicationFlaggedSetStatus.resolved + ? AppearanceStyleType.success + : AppearanceStyleType.info + } + tagLabel={data.status} + /> +
+ +
+
+ {(isSuccess || isError) && ( + reset()} + > + {isSuccess ? "Updated" : t("account.settings.alerts.genericError")} + + )} + +
+
+ + +
+
+ +
+
+
+
+ +
+ + {t("flags.markedAsDuplicate", { + quantity: selectedRows.length, + })} + + + +
+
+
+
+ ) +} + +export default Flag diff --git a/sites/partners/pages/listings/[id]/flags/index.tsx b/sites/partners/pages/listings/[id]/flags/index.tsx new file mode 100644 index 0000000000..d62a383646 --- /dev/null +++ b/sites/partners/pages/listings/[id]/flags/index.tsx @@ -0,0 +1,85 @@ +import React, { useState, useMemo, useEffect } from "react" +import Head from "next/head" +import { useRouter } from "next/router" +import { AgGridReact } from "ag-grid-react" + +import { useFlaggedApplicationsList, useSingleListingData } from "../../../../lib/hooks" +import Layout from "../../../../layouts" +import { t, AgPagination, AG_PER_PAGE_OPTIONS } from "@bloom-housing/ui-components" +import { getFlagSetCols } from "../../../../src/flags/flagSetCols" +import { ApplicationSecondaryNav } from "../../../../src/applications/ApplicationSecondaryNav" + +const FlagsPage = () => { + const router = useRouter() + const listingId = router.query.id as string + + /* Pagination */ + const [itemsPerPage, setItemsPerPage] = useState(AG_PER_PAGE_OPTIONS[0]) + const [currentPage, setCurrentPage] = useState(1) + + // reset page to 1 when user change limit + useEffect(() => { + setCurrentPage(1) + }, [itemsPerPage]) + + const { listingDto } = useSingleListingData(listingId) + + const { data } = useFlaggedApplicationsList({ + listingId, + page: currentPage, + limit: itemsPerPage, + }) + + const listingName = listingDto?.name + + const defaultColDef = { + resizable: true, + maxWidth: 300, + } + + const columns = useMemo(() => getFlagSetCols(), []) + + if (!data) return null + + return ( + + + {t("nav.siteTitlePartners")} + + + + +
+
+
+ + + +
+
+
+
+ ) +} + +export default FlagsPage diff --git a/sites/partners/pages/listings/[id]/index.tsx b/sites/partners/pages/listings/[id]/index.tsx new file mode 100644 index 0000000000..20d7e3b8d7 --- /dev/null +++ b/sites/partners/pages/listings/[id]/index.tsx @@ -0,0 +1,174 @@ +import React, { useMemo, useState } from "react" +import { useRouter } from "next/router" +import Head from "next/head" +import axios from "axios" +import { + AppearanceStyleType, + PageHeader, + t, + Tag, + Button, + AlertBox, + SiteAlert, +} from "@bloom-housing/ui-components" +import { Listing, ListingStatus } from "@bloom-housing/backend-core/types" + +import ListingGuard from "../../../src/ListingGuard" +import Layout from "../../../layouts" +import Aside from "../../../src/listings/Aside" +import { ListingContext } from "../../../src/listings/ListingContext" +import DetailListingData from "../../../src/listings/PaperListingDetails/sections/DetailListingData" +import DetailListingIntro from "../../../src/listings/PaperListingDetails/sections/DetailListingIntro" +import DetailListingPhoto from "../../../src/listings/PaperListingDetails/sections/DetailListingPhoto" +import DetailBuildingDetails from "../../../src/listings/PaperListingDetails/sections/DetailBuildingDetails" +import DetailAdditionalDetails from "../../../src/listings/PaperListingDetails/sections/DetailAdditionalDetails" +import DetailAdditionalEligibility from "../../../src/listings/PaperListingDetails/sections/DetailAdditionalEligibility" +import DetailLeasingAgent from "../../../src/listings/PaperListingDetails/sections/DetailLeasingAgent" +import DetailAdditionalFees from "../../../src/listings/PaperListingDetails/sections/DetailAdditionalFees" +import { DetailUnits } from "../../../src/listings/PaperListingDetails/sections/DetailUnits" +import DetailUnitDrawer, { + UnitDrawer, +} from "../../../src/listings/PaperListingDetails/DetailsUnitDrawer" +import DetailBuildingFeatures from "../../../src/listings/PaperListingDetails/sections/DetailBuildingFeatures" +import DetailRankingsAndResults from "../../../src/listings/PaperListingDetails/sections/DetailRankingsAndResults" +import DetailApplicationTypes from "../../../src/listings/PaperListingDetails/sections/DetailApplicationTypes" +import DetailApplicationAddress from "../../../src/listings/PaperListingDetails/sections/DetailApplicationAddress" +import DetailApplicationDates from "../../../src/listings/PaperListingDetails/sections/DetailApplicationDates" +import DetailPrograms from "../../../src/listings/PaperListingDetails/sections/DetailPrograms" +import DetailVerification from "../../../src/listings/PaperListingDetails/sections/DetailVerification" + +interface ListingProps { + listing: Listing +} + +export default function ListingDetail(props: ListingProps) { + const router = useRouter() + /* const listingId = router.query.id as string + const { listingDto, listingLoading } = useSingleListingData(listingId) */ + const { listing } = props + const [errorAlert, setErrorAlert] = useState(false) + const [unitDrawer, setUnitDrawer] = useState(null) + + const listingStatus = useMemo(() => { + switch (listing?.status) { + case ListingStatus.active: + return ( + + {t(`listings.listingStatus.active`)} + + ) + case ListingStatus.closed: + return ( + + {t(`listings.listingStatus.closed`)} + + ) + default: + return ( + + {t(`listings.listingStatus.pending`)} + + ) + } + }, [listing?.status]) + + if (!listing) return null + + return ( + + + <> + + + {t("nav.siteTitlePartners")} + + + +

+ {listing.name} +

+ +

{listing.id}

+ + } + > +
+ +
+
+
+
+ + +
{listingStatus}
+
+
+ +
+
+ {errorAlert && ( + setErrorAlert(false)} + closeable + type="alert" + > + {t("authentication.signIn.errorGenericMessage")} + + )} + +
+
+ + + + + + + + + + + + + + + + +
+ +
+
+
+
+
+
+ + + +
+
+ ) +} + +export async function getServerSideProps(context: { params: Record }) { + let response + + try { + response = await axios.get(`${process.env.backendApiBase}/listings/${context.params.id}`) + } catch (e) { + console.log("e = ", e) + return { notFound: true } + } + + return { props: { listing: response.data } } +} diff --git a/sites/partners/pages/listings/add.tsx b/sites/partners/pages/listings/add.tsx new file mode 100644 index 0000000000..0e11fd3678 --- /dev/null +++ b/sites/partners/pages/listings/add.tsx @@ -0,0 +1,46 @@ +import React, { useContext, useEffect } from "react" +import Head from "next/head" +import { useRouter } from "next/router" +import { PageHeader, SiteAlert, t, AuthContext } from "@bloom-housing/ui-components" +import Layout from "../../layouts" +import PaperListingForm from "../../src/listings/PaperListingForm" +import { MetaTags } from "../../src/MetaTags" +import ListingGuard from "../../src/ListingGuard" + +const NewListing = () => { + const router = useRouter() + const metaDescription = "" + const metaImage = "" // TODO: replace with hero image + const { profile } = useContext(AuthContext) + + useEffect(() => { + if (!profile?.roles.isAdmin) { + void router.push("/") + } + }, [profile, router]) + + return ( + + + + {t("nav.siteTitlePartners")} + + + + +
+ +
+
+ + +
+
+ ) +} + +export default NewListing diff --git a/sites/partners/pages/reset-password.tsx b/sites/partners/pages/reset-password.tsx new file mode 100644 index 0000000000..e5f85a189d --- /dev/null +++ b/sites/partners/pages/reset-password.tsx @@ -0,0 +1,97 @@ +import React, { useState, useContext } from "react" +import { useRouter } from "next/router" +import { useForm } from "react-hook-form" +import { + AppearanceStyleType, + Button, + Field, + Form, + FormCard, + Icon, + AuthContext, + t, + AlertBox, + SiteAlert, + setSiteAlertMessage, +} from "@bloom-housing/ui-components" +import FormsLayout from "../layouts/forms" + +const ResetPassword = () => { + const router = useRouter() + const { token } = router.query + const { resetPassword } = useContext(AuthContext) + /* Form Handler */ + // This is causing a linting issue with unbound-method, see open issue as of 10/21/2020: + // https://github.com/react-hook-form/react-hook-form/issues/2887 + // eslint-disable-next-line @typescript-eslint/unbound-method + const { register, handleSubmit, errors } = useForm() + const [requestError, setRequestError] = useState() + + const onSubmit = async (data: { password: string; passwordConfirmation: string }) => { + const { password, passwordConfirmation } = data + + try { + const user = await resetPassword(token.toString(), password, passwordConfirmation) + setSiteAlertMessage(t(`authentication.signIn.success`, { name: user.firstName }), "success") + await router.push("/") + window.scrollTo(0, 0) + } catch (err) { + const { status, data } = err.response || {} + if (status === 400) { + setRequestError(`${t(`authentication.forgotPassword.errors.${data.message}`)}`) + } else { + console.error(err) + setRequestError(`${t("account.settings.alerts.genericError")}`) + } + } + } + + return ( + + +
+ +

{t("authentication.forgotPassword.changePassword")}

+
+ {requestError && ( + setRequestError(undefined)} type="alert"> + {requestError} + + )} + +
+
+ + + +
+ +
+ +
+
+
+ ) +} + +export { ResetPassword as default, ResetPassword } diff --git a/sites/partners/pages/sign-in.tsx b/sites/partners/pages/sign-in.tsx new file mode 100644 index 0000000000..5f7b7391ed --- /dev/null +++ b/sites/partners/pages/sign-in.tsx @@ -0,0 +1,212 @@ +import React, { useContext, useState, useRef, useEffect } from "react" +import { useForm } from "react-hook-form" +import { useRouter } from "next/router" +import { + useCatchNetworkError, + NetworkStatusType, + NetworkStatusContent, +} from "@bloom-housing/shared-helpers" +import { + AuthContext, + FormSignIn, + FormSignInMFAType, + FormSignInMFACode, + FormSignInAddPhone, + t, +} from "@bloom-housing/ui-components" +import FormsLayout from "../layouts/forms" +import { + EnumRequestMfaCodeMfaType, + EnumUserErrorExtraModelUserErrorMessages, +} from "@bloom-housing/backend-core/types" +import { + EnumRenderStep, + onSubmitEmailAndPassword, + onSubmitMfaType, + onSubmitMfaCodeWithPhone, + onSubmitMfaCode, +} from "../lib/signInHelpers" +import { ConfirmationModal } from "../src/ConfirmationModal" + +const SignIn = () => { + const { login, requestMfaCode } = useContext(AuthContext) + /* Form Handler */ + // eslint-disable-next-line @typescript-eslint/unbound-method + const { register, handleSubmit, errors, setValue, control, watch, reset } = useForm() + const { networkError, determineNetworkError, resetNetworkError } = useCatchNetworkError() + const router = useRouter() + const [email, setEmail] = useState(undefined) + const [password, setPassword] = useState(undefined) + const [mfaType, setMfaType] = useState(undefined) + const [renderStep, setRenderStep] = useState( + EnumRenderStep.emailAndPassword + ) + const [allowPhoneNumberEdit, setAllowPhoneNumberEdit] = useState(false) + const [phoneNumber, setPhoneNumber] = useState("") + + const emailValue = useRef({}) + emailValue.current = watch("email", "") + + const [confirmationStatusModal, setConfirmationStatusModal] = useState(false) + const [confirmationStatusMessage, setConfirmationStatusMessage] = useState<{ + message: NetworkStatusContent + type: NetworkStatusType + }>() + + useEffect(() => { + if ( + networkError?.error.response.data?.message === + EnumUserErrorExtraModelUserErrorMessages.accountNotConfirmed + ) { + setConfirmationStatusModal(true) + } + }, [networkError]) + + let formToRender: JSX.Element + + if (Object.keys(errors).length && !!networkError) { + resetNetworkError() + } + + if (renderStep === EnumRenderStep.emailAndPassword) { + const networkStatusContent = (() => { + // the confirmation modal is active, do not show any alert + if (confirmationStatusModal) return undefined + + // the confirmation form has been sent, show success or error + if (confirmationStatusMessage) return confirmationStatusMessage?.message + + // show default sign-in form network status + return networkError + })() + + const networkStatusType = (() => { + if (confirmationStatusModal) return undefined + if (confirmationStatusMessage) return confirmationStatusMessage?.type + return undefined + })() + + formToRender = ( + { + reset() + resetNetworkError() + setConfirmationStatusMessage(undefined) + }, + }} + /> + ) + } else if (renderStep === EnumRenderStep.mfaType) { + formToRender = ( + + ) + } else if (renderStep === EnumRenderStep.phoneNumber) { + formToRender = ( + + ) + } else if (renderStep === EnumRenderStep.enterCode) { + formToRender = ( + setRenderStep(EnumRenderStep.phoneNumber)} + /> + ) + } + + return ( + <> + { + setConfirmationStatusMessage({ + message: { + title: "", + description: t("authentication.createAccount.emailSent"), + }, + type: "success", + }) + setConfirmationStatusModal(false) + }} + onError={(err) => { + setConfirmationStatusMessage({ + message: { + title: t("errors.somethingWentWrong"), + description: t("authentication.signIn.errorGenericMessage"), + error: err, + }, + type: "alert", + }) + setConfirmationStatusModal(false) + }} + onClose={() => setConfirmationStatusModal(false)} + initialEmailValue={emailValue.current as string} + /> + {formToRender} + + ) +} + +export default SignIn diff --git a/sites/partners/pages/unauthorized.tsx b/sites/partners/pages/unauthorized.tsx new file mode 100644 index 0000000000..4858109761 --- /dev/null +++ b/sites/partners/pages/unauthorized.tsx @@ -0,0 +1,16 @@ +import Layout from "../layouts" +import Head from "next/head" +import { Hero, t } from "@bloom-housing/ui-components" + +export default () => { + const pageTitle = t("errors.unauthorized.title") + + return ( + + + {pageTitle} + + {t("errors.unauthorized.message")} + + ) +} diff --git a/sites/partners/pages/users/confirm.tsx b/sites/partners/pages/users/confirm.tsx new file mode 100644 index 0000000000..078ba3270f --- /dev/null +++ b/sites/partners/pages/users/confirm.tsx @@ -0,0 +1,28 @@ +import React from "react" +import Head from "next/head" +import Layout from "../../layouts" +import { t, SiteAlert } from "@bloom-housing/ui-components" +import { FormUserConfirm } from "../../src/users/FormUserConfirm" + +const ConfirmPage = () => { + return ( + + + {t("nav.siteTitlePartners")} + + +
+
+ + +
+ +
+ +
+
+
+ ) +} + +export { ConfirmPage as default, ConfirmPage } diff --git a/sites/partners/pages/users/index.tsx b/sites/partners/pages/users/index.tsx new file mode 100644 index 0000000000..b7b364d4fc --- /dev/null +++ b/sites/partners/pages/users/index.tsx @@ -0,0 +1,193 @@ +import React, { useMemo, useState } from "react" +import Head from "next/head" +import { AgGridReact } from "ag-grid-react" +import dayjs from "dayjs" +import { + PageHeader, + AgPagination, + Button, + t, + Drawer, + AG_PER_PAGE_OPTIONS, + SiteAlert, +} from "@bloom-housing/ui-components" +import { User } from "@bloom-housing/backend-core/types" +import Layout from "../../layouts" +import { useUserList, useListingsData } from "../../lib/hooks" +import { FormUserManage } from "../../src/users/FormUserManage" + +const defaultColDef = { + resizable: true, + maxWidth: 300, +} + +type UserDrawerValue = { + type: "add" | "edit" + user?: User +} + +const Users = () => { + /* Add user drawer */ + const [userDrawer, setUserDrawer] = useState(null) + + /* Ag Grid column definitions */ + const columns = useMemo(() => { + return [ + { + headerName: t("t.name"), + field: "", + valueGetter: ({ data }) => { + const { firstName, lastName } = data + return `${firstName} ${lastName}` + }, + cellRendererFramework: (params) => { + const user = params.data + return ( + + ) + }, + }, + { + headerName: t("t.email"), + field: "email", + }, + { + headerName: t("t.listing"), + field: "leasingAgentInListings", + valueFormatter: ({ value }) => { + return value.map((item) => item.name).join(", ") + }, + }, + { + headerName: t("t.role"), + field: "roles", + valueFormatter: ({ value }) => { + const { isAdmin, isPartner } = value || {} + + const roles = [] + + if (isAdmin) { + roles.push(t("users.administrator")) + } + + if (isPartner) { + roles.push(t("users.partner")) + } + + return roles.join(", ") + }, + }, + { + headerName: t("listings.details.createdDate"), + field: "createdAt", + valueFormatter: ({ value }) => dayjs(value).format("MM/DD/YYYY"), + }, + { + headerName: t("listings.unit.status"), + field: "confirmedAt", + valueFormatter: ({ value }) => (value ? t("users.confirmed") : t("users.unconfirmed")), + }, + ] + }, []) + + /* Pagination */ + const [itemsPerPage, setItemsPerPage] = useState(AG_PER_PAGE_OPTIONS[0]) + const [currentPage, setCurrentPage] = useState(1) + + /* Fetch user list */ + const { data: userList } = useUserList({ + page: currentPage, + limit: itemsPerPage, + }) + + /* Fetch listings */ + const { listingDtos } = useListingsData({ + limit: "all", + }) + + const resetPagination = () => { + setCurrentPage(1) + } + + if (!userList) return null + + return ( + + + {t("nav.siteTitlePartners")} + + + +
+ + +
+
+ +
+
+
+
+
+
+ +
+
+
+ + + +
+
+
+
+ + setUserDrawer(null)} + > + setUserDrawer(null)} + /> + +
+ ) +} + +export default Users diff --git a/sites/partners/postcss.config.js b/sites/partners/postcss.config.js new file mode 100644 index 0000000000..e04485a990 --- /dev/null +++ b/sites/partners/postcss.config.js @@ -0,0 +1,5 @@ +/*eslint-env node*/ + +module.exports = { + plugins: ["tailwindcss", "autoprefixer"], +} diff --git a/sites/partners/public/favicon.ico b/sites/partners/public/favicon.ico new file mode 100644 index 0000000000..d5f53ef79d Binary files /dev/null and b/sites/partners/public/favicon.ico differ diff --git a/sites/partners/public/images/arrow-down.png b/sites/partners/public/images/arrow-down.png new file mode 100644 index 0000000000..02b605ee91 Binary files /dev/null and b/sites/partners/public/images/arrow-down.png differ diff --git a/sites/partners/public/images/arrow-down.svg b/sites/partners/public/images/arrow-down.svg new file mode 100644 index 0000000000..dfc0949b02 --- /dev/null +++ b/sites/partners/public/images/arrow-down.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/sites/partners/public/images/check.png b/sites/partners/public/images/check.png new file mode 100755 index 0000000000..8f1fb0e871 Binary files /dev/null and b/sites/partners/public/images/check.png differ diff --git a/sites/partners/public/images/check.svg b/sites/partners/public/images/check.svg new file mode 100644 index 0000000000..442c604ecb --- /dev/null +++ b/sites/partners/public/images/check.svg @@ -0,0 +1,11 @@ + + + + + + diff --git a/sites/partners/public/images/detroit-logo-white.png b/sites/partners/public/images/detroit-logo-white.png new file mode 100644 index 0000000000..25416bba0e Binary files /dev/null and b/sites/partners/public/images/detroit-logo-white.png differ diff --git a/sites/partners/public/images/detroit-logo.png b/sites/partners/public/images/detroit-logo.png new file mode 100644 index 0000000000..047208fe65 Binary files /dev/null and b/sites/partners/public/images/detroit-logo.png differ diff --git a/sites/partners/public/images/favicon.ico b/sites/partners/public/images/favicon.ico new file mode 100644 index 0000000000..4965832f2c Binary files /dev/null and b/sites/partners/public/images/favicon.ico differ diff --git a/sites/partners/public/images/listing-eligibility.svg b/sites/partners/public/images/listing-eligibility.svg new file mode 100644 index 0000000000..b9f73cdb27 --- /dev/null +++ b/sites/partners/public/images/listing-eligibility.svg @@ -0,0 +1,1211 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/sites/partners/public/images/listing-features.svg b/sites/partners/public/images/listing-features.svg new file mode 100644 index 0000000000..6883882368 --- /dev/null +++ b/sites/partners/public/images/listing-features.svg @@ -0,0 +1,306 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/sites/partners/public/images/listing-legal.svg b/sites/partners/public/images/listing-legal.svg new file mode 100644 index 0000000000..5029834079 --- /dev/null +++ b/sites/partners/public/images/listing-legal.svg @@ -0,0 +1,370 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/sites/partners/public/images/listing-neighborhood.svg b/sites/partners/public/images/listing-neighborhood.svg new file mode 100644 index 0000000000..6b71ed1d4b --- /dev/null +++ b/sites/partners/public/images/listing-neighborhood.svg @@ -0,0 +1,1192 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/sites/partners/public/images/listing-process.svg b/sites/partners/public/images/listing-process.svg new file mode 100644 index 0000000000..15b3561092 --- /dev/null +++ b/sites/partners/public/images/listing-process.svg @@ -0,0 +1,572 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/sites/partners/public/images/logo_glyph.svg b/sites/partners/public/images/logo_glyph.svg new file mode 100644 index 0000000000..e55237f20c --- /dev/null +++ b/sites/partners/public/images/logo_glyph.svg @@ -0,0 +1,11 @@ + + + + logo_portal + Created with Sketch. + + + + + + \ No newline at end of file diff --git a/sites/partners/src/ConfirmationModal.tsx b/sites/partners/src/ConfirmationModal.tsx new file mode 100644 index 0000000000..080459cdcf --- /dev/null +++ b/sites/partners/src/ConfirmationModal.tsx @@ -0,0 +1,125 @@ +import { + AppearanceStyleType, + Button, + Modal, + t, + Form, + Field, + useMutate, + AuthContext, +} from "@bloom-housing/ui-components" +import { useRouter } from "next/router" +import { useEffect, useMemo, useContext } from "react" +import { useForm } from "react-hook-form" +import { emailRegex } from "../lib/helpers" + +export type ConfirmationModalProps = { + isOpen: boolean + initialEmailValue: string + onSuccess: () => void + onError: (error: any) => void + onClose: () => void +} + +const ConfirmationModal = ({ + isOpen, + initialEmailValue, + onClose, + onSuccess, + onError, +}: ConfirmationModalProps) => { + const router = useRouter() + const { userService } = useContext(AuthContext) + const { mutate, reset: resetMutate, isLoading } = useMutate() + + // eslint-disable-next-line @typescript-eslint/unbound-method + const { register, errors, reset, getValues, trigger } = useForm({ + defaultValues: useMemo(() => { + return { + emailResend: initialEmailValue, + } + }, [initialEmailValue]), + }) + + useEffect(() => { + reset({ + emailResend: initialEmailValue, + }) + }, [initialEmailValue, reset]) + + const onSubmit = async () => { + const isValid = await trigger() + if (!isValid) return + + const { emailResend } = getValues() + + void mutate( + () => + userService.resendPartnerConfirmation({ + body: { + email: emailResend, + appUrl: window.location.origin, + }, + }), + { + onSuccess, + onError, + } + ) + } + + return ( + { + void router.push("/") + onClose() + resetMutate() + window.scrollTo(0, 0) + }} + actions={[ + , + , + ]} + > + <> +
+ + + +

{t("authentication.createAccount.resendEmailInfo")}

+ +
+ ) +} + +export { ConfirmationModal as default, ConfirmationModal } diff --git a/sites/partners/src/LinkComponent.tsx b/sites/partners/src/LinkComponent.tsx new file mode 100644 index 0000000000..3cdc91661e --- /dev/null +++ b/sites/partners/src/LinkComponent.tsx @@ -0,0 +1,16 @@ +import { PropsWithChildren } from "react" +import { LinkProps } from "@bloom-housing/ui-components" +import Link from "next/link" + +const LinkComponent = (props: PropsWithChildren) => { + const anchorProps = { ...props } + delete anchorProps.href + + return ( + + + + ) +} + +export default LinkComponent diff --git a/sites/partners/src/ListingGuard.tsx b/sites/partners/src/ListingGuard.tsx new file mode 100644 index 0000000000..ed8384d851 --- /dev/null +++ b/sites/partners/src/ListingGuard.tsx @@ -0,0 +1,25 @@ +import React, { useContext } from "react" +import { useRouter } from "next/router" +import { AuthContext } from "@bloom-housing/ui-components" + +type AuthGuardProps = { + children: React.ReactElement +} + +const ListingGuard = ({ children }: AuthGuardProps) => { + const router = useRouter() + const listingId = router.query.id as string + + const { profile } = useContext(AuthContext) + + const leasingAgentInListingsIds = profile.leasingAgentInListings.map((item) => item.id) + const hasPrivileges = profile.roles?.isAdmin || leasingAgentInListingsIds.includes(listingId) + + if (hasPrivileges) { + return children + } + + return null +} + +export default ListingGuard diff --git a/sites/partners/src/MetaTags.tsx b/sites/partners/src/MetaTags.tsx new file mode 100644 index 0000000000..8e6ee4ec74 --- /dev/null +++ b/sites/partners/src/MetaTags.tsx @@ -0,0 +1,28 @@ +import * as React from "react" +import Head from "next/head" + +export interface MetaTagsProps { + title: string + image?: string + description: string +} + +const MetaTags = (props: MetaTagsProps) => { + return ( + + + + + + {props.image && } + + + + + {props.image && } + + + ) +} + +export { MetaTags as default, MetaTags } diff --git a/sites/partners/src/applications/ApplicationContext.ts b/sites/partners/src/applications/ApplicationContext.ts new file mode 100644 index 0000000000..69faeaa470 --- /dev/null +++ b/sites/partners/src/applications/ApplicationContext.ts @@ -0,0 +1,6 @@ +import React from "react" +import { Application } from "@bloom-housing/backend-core/types" + +export const ApplicationContext = React.createContext(null) + +ApplicationContext.displayName = "ApplicationContext" diff --git a/sites/partners/src/applications/ApplicationSecondaryNav.tsx b/sites/partners/src/applications/ApplicationSecondaryNav.tsx new file mode 100644 index 0000000000..cd470b0450 --- /dev/null +++ b/sites/partners/src/applications/ApplicationSecondaryNav.tsx @@ -0,0 +1,65 @@ +import React, { useMemo } from "react" +import { PageHeader, t, TabNav, TabNavItem, AppearanceSizeType } from "@bloom-housing/ui-components" +import { useRouter } from "next/router" + +type ApplicationSecondaryNavProps = { + title: string + listingId: string + flagsQty: number + children?: React.ReactChild +} + +const ApplicationSecondaryNav = ({ + title, + listingId, + flagsQty, + children, +}: ApplicationSecondaryNavProps) => { + const router = useRouter() + const currentPath = router?.asPath + + const tabNavElements = useMemo( + () => [ + { + label: t("nav.applications"), + path: `/listings/${listingId}/applications`, + }, + { + label: t("nav.flags"), + path: `/listings/${listingId}/flags`, + content: <>{flagsQty}, + }, + ], + [flagsQty, listingId] + ) + + const tabs = useMemo(() => { + return ( + + {tabNavElements.map((tab) => ( + + {tab.label} + + ))} + + ) + }, [currentPath, tabNavElements]) + + return ( + + {children} + + ) +} + +export { ApplicationSecondaryNav as default, ApplicationSecondaryNav } diff --git a/sites/partners/src/applications/ApplicationsColDefs.ts b/sites/partners/src/applications/ApplicationsColDefs.ts new file mode 100644 index 0000000000..ad29ed4b6a --- /dev/null +++ b/sites/partners/src/applications/ApplicationsColDefs.ts @@ -0,0 +1,590 @@ +import { t, formatYesNoLabel } from "@bloom-housing/ui-components" +import { IncomePeriod, ApplicationSubmissionType } from "@bloom-housing/backend-core/types" +import { convertDataToPst, formatIncome } from "../../lib/helpers" +import dayjs from "dayjs" +import customParseFormat from "dayjs/plugin/customParseFormat" +dayjs.extend(customParseFormat) + +function compareDates(a, b, node, nextNode, isInverted) { + const dateStringFormat = "MM/DD/YYYY at hh:mm:ss A" + + const dateA = dayjs(a, dateStringFormat) + const dateB = dayjs(b, dateStringFormat) + + if (a && b && dateA.isSame(dateB)) { + return 0 + } else if (a === "") { + return isInverted ? -1 : 1 + } else if (b === "") { + return isInverted ? 1 : -1 + } else { + return dateA.unix() - dateB.unix() + } +} + +function compareStrings(a, b, node, nextNode, isInverted) { + if (a === b) { + return 0 + } else if (a === null) { + return isInverted ? -1 : 1 + } else if (b === null) { + return isInverted ? 1 : -1 + } else { + return a.localeCompare(b) + } +} + +export function getColDefs(maxHouseholdSize: number, countyCode: string) { + const defs = [ + { + headerName: t("application.details.submittedDate"), + field: "submissionDate", + sortable: true, + unSortIcon: true, + filter: false, + pinned: "left", + width: 200, + minWidth: 150, + sort: "asc", + valueGetter: ({ data }) => { + if (!data?.submissionDate) return "" + + const { submissionDate } = data + + const dateTime = convertDataToPst( + submissionDate, + data?.submissionType || ApplicationSubmissionType.electronical + ) + + return `${dateTime.date} ${t("t.at")} ${dateTime.time}` + }, + comparator: compareDates, + }, + { + headerName: t("application.details.number"), + field: "confirmationCode", + sortable: false, + filter: false, + width: 150, + minWidth: 120, + pinned: "left", + cellRenderer: "formatLinkCell", + }, + { + headerName: t("applications.table.applicationType"), + field: "submissionType", + sortable: false, + unSortIcon: true, + filter: false, + width: 150, + minWidth: 120, + pinned: "left", + valueFormatter: ({ value }) => t(`application.details.submissionType.${value}`), + comparator: compareStrings, + }, + { + headerName: t("application.name.firstName"), + field: "applicant.firstName", + colId: "firstName", + sortable: true, + unSortIcon: true, + filter: false, + pinned: "left", + width: 125, + minWidth: 100, + comparator: compareStrings, + }, + { + headerName: t("application.name.lastName"), + field: "applicant.lastName", + colId: "lastName", + sortable: true, + unSortIcon: true, + filter: "agTextColumnFilter", + pinned: "left", + width: 125, + minWidth: 100, + comparator: compareStrings, + }, + { + headerName: t("application.details.householdSize"), + field: "householdSize", + sortable: false, + unSortIcon: true, + filter: false, + width: 140, + minWidth: 140, + type: "rightAligned", + }, + { + headerName: t("applications.table.declaredAnnualIncome"), + field: "income", + sortable: false, + unSortIcon: true, + filter: false, + width: 180, + minWidth: 150, + type: "rightAligned", + valueGetter: (row) => { + if (!row?.data?.income || !row?.data?.incomePeriod) return "" + + const { income, incomePeriod } = row.data + + return incomePeriod === IncomePeriod.perYear + ? formatIncome(income, incomePeriod, IncomePeriod.perYear) + : "" + }, + comparator: compareStrings, + }, + { + headerName: t("applications.table.declaredMonthlyIncome"), + field: "income", + sortable: false, + unSortIcon: true, + filter: false, + width: 180, + minWidth: 150, + type: "rightAligned", + valueGetter: (row) => { + if (!row?.data?.income || !row?.data?.incomePeriod) return "" + + const { income, incomePeriod } = row.data + + return incomePeriod === IncomePeriod.perMonth + ? formatIncome(income, incomePeriod, IncomePeriod.perMonth) + : "" + }, + comparator: compareStrings, + }, + { + headerName: t("applications.table.subsidyOrVoucher"), + field: "incomeVouchers", + sortable: false, + unSortIcon: true, + filter: false, + width: 120, + minWidth: 100, + valueFormatter: (data) => { + if (!data.value) return "" + + return data.value ? t("t.yes") : t("t.no") + }, + comparator: compareStrings, + }, + { + headerName: t("applications.table.requestAda"), + field: "accessibility", + sortable: false, + unSortIcon: true, + filter: true, + width: 120, + minWidth: 100, + valueGetter: (row) => { + if (!row?.data?.accessibility) return "" + + const { accessibility } = row.data + + const posiviveValues = Object.entries(accessibility).reduce((acc, curr) => { + if (curr[1] && !["id", "createdAt", "updatedAt"].includes(curr[0])) { + acc.push(t(`application.ada.${curr[0]}`)) + } + + return acc + }, []) + + return posiviveValues.length ? posiviveValues.join(", ") : t("t.none") + }, + }, + { + headerName: t("applications.table.preferenceClaimed"), + field: "preferences", + sortable: false, + unSortIcon: true, + filter: true, + width: 150, + minWidth: 100, + valueGetter: (row) => { + if (!row?.data?.preferences) return "" + + const { preferences } = row.data + + const claimed = preferences.reduce((acc, curr) => { + if (curr.claimed) { + acc.push(t(`application.preferences.${curr.key}.title`, { county: countyCode })) + } + + return acc + }, []) + + return claimed?.length ? claimed.join(", ") : t("t.none") + }, + }, + { + headerName: t("applications.table.primaryDob"), + field: "applicant", + sortable: false, + filter: false, + width: 150, + minWidth: 100, + valueFormatter: ({ value }) => { + if (!value) return "" + + const isValidDOB = !!value?.birthMonth && !!value?.birthDay && value?.birthYear + + return isValidDOB ? `${value.birthMonth}/${value.birthDay}/${value.birthYear}` : "" + }, + }, + { + headerName: t("t.email"), + field: "applicant.emailAddress", + sortable: false, + filter: false, + width: 150, + minWidth: 100, + }, + { + headerName: t("t.phone"), + field: "applicant.phoneNumber", + sortable: false, + filter: false, + width: 150, + minWidth: 100, + }, + { + headerName: t("applications.table.phoneType"), + field: "applicant.phoneNumberType", + sortable: false, + filter: false, + width: 150, + minWidth: 100, + valueFormatter: ({ value }) => { + if (!value) return "" + return t(`application.contact.phoneNumberTypes.${value}`) + }, + }, + { + headerName: t("t.additionalPhone"), + field: "additionalPhoneNumber", + sortable: false, + filter: false, + width: 150, + minWidth: 100, + valueFormatter: ({ value }) => { + if (!value) return "" + return value ? value : t("t.none") + }, + }, + { + headerName: t("applications.table.additionalPhoneType"), + field: "additionalPhoneNumberType", + sortable: false, + filter: false, + width: 150, + minWidth: 100, + valueFormatter: ({ value }) => { + if (!value) return "" + return value ? t(`application.contact.phoneNumberTypes.${value}`) : t("t.none") + }, + }, + { + headerName: t("applications.table.residenceStreet"), + field: "applicant.address.street", + sortable: false, + filter: false, + width: 175, + minWidth: 150, + }, + { + headerName: t("applications.table.residenceCity"), + field: "applicant.address.city", + sortable: false, + filter: false, + width: 150, + minWidth: 120, + }, + { + headerName: t("applications.table.residenceState"), + field: "applicant.address.state", + sortable: false, + filter: false, + width: 120, + minWidth: 100, + }, + { + headerName: t("applications.table.residenceZip"), + field: "applicant.address.zipCode", + sortable: false, + filter: false, + width: 120, + minWidth: 100, + }, + { + headerName: t("applications.table.mailingStreet"), + field: "mailingAddress.street", + sortable: false, + filter: false, + width: 175, + minWidth: 150, + valueFormatter: function ({ data, value }) { + if (!value) return "" + return `${data.sendMailToMailingAddress ? value : data.applicant.address.street}` + }, + }, + { + headerName: t("applications.table.mailingCity"), + field: "mailingAddress.city", + sortable: false, + filter: false, + width: 150, + minWidth: 120, + valueFormatter: function ({ data, value }) { + if (!value) return "" + + return `${data.sendMailToMailingAddress ? value : data.applicant.address.city}` + }, + }, + { + headerName: t("applications.table.mailingState"), + field: "mailingAddress.state", + sortable: false, + filter: false, + width: 120, + minWidth: 100, + valueFormatter: function ({ data, value }) { + if (!value) return "" + + return `${data.sendMailToMailingAddress ? value : data.applicant.address.state}` + }, + }, + { + headerName: t("applications.table.mailingZip"), + field: "mailingAddress.zipCode", + sortable: false, + filter: false, + width: 120, + minWidth: 100, + valueFormatter: function ({ data, value }) { + if (!value) return "" + + return `${data.sendMailToMailingAddress ? value : data.applicant.address.zipCode}` + }, + }, + { + headerName: t("applications.table.workStreet"), + field: "applicant.workAddress.street", + sortable: false, + filter: false, + width: 175, + minWidth: 150, + }, + { + headerName: t("applications.table.workCity"), + field: "applicant.workAddress.city", + sortable: false, + filter: false, + width: 150, + minWidth: 120, + }, + { + headerName: t("applications.table.workState"), + field: "applicant.workAddress.state", + sortable: false, + filter: false, + width: 120, + minWidth: 100, + }, + { + headerName: t("applications.table.workZip"), + field: "applicant.workAddress.zipCode", + sortable: false, + filter: false, + width: 120, + minWidth: 100, + }, + { + headerName: t("applications.table.altContactFirstName"), + field: "alternateContact.firstName", + sortable: false, + filter: false, + width: 125, + minWidth: 100, + }, + { + headerName: t("applications.table.altContactLastName"), + field: "alternateContact.lastName", + sortable: false, + filter: false, + width: 125, + minWidth: 100, + }, + { + headerName: t("applications.table.altContactRelationship"), + field: "alternateContact.type", + sortable: false, + filter: false, + width: 132, + minWidth: 132, + valueFormatter: ({ data, value }) => { + if (!value) return "" + + return value == "other" + ? data.alternateContact.otherType + : t(`application.alternateContact.type.options.${value}`) + }, + }, + { + headerName: t("applications.table.altContactAgency"), + field: "alternateContact.agency", + sortable: false, + filter: false, + width: 125, + minWidth: 100, + valueFormatter: ({ value }) => { + if (!value) return "" + return value?.length ? value : t("t.none") + }, + }, + { + headerName: t("applications.table.altContactEmail"), + field: "alternateContact.emailAddress", + sortable: false, + filter: false, + width: 150, + minWidth: 100, + }, + { + headerName: t("applications.table.altContactPhone"), + field: "alternateContact.phoneNumber", + sortable: false, + filter: false, + width: 150, + minWidth: 100, + }, + + { + headerName: t("applications.table.altContactStreetAddress"), + field: "alternateContact.mailingAddress.street", + sortable: false, + filter: false, + width: 150, + minWidth: 100, + }, + { + headerName: t("applications.table.altContactCity"), + field: "alternateContact.mailingAddress.city", + sortable: false, + filter: false, + width: 150, + minWidth: 100, + }, + { + headerName: t("applications.table.altContactState"), + field: "alternateContact.mailingAddress.state", + sortable: false, + filter: false, + width: 150, + minWidth: 100, + }, + { + headerName: t("applications.table.altContactZip"), + field: "alternateContact.mailingAddress.zipCode", + sortable: false, + filter: false, + width: 150, + minWidth: 100, + }, + ] + + const householdCols = [] + + for (let i = 0; i < maxHouseholdSize; i++) { + const householdIndex = i + 1 + + householdCols.push( + { + headerName: `${t("application.name.firstName")} HH:${householdIndex}`, + field: "householdMembers", + sortable: false, + filter: false, + width: 125, + minWidth: 100, + valueFormatter: ({ value }) => { + if (!value) return "" + + return value[i]?.firstName ? value[i].firstName : "" + }, + }, + { + headerName: `${t("application.name.lastName")} HH:${householdIndex}`, + field: "householdMembers", + sortable: false, + filter: false, + width: 125, + minWidth: 100, + valueFormatter: ({ value }) => { + if (!value) return "" + + return value[i]?.lastName ? value[i].lastName : "" + }, + }, + { + headerName: `${t("applications.table.householdDob")} HH:${householdIndex}`, + field: "householdMembers", + sortable: false, + filter: false, + width: 125, + minWidth: 100, + valueFormatter: ({ value }) => { + if (!value) return "" + + const isValidDOB = !!value[i]?.birthMonth && !!value[i]?.birthDay && value[i]?.birthYear + + return isValidDOB + ? `${value[i].birthMonth}/${value[i].birthDay}/${value[i].birthYear}` + : "" + }, + }, + { + headerName: `${t("t.relationship")} HH:${householdIndex}`, + field: "householdMembers", + sortable: false, + filter: false, + width: 132, + minWidth: 132, + valueFormatter: ({ value }) => { + if (!value) return "" + + return value[i]?.relationship + ? t(`application.form.options.relationship.${value[i].relationship}`) + : "" + }, + }, + { + headerName: `${t("application.add.sameAddressAsPrimary")} HH:${householdIndex}`, + field: "householdMembers", + sortable: false, + filter: false, + width: 125, + minWidth: 100, + valueFormatter: ({ value }) => { + if (!value) return "" + return formatYesNoLabel(value[i]?.sameAddress) + }, + }, + { + headerName: `${t("application.details.workInRegion")} HH:${householdIndex}`, + field: "householdMembers", + sortable: false, + filter: false, + width: 125, + minWidth: 100, + valueFormatter: ({ value }) => { + if (!value) return "" + return formatYesNoLabel(value[i]?.workInRegion) + }, + } + ) + } + + return [...defs, ...householdCols] +} diff --git a/sites/partners/src/applications/Aside.tsx b/sites/partners/src/applications/Aside.tsx new file mode 100644 index 0000000000..43e4a35a69 --- /dev/null +++ b/sites/partners/src/applications/Aside.tsx @@ -0,0 +1,169 @@ +import React, { useContext, useMemo, useState } from "react" +import dayjs from "dayjs" +import { + t, + StatusAside, + Button, + GridCell, + AppearanceStyleType, + StatusMessages, + LocalizedLink, + Modal, + AppearanceBorderType, + LinkButton, +} from "@bloom-housing/ui-components" +import { ApplicationContext } from "./ApplicationContext" + +type AsideProps = { + type: AsideType + listingId: string + onDelete: () => void + triggerSubmitAndRedirect?: () => void +} + +type AsideType = "add" | "edit" | "details" + +const Aside = ({ listingId, type, onDelete, triggerSubmitAndRedirect }: AsideProps) => { + const application = useContext(ApplicationContext) + const [deleteModal, setDeleteModal] = useState(false) + + const applicationId = application?.id + + const applicationUpdated = useMemo(() => { + if (!application) return null + + const dayjsDate = dayjs(application.updatedAt) + + return dayjsDate.format("MMMM DD, YYYY") + }, [application]) + + const actions = useMemo(() => { + const elements = [] + + const cancel = ( + + + {t("t.cancel")} + + + ) + + if (type === "details") { + elements.push( + + + + + , + + + + ) + } + + if (type === "add" || type === "edit") { + elements.push( + + + + ) + + if (type === "add") { + elements.push( + + + , + cancel + ) + } + + if (type === "edit") { + elements.push( +
+ {cancel} + + + +
+ ) + } + } + + return elements + }, [applicationId, listingId, triggerSubmitAndRedirect, type]) + + return ( + <> + + {type === "edit" && } + + + setDeleteModal(false)} + actions={[ + , + , + ]} + > + {t("application.deleteApplicationDescription")} + + + ) +} + +export { Aside as default, Aside } diff --git a/sites/partners/src/applications/PaperApplicationDetails/DetailsAddressColumns.tsx b/sites/partners/src/applications/PaperApplicationDetails/DetailsAddressColumns.tsx new file mode 100644 index 0000000000..624ce6d886 --- /dev/null +++ b/sites/partners/src/applications/PaperApplicationDetails/DetailsAddressColumns.tsx @@ -0,0 +1,128 @@ +import { t, GridCell, ViewItem } from "@bloom-housing/ui-components" +import { + Application, + HouseholdMemberUpdate, + AddressCreate, +} from "@bloom-housing/backend-core/types" +import { YesNoAnswer } from "../PaperApplicationForm/FormTypes" + +type DetailsAddressColumnsProps = { + type: AddressColsType + application?: Application + addressObject?: AddressCreate + householdMember?: HouseholdMemberUpdate + dataTestId?: string +} + +export enum AddressColsType { + "residence" = "residence", + "mailing" = "mailing", + "work" = "work", + "alternateAddress" = "alternateAddress", + "memberResidence" = "memberResidence", + "memberWork" = "memberWork", + "preferences" = "preferences", +} + +const DetailsAddressColumns = ({ + type, + application, + addressObject, + householdMember, + dataTestId, +}: DetailsAddressColumnsProps) => { + const address = { + city: "", + state: "", + street: "", + street2: "", + zipCode: "", + } + + Object.keys(address).forEach((item) => { + if (type === AddressColsType.residence) { + address[item] = application.applicant.address[item] || t("t.n/a") + } + + if (type === AddressColsType.mailing) { + if (application.sendMailToMailingAddress) { + address[item] = application.mailingAddress[item] + } else { + address[item] = application.applicant.address[item] || t("t.n/a") + } + } + + if (type === AddressColsType.work) { + if (application.applicant.workInRegion === YesNoAnswer.Yes) { + address[item] = application.applicant.workAddress[item] || t("t.n/a") + } else { + address[item] = t("t.n/a") + } + } + + if (type === AddressColsType.alternateAddress) { + address[item] = application.alternateContact.mailingAddress[item] + ? application.alternateContact.mailingAddress[item] + : t("t.n/a") + } + + if (type === AddressColsType.memberWork) { + address[item] = householdMember?.workAddress[item] + ? householdMember?.workAddress[item] + : t("t.n/a") + } + + if (type === AddressColsType.memberResidence) { + if (householdMember?.sameAddress === "yes") { + address[item] = application.applicant.address[item] + ? application.applicant.address[item] + : t("t.n/a") + } else { + address[item] = householdMember?.address[item] ? householdMember?.address[item] : t("t.n/a") + } + } + + if (type === AddressColsType.preferences && addressObject) { + address[item] = addressObject[item] ? addressObject[item] : t("t.n/a") + } + }) + + return ( + <> + + + {address.street} + + + + + + {address.street2} + + + + + + {address.city} + + + + + + {address.state} + + + + + + {address.zipCode} + + + + ) +} + +export { DetailsAddressColumns as default, DetailsAddressColumns } diff --git a/sites/partners/src/applications/PaperApplicationDetails/DetailsMemberDrawer.tsx b/sites/partners/src/applications/PaperApplicationDetails/DetailsMemberDrawer.tsx new file mode 100644 index 0000000000..054a374eef --- /dev/null +++ b/sites/partners/src/applications/PaperApplicationDetails/DetailsMemberDrawer.tsx @@ -0,0 +1,128 @@ +import React from "react" +import { + AppearanceStyleType, + t, + GridSection, + ViewItem, + Button, + Drawer, +} from "@bloom-housing/ui-components" +import { AddressColsType, DetailsAddressColumns } from "./DetailsAddressColumns" +import { Application, HouseholdMemberUpdate } from "@bloom-housing/backend-core/types" +import { YesNoAnswer } from "../PaperApplicationForm/FormTypes" + +export type MembersDrawer = HouseholdMemberUpdate | null + +type DetailsMemberDrawerProps = { + application: Application + membersDrawer: MembersDrawer + setMembersDrawer: (member: MembersDrawer) => void +} + +const DetailsMemberDrawer = ({ + application, + membersDrawer, + setMembersDrawer, +}: DetailsMemberDrawerProps) => { + return ( + setMembersDrawer(null)} + > +
+ + + + + + + + + + + + + + + + + + {!(membersDrawer?.sameAddress === YesNoAnswer.Yes) && ( + + + + )} + + {membersDrawer?.workInRegion === YesNoAnswer.Yes && ( + + + + )} + + +
+ + +
+ ) +} + +export { DetailsMemberDrawer as default, DetailsMemberDrawer } diff --git a/sites/partners/src/applications/PaperApplicationDetails/sections/DetailsAlternateContact.tsx b/sites/partners/src/applications/PaperApplicationDetails/sections/DetailsAlternateContact.tsx new file mode 100644 index 0000000000..a5b686d3cd --- /dev/null +++ b/sites/partners/src/applications/PaperApplicationDetails/sections/DetailsAlternateContact.tsx @@ -0,0 +1,76 @@ +import React, { useContext } from "react" +import { t, GridSection, ViewItem, GridCell } from "@bloom-housing/ui-components" +import { ApplicationContext } from "../../ApplicationContext" +import { DetailsAddressColumns, AddressColsType } from "../DetailsAddressColumns" + +const DetailsAlternateContact = () => { + const application = useContext(ApplicationContext) + + return ( + + + + + {application.alternateContact.firstName || t("t.n/a")} + + + + + + {application.alternateContact.lastName || t("t.n/a")} + + + + + + {(() => { + if (!application.alternateContact.type) return t("t.n/a") + + if (application.alternateContact.otherType) + return application.alternateContact.otherType + + return t( + `application.alternateContact.type.options.${application.alternateContact.type}` + ) + })()} + + + + { + + + {application.alternateContact.agency || t("t.n/a")} + + + } + + + + {application.alternateContact.emailAddress || t("t.n/a")} + + + + + + {application.alternateContact.phoneNumber || t("t.n/a")} + + + + + + + + + ) +} + +export { DetailsAlternateContact as default, DetailsAlternateContact } diff --git a/sites/partners/src/applications/PaperApplicationDetails/sections/DetailsApplicationData.tsx b/sites/partners/src/applications/PaperApplicationDetails/sections/DetailsApplicationData.tsx new file mode 100644 index 0000000000..7d5f7fc2ff --- /dev/null +++ b/sites/partners/src/applications/PaperApplicationDetails/sections/DetailsApplicationData.tsx @@ -0,0 +1,74 @@ +import React, { useContext, useMemo } from "react" +import { t, GridSection, ViewItem, GridCell } from "@bloom-housing/ui-components" +import { ApplicationContext } from "../../ApplicationContext" +import { convertDataToPst } from "../../../../lib/helpers" +import { ApplicationSubmissionType } from "@bloom-housing/backend-core/types" + +const DetailsApplicationData = () => { + const application = useContext(ApplicationContext) + + const applicationDate = useMemo(() => { + if (!application) return null + + return convertDataToPst( + application?.submissionDate, + application?.submissionType || ApplicationSubmissionType.electronical + ) + }, [application]) + + return ( + + + + {application.confirmationCode || application.id} + + + + {application.submissionType && ( + + + {t(`application.details.submissionType.${application.submissionType}`)} + + + )} + + + + {applicationDate.date} + + + + + + {applicationDate.time} + + + + + + {application.language ? t(`languages.${application.language}`) : t("t.n/a")} + + + + + + {!application.householdSize ? 1 : application.householdSize} + + + + + + {application.applicant.firstName && application.applicant.lastName + ? `${application.applicant.firstName} ${application.applicant.lastName}` + : t("t.n/a")} + + + + ) +} + +export { DetailsApplicationData as default, DetailsApplicationData } diff --git a/sites/partners/src/applications/PaperApplicationDetails/sections/DetailsHouseholdDetails.tsx b/sites/partners/src/applications/PaperApplicationDetails/sections/DetailsHouseholdDetails.tsx new file mode 100644 index 0000000000..29414d0909 --- /dev/null +++ b/sites/partners/src/applications/PaperApplicationDetails/sections/DetailsHouseholdDetails.tsx @@ -0,0 +1,78 @@ +import React, { useContext, Fragment } from "react" +import { t, GridSection, ViewItem, GridCell } from "@bloom-housing/ui-components" +import { sortUnitTypes } from "@bloom-housing/shared-helpers" +import { ApplicationContext } from "../../ApplicationContext" + +const DetailsHouseholdDetails = () => { + const application = useContext(ApplicationContext) + + const accessibilityLabels = (accessibility) => { + const labels = [] + if (accessibility.mobility) labels.push(t("application.ada.mobility")) + if (accessibility.vision) labels.push(t("application.ada.vision")) + if (accessibility.hearing) labels.push(t("application.ada.hearing")) + if (labels.length === 0) labels.push(t("t.no")) + + return labels + } + + const preferredUnits = sortUnitTypes(application?.preferredUnit) + + return ( + + + + {(() => { + if (!preferredUnits.length) return t("t.n/a") + + return preferredUnits?.map((item) => ( + + {t(`application.household.preferredUnit.options.${item.name}`)} +
+
+ )) + })()} +
+
+ + + + {accessibilityLabels(application.accessibility).map((item) => ( + + {item} +
+
+ ))} +
+
+ + + {application.householdExpectingChanges ? t("t.yes") : t("t.no")} + + + + + + {application.householdStudent ? t("t.yes") : t("t.no")} + + +
+ ) +} + +export { DetailsHouseholdDetails as default, DetailsHouseholdDetails } diff --git a/sites/partners/src/applications/PaperApplicationDetails/sections/DetailsHouseholdIncome.tsx b/sites/partners/src/applications/PaperApplicationDetails/sections/DetailsHouseholdIncome.tsx new file mode 100644 index 0000000000..245905ff69 --- /dev/null +++ b/sites/partners/src/applications/PaperApplicationDetails/sections/DetailsHouseholdIncome.tsx @@ -0,0 +1,57 @@ +import React, { useContext } from "react" +import { t, GridSection, ViewItem, GridCell } from "@bloom-housing/ui-components" +import { ApplicationContext } from "../../ApplicationContext" +import { IncomePeriod } from "@bloom-housing/backend-core/types" +import { formatIncome } from "../../../../lib/helpers" + +const DetailsHouseholdIncome = () => { + const application = useContext(ApplicationContext) + + return ( + + + + {application.incomePeriod === IncomePeriod.perYear + ? formatIncome( + parseFloat(application.income), + application.incomePeriod, + IncomePeriod.perYear + ) + : t("t.n/a")} + + + + + + {application.incomePeriod === IncomePeriod.perMonth + ? formatIncome( + parseFloat(application.income), + application.incomePeriod, + IncomePeriod.perMonth + ) + : t("t.n/a")} + + + + + + {(() => { + if (application.incomeVouchers === null) return t("t.n/a") + + if (application.incomeVouchers) { + return t("t.yes") + } + + return t("t.no") + })()} + + + + ) +} + +export { DetailsHouseholdIncome as default, DetailsHouseholdIncome } diff --git a/sites/partners/src/applications/PaperApplicationDetails/sections/DetailsHouseholdMembers.tsx b/sites/partners/src/applications/PaperApplicationDetails/sections/DetailsHouseholdMembers.tsx new file mode 100644 index 0000000000..4600d00883 --- /dev/null +++ b/sites/partners/src/applications/PaperApplicationDetails/sections/DetailsHouseholdMembers.tsx @@ -0,0 +1,74 @@ +import React, { useContext, useMemo } from "react" +import { t, GridSection, MinimalTable, Button } from "@bloom-housing/ui-components" +import { ApplicationContext } from "../../ApplicationContext" +import { MembersDrawer } from "../DetailsMemberDrawer" +import { YesNoAnswer } from "../../PaperApplicationForm/FormTypes" + +type DetailsHouseholdMembersProps = { + setMembersDrawer: (member: MembersDrawer) => void +} + +const DetailsHouseholdMembers = ({ setMembersDrawer }: DetailsHouseholdMembersProps) => { + const application = useContext(ApplicationContext) + + const householdMembersHeaders = { + name: t("t.name"), + relationship: t("t.relationship"), + birth: t("application.household.member.dateOfBirth"), + sameResidence: t("application.add.sameResidence"), + workInRegion: t("application.details.workInRegion"), + action: "", + } + + const householdMembersData = useMemo(() => { + const checkAvailablility = (property) => { + if (property === YesNoAnswer.Yes) { + return t("t.yes") + } else if (property === "no") { + return t("t.no") + } + + return t("t.n/a") + } + return application?.householdMembers?.map((item) => ({ + name: `${item.firstName} ${item.middleName} ${item.lastName}`, + relationship: item.relationship + ? t(`application.form.options.relationship.${item.relationship}`) + : t("t.n/a"), + birth: + item.birthMonth && item.birthDay && item.birthYear + ? `${item.birthMonth}/${item.birthDay}/${item.birthYear}` + : t("t.n/a"), + sameResidence: checkAvailablility(item.sameAddress), + workInRegion: checkAvailablility(item.workInRegion), + action: ( + + ), + })) + }, [application, setMembersDrawer]) + + return ( + + {application.householdSize >= 1 ? ( + + ) : ( + {t("t.none")} + )} + + ) +} + +export { DetailsHouseholdMembers as default, DetailsHouseholdMembers } diff --git a/sites/partners/src/applications/PaperApplicationDetails/sections/DetailsPreferences.tsx b/sites/partners/src/applications/PaperApplicationDetails/sections/DetailsPreferences.tsx new file mode 100644 index 0000000000..407d801d2a --- /dev/null +++ b/sites/partners/src/applications/PaperApplicationDetails/sections/DetailsPreferences.tsx @@ -0,0 +1,111 @@ +import React, { useContext, useMemo } from "react" +import { t, GridSection, ViewItem, GridCell } from "@bloom-housing/ui-components" +import { ApplicationContext } from "../../ApplicationContext" +import { InputType, AddressCreate } from "@bloom-housing/backend-core/types" +import { DetailsAddressColumns, AddressColsType } from "../DetailsAddressColumns" +import { useSingleListingData } from "../../../../lib/hooks" + +type DetailsPreferencesProps = { + listingId: string +} + +const DetailsPreferences = ({ listingId }: DetailsPreferencesProps) => { + const { listingDto } = useSingleListingData(listingId) + + const application = useContext(ApplicationContext) + + const listingPreferences = listingDto?.listingPreferences + const preferences = application?.preferences + + const hasMetaData = useMemo(() => { + return !!listingPreferences?.filter( + (listingPreference) => listingPreference.preference?.formMetadata + )?.length + }, [listingPreferences]) + + if (!hasMetaData) { + return null + } + + return ( + + {listingPreferences?.map((listingPreference) => { + const metaKey = listingPreference?.preference?.formMetadata?.key + const optionDetails = preferences.find((item) => item.key === metaKey) + + return ( + + + {(() => { + if (!optionDetails?.claimed) return t("t.none") + + const options = optionDetails.options.filter((option) => option.checked) + + return options.map((option) => { + const extra = option.extraData?.map((extra) => { + if (extra.type === InputType.text) + return ( + + {extra.value} + + ) + + if (extra.type === InputType.boolean) + return ( + + {extra.value ? t("t.yes") : t("t.no")} + + ) + + if (extra.type === InputType.address) + return ( + + + + ) + }) + + return ( +
+

+ {t(`application.preferences.${metaKey}.${option.key}.label`, { + county: listingDto?.countyCode, + })} +

+
{extra}
+
+ ) + }) + })()} +
+
+ ) + })} +
+ ) +} + +export { DetailsPreferences as default, DetailsPreferences } diff --git a/sites/partners/src/applications/PaperApplicationDetails/sections/DetailsPrimaryApplicant.tsx b/sites/partners/src/applications/PaperApplicationDetails/sections/DetailsPrimaryApplicant.tsx new file mode 100644 index 0000000000..028ce5cd0d --- /dev/null +++ b/sites/partners/src/applications/PaperApplicationDetails/sections/DetailsPrimaryApplicant.tsx @@ -0,0 +1,135 @@ +import React, { useContext } from "react" +import { t, GridSection, ViewItem, GridCell } from "@bloom-housing/ui-components" +import { ApplicationContext } from "../../ApplicationContext" +import { DetailsAddressColumns, AddressColsType } from "../DetailsAddressColumns" +import { YesNoAnswer } from "../../PaperApplicationForm/FormTypes" + +const DetailsPrimaryApplicant = () => { + const application = useContext(ApplicationContext) + + return ( + + + + + {application.applicant.firstName || t("t.n/a")} + + + + + + {application.applicant.middleName || t("t.n/a")} + + + + + + {application.applicant.lastName || t("t.n/a")} + + + + + + {(() => { + const { birthMonth, birthDay, birthYear } = application?.applicant + + if (birthMonth && birthDay && birthYear) { + return `${birthMonth}/${birthDay}/${birthYear}` + } + + return t("t.n/a") + })()} + + + + + + {application.applicant.emailAddress || t("t.n/a")} + + + + + + {application.applicant.phoneNumber || t("t.n/a")} + + + + + + {application.additionalPhoneNumber || t("t.n/a")} + + + + + + {(() => { + if (!application.contactPreferences.length) return t("t.n/a") + + return application.contactPreferences.map((item) => ( + + {t(`t.${item}`)} +
+
+ )) + })()} +
+
+ + + + {(() => { + if (!application.applicant.workInRegion) return t("t.n/a") + + return application.applicant.workInRegion === YesNoAnswer.Yes ? t("t.yes") : t("t.no") + })()} + + +
+ + + + + + + + + + + + +
+ ) +} + +export { DetailsPrimaryApplicant as default, DetailsPrimaryApplicant } diff --git a/sites/partners/src/applications/PaperApplicationDetails/sections/DetailsPrograms.tsx b/sites/partners/src/applications/PaperApplicationDetails/sections/DetailsPrograms.tsx new file mode 100644 index 0000000000..80d9bf54db --- /dev/null +++ b/sites/partners/src/applications/PaperApplicationDetails/sections/DetailsPrograms.tsx @@ -0,0 +1,69 @@ +import React, { useContext } from "react" +import { t, GridSection, ViewItem, GridCell } from "@bloom-housing/ui-components" +import { ApplicationContext } from "../../ApplicationContext" +import { useSingleListingData } from "../../../../lib/hooks" + +type DetailsProgramsProps = { + listingId: string +} + +const DetailsPrograms = ({ listingId }: DetailsProgramsProps) => { + const { listingDto } = useSingleListingData(listingId) + + const application = useContext(ApplicationContext) + + const listingPrograms = listingDto?.listingPrograms + const programs = application?.programs + + const hasMetaData = !!listingPrograms?.some( + (listingProgram) => listingProgram.program?.formMetadata + ) + + if (!hasMetaData) { + return null + } + + return ( + + {listingPrograms?.map((listingProgram) => { + const metaKey = listingProgram?.program?.formMetadata?.key + const optionDetails = programs?.find((item) => item.key === metaKey) + + return ( + + + {(() => { + if (!optionDetails?.claimed) return t("t.none") + + const options = optionDetails.options.filter((option) => option.checked) + + return options.map((option) => ( +
+

+ {option.key === "preferNotToSay" + ? t("t.preferNotToSay") + : t(`application.programs.${metaKey}.${option.key}.label`, { + county: listingDto?.countyCode, + })} +

+
+ )) + })()} +
+
+ ) + })} +
+ ) +} + +export { DetailsPrograms as default, DetailsPrograms } diff --git a/sites/partners/src/applications/PaperApplicationDetails/sections/DetailsTerms.tsx b/sites/partners/src/applications/PaperApplicationDetails/sections/DetailsTerms.tsx new file mode 100644 index 0000000000..77f02b6ec8 --- /dev/null +++ b/sites/partners/src/applications/PaperApplicationDetails/sections/DetailsTerms.tsx @@ -0,0 +1,32 @@ +import React, { useContext } from "react" +import { t, GridSection, ViewItem, GridCell } from "@bloom-housing/ui-components" +import { ApplicationContext } from "../../ApplicationContext" + +const DetailsTerms = () => { + const application = useContext(ApplicationContext) + + return ( + + + + {(() => { + if (application.acceptedTerms === null) { + return t("t.n/a") + } else if (application.acceptedTerms) { + return t("t.yes") + } else { + return t("t.no") + } + })()} + + + + ) +} + +export { DetailsTerms as default, DetailsTerms } diff --git a/sites/partners/src/applications/PaperApplicationForm/FormMember.tsx b/sites/partners/src/applications/PaperApplicationForm/FormMember.tsx new file mode 100644 index 0000000000..81499acee3 --- /dev/null +++ b/sites/partners/src/applications/PaperApplicationForm/FormMember.tsx @@ -0,0 +1,254 @@ +import React, { useMemo } from "react" +import { HouseholdMember, Member } from "@bloom-housing/backend-core/types" +import { + t, + GridSection, + ViewItem, + GridCell, + DOBField, + Field, + Select, + AppearanceStyleType, + AppearanceBorderType, + FieldGroup, + Button, + Form, + FormAddress, +} from "@bloom-housing/ui-components" +import { relationshipKeys, stateKeys } from "@bloom-housing/shared-helpers" +import { useForm } from "react-hook-form" +import { YesNoAnswer } from "./FormTypes" + +type ApplicationFormMemberProps = { + onSubmit: (member: HouseholdMember) => void + onClose: () => void + members: HouseholdMember[] + editedMemberId?: number +} + +const FormMember = ({ onSubmit, onClose, members, editedMemberId }: ApplicationFormMemberProps) => { + const currentlyEdited = useMemo(() => { + return members.filter((member) => member.orderId === editedMemberId)[0] + }, [members, editedMemberId]) + + // eslint-disable-next-line @typescript-eslint/unbound-method + const { register, watch, errors, trigger, getValues } = useForm({ + defaultValues: { + firstName: currentlyEdited?.firstName, + middleName: currentlyEdited?.middleName, + lastName: currentlyEdited?.lastName, + relationship: currentlyEdited?.relationship, + sameAddress: currentlyEdited?.sameAddress, + workInRegion: currentlyEdited?.workInRegion, + dateOfBirth: { + birthMonth: currentlyEdited?.birthMonth, + birthDay: currentlyEdited?.birthDay, + birthYear: currentlyEdited?.birthYear, + }, + address: currentlyEdited?.address, + workAddress: currentlyEdited?.workAddress, + }, + }) + + const sameAddressField = watch("sameAddress") + const workInRegionField = watch("workInRegion") + + async function onFormSubmit() { + const validation = await trigger() + + if (!validation) return + + const data = getValues() + + const { sameAddress, workInRegion } = data + const { birthMonth, birthDay, birthYear } = data.dateOfBirth + const formData = { + createdAt: undefined, + updatedAt: undefined, + ...data, + birthMonth, + birthDay, + birthYear, + sameAddress: sameAddress ? sameAddress : null, + workInRegion: workInRegion ? workInRegion : null, + } + + const editedMember = members.find((member) => member.orderId === editedMemberId) + + if (editedMember) { + onSubmit({ ...editedMember, ...formData }) + } else { + const newMember = new Member(members.length + 1) + onSubmit({ ...newMember, ...formData }) + } + + onClose() + } + + const sameAddressOptions = [ + { + id: "sameAddressYes", + label: t("t.yes"), + value: YesNoAnswer.Yes, + }, + { + id: "sameAddressNo", + label: t("t.no"), + value: YesNoAnswer.No, + }, + ] + + const workInRegionOptions = [ + { + id: "workInRegionYes", + label: t("t.yes"), + value: YesNoAnswer.Yes, + }, + { + id: "workInRegionNo", + label: t("t.no"), + value: YesNoAnswer.No, + }, + ] + + return ( +
false}> +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + {alternateContactType === "other" && ( + + + + + + )} + + + + + + + ) +} + +export { FormAlternateContact as default, FormAlternateContact } diff --git a/sites/partners/src/applications/PaperApplicationForm/sections/FormApplicationData.tsx b/sites/partners/src/applications/PaperApplicationForm/sections/FormApplicationData.tsx new file mode 100644 index 0000000000..c6fa784d6a --- /dev/null +++ b/sites/partners/src/applications/PaperApplicationForm/sections/FormApplicationData.tsx @@ -0,0 +1,86 @@ +import React, { useEffect } from "react" +import { + t, + GridSection, + ViewItem, + Select, + applicationLanguageKeys, + TimeField, + DateField, + DateFieldValues, +} from "@bloom-housing/ui-components" +import { useFormContext } from "react-hook-form" + +const FormApplicationData = () => { + const formMethods = useFormContext() + + // eslint-disable-next-line @typescript-eslint/unbound-method + const { register, watch, errors, setValue } = formMethods + + const dateSubmittedValue: DateFieldValues = watch("dateSubmitted") + const dateSubmittedError = !!errors?.dateSubmitted + const isDateFilled = + dateSubmittedValue?.day && dateSubmittedValue?.month && dateSubmittedValue?.year + + const isDateRequired = + dateSubmittedValue?.day || dateSubmittedValue?.month || dateSubmittedValue?.year + + useEffect(() => { + if (dateSubmittedError || !isDateRequired) { + setValue("timeSubmitted.hours", null) + setValue("timeSubmitted.minutes", null) + setValue("timeSubmitted.seconds", null) + } + }, [dateSubmittedError, isDateRequired, setValue]) + + return ( + + + + + + + + + + + + + + + + + + + + + + ) +} + +export { FormDemographics as default, FormDemographics } diff --git a/sites/partners/src/applications/PaperApplicationForm/sections/FormHouseholdDetails.tsx b/sites/partners/src/applications/PaperApplicationForm/sections/FormHouseholdDetails.tsx new file mode 100644 index 0000000000..0e213e9cb4 --- /dev/null +++ b/sites/partners/src/applications/PaperApplicationForm/sections/FormHouseholdDetails.tsx @@ -0,0 +1,141 @@ +import React from "react" +import { useFormContext } from "react-hook-form" +import { t, GridSection, ViewItem, GridCell, Field, FieldGroup } from "@bloom-housing/ui-components" +import { getUniqueUnitTypes } from "@bloom-housing/shared-helpers" +import { Unit, UnitType } from "@bloom-housing/backend-core/types" +import { YesNoAnswer } from "../../PaperApplicationForm/FormTypes" + +type FormHouseholdDetailsProps = { + listingUnits: Unit[] + applicationUnitTypes: UnitType[] +} + +const FormHouseholdDetails = ({ + listingUnits, + applicationUnitTypes, +}: FormHouseholdDetailsProps) => { + const formMethods = useFormContext() + + // eslint-disable-next-line @typescript-eslint/unbound-method + const { register } = formMethods + + const unitTypes = getUniqueUnitTypes(listingUnits) + + const preferredUnitOptions = unitTypes?.map((item) => { + const isChecked = !!applicationUnitTypes?.find((unit) => unit.id === item.id) ?? false + + return { + id: item.id, + label: t(`application.household.preferredUnit.options.${item.name}`), + value: item.id, + defaultChecked: isChecked, + dataTestId: `preferredUnit.${item.name}`, + } + }) + + return ( + + + + + + + + +
+ + + + + +
+
+
+ + +
+ + + +
+
+
+ + +
+ + + +
+
+
+
+ ) +} + +export { FormHouseholdDetails as default, FormHouseholdDetails } diff --git a/sites/partners/src/applications/PaperApplicationForm/sections/FormHouseholdIncome.tsx b/sites/partners/src/applications/PaperApplicationForm/sections/FormHouseholdIncome.tsx new file mode 100644 index 0000000000..8c4e62af2b --- /dev/null +++ b/sites/partners/src/applications/PaperApplicationForm/sections/FormHouseholdIncome.tsx @@ -0,0 +1,108 @@ +import React from "react" +import { t, GridSection, ViewItem, GridCell, Field, Select } from "@bloom-housing/ui-components" +import { useFormContext } from "react-hook-form" +import { IncomePeriod } from "@bloom-housing/backend-core/types" +import { YesNoAnswer } from "../FormTypes" + +const FormHouseholdIncome = () => { + const formMethods = useFormContext() + + // eslint-disable-next-line @typescript-eslint/unbound-method + const { register, setValue, watch } = formMethods + + const incomePeriodValue: string = watch("application.incomePeriod") + + return ( + + + + +
+ { + setValue("incomeMonth", "") + setValue("incomeYear", "") + }, + }} + /> + + { + setValue("incomeMonth", "") + setValue("incomeYear", "") + }, + }} + /> +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + +