diff --git a/.circleci/config.yml b/.circleci/config.yml index e1e3432a9e..2f50cba523 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -1,17 +1,8 @@ version: 2.1 orbs: - cypress: cypress-io/cypress@1.26.0 + node: circleci/node@5 executors: - standard-node: - docker: - - image: "cimg/node:18.14.2" - - image: "cimg/postgres:12.10" - 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:18.14.1" @@ -21,15 +12,29 @@ executors: # Never do this in production or with any sensitive / non-test data: POSTGRES_HOST_AUTH_METHOD: trust POSTGRES_DB: bloom + JURISDICTION_NAME: Bloomington environment: PORT: "3100" EMAIL_API_KEY: "SG.SOME-LONG-SECRET-KEY" APP_SECRET: "CI-LONG-SECRET-KEY" + NODE_ENV: "development" # 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" PARTNERS_PORTAL_URL: "http://localhost:3001" + JURISDICTION_NAME: Bloomington + standard-node: + docker: + - image: "cimg/node:18.14.2" + - image: "cimg/postgres:12.10" + 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_prisma + JURISDICTION_NAME: Bloomington + jobs: setup: executor: standard-node @@ -40,48 +45,95 @@ jobs: 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: + setup-backend: executor: standard-node steps: - - restore_cache: - key: build-cache-{{ .Environment.CIRCLE_SHA1 }} - - run: yarn lint - jest-shared-helpers: - executor: standard-node + - checkout + - run: yarn backend:new:install + - save_cache: + key: build-cache-new-{{ .Environment.CIRCLE_SHA1 }} + paths: + - ~/ + cypress-public: + executor: cypress-node + resource_class: large steps: + - checkout - restore_cache: key: build-cache-{{ .Environment.CIRCLE_SHA1 }} - - run: yarn test:shared:helpers - jest-backend: - executor: standard-node + - node/install-packages: + app-dir: sites/public + pkg-manager: yarn + cache-version: build-cache-{{ .Environment.CIRCLE_SHA1 }} + - node/install-packages: + app-dir: api + pkg-manager: yarn + cache-version: build-cache-{{ .Environment.CIRCLE_SHA1 }} + - run: + name: setup db and seed + command: yarn test:backend:new:dbsetup:withseed + - run: + name: run backend + command: yarn dev + background: true + working_directory: api + - run: + name: build and run public + command: yarn dev:server-wait-new && yarn build && yarn start + background: true + working_directory: sites/public + - run: + name: wait + command: yarn dev:public-wait + working_directory: sites/public + - run: + name: Run Cypress + command: npx cypress run + working_directory: sites/public + - store_artifacts: + path: sites/public/cypress/videos + - store_artifacts: + path: sites/public/cypress/screenshots + cypress-partners: + executor: cypress-node + resource_class: large steps: + - checkout - restore_cache: key: build-cache-{{ .Environment.CIRCLE_SHA1 }} + - node/install-packages: + app-dir: sites/partners + pkg-manager: yarn + cache-version: build-cache-{{ .Environment.CIRCLE_SHA1 }} + - node/install-packages: + app-dir: api + pkg-manager: yarn + cache-version: 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" - 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" - CLOUDINARY_SIGNED_PRESET: "fake_secret" - CLOUDINARY_KEY: "fake_key" - CLOUDINARY_CLOUD_NAME: "exygy" - CLOUDINARY_SECRET: "fake_secret" - PARTNERS_PORTAL_URL: "http://localhost:3001" + name: setup db and seed + command: yarn test:backend:new:dbsetup:withseed + - run: + name: run backend + command: yarn dev + background: true + working_directory: api + - run: + name: build and run partners + command: yarn dev:server-wait-new && yarn build && yarn start + background: true + working_directory: sites/partners + - run: + name: wait + command: yarn dev:partners-wait + working_directory: sites/partners + - run: + name: Run Cypress + command: npx cypress run + working_directory: sites/partners + - store_artifacts: + path: sites/partners/cypress/videos + - store_artifacts: + path: sites/partners/cypress/screenshots build-public: executor: standard-node steps: @@ -106,21 +158,50 @@ jobs: - restore_cache: key: build-cache-{{ .Environment.CIRCLE_SHA1 }} - run: yarn test:app:public:unit + 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-backend: + executor: standard-node + steps: + - checkout + - restore_cache: + key: build-cache-new-{{ .Environment.CIRCLE_SHA1 }} + - run: + name: DB Setup + New Backend Core Tests + working_directory: api + command: | + yarn install + yarn test:cov-ci + environment: + PORT: "3100" + EMAIL_API_KEY: "SG.SOME-LONG-SECRET-KEY" + APP_SECRET: "CI-LONG-SECRET-KEY" + # DB URL for migration and seeds: + DATABASE_URL: "postgres://bloom-ci@localhost:5432/bloom_prisma" + workflows: - version: 2 build: jobs: - setup + - setup-backend - lint: requires: - setup - jest-shared-helpers: requires: - setup - - jest-backend: - requires: - - setup + - jest-backend - build-public: requires: - setup @@ -133,49 +214,11 @@ workflows: - unit-test-partners: requires: - setup - - cypress/run: - name: "cypress-public" + - 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" + - setup-backend + - cypress-partners: requires: - setup - executor: cypress-node - working_directory: sites/partners - yarn: true - build: | - echo 'export FEATURE_LISTINGS_APPROVAL=FALSE' >> "$BASH_ENV" - source "$BASH_ENV" - yarn test:backend:core:dbsetup - start: | - yarn dev:all-cypress - command: | - npx cypress run --spec cypress/e2e/default/**/*.{js,jsx,ts,tsx} - wait-on: "http://0.0.0.0:3001" - store_artifacts: true - - cypress/run: - name: "cypress-partners-listings-approval" - requires: - - setup - executor: cypress-node - working_directory: sites/partners - yarn: true - build: | - echo 'export FEATURE_LISTINGS_APPROVAL=TRUE' >> "$BASH_ENV" - source "$BASH_ENV" - yarn test:backend:core:dbsetup - start: | - yarn dev:all-cypress - command: | - npx cypress run --spec cypress/e2e/listings-approval/**/*.{js,jsx,ts,tsx} - wait-on: "http://0.0.0.0:3001" - store_artifacts: true + - setup-backend \ No newline at end of file diff --git a/.eslintrc.js b/.eslintrc.js index 91fdd21a4d..16d23dbcdd 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -17,7 +17,6 @@ module.exports = { "plugin:import/typescript", "plugin:react-hooks/recommended", // Make sure we follow https://reactjs.org/docs/hooks-rules.html "plugin:jsx-a11y/recommended", - "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. ], rules: { @@ -26,6 +25,7 @@ module.exports = { "@typescript-eslint/explicit-function-return-type": "off", "@typescript-eslint/explicit-module-boundary-types": "off", "@typescript-eslint/no-unused-vars": "warn", + "@typescript-eslint/no-var-requires": "off", "react/jsx-uses-vars": "warn", "react/jsx-uses-react": "warn", "@typescript-eslint/restrict-template-expressions": [ @@ -43,12 +43,14 @@ module.exports = { "@typescript-eslint/no-unsafe-assignment": "off", "@typescript-eslint/no-unsafe-return": "off", "@typescript-eslint/no-unsafe-argument": "off", + "@typescript-eslint/ban-ts-comment": "off", }, ignorePatterns: [ "node_modules", "storybook-static", ".next", "dist", + "api", "migration/", "**/*.stories.tsx", "**/.eslintrc.js", diff --git a/README.md b/README.md index 91eec4df37..33b89f221e 100644 --- a/README.md +++ b/README.md @@ -12,29 +12,29 @@ The backend can be simultaenously deployed to PaaS-style hosts such as Heroku. I ### Structure -Bloom uses a monorepo-style repository containing multiple user-facing applications and backend services. The three main high-level packages are `backend/core`, `sites`, and `shared-helpers`. Additionally, Bloom's UI leverages the in-house npm package `@bloom-housing/ui-components`. +Bloom uses a monorepo-style repository containing multiple user-facing applications and backend services. The three main high-level packages are `api`, `sites`, and `shared-helpers`. Additionally, Bloom's UI leverages the in-house npm package `@bloom-housing/ui-components`. The `sites` package contains reference implementations for the two 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 build and maintain) or an external link to a third-party online or paper application. -- Visit [sites/public/README](https://github.com/bloom-housing/bloom/blob/dev/sites/public/README.md) for more details. +- Visit [sites/public/README](https://github.com/bloom-housing/bloom/blob/main/sites/public/README.md) for more details. - `sites/partners` is the site designed for housing developers, property managers, and city/county (jurisdiction) employees. For application management, it offers the ability to view, edit, and export applications for listings and other administrative tasks. For listing management, it offers the ability to create, edit, and publish listings. 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. +- Visit [sites/partners/README](https://github.com/bloom-housing/bloom/blob/main/sites/partners/README.md) for more details. In some cases the sites diverge slightly to accomodate jurisdictional customizations. The [housingbayarea Bloom fork](https://github.com/housingbayarea/bloom) is a fork of Bloom core for Bay Area jurisdictions which is loosely customized for that location. In this fork, 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. -- Visit [backend/core/README](https://github.com/bloom-housing/bloom/blob/dev/backend/core/README.md) for more details. +- `api` 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. +- Visit [api/README](https://github.com/bloom-housing/bloom/blob/main/api/README.md) for more details. --- - `shared-helpers` contains types and functions intended for shared use between the public and partners sites. -- Visit [shared-helpers/README](https://github.com/bloom-housing/bloom/blob/dev/shared-helpers/README.md) for more details. +- Visit [shared-helpers/README](https://github.com/bloom-housing/bloom/blob/main/shared-helpers/README.md) for more details. --- @@ -42,7 +42,7 @@ In some cases the sites diverge slightly to accomodate jurisdictional customizat ## 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. +If this is your first time working with Bloom, please be sure to check out the `sites/public`, `sites/partners` and `api` README files for important configuration information specific to those pieces. ## General Local Setup @@ -66,7 +66,13 @@ This runs 3 processes for both apps and the backend services on 3 different port - 3000 for the public app - 3001 for the partners app -- 3100 for backend/core +- 3100 for api + +There is a chance that this won't work on your machine. If that is the case you can run each individually on separate terminals with the following command in each directory. + +``` +yarn dev +``` ### Bloom's UI-Component Development - Because Bloom's ui-components package is a separate open source repository, developing within both repos locally requires linking the folders with the following steps: @@ -103,11 +109,7 @@ On commit, two steps automatically run: (1) linting and (2) a verification of th In addition to commits needing to be formatted as conventional commits, if you are making different levels of version change across multiple packages, your commits must also be separated by package in order to avoid improperly versioning a package. -On every merge to dev, our Netlify `development` environment is updated and a pre-release of the ui-components package is automatically published to npm. - -On every merge to master (roughly 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`. +On every merge to `main`, our Netlify and Heroku environment automatically deploys. ### Pull Requests diff --git a/api/.env.template b/api/.env.template new file mode 100644 index 0000000000..637b94ffb5 --- /dev/null +++ b/api/.env.template @@ -0,0 +1,46 @@ +# url of the db we are trying to connect to +DATABASE_URL="postgres://@localhost:5432/bloom_prisma" +# port from which the api is accessible +PORT=3100 +# google translate api email +GOOGLE_API_EMAIL= +# google translate api id +GOOGLE_API_ID= +# google translate api key +GOOGLE_API_KEY= +# cloudinary secret +CLOUDINARY_SECRET= +# app secret +APP_SECRET= +# url for the proxy +PROXY_URL= +# the node env the app should be running as +NODE_ENV=development +# how long a generated multi-factor authentication code should be +MFA_CODE_LENGTH=5 +# TTL for the mfa code, stored in milliseconds +MFA_CODE_VALID=60000 +# how long logins are locked after too many failed login attempts in milliseconds +AUTH_LOCK_LOGIN_COOLDOWN=1800000 +# how many failed login attempts before a lock occurs +AUTH_LOCK_LOGIN_AFTER_FAILED_ATTEMPTS=5 +# phone number for twilio account +TWILIO_PHONE_NUMBER= +# account sid for twilio +TWILIO_ACCOUNT_SID= +# account auth token for twilio +TWILIO_AUTH_TOKEN= +# url for the partner front end +PARTNERS_PORTAL_URL=http://localhost:3001/ +# sendgrid email api key +EMAIL_API_KEY=SG.ExampleApiKey +# controls the repetition of the afs cron job +AFS_PROCESSING_CRON_STRING=0,3,15 * * * * +# controls the repetition of the listing cron job +LISTING_PROCESSING_CRON_STRING=0 * * * * +# the list of allowed urls that can make requests to the api (strings must be exact matches) +CORS_ORIGINS=["http://localhost:3000", "http://localhost:3001"] +# spill over list of allowed urls that can make requests to the api (strings are turned into regex) +CORS_REGEX=["test1", "test2"] +# controls the repetition of the temp file clearing cron job +TEMP_FILE_CLEAR_CRON_STRING=0 * * * diff --git a/api/.eslintrc.js b/api/.eslintrc.js new file mode 100644 index 0000000000..f6c62bee27 --- /dev/null +++ b/api/.eslintrc.js @@ -0,0 +1,24 @@ +module.exports = { + parser: '@typescript-eslint/parser', + parserOptions: { + project: 'tsconfig.json', + sourceType: 'module', + }, + plugins: ['@typescript-eslint/eslint-plugin'], + extends: [ + 'plugin:@typescript-eslint/recommended', + 'plugin:prettier/recommended', + ], + root: true, + env: { + node: true, + jest: true, + }, + ignorePatterns: ['.eslintrc.js'], + rules: { + '@typescript-eslint/interface-name-prefix': 'off', + '@typescript-eslint/explicit-function-return-type': 'off', + '@typescript-eslint/explicit-module-boundary-types': 'off', + '@typescript-eslint/no-explicit-any': 'off', + }, +}; diff --git a/api/.gitignore b/api/.gitignore new file mode 100644 index 0000000000..22f55adc56 --- /dev/null +++ b/api/.gitignore @@ -0,0 +1,35 @@ +# compiled output +/dist +/node_modules + +# Logs +logs +*.log +npm-debug.log* +pnpm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* + +# OS +.DS_Store + +# Tests +/coverage +/.nyc_output + +# IDEs and editors +/.idea +.project +.classpath +.c9/ +*.launch +.settings/ +*.sublime-workspace + +# IDE - VSCode +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json \ No newline at end of file diff --git a/api/.prettierrc b/api/.prettierrc new file mode 100644 index 0000000000..dcb72794f5 --- /dev/null +++ b/api/.prettierrc @@ -0,0 +1,4 @@ +{ + "singleQuote": true, + "trailingComma": "all" +} \ No newline at end of file diff --git a/api/Procfile b/api/Procfile new file mode 100644 index 0000000000..36c6b6bdf1 --- /dev/null +++ b/api/Procfile @@ -0,0 +1 @@ +web: yarn start:prod diff --git a/api/README.md b/api/README.md new file mode 100644 index 0000000000..3e5bf65fd3 --- /dev/null +++ b/api/README.md @@ -0,0 +1,162 @@ +# Setup +## Installation +Make sure the .env file's db placement is what works for your set up, Then run the following: + +```bash +$ yarn install +$ yarn prisma generate +$ yarn build +$ yarn db:setup:staging +``` + +These commands are also encapsulated in: +```bash +$ yarn setup +``` + + +If you would prefer to have it setup with more realistic data you can run `yarn db:setup:staging` instead of `yarn db:setup`. + +## Starting the application +In order to run the application you can run : +```bash +$ yarn start +``` + +or to run in watch mode run: +```bash +$ yarn dev +``` + +# Modifying the Schema + +We use [Prisma](https://www.prisma.io/) as the ORM. To modify the schema you will need to work with the schema.prisma file. This file controls the following: +
    +
  1. The Structure of each model (entity if you are more familiar with TypeORM)
  2. +
  3. The Relationships between models
  4. +
  5. Enum creation for use in both the API and the database
  6. +
  7. How Prisma connects to the database
  8. +
+ +## Conventions +We use the following conventions: + +This is to make the api easier to work with, and to respect postgres's name space conventions. +

+ +# Controllers +Controllers are where backend endpoints are housed. They follow the [Nestjs standards](https://docs.nestjs.com/controllers) + +They are housed under `/src/controllers`. + +## Conventions +Controllers are given the extension `.contoller.ts` and the model name (listing, application, etc) is singular. So for example `listing.controller.ts`. + +The exported class should be in capitalized camelcase (e.g. `ListingController`). + +# DTOs +Data Transfer Objects. These are how we flag what fields endpoints will take in, and what the form of the response from the backend will be. + +We use the packages [class-transformer](https://www.npmjs.com/package/class-transformer) & [class-validator](https://www.npmjs.com/package/class-validator) for this. + +They are housed under `src/dtos`, and are broken up by what model they are related too. There are also shared DTOs which are housed under the shared sub-directory. + +## Conventions +DTOs are given the extension `.dto.ts` and the file name is lowercase kebabcase. + +So for example `listings-filter-params.dto.ts`. + +The exported class should be in capitalized camelcase (e.g. `ListingFilterParams`) and does not include the DTO as a suffix. + +# Enums +These are enums used by NestJs primarily for either taking in a request or sending out a response. Database enums (enums from Prisma) are part of the primsa schema and are not housed here. + +They are housed under `src/enums` and the file name is lowercase kebabcase and end with `-enum.ts`. + +So for example `filter-key-enum.ts`. + +## Conventions +The exported enum should be in capitalized camelcase (e.g. `ListingFilterKeys`). + +# Modules +Modules connect the controllers to services and follow [NestJS standards](https://docs.nestjs.com/modules). + +## Conventions +Modules are housed under `src/modules` and are given the extension `.module.ts`. The model name (listing, application, etc) is singular. So for example `listing.module.ts`. + +The exported class should be in capitalized camelcase (e.g. `ListingModule`). + +# Services +Services are where business logic is performed as well as interfacing with the database. + +Controllers should be calling functions in services in order to do their work. + +The follow the [NestJS standards](https://docs.nestjs.com/providers). + +## Conventions +Services are housed under `src/services` and are given the extension `.services.ts`. The model name (listing, application, etc) is singular. So for example `listing.service.ts`. + +The exported class should be in capitalized camelcase (e.g. `ListingService`). + +# Guards & Passport Strategies +We currently use guards for 2 purposes. Passport guards and Permissioning guards. + +Passport guards (jwt.guard.ts, mfa.guard.ts, and optional.guard.ts) verify that the request is from a legitimate user. JwtAuthGuard does this by verifying the incoming jwt token (off the request's cookies) matches a user. MfaAuthGuard does this by verifying the incoming login information (email, password, mfaCode) matches a user's information. OptionalAuthGuard is used to allow requests from users not logged in through. It will still verify the user through the JwtAuthGuard if a user was logged in. + +Passport guards are paired with a passport strategy (jwt.strategy.ts, and mfa.strategy.ts), this is where the code to actually verify the requester lives. + +Hopefully that makes sense, if not think of guards as customs agents, and the passport strategy is what the guards look for in a request to allow entry to a requester. Allowing them access the endpoint that the guard protects. + +[NestJS passport docs](https://docs.nestjs.com/recipes/passport) +[NestJS guards docs](https://docs.nestjs.com/guards) + +Permissioning guards (permission.guard.ts, and user-profile-permission-guard.ts) verify that the requester has access to the resource and action they are trying to perform. For example a user that is not logged in (anonymous user) can submit applications, but cannot create listings. We leverage [Casbin](https://www.npmjs.com/package/casbin) to do user verification. + + +# Testing +There are 2 different kinds of tests that the backend supports: Integration tests and Unit tests. + +Integration Tests are tests that DO interface with the database, reading/writing/updating/deleting data from that database. + +Unit Tests are tests that MOCK interaction with the database, or test functionality directly that does not interact with the database. + + +## Integration Testing +Integration Tests are housed under `test/integration`, and files are given the extension `.e2e-spec.ts`. + +These tests will generally test going through the controller's endpoints and will mock as little as possible. When testing the database should start off as empty and should be reset to empty once tests are completed (i.e. data is cleaned up). + +## How to run integration tests +Running the following will run all integration tests: +```bash +$ yarn test:e2e +``` + +## Unit Testing +Unit Tests are housed under `test/unit`, and files are given the extension `.spec.ts`. + +These tests will generally test the functions of a service, or helper functions. +These tests will mock Prisma and therefore will not interface directly with the database. This allows for verifying the correct business logic is performed without having to set up the database. + +## How to run unit tests +Running the following will run all unit tests: +```bash +$ yarn test +``` + + +## Testing with code coverage +We have set up both code coverage and code coverage benchmarks. These benchmarks must be met for your PR to pass CI checks. Test coverage is calculated against both the integration and unit test runs. You can run test coverage with the followig: +```bash +$ yarn test:cov +``` + + +# Considerations For Detroit +As it stands right now `core` uses the AmiChart items column and `detroit` uses the AmiChartItem table. +As we move through converting detroit over to prisma we will unify those and choose one of the two approaches. \ No newline at end of file diff --git a/api/nest-cli.json b/api/nest-cli.json new file mode 100644 index 0000000000..edc18842b6 --- /dev/null +++ b/api/nest-cli.json @@ -0,0 +1,10 @@ +{ + "collection": "@nestjs/schematics", + "sourceRoot": "src", + "compilerOptions": { + "assets": [ + { "include": "permission-configs/*.{conf,csv}", "outDir": "dist/src" }, + { "include": "views/**/*.hbs", "outDir": "dist/src" } + ] + } +} diff --git a/api/package.json b/api/package.json new file mode 100644 index 0000000000..447791dc3f --- /dev/null +++ b/api/package.json @@ -0,0 +1,121 @@ +{ + "name": "@bloom-housing/backend", + "version": "0.0.1", + "description": "", + "author": "", + "private": true, + "license": "UNLICENSED", + "scripts": { + "prebuild": "rimraf dist", + "build": "nest build", + "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", + "start": "nest start", + "dev": "nest start --watch", + "start:debug": "nest start --debug --watch", + "start:prod": "node dist/src/main", + "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", + "test": "jest --config ./test/jest.config.js", + "test:watch": "jest --watch", + "test:cov": "yarn db:resetup && yarn db:migration:run && jest --config ./test/jest-with-coverage.config.js --logHeapUsage", + "test:cov-ci": "yarn db:migration:run && jest --config ./test/jest-with-coverage.config.js --runInBand --logHeapUsage", + "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", + "db:resetup": "psql -c 'DROP DATABASE IF EXISTS bloom_prisma;' && psql -c 'CREATE DATABASE bloom_prisma;' && psql -d bloom_prisma -c 'CREATE EXTENSION IF NOT EXISTS \"uuid-ossp\";'", + "db:migration:run": "yarn prisma migrate deploy", + "db:seed:production": "npx prisma db seed -- --environment production", + "db:seed:staging": "npx prisma db seed -- --environment staging", + "db:seed:development": "npx prisma db seed -- --environment development --jurisdictionName Bloomington", + "generate:client": "ts-node scripts/generate-axios-client.ts && prettier -w ../shared-helpers/src/types/backend-swagger.ts", + "test:e2e": "yarn db:resetup && yarn db:migration:run && jest --config ./test/jest-e2e.config.js", + "db:setup": "yarn db:resetup && yarn db:migration:run && yarn db:seed:development", + "db:setup:staging": "yarn db:resetup && yarn db:migration:run && yarn db:seed:staging --jurisdictionName Bloomington", + "setup": "yarn install && yarn prisma generate && yarn build && yarn db:setup:staging", + "db:migration:skip": "yarn prisma migrate resolve --applied ", + "setup:dev": "yarn install && yarn prisma generate && yarn build && yarn db:setup" + }, + "dependencies": { + "@google-cloud/translate": "^7.2.1", + "@nestjs/axios": "~3.0.0", + "@nestjs/common": "^8.0.0", + "@nestjs/config": "~3.0.0", + "@nestjs/core": "^8.0.0", + "@nestjs/jwt": "~10.1.0", + "@nestjs/passport": "~10.0.1", + "@nestjs/platform-express": "^8.0.0", + "@nestjs/schedule": "^3.0.4", + "@nestjs/swagger": "~7.1.12", + "@prisma/client": "^5.0.0", + "@sendgrid/mail": "7.7.0", + "@turf/buffer": "6.5.0", + "@turf/helpers": "6.5.0", + "@turf/boolean-point-in-polygon": "6.5.0", + "@turf/points-within-polygon": "6.5.0", + "@types/archiver": "^6.0.2", + "archiver": "^6.0.1", + "casbin": "^5.27.1", + "class-transformer": "~0.5.1", + "class-validator": "~0.14.0", + "cloudinary": "^1.37.3", + "compression": "^1.7.4", + "cookie-parser": "~1.4.6", + "dayjs": "~1.11.9", + "handlebars": "~4.7.8", + "jsonwebtoken": "~9.0.1", + "lodash": "~4.17.21", + "node-polyglot": "~2.5.0", + "passport": "~0.6.0", + "passport-jwt": "~4.0.1", + "passport-local": "~1.0.0", + "prisma": "^5.0.0", + "qs": "~6.11.2", + "reflect-metadata": "~0.1.13", + "rimraf": "^3.0.2", + "rxjs": "~7.8.1", + "swagger-axios-codegen": "~0.15.12", + "twilio": "^4.15.0" + }, + "devDependencies": { + "@nestjs/cli": "^8.0.0", + "@nestjs/schematics": "^8.0.0", + "@nestjs/testing": "^8.0.0", + "@types/compression": "^1.7.5", + "@types/express": "~4.17.17", + "@types/jest": "~29.5.3", + "@types/node": "^18.7.14", + "@types/supertest": "~2.0.11", + "@typescript-eslint/eslint-plugin": "^5.0.0", + "@typescript-eslint/parser": "^5.0.0", + "eslint": "^8.0.1", + "eslint-config-prettier": "^8.3.0", + "eslint-plugin-prettier": "^4.0.0", + "jest": "~29.6.2", + "jest-environment-jsdom": "~29.6.2", + "prettier": "^2.3.2", + "source-map-support": "~0.5.20", + "supertest": "^6.1.3", + "ts-jest": "~29.1.1", + "ts-loader": "^9.2.3", + "ts-node": "^10.0.0", + "tsconfig-paths": "^3.10.1", + "typescript": "^5.1.6" + }, + "jest": { + "moduleFileExtensions": [ + "js", + "json", + "ts" + ], + "rootDir": "src", + "testRegex": ".*\\.spec\\.ts$", + "transform": { + "^.+\\.(t|j)s$": "ts-jest" + }, + "collectCoverageFrom": [ + "**/*.(t|j)s" + ], + "coverageDirectory": "../coverage", + "testEnvironment": "node" + }, + "prisma": { + "seed": "ts-node prisma/seed.ts" + } +} diff --git a/api/prisma/constants.ts b/api/prisma/constants.ts new file mode 100644 index 0000000000..6359552f55 --- /dev/null +++ b/api/prisma/constants.ts @@ -0,0 +1,2 @@ +export const LISTINGS_TO_SEED = 10; +export const APPLICATIONS_PER_LISTINGS = 5; diff --git a/api/prisma/migrations/00_init/migration.sql b/api/prisma/migrations/00_init/migration.sql new file mode 100644 index 0000000000..2413336545 --- /dev/null +++ b/api/prisma/migrations/00_init/migration.sql @@ -0,0 +1,1295 @@ +-- CreateExtension +CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; + +-- CreateEnum +CREATE TYPE "application_methods_type_enum" AS ENUM ( + 'Internal', + 'FileDownload', + 'ExternalLink', + 'PaperPickup', + 'POBox', + 'LeasingAgent', + 'Referral' +); + +-- CreateEnum +CREATE TYPE "jurisdictions_languages_enum" AS ENUM ('en', 'es', 'vi', 'zh', 'tl'); + +-- CreateEnum +CREATE TYPE "jurisdictions_listing_approval_permissions_enum" AS ENUM ('user', 'partner', 'admin', 'jurisdictionAdmin'); + +-- CreateEnum +CREATE TYPE "listing_events_type_enum" AS ENUM ('openHouse', 'publicLottery', 'lotteryResults'); + +-- CreateEnum +CREATE TYPE "listings_application_drop_off_address_type_enum" AS ENUM ('leasingAgent'); + +-- CreateEnum +CREATE TYPE "listings_application_mailing_address_type_enum" AS ENUM ('leasingAgent'); + +-- CreateEnum +CREATE TYPE "listings_application_pick_up_address_type_enum" AS ENUM ('leasingAgent'); + +-- CreateEnum +CREATE TYPE "listings_review_order_type_enum" AS ENUM ('lottery', 'firstComeFirstServe', 'waitlist'); + +-- CreateEnum +CREATE TYPE "listings_status_enum" AS ENUM ( + 'active', + 'pending', + 'closed', + 'changesRequested', + 'pendingReview' +); + +-- CreateEnum +CREATE TYPE "multiselect_questions_application_section_enum" AS ENUM ('programs', 'preferences'); + +-- CreateTable +CREATE TABLE "accessibility" ( + "id" UUID NOT NULL DEFAULT uuid_generate_v4(), + "created_at" TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "mobility" BOOLEAN, + "vision" BOOLEAN, + "hearing" BOOLEAN, + CONSTRAINT "PK_9729339e162bc7ec98a8815758c" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "activity_logs" ( + "id" UUID NOT NULL DEFAULT uuid_generate_v4(), + "created_at" TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "module" VARCHAR NOT NULL, + "record_id" UUID NOT NULL, + "action" VARCHAR NOT NULL, + "metadata" JSONB, + "user_id" UUID, + CONSTRAINT "PK_f25287b6140c5ba18d38776a796" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "address" ( + "id" UUID NOT NULL DEFAULT uuid_generate_v4(), + "created_at" TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "place_name" TEXT, + "city" TEXT, + "county" TEXT, + "state" TEXT, + "street" TEXT, + "street2" TEXT, + "zip_code" TEXT, + "latitude" DECIMAL, + "longitude" DECIMAL, + CONSTRAINT "PK_d92de1f82754668b5f5f5dd4fd5" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "alternate_contact" ( + "id" UUID NOT NULL DEFAULT uuid_generate_v4(), + "created_at" TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "type" TEXT, + "other_type" TEXT, + "first_name" TEXT, + "last_name" TEXT, + "agency" TEXT, + "phone_number" TEXT, + "email_address" TEXT, + "mailing_address_id" UUID, + CONSTRAINT "PK_4b35560218b2062cccb339975e7" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "ami_chart" ( + "id" UUID NOT NULL DEFAULT uuid_generate_v4(), + "created_at" TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "items" JSONB NOT NULL, + "name" VARCHAR NOT NULL, + "jurisdiction_id" UUID NOT NULL, + CONSTRAINT "PK_e079bbfad233fdc79072acb33b5" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "applicant" ( + "id" UUID NOT NULL DEFAULT uuid_generate_v4(), + "created_at" TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "first_name" TEXT, + "middle_name" TEXT, + "last_name" TEXT, + "birth_month" TEXT, + "birth_day" TEXT, + "birth_year" TEXT, + "email_address" TEXT, + "no_email" BOOLEAN, + "phone_number" TEXT, + "phone_number_type" TEXT, + "no_phone" BOOLEAN, + "work_in_region" TEXT, + "work_address_id" UUID, + "address_id" UUID, + CONSTRAINT "PK_f4a6e907b8b17f293eb073fc5ea" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "application_flagged_set" ( + "id" UUID NOT NULL DEFAULT uuid_generate_v4(), + "created_at" TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "rule" VARCHAR NOT NULL, + "rule_key" VARCHAR NOT NULL, + "resolved_time" TIMESTAMPTZ(6), + "listing_id" UUID NOT NULL, + "show_confirmation_alert" BOOLEAN NOT NULL DEFAULT false, + "status" VARCHAR NOT NULL DEFAULT 'pending', + "resolving_user_id" UUID, + CONSTRAINT "PK_81969e689800a802b75ffd883cc" PRIMARY KEY ("id") +); + +-- CreateTable +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" + ) +); + +-- CreateTable +CREATE TABLE "application_methods" ( + "id" UUID NOT NULL DEFAULT uuid_generate_v4(), + "created_at" TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "type" "application_methods_type_enum" NOT NULL, + "label" TEXT, + "external_reference" TEXT, + "accepts_postmarked_applications" BOOLEAN, + "phone_number" TEXT, + "listing_id" UUID, + CONSTRAINT "PK_c58506819ffaba3863a4edc5e9e" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "applications" ( + "id" UUID NOT NULL DEFAULT uuid_generate_v4(), + "created_at" TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deleted_at" TIMESTAMP(6), + "app_url" TEXT, + "additional_phone" BOOLEAN, + "additional_phone_number" TEXT, + "additional_phone_number_type" TEXT, + "contact_preferences" TEXT [], + "household_size" INTEGER, + "housing_status" TEXT, + "send_mail_to_mailing_address" BOOLEAN, + "household_expecting_changes" BOOLEAN, + "household_student" BOOLEAN, + "income_vouchers" BOOLEAN, + "income" TEXT, + "income_period" VARCHAR, + "preferences" JSONB NOT NULL, + "programs" JSONB, + "status" VARCHAR NOT NULL, + "language" VARCHAR, + "submission_type" VARCHAR NOT NULL, + "accepted_terms" BOOLEAN, + "submission_date" TIMESTAMPTZ(6), + "marked_as_duplicate" BOOLEAN NOT NULL DEFAULT false, + "confirmation_code" TEXT NOT NULL, + "review_status" VARCHAR NOT NULL DEFAULT 'valid', + "user_id" UUID, + "listing_id" UUID, + "applicant_id" UUID, + "mailing_address_id" UUID, + "alternate_address_id" UUID, + "alternate_contact_id" UUID, + "accessibility_id" UUID, + "demographics_id" UUID, + CONSTRAINT "PK_938c0a27255637bde919591888f" PRIMARY KEY ("id") +); + +-- CreateTable +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" + ) +); + +-- CreateTable +CREATE TABLE "assets" ( + "id" UUID NOT NULL DEFAULT uuid_generate_v4(), + "created_at" TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "file_id" TEXT NOT NULL, + "label" TEXT NOT NULL, + CONSTRAINT "PK_da96729a8b113377cfb6a62439c" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "cron_job" ( + "id" UUID NOT NULL DEFAULT uuid_generate_v4(), + "name" TEXT, + "last_run_date" TIMESTAMPTZ(6), + "created_at" TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "PK_3f180d097e1216411578b642513" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "demographics" ( + "id" UUID NOT NULL DEFAULT uuid_generate_v4(), + "created_at" TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "ethnicity" TEXT, + "gender" TEXT, + "sexual_orientation" TEXT, + "how_did_you_hear" TEXT [], + "race" TEXT [], + CONSTRAINT "PK_17bf4db5727bd0ad0462c67eda9" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "generated_listing_translations" ( + "id" UUID NOT NULL DEFAULT uuid_generate_v4(), + "created_at" TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "listing_id" VARCHAR NOT NULL, + "jurisdiction_id" VARCHAR NOT NULL, + "language" VARCHAR NOT NULL, + "translations" JSONB NOT NULL, + "timestamp" TIMESTAMP(6) NOT NULL, + CONSTRAINT "PK_4059452831439aefc27c1990b20" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "household_member" ( + "id" UUID NOT NULL DEFAULT uuid_generate_v4(), + "created_at" TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "order_id" INTEGER, + "first_name" TEXT, + "middle_name" TEXT, + "last_name" TEXT, + "birth_month" TEXT, + "birth_day" TEXT, + "birth_year" TEXT, + "same_address" TEXT, + "relationship" TEXT, + "work_in_region" TEXT, + "address_id" UUID, + "work_address_id" UUID, + "application_id" UUID, + CONSTRAINT "PK_84e1d1f2553646d38e7c8b72a10" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "jurisdictions" ( + "id" UUID NOT NULL DEFAULT uuid_generate_v4(), + "created_at" TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "name" TEXT NOT NULL, + "notifications_sign_up_url" TEXT, + "languages" "jurisdictions_languages_enum" [] DEFAULT ARRAY ['en'] :: "jurisdictions_languages_enum" [], + "partner_terms" TEXT, + "public_url" TEXT NOT NULL DEFAULT '', + "email_from_address" TEXT, + "rental_assistance_default" TEXT NOT NULL, + "enable_partner_settings" BOOLEAN NOT NULL DEFAULT false, + "enable_accessibility_features" BOOLEAN NOT NULL DEFAULT false, + "enable_utilities_included" BOOLEAN NOT NULL DEFAULT false, + "listing_approval_permissions" "jurisdictions_listing_approval_permissions_enum" [], + "enable_geocoding_preferences" BOOLEAN NOT NULL DEFAULT false, + CONSTRAINT "PK_7cc0bed21c9e2b32866c1109ec5" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "jurisdictions_multiselect_questions_multiselect_questions" ( + "jurisdictions_id" UUID NOT NULL, + "multiselect_questions_id" UUID NOT NULL, + CONSTRAINT "PK_b43958a0ef8fbfef97db9c23f8f" PRIMARY KEY ( + "jurisdictions_id", + "multiselect_questions_id" + ) +); + +-- CreateTable +CREATE TABLE "listing_events" ( + "id" UUID NOT NULL DEFAULT uuid_generate_v4(), + "created_at" TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "type" "listing_events_type_enum" NOT NULL, + "start_date" TIMESTAMPTZ(6), + "start_time" TIMESTAMPTZ(6), + "end_time" TIMESTAMPTZ(6), + "url" TEXT, + "note" TEXT, + "label" TEXT, + "listing_id" UUID, + "file_id" UUID, + CONSTRAINT "PK_a9a209828028e14e2caf8def25c" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "listing_features" ( + "id" UUID NOT NULL DEFAULT uuid_generate_v4(), + "created_at" TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "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, + "hearing" BOOLEAN, + "visual" BOOLEAN, + "mobility" BOOLEAN, + CONSTRAINT "PK_88e4fe3e46d21d8b4fdadeb7599" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "listing_images" ( + "ordinal" INTEGER, + "listing_id" UUID NOT NULL, + "image_id" UUID NOT NULL, + "id" UUID NOT NULL DEFAULT uuid_generate_v4(), + "created_at" TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "PK_2abb5c9d795f27dbc4b10ced9dc" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "listing_multiselect_questions" ( + "ordinal" INTEGER, + "listing_id" UUID NOT NULL, + "multiselect_question_id" UUID NOT NULL, + "id" UUID NOT NULL DEFAULT uuid_generate_v4(), + "created_at" TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "PK_2ceddbd7c705edaf32f00642ce7" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "listing_utilities" ( + "id" UUID NOT NULL DEFAULT uuid_generate_v4(), + "created_at" TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "water" BOOLEAN, + "gas" BOOLEAN, + "trash" BOOLEAN, + "sewer" BOOLEAN, + "electricity" BOOLEAN, + "cable" BOOLEAN, + "phone" BOOLEAN, + "internet" BOOLEAN, + CONSTRAINT "PK_8e88f883b389f7b31d331de764f" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "listings" ( + "id" UUID NOT NULL DEFAULT uuid_generate_v4(), + "created_at" TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "additional_application_submission_notes" TEXT, + "digital_application" BOOLEAN, + "common_digital_application" BOOLEAN, + "paper_application" BOOLEAN, + "referral_opportunity" BOOLEAN, + "assets" JSONB NOT NULL, + "accessibility" TEXT, + "amenities" TEXT, + "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, + "services_offered" TEXT, + "year_built" INTEGER, + "application_due_date" TIMESTAMPTZ(6), + "application_open_date" TIMESTAMPTZ(6), + "application_fee" TEXT, + "application_organization" TEXT, + "application_pick_up_address_office_hours" TEXT, + "application_pick_up_address_type" "listings_application_pick_up_address_type_enum", + "application_drop_off_address_office_hours" TEXT, + "application_drop_off_address_type" "listings_application_drop_off_address_type_enum", + "application_mailing_address_type" "listings_application_mailing_address_type_enum", + "building_selection_criteria" TEXT, + "costs_not_included" TEXT, + "credit_history" TEXT, + "criminal_background" TEXT, + "deposit_min" TEXT, + "deposit_max" TEXT, + "deposit_helper_text" TEXT, + "disable_units_accordion" BOOLEAN, + "leasing_agent_email" TEXT, + "leasing_agent_name" TEXT, + "leasing_agent_office_hours" TEXT, + "leasing_agent_phone" TEXT, + "leasing_agent_title" TEXT, + "name" TEXT NOT NULL, + "postmarked_applications_received_by_date" TIMESTAMPTZ(6), + "program_rules" TEXT, + "rental_assistance" TEXT, + "rental_history" TEXT, + "required_documents" TEXT, + "special_notes" TEXT, + "waitlist_current_size" INTEGER, + "waitlist_max_size" INTEGER, + "what_to_expect" TEXT, + "status" "listings_status_enum" NOT NULL DEFAULT 'pending', + "review_order_type" "listings_review_order_type_enum", + "display_waitlist_size" BOOLEAN NOT NULL, + "reserved_community_description" TEXT, + "reserved_community_min_age" INTEGER, + "result_link" TEXT, + "is_waitlist_open" BOOLEAN, + "waitlist_open_spots" INTEGER, + "custom_map_pin" BOOLEAN, + "published_at" TIMESTAMPTZ(6), + "closed_at" TIMESTAMPTZ(6), + "afs_last_run_at" TIMESTAMPTZ(6) DEFAULT '1970-01-01 00:00:00-07' :: timestamp with time zone, + "last_application_update_at" TIMESTAMPTZ(6) DEFAULT '1970-01-01 00:00:00-07' :: timestamp with time zone, + "building_address_id" UUID, + "application_pick_up_address_id" UUID, + "application_drop_off_address_id" UUID, + "application_mailing_address_id" UUID, + "building_selection_criteria_file_id" UUID, + "jurisdiction_id" UUID, + "leasing_agent_address_id" UUID, + "reserved_community_type_id" UUID, + "result_id" UUID, + "features_id" UUID, + "utilities_id" UUID, + "requested_changes" TEXT, + "requested_changes_date" TIMESTAMPTZ(6), + "requested_changes_user_id" UUID, + CONSTRAINT "PK_520ecac6c99ec90bcf5a603cdcb" PRIMARY KEY ("id") +); + +-- CreateTable +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" + ) +); + +-- CreateTable +CREATE TABLE "migrations" ( + "id" SERIAL NOT NULL, + "timestamp" BIGINT NOT NULL, + "name" VARCHAR NOT NULL, + CONSTRAINT "PK_8c82d7f526340ab734260ea46be" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "multiselect_questions" ( + "id" UUID NOT NULL DEFAULT uuid_generate_v4(), + "created_at" TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "text" TEXT NOT NULL, + "sub_text" TEXT, + "description" TEXT, + "links" JSONB, + "options" JSONB, + "opt_out_text" TEXT, + "hide_from_listing" BOOLEAN, + "application_section" "multiselect_questions_application_section_enum" NOT NULL, + CONSTRAINT "PK_671931eccff7fb3b7cf2050cce0" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "paper_applications" ( + "id" UUID NOT NULL DEFAULT uuid_generate_v4(), + "created_at" TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "language" VARCHAR NOT NULL, + "file_id" UUID, + "application_method_id" UUID, + CONSTRAINT "PK_1bc5b0234d874ec03f500621d43" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "reserved_community_types" ( + "id" UUID NOT NULL DEFAULT uuid_generate_v4(), + "created_at" TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "name" TEXT NOT NULL, + "description" TEXT, + "jurisdiction_id" UUID NOT NULL, + CONSTRAINT "PK_af3937276e7bb53c30159d6ca0b" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "revoked_tokens" ( + "token" VARCHAR NOT NULL, + "revoked_at" TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "PK_f38f625b4823c8903e819bfedd1" PRIMARY KEY ("token") +); + +-- CreateTable +CREATE TABLE "translations" ( + "id" UUID NOT NULL DEFAULT uuid_generate_v4(), + "created_at" TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "language" VARCHAR NOT NULL, + "translations" JSONB NOT NULL, + "jurisdiction_id" UUID, + CONSTRAINT "PK_aca248c72ae1fb2390f1bf4cd87" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "unit_accessibility_priority_types" ( + "id" UUID NOT NULL DEFAULT uuid_generate_v4(), + "created_at" TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "name" TEXT NOT NULL, + CONSTRAINT "PK_2cf31d2ceea36e6a6b970608565" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "unit_ami_chart_overrides" ( + "id" UUID NOT NULL DEFAULT uuid_generate_v4(), + "created_at" TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "items" JSONB NOT NULL, + CONSTRAINT "PK_839676df1bd1ac12ff09b9d920d" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "unit_rent_types" ( + "id" UUID NOT NULL DEFAULT uuid_generate_v4(), + "created_at" TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "name" TEXT NOT NULL, + CONSTRAINT "PK_fb6b318fdee0a5b30521f63c516" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "unit_types" ( + "id" UUID NOT NULL DEFAULT uuid_generate_v4(), + "created_at" TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "name" TEXT NOT NULL, + "num_bedrooms" INTEGER NOT NULL, + CONSTRAINT "PK_105c42fcf447c1da21fd20bcb85" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "units" ( + "id" UUID NOT NULL DEFAULT uuid_generate_v4(), + "created_at" TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "ami_percentage" TEXT, + "annual_income_min" TEXT, + "monthly_income_min" TEXT, + "floor" INTEGER, + "annual_income_max" TEXT, + "max_occupancy" INTEGER, + "min_occupancy" INTEGER, + "monthly_rent" TEXT, + "num_bathrooms" INTEGER, + "num_bedrooms" INTEGER, + "number" TEXT, + "sq_feet" DECIMAL(8, 2), + "monthly_rent_as_percent_of_income" DECIMAL(8, 2), + "bmr_program_chart" BOOLEAN, + "ami_chart_id" UUID, + "listing_id" UUID, + "unit_type_id" UUID, + "unit_rent_type_id" UUID, + "priority_type_id" UUID, + "ami_chart_override_id" UUID, + CONSTRAINT "PK_5a8f2f064919b587d93936cb223" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "units_summary" ( + "id" UUID NOT NULL DEFAULT uuid_generate_v4(), + "monthly_rent_min" INTEGER, + "monthly_rent_max" INTEGER, + "monthly_rent_as_percent_of_income" DECIMAL(8, 2), + "ami_percentage" INTEGER, + "minimum_income_min" TEXT, + "minimum_income_max" TEXT, + "max_occupancy" INTEGER, + "min_occupancy" INTEGER, + "floor_min" INTEGER, + "floor_max" INTEGER, + "sq_feet_min" DECIMAL(8, 2), + "sq_feet_max" DECIMAL(8, 2), + "total_count" INTEGER, + "total_available" INTEGER, + "unit_type_id" UUID, + "listing_id" UUID, + "priority_type_id" UUID, + CONSTRAINT "PK_8d8c4940fab2a9d1b2e7ddd9e49" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "user_accounts" ( + "id" UUID NOT NULL DEFAULT uuid_generate_v4(), + "password_hash" VARCHAR NOT NULL, + "password_updated_at" TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "password_valid_for_days" INTEGER NOT NULL DEFAULT 180, + "reset_token" VARCHAR, + "confirmation_token" VARCHAR, + "confirmed_at" TIMESTAMPTZ(6), + "email" VARCHAR NOT NULL, + "first_name" VARCHAR NOT NULL, + "middle_name" VARCHAR, + "last_name" VARCHAR NOT NULL, + "dob" TIMESTAMP(6), + "phone_number" VARCHAR, + "created_at" TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "language" VARCHAR, + "mfa_enabled" BOOLEAN NOT NULL DEFAULT false, + "mfa_code" VARCHAR, + "mfa_code_updated_at" TIMESTAMPTZ(6), + "last_login_at" TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "failed_login_attempts_count" INTEGER NOT NULL DEFAULT 0, + "phone_number_verified" BOOLEAN DEFAULT false, + "agreed_to_terms_of_service" BOOLEAN NOT NULL DEFAULT false, + "hit_confirmation_url" TIMESTAMPTZ(6), + "active_access_token" VARCHAR, + "active_refresh_token" VARCHAR, + CONSTRAINT "PK_125e915cf23ad1cfb43815ce59b" PRIMARY KEY ("id") +); + +-- CreateTable +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" + ) +); + +-- CreateTable +CREATE TABLE "user_roles" ( + "is_admin" BOOLEAN NOT NULL DEFAULT false, + "is_jurisdictional_admin" BOOLEAN NOT NULL DEFAULT false, + "is_partner" BOOLEAN NOT NULL DEFAULT false, + "user_id" UUID NOT NULL, + CONSTRAINT "PK_87b8888186ca9769c960e926870" PRIMARY KEY ("user_id") +); + +CREATE TABLE "map_layers" ( + "id" UUID NOT NULL DEFAULT uuid_generate_v4(), + "name" TEXT NOT NULL, + "jurisdiction_id" TEXT NOT NULL, + "feature_collection" JSONB NOT NULL DEFAULT '{}', + CONSTRAINT "PK_d1bcb10041ba88ffea330dc10d9" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "REL_5eb038a51b9cd6872359a687b1" ON "alternate_contact"("mailing_address_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "REL_7d357035705ebbbe91b5034678" ON "applicant"("work_address_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "REL_8ba2b09030c3a2b857dda5f83f" ON "applicant"("address_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "UQ_2983d3205a16bfae28323d021ea" ON "application_flagged_set"("rule_key"); + +-- CreateIndex +CREATE INDEX "IDX_f2ace84eebd770f1387b47e5e4" ON "application_flagged_set"("listing_id"); + +-- CreateIndex +CREATE INDEX "IDX_93f583f2d43fb21c5d7ceac57e" ON "application_flagged_set_applications_applications"("application_flagged_set_id"); + +-- CreateIndex +CREATE INDEX "IDX_bbae218ba0eff977157fad5ea3" ON "application_flagged_set_applications_applications"("applications_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "REL_194d0fca275b8661a56e486cb6" ON "applications"("applicant_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "REL_b72ba26ebc88981f441b30fe3c" ON "applications"("mailing_address_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "REL_7fc41f89f22ca59ffceab5da80" ON "applications"("alternate_address_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "REL_56abaa378952856aaccc64d7eb" ON "applications"("alternate_contact_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "REL_3a4c71bc34dce9f6c196f11093" ON "applications"("accessibility_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "REL_fed5da45b7b4dafd9f025a37dd" ON "applications"("demographics_id"); + +-- CreateIndex +CREATE INDEX "IDX_cc9d65c58d8deb0ef5353e9037" ON "applications"("listing_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "UQ_556c258a4439f1b7f53de2ed74f" ON "applications"("listing_id", "confirmation_code"); + +-- CreateIndex +CREATE INDEX "IDX_5838635fbe9294cac64d1a0b60" ON "applications_preferred_unit_unit_types"("unit_types_id"); + +-- CreateIndex +CREATE INDEX "IDX_8249d47edacc30250c18c53915" ON "applications_preferred_unit_unit_types"("applications_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "REL_7b61da64f1b7a6bbb48eb5bbb4" ON "household_member"("address_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "REL_f390552cbb929761927c70b7a0" ON "household_member"("work_address_id"); + +-- CreateIndex +CREATE INDEX "IDX_520996eeecf9f6fb9425dc7352" ON "household_member"("application_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "UQ_60b3294568b273d896687dea59f" ON "jurisdictions"("name"); + +-- CreateIndex +CREATE INDEX "IDX_3f7126f5da7c0368aea2f9459c" ON "jurisdictions_multiselect_questions_multiselect_questions"("jurisdictions_id"); + +-- CreateIndex +CREATE INDEX "IDX_ab91e5d403a6cf21656f7d5ae2" ON "jurisdictions_multiselect_questions_multiselect_questions"("multiselect_questions_id"); + +-- CreateIndex +CREATE INDEX "IDX_94041359df3c1b14c4420808d1" ON "listing_images"("listing_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "REL_ac59a58a02199c57a588f04583" ON "listings"("features_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "REL_61b80a947c9db249548ba3c73a" ON "listings"("utilities_id"); + +-- CreateIndex +CREATE INDEX "IDX_ba0026e02ecfe91791aed1a481" ON "listings"("jurisdiction_id"); + +-- CreateIndex +CREATE INDEX "IDX_de53131bc8a08f824a5d3dd51e" ON "listings_leasing_agents_user_accounts"("user_accounts_id"); + +-- CreateIndex +CREATE INDEX "IDX_f7b22af2c421e823f60c5f7d28" ON "listings_leasing_agents_user_accounts"("listings_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "IDX_4655e7b2c26deb4b8156ea8100" ON "translations"("jurisdiction_id", "language"); + +-- CreateIndex +CREATE UNIQUE INDEX "REL_4ca3d4c823e6bd5149ecaad363" ON "units"("ami_chart_override_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "UQ_df3802ec9c31dd9491e3589378d" ON "user_accounts"("email"); + +-- CreateIndex +CREATE INDEX "IDX_e51e812700e143101aeaabbccc" ON "user_accounts_jurisdictions_jurisdictions"("user_accounts_id"); + +-- CreateIndex +CREATE INDEX "IDX_fe359f4430f9e0e7b278e03f0f" ON "user_accounts_jurisdictions_jurisdictions"("jurisdictions_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "UQ_87b8888186ca9769c960e926870" ON "user_roles"("user_id"); + +-- AddForeignKey +ALTER TABLE + "activity_logs" +ADD + CONSTRAINT "FK_d54f841fa5478e4734590d44036" FOREIGN KEY ("user_id") REFERENCES "user_accounts"("id") ON +DELETE +SET + NULL ON +UPDATE NO ACTION; + +-- AddForeignKey +ALTER TABLE + "alternate_contact" +ADD + CONSTRAINT "FK_5eb038a51b9cd6872359a687b18" FOREIGN KEY ("mailing_address_id") REFERENCES "address"("id") ON +DELETE NO ACTION ON +UPDATE NO ACTION; + +-- AddForeignKey +ALTER TABLE + "ami_chart" +ADD + CONSTRAINT "FK_5566b52b2e7c0056e3b81c171f1" FOREIGN KEY ("jurisdiction_id") REFERENCES "jurisdictions"("id") ON +DELETE NO ACTION ON +UPDATE NO ACTION; + +-- AddForeignKey +ALTER TABLE + "applicant" +ADD + CONSTRAINT "FK_7d357035705ebbbe91b50346781" FOREIGN KEY ("work_address_id") REFERENCES "address"("id") ON +DELETE NO ACTION ON +UPDATE NO ACTION; + +-- AddForeignKey +ALTER TABLE + "applicant" +ADD + CONSTRAINT "FK_8ba2b09030c3a2b857dda5f83fe" FOREIGN KEY ("address_id") REFERENCES "address"("id") ON +DELETE NO ACTION ON +UPDATE NO ACTION; + +-- AddForeignKey +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; + +-- AddForeignKey +ALTER TABLE + "application_flagged_set" +ADD + CONSTRAINT "FK_f2ace84eebd770f1387b47e5e45" FOREIGN KEY ("listing_id") REFERENCES "listings"("id") ON +DELETE NO ACTION ON +UPDATE NO ACTION; + +-- AddForeignKey +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; + +-- AddForeignKey +ALTER TABLE + "application_flagged_set_applications_applications" +ADD + CONSTRAINT "FK_bbae218ba0eff977157fad5ea31" FOREIGN KEY ("applications_id") REFERENCES "applications"("id") ON +DELETE CASCADE ON +UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE + "application_methods" +ADD + CONSTRAINT "FK_3057650361c2aeab15dfee5c3cc" FOREIGN KEY ("listing_id") REFERENCES "listings"("id") ON +DELETE NO ACTION ON +UPDATE NO ACTION; + +-- AddForeignKey +ALTER TABLE + "applications" +ADD + CONSTRAINT "FK_194d0fca275b8661a56e486cb64" FOREIGN KEY ("applicant_id") REFERENCES "applicant"("id") ON +DELETE NO ACTION ON +UPDATE NO ACTION; + +-- AddForeignKey +ALTER TABLE + "applications" +ADD + CONSTRAINT "FK_3a4c71bc34dce9f6c196f110935" FOREIGN KEY ("accessibility_id") REFERENCES "accessibility"("id") ON +DELETE NO ACTION ON +UPDATE NO ACTION; + +-- AddForeignKey +ALTER TABLE + "applications" +ADD + CONSTRAINT "FK_56abaa378952856aaccc64d7eb3" FOREIGN KEY ("alternate_contact_id") REFERENCES "alternate_contact"("id") ON +DELETE NO ACTION ON +UPDATE NO ACTION; + +-- AddForeignKey +ALTER TABLE + "applications" +ADD + CONSTRAINT "FK_7fc41f89f22ca59ffceab5da80e" FOREIGN KEY ("alternate_address_id") REFERENCES "address"("id") ON +DELETE NO ACTION ON +UPDATE NO ACTION; + +-- AddForeignKey +ALTER TABLE + "applications" +ADD + CONSTRAINT "FK_9e7594d5b474d9cbebba15c1ae7" FOREIGN KEY ("user_id") REFERENCES "user_accounts"("id") ON +DELETE +SET + NULL ON +UPDATE NO ACTION; + +-- AddForeignKey +ALTER TABLE + "applications" +ADD + CONSTRAINT "FK_b72ba26ebc88981f441b30fe3c5" FOREIGN KEY ("mailing_address_id") REFERENCES "address"("id") ON +DELETE NO ACTION ON +UPDATE NO ACTION; + +-- AddForeignKey +ALTER TABLE + "applications" +ADD + CONSTRAINT "FK_cc9d65c58d8deb0ef5353e9037d" FOREIGN KEY ("listing_id") REFERENCES "listings"("id") ON +DELETE NO ACTION ON +UPDATE NO ACTION; + +-- AddForeignKey +ALTER TABLE + "applications" +ADD + CONSTRAINT "FK_fed5da45b7b4dafd9f025a37dd1" FOREIGN KEY ("demographics_id") REFERENCES "demographics"("id") ON +DELETE NO ACTION ON +UPDATE NO ACTION; + +-- AddForeignKey +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; + +-- AddForeignKey +ALTER TABLE + "applications_preferred_unit_unit_types" +ADD + CONSTRAINT "FK_8249d47edacc30250c18c53915a" FOREIGN KEY ("applications_id") REFERENCES "applications"("id") ON +DELETE CASCADE ON +UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE + "household_member" +ADD + CONSTRAINT "FK_520996eeecf9f6fb9425dc7352c" FOREIGN KEY ("application_id") REFERENCES "applications"("id") ON +DELETE NO ACTION ON +UPDATE NO ACTION; + +-- AddForeignKey +ALTER TABLE + "household_member" +ADD + CONSTRAINT "FK_7b61da64f1b7a6bbb48eb5bbb43" FOREIGN KEY ("address_id") REFERENCES "address"("id") ON +DELETE NO ACTION ON +UPDATE NO ACTION; + +-- AddForeignKey +ALTER TABLE + "household_member" +ADD + CONSTRAINT "FK_f390552cbb929761927c70b7a0d" FOREIGN KEY ("work_address_id") REFERENCES "address"("id") ON +DELETE NO ACTION ON +UPDATE NO ACTION; + +-- AddForeignKey +ALTER TABLE + "jurisdictions_multiselect_questions_multiselect_questions" +ADD + CONSTRAINT "FK_3f7126f5da7c0368aea2f9459c0" FOREIGN KEY ("jurisdictions_id") REFERENCES "jurisdictions"("id") ON +DELETE CASCADE ON +UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE + "jurisdictions_multiselect_questions_multiselect_questions" +ADD + CONSTRAINT "FK_ab91e5d403a6cf21656f7d5ae20" FOREIGN KEY ("multiselect_questions_id") REFERENCES "multiselect_questions"("id") ON +DELETE CASCADE ON +UPDATE NO ACTION; + +-- AddForeignKey +ALTER TABLE + "listing_events" +ADD + CONSTRAINT "FK_4fd176b179ce281bedb1b7b9f2b" FOREIGN KEY ("file_id") REFERENCES "assets"("id") ON +DELETE NO ACTION ON +UPDATE NO ACTION; + +-- AddForeignKey +ALTER TABLE + "listing_events" +ADD + CONSTRAINT "FK_d0b9892bc613e4d9f8b5c25d03e" FOREIGN KEY ("listing_id") REFERENCES "listings"("id") ON +DELETE NO ACTION ON +UPDATE NO ACTION; + +-- AddForeignKey +ALTER TABLE + "listing_images" +ADD + CONSTRAINT "FK_6fc0fefe11fb46d5ee863ed483a" FOREIGN KEY ("image_id") REFERENCES "assets"("id") ON +DELETE NO ACTION ON +UPDATE NO ACTION; + +-- AddForeignKey +ALTER TABLE + "listing_images" +ADD + CONSTRAINT "FK_94041359df3c1b14c4420808d16" FOREIGN KEY ("listing_id") REFERENCES "listings"("id") ON +DELETE NO ACTION ON +UPDATE NO ACTION; + +-- AddForeignKey +ALTER TABLE + "listing_multiselect_questions" +ADD + CONSTRAINT "FK_92adcb35f2f14e316b4cb12a84e" FOREIGN KEY ("multiselect_question_id") REFERENCES "multiselect_questions"("id") ON +DELETE NO ACTION ON +UPDATE NO ACTION; + +-- AddForeignKey +ALTER TABLE + "listing_multiselect_questions" +ADD + CONSTRAINT "FK_d123697625fe564c2bae54dcecf" FOREIGN KEY ("listing_id") REFERENCES "listings"("id") ON +DELETE NO ACTION ON +UPDATE NO ACTION; + +-- AddForeignKey +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; + +-- AddForeignKey +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; + +-- AddForeignKey +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; + +-- AddForeignKey +ALTER TABLE + "listings" +ADD + CONSTRAINT "FK_3f7b2aedbfccd6297923943e311" FOREIGN KEY ("result_id") REFERENCES "assets"("id") ON +DELETE NO ACTION ON +UPDATE NO ACTION; + +-- AddForeignKey +ALTER TABLE + "listings" +ADD + CONSTRAINT "FK_61b80a947c9db249548ba3c73a5" FOREIGN KEY ("utilities_id") REFERENCES "listing_utilities"("id") ON +DELETE NO ACTION ON +UPDATE NO ACTION; + +-- AddForeignKey +ALTER TABLE + "listings" +ADD + CONSTRAINT "FK_7cedb0a800e3c0af7ede27ab1ec" FOREIGN KEY ("application_mailing_address_id") REFERENCES "address"("id") ON +DELETE NO ACTION ON +UPDATE NO ACTION; + +-- AddForeignKey +ALTER TABLE + "listings" +ADD + CONSTRAINT "FK_8a93cc462d190d3f1a04fa69156" FOREIGN KEY ("leasing_agent_address_id") REFERENCES "address"("id") ON +DELETE NO ACTION ON +UPDATE NO ACTION; + +-- AddForeignKey +ALTER TABLE + "listings" +ADD + CONSTRAINT "FK_ac59a58a02199c57a588f045830" FOREIGN KEY ("features_id") REFERENCES "listing_features"("id") ON +DELETE NO ACTION ON +UPDATE NO ACTION; + +-- AddForeignKey +ALTER TABLE + "listings" +ADD + CONSTRAINT "FK_ba0026e02ecfe91791aed1a4818" FOREIGN KEY ("jurisdiction_id") REFERENCES "jurisdictions"("id") ON +DELETE NO ACTION ON +UPDATE NO ACTION; + +-- AddForeignKey +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; + +-- AddForeignKey +ALTER TABLE + "listings" +ADD + CONSTRAINT "FK_e5d5291cd6ab92cbec304aab905" FOREIGN KEY ("building_address_id") REFERENCES "address"("id") ON +DELETE NO ACTION ON +UPDATE NO ACTION; + +-- AddForeignKey +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; + +-- AddForeignKey +ALTER TABLE + "listings_leasing_agents_user_accounts" +ADD + CONSTRAINT "FK_f7b22af2c421e823f60c5f7d28b" FOREIGN KEY ("listings_id") REFERENCES "listings"("id") ON +DELETE CASCADE ON +UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE + "paper_applications" +ADD + CONSTRAINT "FK_493291d04c708dda2ffe5b521e7" FOREIGN KEY ("file_id") REFERENCES "assets"("id") ON +DELETE NO ACTION ON +UPDATE NO ACTION; + +-- AddForeignKey +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; + +-- AddForeignKey +ALTER TABLE + "reserved_community_types" +ADD + CONSTRAINT "FK_8b43c85a0dd0c39ca795c369edc" FOREIGN KEY ("jurisdiction_id") REFERENCES "jurisdictions"("id") ON +DELETE NO ACTION ON +UPDATE NO ACTION; + +-- AddForeignKey +ALTER TABLE + "translations" +ADD + CONSTRAINT "FK_181f8168d13457f0fd00b08b359" FOREIGN KEY ("jurisdiction_id") REFERENCES "jurisdictions"("id") ON +DELETE NO ACTION ON +UPDATE NO ACTION; + +-- AddForeignKey +ALTER TABLE + "units" +ADD + CONSTRAINT "FK_1e193f5ffdda908517e47d4e021" FOREIGN KEY ("unit_type_id") REFERENCES "unit_types"("id") ON +DELETE NO ACTION ON +UPDATE NO ACTION; + +-- AddForeignKey +ALTER TABLE + "units" +ADD + CONSTRAINT "FK_35571c6bd2a1ff690201d1dff08" FOREIGN KEY ("ami_chart_id") REFERENCES "ami_chart"("id") ON +DELETE NO ACTION ON +UPDATE NO ACTION; + +-- AddForeignKey +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; + +-- AddForeignKey +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; + +-- AddForeignKey +ALTER TABLE + "units" +ADD + CONSTRAINT "FK_9aebcde52d6e054e5ac5d26228c" FOREIGN KEY ("listing_id") REFERENCES "listings"("id") ON +DELETE CASCADE ON +UPDATE CASCADE; + +-- AddForeignKey +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; + +-- AddForeignKey +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; + +-- AddForeignKey +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; + +-- AddForeignKey +ALTER TABLE + "units_summary" +ADD + CONSTRAINT "FK_4edda29192dbc0c6a18e15437a0" FOREIGN KEY ("listing_id") REFERENCES "listings"("id") ON +DELETE NO ACTION ON +UPDATE NO ACTION; + +-- AddForeignKey +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; + +-- AddForeignKey +ALTER TABLE + "user_accounts_jurisdictions_jurisdictions" +ADD + CONSTRAINT "FK_fe359f4430f9e0e7b278e03f0f3" FOREIGN KEY ("jurisdictions_id") REFERENCES "jurisdictions"("id") ON +DELETE CASCADE ON +UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE + "user_roles" +ADD + CONSTRAINT "FK_87b8888186ca9769c960e926870" FOREIGN KEY ("user_id") REFERENCES "user_accounts"("id") ON +DELETE CASCADE ON +UPDATE CASCADE; \ No newline at end of file diff --git a/api/prisma/migrations/01_change_to_prisma/migration.sql b/api/prisma/migrations/01_change_to_prisma/migration.sql new file mode 100644 index 0000000000..695fe804ac --- /dev/null +++ b/api/prisma/migrations/01_change_to_prisma/migration.sql @@ -0,0 +1,1692 @@ +-- CreateEnum + +CREATE TYPE "user_role_enum" AS ENUM ('user', 'partner', 'admin', 'jurisdictionAdmin'); + +-- CreateEnum + +CREATE TYPE "languages_enum" AS ENUM ('en', 'es', 'vi', 'zh', 'tl'); + +-- CreateEnum + +CREATE TYPE "listings_application_address_type_enum" AS ENUM ('leasingAgent'); + +-- CreateEnum + +CREATE TYPE "yes_no_enum" AS ENUM ('yes', 'no'); + +-- CreateEnum + +CREATE TYPE "rule_enum" AS ENUM ('nameAndDOB', 'email'); + +-- CreateEnum + +CREATE TYPE "flagged_set_status_enum" AS ENUM ('flagged', 'pending', 'resolved'); + +-- CreateEnum + +CREATE TYPE "income_period_enum" AS ENUM ('perMonth', 'perYear'); + +-- CreateEnum + +CREATE TYPE "application_status_enum" AS ENUM ('draft', 'submitted', 'removed'); + +-- CreateEnum + +CREATE TYPE "application_submission_type_enum" AS ENUM ('paper', 'electronical'); + +-- CreateEnum + +CREATE TYPE "application_review_status_enum" AS ENUM ('pending', 'pendingAndValid', 'valid', 'duplicate'); + +-- CreateEnum + +CREATE TYPE "units_status_enum" AS ENUM ('unknown', 'available', 'occupied', 'unavailable'); + +-- CreateEnum + +CREATE TYPE "listings_home_type_enum" AS ENUM ('apartment', 'duplex', 'house', 'townhome'); + +-- CreateEnum + +CREATE TYPE "listings_marketing_season_enum" AS ENUM ('spring', 'summer', 'fall', 'winter'); + +-- CreateEnum + +CREATE TYPE "listings_marketing_type_enum" AS ENUM ('marketing', 'comingSoon'); + +-- CreateEnum + +CREATE TYPE "property_region_enum" AS ENUM ('Greater_Downtown', 'Eastside', 'Southwest', 'Westside'); + +-- CreateEnum + +CREATE TYPE "monthly_rent_determination_type_enum" AS ENUM ('flatRent', 'percentageOfIncome'); + +-- CreateEnum + +CREATE TYPE "unit_rent_type_enum" AS ENUM ('fixed', 'percentageOfIncome'); + +-- CreateEnum + +CREATE TYPE "unit_type_enum" AS ENUM ('studio', 'oneBdrm', 'twoBdrm', 'threeBdrm', 'fourBdrm', 'SRO', 'fiveBdrm'); + +-- CreateEnum +-- CREATE TYPE "unit_accessibility_priority_type_enum" AS ENUM ( +-- 'mobility', +-- 'mobilityAndHearing', +-- 'hearing', +-- 'visual', +-- 'hearingAndVisual', +-- 'mobilityAndVisual', +-- 'mobilityHearingAndVisual' +-- ); + -- DropForeignKey + +ALTER TABLE "activity_logs" +DROP CONSTRAINT "FK_d54f841fa5478e4734590d44036"; + +-- DropForeignKey + +ALTER TABLE "application_flagged_set_applications_applications" +DROP CONSTRAINT "FK_93f583f2d43fb21c5d7ceac57e7"; + +-- DropForeignKey + +ALTER TABLE "application_flagged_set_applications_applications" +DROP CONSTRAINT "FK_bbae218ba0eff977157fad5ea31"; + +-- DropForeignKey + +ALTER TABLE "applications_preferred_unit_unit_types" +DROP CONSTRAINT "FK_5838635fbe9294cac64d1a0b605"; + +-- DropForeignKey + +ALTER TABLE "applications_preferred_unit_unit_types" +DROP CONSTRAINT "FK_8249d47edacc30250c18c53915a"; + +-- DropForeignKey + +ALTER TABLE "jurisdictions_multiselect_questions_multiselect_questions" +DROP CONSTRAINT "FK_3f7126f5da7c0368aea2f9459c0"; + +-- DropForeignKey + +ALTER TABLE "jurisdictions_multiselect_questions_multiselect_questions" +DROP CONSTRAINT "FK_ab91e5d403a6cf21656f7d5ae20"; + +-- DropForeignKey + +ALTER TABLE "listings_leasing_agents_user_accounts" +DROP CONSTRAINT "FK_de53131bc8a08f824a5d3dd51e3"; + +-- DropForeignKey + +ALTER TABLE "listings_leasing_agents_user_accounts" +DROP CONSTRAINT "FK_f7b22af2c421e823f60c5f7d28b"; + +-- DropForeignKey + +ALTER TABLE "user_accounts_jurisdictions_jurisdictions" +DROP CONSTRAINT "FK_e51e812700e143101aeaabbccc6"; + +-- DropForeignKey + +ALTER TABLE "user_accounts_jurisdictions_jurisdictions" +DROP CONSTRAINT "FK_fe359f4430f9e0e7b278e03f0f3"; + +-- AlterTable "accessibility" + +ALTER TABLE "accessibility" RENAME CONSTRAINT "PK_9729339e162bc7ec98a8815758c" TO "accessibility_pkey"; + + +ALTER TABLE "accessibility" +ALTER COLUMN "updated_at" +DROP DEFAULT; + +-- AlterTable "address" + +ALTER TABLE "address" RENAME CONSTRAINT "PK_d92de1f82754668b5f5f5dd4fd5" TO "address_pkey"; + + +ALTER TABLE "address" +ALTER COLUMN "updated_at" +DROP DEFAULT; + +-- AlterTable "alternate_contact" + +ALTER TABLE "alternate_contact" RENAME CONSTRAINT "PK_4b35560218b2062cccb339975e7" TO "alternate_contact_pkey"; + + +ALTER TABLE "alternate_contact" +ALTER COLUMN "updated_at" +DROP DEFAULT; + +-- AlterTable "ami_chart" + +ALTER TABLE "ami_chart" RENAME CONSTRAINT "PK_e079bbfad233fdc79072acb33b5" TO "ami_chart_pkey"; + + +ALTER TABLE "ami_chart" +ALTER COLUMN "updated_at" +DROP DEFAULT; + +-- AlterTable "applicant" + +ALTER TABLE "applicant" RENAME CONSTRAINT "PK_f4a6e907b8b17f293eb073fc5ea" TO "applicant_pkey"; + + +ALTER TABLE "applicant" +ALTER COLUMN "updated_at" +DROP DEFAULT; + + +ALTER TABLE "applicant" ADD COLUMN "work_in_region_TEMP" TEXT; + + +UPDATE "applicant" +SET "work_in_region_TEMP" = "work_in_region"; + + +ALTER TABLE "applicant" +DROP COLUMN "work_in_region"; + + +ALTER TABLE "applicant" ADD COLUMN "work_in_region" "yes_no_enum"; + + +UPDATE "applicant" +SET "work_in_region" = CAST ("work_in_region_TEMP" AS "yes_no_enum"); + + +ALTER TABLE "applicant" +DROP COLUMN "work_in_region_TEMP"; + +-- AlterTable "application_flagged_set" + +ALTER TABLE "application_flagged_set" RENAME CONSTRAINT "PK_81969e689800a802b75ffd883cc" TO "application_flagged_set_pkey"; + + +ALTER TABLE "application_flagged_set" +ALTER COLUMN "updated_at" +DROP DEFAULT; + + +ALTER TABLE "application_flagged_set" ADD COLUMN "rule_TEMP" VARCHAR NOT NULL DEFAULT ''; + + +ALTER TABLE "application_flagged_set" ADD COLUMN "status_TEMP" VARCHAR NOT NULL DEFAULT 'pending'; + + +UPDATE "application_flagged_set" +SET "rule_TEMP" = "rule", + "status_TEMP" = "status"; + + +ALTER TABLE "application_flagged_set" +DROP COLUMN "rule"; + + +ALTER TABLE "application_flagged_set" ADD COLUMN "rule" "rule_enum"; + + +ALTER TABLE "application_flagged_set" +DROP COLUMN "status"; + + +ALTER TABLE "application_flagged_set" ADD COLUMN "status" "flagged_set_status_enum" NOT NULL DEFAULT 'pending'; + + +UPDATE "application_flagged_set" +SET "rule" = CASE + WHEN "rule_TEMP" = 'Email' THEN CAST('email' as "rule_enum") + WHEN "rule_TEMP" = 'Name and DOB' THEN CAST ('nameAndDOB' as "rule_enum") + END, + "status" = CAST("status_TEMP" as "flagged_set_status_enum"); + + +ALTER TABLE "application_flagged_set" +DROP COLUMN "rule_TEMP"; + + +ALTER TABLE "application_flagged_set" +DROP COLUMN "status_TEMP"; + +-- AlterTable + +ALTER TABLE "application_methods" RENAME CONSTRAINT "PK_c58506819ffaba3863a4edc5e9e" TO "application_methods_pkey"; + + +ALTER TABLE "application_methods" +ALTER COLUMN "updated_at" +DROP DEFAULT; + +-- AlterTable "applications" + +ALTER TABLE "applications" RENAME CONSTRAINT "PK_938c0a27255637bde919591888f" TO "applications_pkey"; + + +ALTER TABLE "applications" +ALTER COLUMN "updated_at" +DROP DEFAULT; + + +ALTER TABLE "applications" ADD COLUMN "income_period_TEMP" VARCHAR; + + +UPDATE "applications" +SET "income_period_TEMP" = "income_period"; + + +ALTER TABLE "applications" +DROP COLUMN "income_period"; + + +ALTER TABLE "applications" ADD COLUMN "income_period" "income_period_enum"; + + +UPDATE "applications" +SET "income_period" = CAST("income_period_TEMP" as "income_period_enum"); + + +ALTER TABLE "applications" +DROP COLUMN "income_period_TEMP"; + + +ALTER TABLE "applications" ADD COLUMN "status_TEMP" VARCHAR; + + +UPDATE "applications" +SET "status_TEMP" = "status"; + + +ALTER TABLE "applications" +DROP COLUMN "status"; + + +ALTER TABLE "applications" ADD COLUMN "status" "application_status_enum"; + + +UPDATE "applications" +SET "status" = CAST("status_TEMP" as "application_status_enum"); + + +ALTER TABLE "applications" +DROP COLUMN "status_TEMP"; + + +ALTER TABLE "applications" ADD COLUMN "language_TEMP" VARCHAR; + + +UPDATE "applications" +SET "language_TEMP" = "language"; + + +ALTER TABLE "applications" +DROP COLUMN "language"; + + +ALTER TABLE "applications" ADD COLUMN "language" "languages_enum"; + + +UPDATE "applications" +SET "language" = CAST("language_TEMP" as "languages_enum"); + + +ALTER TABLE "applications" +DROP COLUMN "language_TEMP"; + + +ALTER TABLE "applications" ADD COLUMN "submission_type_TEMP" VARCHAR; + + +UPDATE "applications" +SET "submission_type_TEMP" = "submission_type"; + + +ALTER TABLE "applications" +DROP COLUMN "submission_type"; + + +ALTER TABLE "applications" ADD COLUMN "submission_type" "application_submission_type_enum"; + + +UPDATE "applications" +SET "submission_type" = CAST("submission_type_TEMP" as "application_submission_type_enum"); + + +ALTER TABLE "applications" +DROP COLUMN "submission_type_TEMP"; + + +ALTER TABLE "applications" ADD COLUMN "review_status_TEMP" VARCHAR NOT NULL DEFAULT 'valid'; + + +UPDATE "applications" +SET "review_status_TEMP" = "review_status"; + +-- Setting all applications with "flagged" to "duplicate". The "flagged" enum hasn't been used +-- since before the duplicates v2 refactor in 2022 + +UPDATE "applications" +SET "review_status_TEMP" = 'duplicate' +WHERE "review_status" = 'flagged'; + + +ALTER TABLE "applications" +DROP COLUMN "review_status"; + + +ALTER TABLE "applications" ADD COLUMN "review_status" "application_review_status_enum" NOT NULL DEFAULT 'pending'; + + +UPDATE "applications" +SET "review_status" = CAST("review_status_TEMP" as "application_review_status_enum"); + + +ALTER TABLE "applications" +DROP COLUMN "review_status_TEMP"; + +-- AlterTable + +ALTER TABLE "assets" RENAME CONSTRAINT "PK_da96729a8b113377cfb6a62439c" TO "assets_pkey"; + + +ALTER TABLE "assets" +ALTER COLUMN "updated_at" +DROP DEFAULT; + +-- AlterTable + +ALTER TABLE "cron_job" RENAME CONSTRAINT "PK_3f180d097e1216411578b642513" TO "cron_job_pkey"; + + +ALTER TABLE "cron_job" +ALTER COLUMN "updated_at" +DROP DEFAULT; + +-- AlterTable + +ALTER TABLE "demographics" RENAME CONSTRAINT "PK_17bf4db5727bd0ad0462c67eda9" TO "demographics_pkey"; + + +ALTER TABLE "demographics" +ALTER COLUMN "updated_at" +DROP DEFAULT; + +-- AlterTable "generated_listing_translations" + +ALTER TABLE "generated_listing_translations" RENAME CONSTRAINT "PK_4059452831439aefc27c1990b20" TO "generated_listing_translations_pkey"; + + +ALTER TABLE "generated_listing_translations" +ALTER COLUMN "updated_at" +DROP DEFAULT; + + +ALTER TABLE "generated_listing_translations" ADD COLUMN "language_TEMP" VARCHAR; + + +UPDATE "generated_listing_translations" +SET "language_TEMP" = "language"; + + +ALTER TABLE "generated_listing_translations" +DROP COLUMN "language"; + + +ALTER TABLE "generated_listing_translations" ADD COLUMN "language" "languages_enum"; + + +UPDATE "generated_listing_translations" +SET "language" = CAST("language_TEMP" as "languages_enum"); + + +ALTER TABLE "generated_listing_translations" +DROP COLUMN "language_TEMP"; + +-- AlterTable "household_member" + +ALTER TABLE "household_member" RENAME CONSTRAINT "PK_84e1d1f2553646d38e7c8b72a10" TO "household_member_pkey"; + + +ALTER TABLE "household_member" +ALTER COLUMN "updated_at" +DROP DEFAULT; + + +ALTER TABLE "household_member" ADD COLUMN "same_address_TEMP" TEXT; + + +UPDATE "household_member" +SET "same_address_TEMP" = "same_address"; + + +ALTER TABLE "household_member" +DROP COLUMN "same_address"; + + +ALTER TABLE "household_member" ADD COLUMN "same_address" "yes_no_enum"; + + +UPDATE "household_member" +SET "same_address" = CAST("same_address_TEMP" AS "yes_no_enum"); + + +ALTER TABLE "household_member" +DROP COLUMN "same_address_TEMP"; + + +ALTER TABLE "household_member" ADD COLUMN "work_in_region_TEMP" TEXT; + + +UPDATE "household_member" +SET "work_in_region_TEMP" = "work_in_region"; + + +ALTER TABLE "household_member" +DROP COLUMN "work_in_region"; + + +ALTER TABLE "household_member" ADD COLUMN "work_in_region" "yes_no_enum"; + + +UPDATE "household_member" +SET "work_in_region" = CAST("work_in_region_TEMP" AS "yes_no_enum"); + + +ALTER TABLE "household_member" +DROP COLUMN "work_in_region_TEMP"; + +-- AlterTable "jurisdictions" + +ALTER TABLE "jurisdictions" RENAME CONSTRAINT "PK_7cc0bed21c9e2b32866c1109ec5" TO "jurisdictions_pkey"; + + +ALTER TABLE "jurisdictions" +ALTER COLUMN "updated_at" +DROP DEFAULT; + + +ALTER TABLE "jurisdictions" ADD COLUMN "listing_approval_permissions_TEMP" "jurisdictions_listing_approval_permissions_enum" []; + + +UPDATE "jurisdictions" +SET "listing_approval_permissions_TEMP" = "listing_approval_permissions"; + + +ALTER TABLE "jurisdictions" +DROP COLUMN "listing_approval_permissions"; + + +ALTER TABLE "jurisdictions" ADD COLUMN "listing_approval_permission" "user_role_enum" []; + + +UPDATE "jurisdictions" +SET "listing_approval_permission" = CAST(CAST("listing_approval_permissions_TEMP" AS TEXT []) AS "user_role_enum" []); + + +ALTER TABLE "jurisdictions" +DROP COLUMN "listing_approval_permissions_TEMP"; + + +ALTER TABLE "jurisdictions" ADD COLUMN "languages_TEMP" "jurisdictions_languages_enum" []; + + +UPDATE "jurisdictions" +SET "languages_TEMP" = "languages"; + + +ALTER TABLE "jurisdictions" +DROP COLUMN "languages"; + + +ALTER TABLE "jurisdictions" ADD COLUMN "languages" "languages_enum" [] DEFAULT ARRAY ['en'] :: "languages_enum" []; + + +UPDATE "jurisdictions" +SET "languages" = CAST(CAST("languages_TEMP" AS TEXT []) AS "languages_enum" []); + + +ALTER TABLE "jurisdictions" +DROP COLUMN "languages_TEMP"; + +-- AlterTable + +ALTER TABLE "listing_events" RENAME CONSTRAINT "PK_a9a209828028e14e2caf8def25c" TO "listing_events_pkey"; + + +ALTER TABLE "listing_events" +ALTER COLUMN "updated_at" +DROP DEFAULT; + +-- AlterTable "listing_features" + +ALTER TABLE "listing_features" RENAME CONSTRAINT "PK_88e4fe3e46d21d8b4fdadeb7599" TO "listing_features_pkey"; + + +ALTER TABLE "listing_features" ADD COLUMN "barrier_free_bathroom" BOOLEAN; + + +ALTER TABLE "listing_features" ADD COLUMN "barrier_free_unit_entrance" BOOLEAN; + + +ALTER TABLE "listing_features" ADD COLUMN "lowered_cabinets" BOOLEAN; + + +ALTER TABLE "listing_features" ADD COLUMN "lowered_light_switch" BOOLEAN; + + +ALTER TABLE "listing_features" ADD COLUMN "wide_doorways" BOOLEAN; + + +ALTER TABLE "listing_features" +ALTER COLUMN "updated_at" +DROP DEFAULT; + +-- AlterTable "listing_images" + +ALTER TABLE "listing_images" +DROP CONSTRAINT "PK_2abb5c9d795f27dbc4b10ced9dc"; + + +ALTER TABLE "listing_images" +DROP COLUMN "created_at"; + + +ALTER TABLE "listing_images" +DROP COLUMN "id"; + + +ALTER TABLE "listing_images" +DROP COLUMN "updated_at"; + + +ALTER TABLE "listing_images" ADD CONSTRAINT "listing_images_pkey" PRIMARY KEY ("listing_id", + "image_id"); + +-- AlterTable "listing_multiselect_questions" + +ALTER TABLE "listing_multiselect_questions" +DROP CONSTRAINT "PK_2ceddbd7c705edaf32f00642ce7"; + + +ALTER TABLE "listing_multiselect_questions" +DROP COLUMN "created_at"; + + +ALTER TABLE "listing_multiselect_questions" +DROP COLUMN "id"; + + +ALTER TABLE "listing_multiselect_questions" +DROP COLUMN "updated_at"; + + +ALTER TABLE "listing_multiselect_questions" ADD CONSTRAINT "listing_multiselect_questions_pkey" PRIMARY KEY ("listing_id", + "multiselect_question_id"); + +-- AlterTable "listing_utilities" + +ALTER TABLE "listing_utilities" RENAME CONSTRAINT "PK_8e88f883b389f7b31d331de764f" TO "listing_utilities_pkey"; + + +ALTER TABLE "listing_utilities" +ALTER COLUMN "updated_at" +DROP DEFAULT; + +-- AlterTable "listings" + +ALTER TABLE "listings" RENAME CONSTRAINT "PK_520ecac6c99ec90bcf5a603cdcb" TO "listings_pkey"; + + +ALTER TABLE "listings" ADD COLUMN "ami_percentage_max" INTEGER; + + +ALTER TABLE "listings" ADD COLUMN "ami_percentage_min" INTEGER; + + +ALTER TABLE "listings" ADD COLUMN "home_type" "listings_home_type_enum"; + + +ALTER TABLE "listings" ADD COLUMN "hrd_id" TEXT; + + +ALTER TABLE "listings" ADD COLUMN "is_verified" BOOLEAN DEFAULT false; + + +ALTER TABLE "listings" ADD COLUMN "management_company" TEXT; + + +ALTER TABLE "listings" ADD COLUMN "management_website" TEXT; + + +ALTER TABLE "listings" ADD COLUMN "marketing_date" TIMESTAMPTZ(6); + + +ALTER TABLE "listings" ADD COLUMN "marketing_season" "listings_marketing_season_enum"; + + +ALTER TABLE "listings" ADD COLUMN "marketing_type" "listings_marketing_type_enum" NOT NULL DEFAULT 'marketing'; + + +ALTER TABLE "listings" ADD COLUMN "neighborhood_amenities_id" UUID; + + +ALTER TABLE "listings" ADD COLUMN "owner_company" TEXT; + + +ALTER TABLE "listings" ADD COLUMN "phone_number" TEXT; + + +ALTER TABLE "listings" ADD COLUMN "region" "property_region_enum"; + + +ALTER TABLE "listings" ADD COLUMN "section8_acceptance" BOOLEAN; + + +ALTER TABLE "listings" ADD COLUMN "temporary_listing_id" INTEGER; + + +ALTER TABLE "listings" ADD COLUMN "verified_at" TIMESTAMPTZ(6); + + +ALTER TABLE "listings" ADD COLUMN "what_to_expect_additional_text" TEXT; + + +ALTER TABLE "listings" +ALTER COLUMN "updated_at" +DROP DEFAULT; + + +ALTER TABLE "listings" ADD COLUMN "application_pick_up_address_type_TEMP" "listings_application_pick_up_address_type_enum"; + + +UPDATE "listings" +SET "application_pick_up_address_type_TEMP" = "application_pick_up_address_type"; + + +ALTER TABLE "listings" +DROP COLUMN "application_pick_up_address_type"; + + +ALTER TABLE "listings" ADD COLUMN "application_pick_up_address_type" "listings_application_address_type_enum"; + + +UPDATE "listings" +SET "application_pick_up_address_type" = CAST(CAST("application_pick_up_address_type_TEMP" AS TEXT) AS "listings_application_address_type_enum"); + + +ALTER TABLE "listings" +DROP COLUMN "application_pick_up_address_type_TEMP"; + + +ALTER TABLE "listings" ADD COLUMN "application_drop_off_address_type_TEMP" "listings_application_drop_off_address_type_enum"; + + +UPDATE "listings" +SET "application_drop_off_address_type_TEMP" = "application_drop_off_address_type"; + + +ALTER TABLE "listings" +DROP COLUMN "application_drop_off_address_type"; + + +ALTER TABLE "listings" ADD COLUMN "application_drop_off_address_type" "listings_application_address_type_enum"; + + +UPDATE "listings" +SET "application_drop_off_address_type" = CAST(CAST("application_drop_off_address_type_TEMP" AS TEXT) AS "listings_application_address_type_enum"); + + +ALTER TABLE "listings" +DROP COLUMN "application_drop_off_address_type_TEMP"; + + +ALTER TABLE "listings" ADD COLUMN "application_mailing_address_type_TEMP" "listings_application_mailing_address_type_enum"; + + +UPDATE "listings" +SET "application_mailing_address_type_TEMP" = "application_mailing_address_type"; + + +ALTER TABLE "listings" +DROP COLUMN "application_mailing_address_type"; + + +ALTER TABLE "listings" ADD COLUMN "application_mailing_address_type" "listings_application_address_type_enum"; + + +UPDATE "listings" +SET "application_mailing_address_type" = CAST(CAST("application_mailing_address_type_TEMP" AS TEXT) AS "listings_application_address_type_enum"); + + +ALTER TABLE "listings" +DROP COLUMN "application_mailing_address_type_TEMP"; + + +ALTER TABLE "listings" +ALTER COLUMN "requested_changes_date" +SET DEFAULT '1970-01-01 00:00:00-07' :: timestamp with time zone; + +-- AlterTable + +ALTER TABLE "migrations" RENAME CONSTRAINT "PK_8c82d7f526340ab734260ea46be" TO "migrations_pkey"; + +-- AlterTable + +ALTER TABLE "multiselect_questions" RENAME CONSTRAINT "PK_671931eccff7fb3b7cf2050cce0" TO "multiselect_questions_pkey"; + + +ALTER TABLE "multiselect_questions" +ALTER COLUMN "updated_at" +DROP DEFAULT; + +-- AlterTable "paper_applications" + +ALTER TABLE "paper_applications" RENAME CONSTRAINT "PK_1bc5b0234d874ec03f500621d43" TO "paper_applications_pkey"; + + +ALTER TABLE "paper_applications" +ALTER COLUMN "updated_at" +DROP DEFAULT; + + +ALTER TABLE "paper_applications" ADD COLUMN "language_TEMP" VARCHAR; + + +UPDATE "paper_applications" +SET "language_TEMP" = "language"; + + +ALTER TABLE "paper_applications" +DROP COLUMN "language"; + + +ALTER TABLE "paper_applications" ADD COLUMN "language" "languages_enum"; + + +UPDATE "paper_applications" +SET "language" = CAST("language_TEMP" AS "languages_enum"); + + +ALTER TABLE "paper_applications" +DROP COLUMN "language_TEMP"; + +-- AlterTable + +ALTER TABLE "reserved_community_types" RENAME CONSTRAINT "PK_af3937276e7bb53c30159d6ca0b" TO "reserved_community_types_pkey"; + + +ALTER TABLE "reserved_community_types" +ALTER COLUMN "updated_at" +DROP DEFAULT; + +-- AlterTable + +ALTER TABLE "unit_ami_chart_overrides" RENAME CONSTRAINT "PK_839676df1bd1ac12ff09b9d920d" TO "unit_ami_chart_overrides_pkey"; + + +ALTER TABLE "unit_ami_chart_overrides" +ALTER COLUMN "updated_at" +DROP DEFAULT; + +-- AlterTable + +ALTER TABLE "units_summary" RENAME CONSTRAINT "PK_8d8c4940fab2a9d1b2e7ddd9e49" TO "units_summary_pkey"; + +-- AlterTable + +ALTER TABLE "user_roles" RENAME CONSTRAINT "PK_87b8888186ca9769c960e926870" TO "user_roles_pkey"; + + +ALTER TABLE "translations" +ALTER COLUMN "updated_at" +DROP DEFAULT; + + +ALTER TABLE "translations" ADD COLUMN "language_TEMP" VARCHAR; + + +UPDATE "translations" +SET "language_TEMP" = "language"; + + +ALTER TABLE "translations" +DROP COLUMN "language"; + + +ALTER TABLE "translations" ADD COLUMN "language" "languages_enum"; + + +UPDATE "translations" +SET "language" = CAST("language_TEMP" AS "languages_enum"); + + +ALTER TABLE "translations" +DROP COLUMN "language_TEMP"; + +-- AlterTable "unit_accessibility_priority_types" + +ALTER TABLE "unit_accessibility_priority_types" RENAME CONSTRAINT "PK_2cf31d2ceea36e6a6b970608565" TO "unit_accessibility_priority_types_pkey"; + + +ALTER TABLE "unit_accessibility_priority_types" +ALTER COLUMN "updated_at" +DROP DEFAULT; + +-- AlterTable "unit_rent_types" + +ALTER TABLE "unit_rent_types" RENAME CONSTRAINT "PK_fb6b318fdee0a5b30521f63c516" TO "unit_rent_types_pkey"; + + +ALTER TABLE "unit_rent_types" +ALTER COLUMN "updated_at" +DROP DEFAULT; + + +ALTER TABLE "unit_rent_types" ADD COLUMN "name_TEMP" TEXT; + + +UPDATE "unit_rent_types" +SET "name_TEMP" = "name"; + + +ALTER TABLE "unit_rent_types" +DROP COLUMN "name"; + + +ALTER TABLE "unit_rent_types" ADD COLUMN "name" "unit_rent_type_enum"; + + +UPDATE "unit_rent_types" +SET "name" = CAST("name_TEMP" AS "unit_rent_type_enum"); + + +ALTER TABLE "unit_rent_types" +DROP COLUMN "name_TEMP"; + +-- AlterTable "unit_types" + +ALTER TABLE "unit_types" RENAME CONSTRAINT "PK_105c42fcf447c1da21fd20bcb85" TO "unit_types_pkey"; + + +ALTER TABLE "unit_types" +ALTER COLUMN "updated_at" +DROP DEFAULT; + + +ALTER TABLE "unit_types" ADD COLUMN "name_TEMP" TEXT; + + +UPDATE "unit_types" +SET "name_TEMP" = "name"; + + +ALTER TABLE "unit_types" +DROP COLUMN "name"; + + +ALTER TABLE "unit_types" ADD COLUMN "name" "unit_type_enum"; + + +UPDATE "unit_types" +SET "name" = CAST("name_TEMP" AS "unit_type_enum"); + + +ALTER TABLE "unit_types" +DROP COLUMN "name_TEMP"; + +-- AlterTable "units" + +ALTER TABLE "units" RENAME CONSTRAINT "PK_5a8f2f064919b587d93936cb223" TO "units_pkey"; + + +ALTER TABLE "units" ADD COLUMN "status" "units_status_enum" NOT NULL DEFAULT 'unknown'; + + +ALTER TABLE "units" +ALTER COLUMN "updated_at" +DROP DEFAULT; + +-- AlterTable "user_accounts" + +ALTER TABLE "user_accounts" RENAME CONSTRAINT "PK_125e915cf23ad1cfb43815ce59b" TO "user_accounts_pkey"; + + +ALTER TABLE "user_accounts" +ALTER COLUMN "updated_at" +DROP DEFAULT; + + +ALTER TABLE "user_accounts" ADD COLUMN "language_TEMP" VARCHAR; + + +UPDATE "user_accounts" +SET "language_TEMP" = "language"; + + +ALTER TABLE "user_accounts" +DROP COLUMN "language"; + + +ALTER TABLE "user_accounts" ADD COLUMN "language" "languages_enum"; + + +UPDATE "user_accounts" +SET "language" = CAST("language_TEMP" AS "languages_enum"); + + +ALTER TABLE "user_accounts" +DROP COLUMN "language_TEMP"; + +-- CreateTable + +CREATE TABLE "activity_log" ("id" UUID NOT NULL DEFAULT uuid_generate_v4(), + "created_at" TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(6) NOT NULL, + "module" VARCHAR NOT NULL, + "record_id" UUID NOT NULL, + "action" VARCHAR NOT NULL, + "metadata" JSONB, + "user_id" UUID, + CONSTRAINT "activity_log_pkey" PRIMARY KEY ("id")); + + +INSERT INTO "activity_log" ("created_at", + "updated_at", + "module", + "record_id", + "action", + "metadata", + "user_id") +SELECT "created_at", + "updated_at", + "module", + "record_id", + "action", + "metadata", + "user_id" +FROM "activity_logs"; + +-- DropTable + +DROP TABLE "activity_logs"; + +-- CreateTable + +CREATE TABLE "listing_neighborhood_amenities" ("id" UUID NOT NULL DEFAULT uuid_generate_v4(), + "created_at" TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "grocery_stores" TEXT, "pharmacies" TEXT, "health_care_resources" TEXT, "parks_and_community_centers" TEXT, "schools" TEXT, "public_transportation" TEXT, CONSTRAINT "listing_neighborhood_amenities_pkey" PRIMARY KEY ("id")); + +-- CreateTable + +CREATE TABLE "unit_group" ("max_occupancy" INTEGER, "min_occupancy" INTEGER, "floor_min" INTEGER, "floor_max" INTEGER, "total_count" INTEGER, "total_available" INTEGER, "priority_type_id" UUID, + "id" UUID NOT NULL DEFAULT uuid_generate_v4(), + "listing_id" UUID, + "bathroom_min" DECIMAL, "bathroom_max" DECIMAL, "open_waitlist" BOOLEAN NOT NULL DEFAULT true, + "sq_feet_min" DECIMAL, "sq_feet_max" DECIMAL, CONSTRAINT "unit_group_pkey" PRIMARY KEY ("id")); + +-- CreateTable + +CREATE TABLE "unit_group_ami_levels" ("id" UUID NOT NULL DEFAULT uuid_generate_v4(), + "ami_percentage" INTEGER, "monthly_rent_determination_type" "monthly_rent_determination_type_enum" NOT NULL, + "percentage_of_income_value" DECIMAL, "ami_chart_id" UUID, + "unit_group_id" UUID, + "flat_rent_value" DECIMAL, CONSTRAINT "unit_group_ami_levels_pkey" PRIMARY KEY ("id")); + +-- CreateTable + +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, + "favorite_ids" TEXT [] DEFAULT ARRAY [] :: TEXT [], CONSTRAINT "user_preferences_pkey" PRIMARY KEY ("user_id")); + +-- CreateTable + +CREATE TABLE "_ApplicationFlaggedSetToApplications" ("A" UUID NOT NULL, + "B" UUID NOT NULL); + + +INSERT INTO "_ApplicationFlaggedSetToApplications" ("A", + "B") +SELECT "application_flagged_set_id", + "applications_id" +FROM "application_flagged_set_applications_applications"; + +-- DropTable + +DROP TABLE "application_flagged_set_applications_applications"; + +-- CreateTable + +CREATE TABLE "_ApplicationsToUnitTypes" ("A" UUID NOT NULL, + "B" UUID NOT NULL); + + +INSERT INTO "_ApplicationsToUnitTypes" ("A", + "B") +SELECT "applications_id", + "unit_types_id" +FROM "applications_preferred_unit_unit_types"; + +-- DropTable + +DROP TABLE "applications_preferred_unit_unit_types"; + +-- CreateTable + +CREATE TABLE "_JurisdictionsToMultiselectQuestions" ("A" UUID NOT NULL, + "B" UUID NOT NULL); + + +INSERT INTO "_JurisdictionsToMultiselectQuestions" ("A", + "B") +SELECT "jurisdictions_id", + "multiselect_questions_id" +FROM "jurisdictions_multiselect_questions_multiselect_questions"; + +-- DropTable + +DROP TABLE "jurisdictions_multiselect_questions_multiselect_questions"; + +-- CreateTable + +CREATE TABLE "_JurisdictionsToUserAccounts" ("A" UUID NOT NULL, + "B" UUID NOT NULL); + + +INSERT INTO "_JurisdictionsToUserAccounts" ("A", + "B") +SELECT "jurisdictions_id", + "user_accounts_id" +FROM "user_accounts_jurisdictions_jurisdictions"; + +-- DropTable + +DROP TABLE "user_accounts_jurisdictions_jurisdictions"; + +-- CreateTable + +CREATE TABLE "_ListingsToUserAccounts" ("A" UUID NOT NULL, + "B" UUID NOT NULL); + + +INSERT INTO "_ListingsToUserAccounts" ("A", + "B") +SELECT "listings_id", + "user_accounts_id" +FROM "listings_leasing_agents_user_accounts"; + +-- DropTable + +DROP TABLE "listings_leasing_agents_user_accounts"; + +-- CreateTable + +CREATE TABLE "_ListingsToUserPreferences" ("A" UUID NOT NULL, + "B" UUID NOT NULL); + +-- CreateTable + +CREATE TABLE "_UnitGroupToUnitTypes" ("A" UUID NOT NULL, + "B" UUID NOT NULL); + +-- DropTable + +DROP TABLE "revoked_tokens"; + +-- DropEnum + +DROP TYPE "jurisdictions_languages_enum"; + +-- DropEnum + +DROP TYPE "jurisdictions_listing_approval_permissions_enum"; + +-- DropEnum + +DROP TYPE "listings_application_drop_off_address_type_enum"; + +-- DropEnum + +DROP TYPE "listings_application_mailing_address_type_enum"; + +-- DropEnum + +DROP TYPE "listings_application_pick_up_address_type_enum"; + +-- CreateIndex + +CREATE UNIQUE INDEX "user_preferences_user_id_key" ON "user_preferences"("user_id"); + +-- CreateIndex + +CREATE UNIQUE INDEX "_ApplicationFlaggedSetToApplications_AB_unique" ON "_ApplicationFlaggedSetToApplications"("A", + "B"); + +-- CreateIndex + +CREATE INDEX "_ApplicationFlaggedSetToApplications_B_index" ON "_ApplicationFlaggedSetToApplications"("B"); + +-- CreateIndex + +CREATE UNIQUE INDEX "_ApplicationsToUnitTypes_AB_unique" ON "_ApplicationsToUnitTypes"("A", + "B"); + +-- CreateIndex + +CREATE INDEX "_ApplicationsToUnitTypes_B_index" ON "_ApplicationsToUnitTypes"("B"); + +-- CreateIndex + +CREATE UNIQUE INDEX "_JurisdictionsToMultiselectQuestions_AB_unique" ON "_JurisdictionsToMultiselectQuestions"("A", + "B"); + +-- CreateIndex + +CREATE INDEX "_JurisdictionsToMultiselectQuestions_B_index" ON "_JurisdictionsToMultiselectQuestions"("B"); + +-- CreateIndex + +CREATE UNIQUE INDEX "_JurisdictionsToUserAccounts_AB_unique" ON "_JurisdictionsToUserAccounts"("A", + "B"); + +-- CreateIndex + +CREATE INDEX "_JurisdictionsToUserAccounts_B_index" ON "_JurisdictionsToUserAccounts"("B"); + +-- CreateIndex + +CREATE UNIQUE INDEX "_ListingsToUserAccounts_AB_unique" ON "_ListingsToUserAccounts"("A", + "B"); + +-- CreateIndex + +CREATE INDEX "_ListingsToUserAccounts_B_index" ON "_ListingsToUserAccounts"("B"); + +-- CreateIndex + +CREATE UNIQUE INDEX "_ListingsToUserPreferences_AB_unique" ON "_ListingsToUserPreferences"("A", + "B"); + +-- CreateIndex + +CREATE INDEX "_ListingsToUserPreferences_B_index" ON "_ListingsToUserPreferences"("B"); + +-- CreateIndex + +CREATE UNIQUE INDEX "_UnitGroupToUnitTypes_AB_unique" ON "_UnitGroupToUnitTypes"("A", + "B"); + +-- CreateIndex + +CREATE INDEX "_UnitGroupToUnitTypes_B_index" ON "_UnitGroupToUnitTypes"("B"); + +-- CreateIndex + +CREATE UNIQUE INDEX "cron_job_name_key" ON "cron_job"("name"); + +-- CreateIndex + +CREATE UNIQUE INDEX "listings_neighborhood_amenities_id_key" ON "listings"("neighborhood_amenities_id"); + +-- CreateIndex + +CREATE UNIQUE INDEX "translations_jurisdiction_id_language_key" ON "translations"("jurisdiction_id", + "language"); + +-- RenameForeignKey + +ALTER TABLE "alternate_contact" RENAME CONSTRAINT "FK_5eb038a51b9cd6872359a687b18" TO "alternate_contact_mailing_address_id_fkey"; + +-- RenameForeignKey + +ALTER TABLE "ami_chart" RENAME CONSTRAINT "FK_5566b52b2e7c0056e3b81c171f1" TO "ami_chart_jurisdiction_id_fkey"; + +-- RenameForeignKey + +ALTER TABLE "applicant" RENAME CONSTRAINT "FK_7d357035705ebbbe91b50346781" TO "applicant_work_address_id_fkey"; + +-- RenameForeignKey + +ALTER TABLE "applicant" RENAME CONSTRAINT "FK_8ba2b09030c3a2b857dda5f83fe" TO "applicant_address_id_fkey"; + +-- RenameForeignKey + +ALTER TABLE "application_flagged_set" RENAME CONSTRAINT "FK_3aed12c210529ed798beee9d09e" TO "application_flagged_set_resolving_user_id_fkey"; + +-- RenameForeignKey + +ALTER TABLE "application_flagged_set" RENAME CONSTRAINT "FK_f2ace84eebd770f1387b47e5e45" TO "application_flagged_set_listing_id_fkey"; + +-- RenameForeignKey + +ALTER TABLE "application_methods" RENAME CONSTRAINT "FK_3057650361c2aeab15dfee5c3cc" TO "application_methods_listing_id_fkey"; + +-- RenameForeignKey + +ALTER TABLE "applications" RENAME CONSTRAINT "FK_194d0fca275b8661a56e486cb64" TO "applications_applicant_id_fkey"; + +-- RenameForeignKey + +ALTER TABLE "applications" RENAME CONSTRAINT "FK_3a4c71bc34dce9f6c196f110935" TO "applications_accessibility_id_fkey"; + +-- RenameForeignKey + +ALTER TABLE "applications" RENAME CONSTRAINT "FK_56abaa378952856aaccc64d7eb3" TO "applications_alternate_contact_id_fkey"; + +-- RenameForeignKey + +ALTER TABLE "applications" RENAME CONSTRAINT "FK_7fc41f89f22ca59ffceab5da80e" TO "applications_alternate_address_id_fkey"; + +-- RenameForeignKey + +ALTER TABLE "applications" RENAME CONSTRAINT "FK_9e7594d5b474d9cbebba15c1ae7" TO "applications_user_id_fkey"; + +-- RenameForeignKey + +ALTER TABLE "applications" RENAME CONSTRAINT "FK_b72ba26ebc88981f441b30fe3c5" TO "applications_mailing_address_id_fkey"; + +-- RenameForeignKey + +ALTER TABLE "applications" RENAME CONSTRAINT "FK_cc9d65c58d8deb0ef5353e9037d" TO "applications_listing_id_fkey"; + +-- RenameForeignKey + +ALTER TABLE "applications" RENAME CONSTRAINT "FK_fed5da45b7b4dafd9f025a37dd1" TO "applications_demographics_id_fkey"; + +-- RenameForeignKey + +ALTER TABLE "household_member" RENAME CONSTRAINT "FK_520996eeecf9f6fb9425dc7352c" TO "household_member_application_id_fkey"; + +-- RenameForeignKey + +ALTER TABLE "household_member" RENAME CONSTRAINT "FK_7b61da64f1b7a6bbb48eb5bbb43" TO "household_member_address_id_fkey"; + +-- RenameForeignKey + +ALTER TABLE "household_member" RENAME CONSTRAINT "FK_f390552cbb929761927c70b7a0d" TO "household_member_work_address_id_fkey"; + +-- RenameForeignKey + +ALTER TABLE "listing_events" RENAME CONSTRAINT "FK_4fd176b179ce281bedb1b7b9f2b" TO "listing_events_file_id_fkey"; + +-- RenameForeignKey + +ALTER TABLE "listing_events" RENAME CONSTRAINT "FK_d0b9892bc613e4d9f8b5c25d03e" TO "listing_events_listing_id_fkey"; + +-- RenameForeignKey + +ALTER TABLE "listing_images" RENAME CONSTRAINT "FK_6fc0fefe11fb46d5ee863ed483a" TO "listing_images_image_id_fkey"; + +-- RenameForeignKey + +ALTER TABLE "listing_images" RENAME CONSTRAINT "FK_94041359df3c1b14c4420808d16" TO "listing_images_listing_id_fkey"; + +-- RenameForeignKey + +ALTER TABLE "listing_multiselect_questions" RENAME CONSTRAINT "FK_92adcb35f2f14e316b4cb12a84e" TO "listing_multiselect_questions_multiselect_question_id_fkey"; + +-- RenameForeignKey + +ALTER TABLE "listing_multiselect_questions" RENAME CONSTRAINT "FK_d123697625fe564c2bae54dcecf" TO "listing_multiselect_questions_listing_id_fkey"; + +-- RenameForeignKey + +ALTER TABLE "listings" RENAME CONSTRAINT "FK_17e861d96c1bde13c1f4c344cb6" TO "listings_application_drop_off_address_id_fkey"; + +-- RenameForeignKey + +ALTER TABLE "listings" RENAME CONSTRAINT "FK_1f6fac73d27c81b656cc6100267" TO "listings_reserved_community_type_id_fkey"; + +-- RenameForeignKey + +ALTER TABLE "listings" RENAME CONSTRAINT "FK_2634b9bcb29ec36a629d9e379f0" TO "listings_building_selection_criteria_file_id_fkey"; + +-- RenameForeignKey + +ALTER TABLE "listings" RENAME CONSTRAINT "FK_3f7b2aedbfccd6297923943e311" TO "listings_result_id_fkey"; + +-- RenameForeignKey + +ALTER TABLE "listings" RENAME CONSTRAINT "FK_61b80a947c9db249548ba3c73a5" TO "listings_utilities_id_fkey"; + +-- RenameForeignKey + +ALTER TABLE "listings" RENAME CONSTRAINT "FK_7cedb0a800e3c0af7ede27ab1ec" TO "listings_application_mailing_address_id_fkey"; + +-- RenameForeignKey + +ALTER TABLE "listings" RENAME CONSTRAINT "FK_8a93cc462d190d3f1a04fa69156" TO "listings_leasing_agent_address_id_fkey"; + +-- RenameForeignKey + +ALTER TABLE "listings" RENAME CONSTRAINT "FK_ac59a58a02199c57a588f045830" TO "listings_features_id_fkey"; + +-- RenameForeignKey + +ALTER TABLE "listings" RENAME CONSTRAINT "FK_ba0026e02ecfe91791aed1a4818" TO "listings_jurisdiction_id_fkey"; + +-- RenameForeignKey + +ALTER TABLE "listings" RENAME CONSTRAINT "FK_d54596fd877e83a3126d3953f36" TO "listings_application_pick_up_address_id_fkey"; + +-- RenameForeignKey + +ALTER TABLE "listings" RENAME CONSTRAINT "FK_e5d5291cd6ab92cbec304aab905" TO "listings_building_address_id_fkey"; + +-- RenameForeignKey + +ALTER TABLE "paper_applications" RENAME CONSTRAINT "FK_493291d04c708dda2ffe5b521e7" TO "paper_applications_file_id_fkey"; + +-- RenameForeignKey + +ALTER TABLE "paper_applications" RENAME CONSTRAINT "FK_bd67da96ae3e2c0e37394ba1dd3" TO "paper_applications_application_method_id_fkey"; + +-- RenameForeignKey + +ALTER TABLE "reserved_community_types" RENAME CONSTRAINT "FK_8b43c85a0dd0c39ca795c369edc" TO "reserved_community_types_jurisdiction_id_fkey"; + +-- RenameForeignKey + +ALTER TABLE "translations" RENAME CONSTRAINT "FK_181f8168d13457f0fd00b08b359" TO "translations_jurisdiction_id_fkey"; + +-- RenameForeignKey + +ALTER TABLE "units" RENAME CONSTRAINT "FK_1e193f5ffdda908517e47d4e021" TO "units_unit_type_id_fkey"; + +-- RenameForeignKey + +ALTER TABLE "units" RENAME CONSTRAINT "FK_35571c6bd2a1ff690201d1dff08" TO "units_ami_chart_id_fkey"; + +-- RenameForeignKey + +ALTER TABLE "units" RENAME CONSTRAINT "FK_4ca3d4c823e6bd5149ecaad363a" TO "units_ami_chart_override_id_fkey"; + +-- RenameForeignKey + +ALTER TABLE "units" RENAME CONSTRAINT "FK_6981f323d01ba8d55190480078d" TO "units_priority_type_id_fkey"; + +-- RenameForeignKey + +ALTER TABLE "units" RENAME CONSTRAINT "FK_9aebcde52d6e054e5ac5d26228c" TO "units_listing_id_fkey"; + +-- RenameForeignKey + +ALTER TABLE "units" RENAME CONSTRAINT "FK_ff9559bf9a1daecef4a89bad4a9" TO "units_unit_rent_type_id_fkey"; + +-- RenameForeignKey + +ALTER TABLE "units_summary" RENAME CONSTRAINT "FK_0eae6ec11a6109496d80d9a88f9" TO "units_summary_unit_type_id_fkey"; + +-- RenameForeignKey + +ALTER TABLE "units_summary" RENAME CONSTRAINT "FK_4791099ef82551aa9819a71d8f5" TO "units_summary_priority_type_id_fkey"; + +-- RenameForeignKey + +ALTER TABLE "units_summary" RENAME CONSTRAINT "FK_4edda29192dbc0c6a18e15437a0" TO "units_summary_listing_id_fkey"; + +-- RenameForeignKey + +ALTER TABLE "user_roles" RENAME CONSTRAINT "FK_87b8888186ca9769c960e926870" TO "user_roles_user_id_fkey"; + +-- AddForeignKey + +ALTER TABLE "activity_log" ADD CONSTRAINT "activity_log_user_id_fkey" +FOREIGN KEY ("user_id") REFERENCES "user_accounts"("id") ON +DELETE +SET NULL ON +UPDATE NO ACTION; + +-- AddForeignKey + +ALTER TABLE "listings" ADD CONSTRAINT "listings_neighborhood_amenities_id_fkey" +FOREIGN KEY ("neighborhood_amenities_id") REFERENCES "listing_neighborhood_amenities"("id") ON +DELETE NO ACTION ON +UPDATE NO ACTION; + +-- AddForeignKey + +ALTER TABLE "unit_group" ADD CONSTRAINT "unit_group_listing_id_fkey" +FOREIGN KEY ("listing_id") REFERENCES "listings"("id") ON +DELETE NO ACTION ON +UPDATE NO ACTION; + +-- AddForeignKey + +ALTER TABLE "unit_group" ADD CONSTRAINT "unit_group_priority_type_id_fkey" +FOREIGN KEY ("priority_type_id") REFERENCES "unit_accessibility_priority_types"("id") ON +DELETE NO ACTION ON +UPDATE NO ACTION; + +-- AddForeignKey + +ALTER TABLE "unit_group_ami_levels" ADD CONSTRAINT "unit_group_ami_levels_unit_group_id_fkey" +FOREIGN KEY ("unit_group_id") REFERENCES "unit_group"("id") ON +DELETE NO ACTION ON +UPDATE NO ACTION; + +-- AddForeignKey + +ALTER TABLE "unit_group_ami_levels" ADD CONSTRAINT "unit_group_ami_levels_ami_chart_id_fkey" +FOREIGN KEY ("ami_chart_id") REFERENCES "ami_chart"("id") ON +DELETE NO ACTION ON +UPDATE NO ACTION; + +-- AddForeignKey + +ALTER TABLE "user_preferences" ADD CONSTRAINT "user_preferences_user_id_fkey" +FOREIGN KEY ("user_id") REFERENCES "user_accounts"("id") ON +DELETE CASCADE ON +UPDATE NO ACTION; + +-- AddForeignKey + +ALTER TABLE "_ApplicationFlaggedSetToApplications" ADD CONSTRAINT "_ApplicationFlaggedSetToApplications_A_fkey" +FOREIGN KEY ("A") REFERENCES "application_flagged_set"("id") ON +DELETE CASCADE ON +UPDATE CASCADE; + +-- AddForeignKey + +ALTER TABLE "_ApplicationFlaggedSetToApplications" ADD CONSTRAINT "_ApplicationFlaggedSetToApplications_B_fkey" +FOREIGN KEY ("B") REFERENCES "applications"("id") ON +DELETE CASCADE ON +UPDATE CASCADE; + +-- AddForeignKey + +ALTER TABLE "_ApplicationsToUnitTypes" ADD CONSTRAINT "_ApplicationsToUnitTypes_A_fkey" +FOREIGN KEY ("A") REFERENCES "applications"("id") ON +DELETE CASCADE ON +UPDATE CASCADE; + +-- AddForeignKey + +ALTER TABLE "_ApplicationsToUnitTypes" ADD CONSTRAINT "_ApplicationsToUnitTypes_B_fkey" +FOREIGN KEY ("B") REFERENCES "unit_types"("id") ON +DELETE CASCADE ON +UPDATE CASCADE; + +-- AddForeignKey + +ALTER TABLE "_JurisdictionsToMultiselectQuestions" ADD CONSTRAINT "_JurisdictionsToMultiselectQuestions_A_fkey" +FOREIGN KEY ("A") REFERENCES "jurisdictions"("id") ON +DELETE CASCADE ON +UPDATE CASCADE; + +-- AddForeignKey + +ALTER TABLE "_JurisdictionsToMultiselectQuestions" ADD CONSTRAINT "_JurisdictionsToMultiselectQuestions_B_fkey" +FOREIGN KEY ("B") REFERENCES "multiselect_questions"("id") ON +DELETE CASCADE ON +UPDATE CASCADE; + +-- AddForeignKey + +ALTER TABLE "_JurisdictionsToUserAccounts" ADD CONSTRAINT "_JurisdictionsToUserAccounts_A_fkey" +FOREIGN KEY ("A") REFERENCES "jurisdictions"("id") ON +DELETE CASCADE ON +UPDATE CASCADE; + +-- AddForeignKey + +ALTER TABLE "_JurisdictionsToUserAccounts" ADD CONSTRAINT "_JurisdictionsToUserAccounts_B_fkey" +FOREIGN KEY ("B") REFERENCES "user_accounts"("id") ON +DELETE CASCADE ON +UPDATE CASCADE; + +-- AddForeignKey + +ALTER TABLE "_ListingsToUserAccounts" ADD CONSTRAINT "_ListingsToUserAccounts_A_fkey" +FOREIGN KEY ("A") REFERENCES "listings"("id") ON +DELETE CASCADE ON +UPDATE CASCADE; + +-- AddForeignKey + +ALTER TABLE "_ListingsToUserAccounts" ADD CONSTRAINT "_ListingsToUserAccounts_B_fkey" +FOREIGN KEY ("B") REFERENCES "user_accounts"("id") ON +DELETE CASCADE ON +UPDATE CASCADE; + +-- AddForeignKey + +ALTER TABLE "_ListingsToUserPreferences" ADD CONSTRAINT "_ListingsToUserPreferences_A_fkey" +FOREIGN KEY ("A") REFERENCES "listings"("id") ON +DELETE CASCADE ON +UPDATE CASCADE; + +-- AddForeignKey + +ALTER TABLE "_ListingsToUserPreferences" ADD CONSTRAINT "_ListingsToUserPreferences_B_fkey" +FOREIGN KEY ("B") REFERENCES "user_preferences"("user_id") ON +DELETE CASCADE ON +UPDATE CASCADE; + +-- AddForeignKey + +ALTER TABLE "_UnitGroupToUnitTypes" ADD CONSTRAINT "_UnitGroupToUnitTypes_A_fkey" +FOREIGN KEY ("A") REFERENCES "unit_group"("id") ON +DELETE CASCADE ON +UPDATE CASCADE; + +-- AddForeignKey + +ALTER TABLE "_UnitGroupToUnitTypes" ADD CONSTRAINT "_UnitGroupToUnitTypes_B_fkey" +FOREIGN KEY ("B") REFERENCES "unit_types"("id") ON +DELETE CASCADE ON +UPDATE CASCADE; + +-- RenameIndex + +ALTER INDEX "REL_7d357035705ebbbe91b5034678" RENAME TO "applicant_work_address_id_key"; + +-- RenameIndex + +ALTER INDEX "REL_8ba2b09030c3a2b857dda5f83f" RENAME TO "applicant_address_id_key"; + +-- RenameIndex + +ALTER INDEX "IDX_f2ace84eebd770f1387b47e5e4" RENAME TO "application_flagged_set_listing_id_idx"; + +-- RenameIndex + +ALTER INDEX "UQ_2983d3205a16bfae28323d021ea" RENAME TO "application_flagged_set_rule_key_key"; + +-- RenameIndex + +ALTER INDEX "IDX_cc9d65c58d8deb0ef5353e9037" RENAME TO "applications_listing_id_idx"; + +-- RenameIndex + +ALTER INDEX "UQ_556c258a4439f1b7f53de2ed74f" RENAME TO "applications_listing_id_confirmation_code_key"; + +-- RenameIndex + +ALTER INDEX "IDX_520996eeecf9f6fb9425dc7352" RENAME TO "household_member_application_id_idx"; + +-- RenameIndex + +ALTER INDEX "REL_7b61da64f1b7a6bbb48eb5bbb4" RENAME TO "household_member_address_id_key"; + +-- RenameIndex + +ALTER INDEX "REL_f390552cbb929761927c70b7a0" RENAME TO "household_member_work_address_id_key"; + +-- RenameIndex + +ALTER INDEX "UQ_60b3294568b273d896687dea59f" RENAME TO "jurisdictions_name_key"; + +-- RenameIndex + +ALTER INDEX "IDX_94041359df3c1b14c4420808d1" RENAME TO "listing_images_listing_id_idx"; + +-- RenameIndex + +ALTER INDEX "IDX_ba0026e02ecfe91791aed1a481" RENAME TO "listings_jurisdiction_id_idx"; + +-- RenameIndex + +ALTER INDEX "UQ_df3802ec9c31dd9491e3589378d" RENAME TO "user_accounts_email_key"; + +-- DropForeignKey + +ALTER TABLE "paper_applications" +DROP CONSTRAINT "paper_applications_file_id_fkey"; + +-- AddForeignKey + +ALTER TABLE "paper_applications" ADD CONSTRAINT "paper_applications_file_id_fkey" +FOREIGN KEY ("file_id") REFERENCES "assets"("id") ON +DELETE CASCADE ON +UPDATE NO ACTION; + +-- Re-add non-null fields + -- AlterTable + +ALTER TABLE "application_flagged_set" +ALTER COLUMN "rule" +SET NOT NULL; + +-- AlterTable + +ALTER TABLE "applications" +ALTER COLUMN "status" +SET NOT NULL, +ALTER COLUMN "submission_type" +SET NOT NULL; + +-- AlterTable + +ALTER TABLE "translations" +ALTER COLUMN "language" +SET NOT NULL; + +-- AlterTable + +ALTER TABLE "unit_rent_types" +ALTER COLUMN "name" +SET NOT NULL; + +-- AlterTable + +ALTER TABLE "unit_types" +ALTER COLUMN "name" +SET NOT NULL; + + +ALTER TABLE "generated_listing_translations" +ALTER COLUMN "language" +SET NOT NULL; + +-- AlterTable + +ALTER TABLE "paper_applications" +ALTER COLUMN "language" +SET NOT NULL; + +ALTER TABLE "map_layers" RENAME CONSTRAINT "PK_d1bcb10041ba88ffea330dc10d9" TO "map_layers_pkey"; diff --git a/api/prisma/migrations/02_hba_to_prisma/migration.sql b/api/prisma/migrations/02_hba_to_prisma/migration.sql new file mode 100644 index 0000000000..45055b902d --- /dev/null +++ b/api/prisma/migrations/02_hba_to_prisma/migration.sql @@ -0,0 +1,35 @@ +-- DropIndex +DROP INDEX "UQ_87b8888186ca9769c960e926870"; + +-- AlterTable +ALTER TABLE "translations" RENAME CONSTRAINT "PK_aca248c72ae1fb2390f1bf4cd87" TO "translations_pkey"; + +-- RenameIndex +ALTER INDEX "REL_5eb038a51b9cd6872359a687b1" RENAME TO "alternate_contact_mailing_address_id_key"; + +-- RenameIndex +ALTER INDEX "REL_194d0fca275b8661a56e486cb6" RENAME TO "applications_applicant_id_key"; + +-- RenameIndex +ALTER INDEX "REL_3a4c71bc34dce9f6c196f11093" RENAME TO "applications_accessibility_id_key"; + +-- RenameIndex +ALTER INDEX "REL_56abaa378952856aaccc64d7eb" RENAME TO "applications_alternate_contact_id_key"; + +-- RenameIndex +ALTER INDEX "REL_7fc41f89f22ca59ffceab5da80" RENAME TO "applications_alternate_address_id_key"; + +-- RenameIndex +ALTER INDEX "REL_b72ba26ebc88981f441b30fe3c" RENAME TO "applications_mailing_address_id_key"; + +-- RenameIndex +ALTER INDEX "REL_fed5da45b7b4dafd9f025a37dd" RENAME TO "applications_demographics_id_key"; + +-- RenameIndex +ALTER INDEX "REL_61b80a947c9db249548ba3c73a" RENAME TO "listings_utilities_id_key"; + +-- RenameIndex +ALTER INDEX "REL_ac59a58a02199c57a588f04583" RENAME TO "listings_features_id_key"; + +-- RenameIndex +ALTER INDEX "REL_4ca3d4c823e6bd5149ecaad363" RENAME TO "units_ami_chart_override_id_key"; diff --git a/api/prisma/migrations/migration_lock.toml b/api/prisma/migrations/migration_lock.toml new file mode 100644 index 0000000000..fbffa92c2b --- /dev/null +++ b/api/prisma/migrations/migration_lock.toml @@ -0,0 +1,3 @@ +# Please do not edit this file manually +# It should be added in your version-control system (i.e. Git) +provider = "postgresql" \ No newline at end of file diff --git a/api/prisma/schema.prisma b/api/prisma/schema.prisma new file mode 100644 index 0000000000..13f6e4231d --- /dev/null +++ b/api/prisma/schema.prisma @@ -0,0 +1,1060 @@ +generator client { + provider = "prisma-client-js" + previewFeatures = ["postgresqlExtensions"] +} + +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") + // directUrl = env("DIRECT_URL") + extensions = [uuidOssp(map: "uuid-ossp")] +} + +model Accessibility { + id String @id() @default(dbgenerated("uuid_generate_v4()")) @db.Uuid + createdAt DateTime @default(now()) @map("created_at") @db.Timestamp(6) + updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamp(6) + mobility Boolean? + vision Boolean? + hearing Boolean? + applications Applications? + + @@map("accessibility") +} + +model ActivityLog { + id String @id() @default(dbgenerated("uuid_generate_v4()")) @db.Uuid + createdAt DateTime @default(now()) @map("created_at") @db.Timestamp(6) + updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamp(6) + module String @db.VarChar + recordId String @map("record_id") @db.Uuid + action String @db.VarChar + metadata Json? + userId String? @map("user_id") @db.Uuid + userAccounts UserAccounts? @relation(fields: [userId], references: [id], onUpdate: NoAction) + + @@map("activity_log") +} + +// Note: [place_name, city, county, state, street, street2, zip_code] formerly had max length of 64 characters +model Address { + id String @id() @default(dbgenerated("uuid_generate_v4()")) @db.Uuid + createdAt DateTime @default(now()) @map("created_at") @db.Timestamp(6) + updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamp(6) + placeName String? @map("place_name") + city String? + county String? + state String? + street String? + street2 String? + zipCode String? @map("zip_code") + latitude Decimal? @db.Decimal + longitude Decimal? @db.Decimal + alternateContact AlternateContact? + applicantWorkAddress Applicant? @relation("applicant_work_address") + applicantAddress Applicant? @relation("applicant_address") + applicationsAlternateAddress Applications? @relation("applications_alternate_address") + applicationsMailingAddress Applications? @relation("applications_mailing_address") + householdMemberAddress HouseholdMember? @relation("household_member_address") + householdMemberWorkAddress HouseholdMember? @relation("household_member_work_address") + applicationDropOffAddress Listings[] @relation("application_drop_off_address") + applicationMailingAddress Listings[] @relation("application_mailing_address") + leasingAgentAddress Listings[] @relation("leasing_agent_address") + applicationPickUpAddress Listings[] @relation("application_pick_up_address") + buildingAddress Listings[] @relation("building_address") + + @@map("address") +} + +// Note: [type, phone_number] formerly max length 16; [other_type, first_name, last_name] formerly max length 64; [agency] formerly max length 128 +// Note: [email_address] has an isEmail validator attached to it +model AlternateContact { + id String @id() @default(dbgenerated("uuid_generate_v4()")) @db.Uuid + createdAt DateTime @default(now()) @map("created_at") @db.Timestamp(6) + updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamp(6) + type String? + otherType String? @map("other_type") + firstName String? @map("first_name") + lastName String? @map("last_name") + agency String? + phoneNumber String? @map("phone_number") + emailAddress String? @map("email_address") + mailingAddressId String? @unique() @map("mailing_address_id") @db.Uuid + address Address? @relation(fields: [mailingAddressId], references: [id], onDelete: NoAction, onUpdate: NoAction) + applications Applications? + + @@map("alternate_contact") +} + +// Note: [items] was formerly type protected as AmiChartItem +model AmiChart { + id String @id() @default(dbgenerated("uuid_generate_v4()")) @db.Uuid + createdAt DateTime @default(now()) @map("created_at") @db.Timestamp(6) + updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamp(6) + items Json + name String @db.VarChar + jurisdictionId String @map("jurisdiction_id") @db.Uuid + jurisdictions Jurisdictions @relation(fields: [jurisdictionId], references: [id], onDelete: NoAction, onUpdate: NoAction) + unitGroupAmiLevels UnitGroupAmiLevels[] + units Units[] + + @@map("ami_chart") +} + +// Note: [birth_month, birth_day, birth_year] formerly max length 8; [phone_number, phone_number_type] formerly max length 16; +// [first_name, middle_name, last_name] formerly max length 64 +// Note: [first_name, last_name] formerly min length 1 +// Note: [email_address] needs to have lower case enforcement and needs isEmail validator +model Applicant { + id String @id() @default(dbgenerated("uuid_generate_v4()")) @db.Uuid + createdAt DateTime @default(now()) @map("created_at") @db.Timestamp(6) + updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamp(6) + firstName String? @map("first_name") + middleName String? @map("middle_name") + lastName String? @map("last_name") + birthMonth String? @map("birth_month") + birthDay String? @map("birth_day") + birthYear String? @map("birth_year") + emailAddress String? @map("email_address") + noEmail Boolean? @map("no_email") + phoneNumber String? @map("phone_number") + phoneNumberType String? @map("phone_number_type") + noPhone Boolean? @map("no_phone") + workInRegion YesNoEnum? @map("work_in_region") + workAddressId String? @unique() @map("work_address_id") @db.Uuid + addressId String? @unique() @map("address_id") @db.Uuid + applicantWorkAddress Address? @relation("applicant_work_address", fields: [workAddressId], references: [id], onDelete: NoAction, onUpdate: NoAction) + applicantAddress Address? @relation("applicant_address", fields: [addressId], references: [id], onDelete: NoAction, onUpdate: NoAction) + applications Applications? + + @@map("applicant") +} + +// Note: [rule] used to be a different enum but prisma doesn't support that kind of enum yet. See: https://github.com/prisma/prisma/issues/273 +model ApplicationFlaggedSet { + id String @id() @default(dbgenerated("uuid_generate_v4()")) @db.Uuid + createdAt DateTime @default(now()) @map("created_at") @db.Timestamp(6) + updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamp(6) + rule RuleEnum + ruleKey String @unique() @map("rule_key") @db.VarChar + resolvedTime DateTime? @map("resolved_time") @db.Timestamptz(6) + listingId String @map("listing_id") @db.Uuid + showConfirmationAlert Boolean @default(false) @map("show_confirmation_alert") + status FlaggedSetStatusEnum @default(pending) + resolvingUserId String? @map("resolving_user_id") @db.Uuid + userAccounts UserAccounts? @relation(fields: [resolvingUserId], references: [id], onDelete: NoAction, onUpdate: NoAction) + listings Listings @relation(fields: [listingId], references: [id], onDelete: NoAction, onUpdate: NoAction) + applications Applications[] + + @@index([listingId]) + @@map("application_flagged_set") +} + +// Note: [phone_number] formerly max length 16; [label] formerly max length 256; [external_reference] formerly max length 4096 +model ApplicationMethods { + id String @id() @default(dbgenerated("uuid_generate_v4()")) @db.Uuid + createdAt DateTime @default(now()) @map("created_at") @db.Timestamp(6) + updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamp(6) + type ApplicationMethodsTypeEnum + label String? + externalReference String? @map("external_reference") + acceptsPostmarkedApplications Boolean? @map("accepts_postmarked_applications") + phoneNumber String? @map("phone_number") + listingId String? @map("listing_id") @db.Uuid + listings Listings? @relation(fields: [listingId], references: [id], onDelete: NoAction, onUpdate: NoAction) + paperApplications PaperApplications[] + + @@map("application_methods") +} + +// Note: [additional_phone_number, additional_phone_number_type, household_size] formerly max length 16; +// [contact_preferences, income] formerly max length 64; +// [app_url] formerly max length 256; +// Note: [contact_preferences] formerly max array length of 8 +// Note: [household_member] formerly had max array lenght of 32 +// Note: missing virtual [flagged] field +model Applications { + id String @id() @default(dbgenerated("uuid_generate_v4()")) @db.Uuid + createdAt DateTime @default(now()) @map("created_at") @db.Timestamp(6) + updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamp(6) + deletedAt DateTime? @map("deleted_at") @db.Timestamp(6) + appUrl String? @map("app_url") + additionalPhone Boolean? @map("additional_phone") + additionalPhoneNumber String? @map("additional_phone_number") + additionalPhoneNumberType String? @map("additional_phone_number_type") + contactPreferences String[] @map("contact_preferences") + householdSize Int? @map("household_size") + housingStatus String? @map("housing_status") + sendMailToMailingAddress Boolean? @map("send_mail_to_mailing_address") + householdExpectingChanges Boolean? @map("household_expecting_changes") + householdStudent Boolean? @map("household_student") + incomeVouchers Boolean? @map("income_vouchers") + income String? + incomePeriod IncomePeriodEnum? @map("income_period") + preferences Json + programs Json? + status ApplicationStatusEnum + language LanguagesEnum? + submissionType ApplicationSubmissionTypeEnum @map("submission_type") + acceptedTerms Boolean? @map("accepted_terms") + submissionDate DateTime? @map("submission_date") @db.Timestamptz(6) + // if this field is true then the application is a confirmed duplicate + // meaning that the record in the application flagged set table has a status of duplicate + markedAsDuplicate Boolean @default(false) @map("marked_as_duplicate") + confirmationCode String @map("confirmation_code") + reviewStatus ApplicationReviewStatusEnum @default(pending) @map("review_status") + userId String? @map("user_id") @db.Uuid + listingId String? @map("listing_id") @db.Uuid + applicantId String? @unique() @map("applicant_id") @db.Uuid + mailingAddressId String? @unique() @map("mailing_address_id") @db.Uuid + alternateAddressId String? @unique() @map("alternate_address_id") @db.Uuid + alternateContactId String? @unique() @map("alternate_contact_id") @db.Uuid + accessibilityId String? @unique() @map("accessibility_id") @db.Uuid + demographicsId String? @unique() @map("demographics_id") @db.Uuid + applicationFlaggedSet ApplicationFlaggedSet[] + applicant Applicant? @relation(fields: [applicantId], references: [id], onDelete: NoAction, onUpdate: NoAction) + accessibility Accessibility? @relation(fields: [accessibilityId], references: [id], onDelete: NoAction, onUpdate: NoAction) + alternateContact AlternateContact? @relation(fields: [alternateContactId], references: [id], onDelete: NoAction, onUpdate: NoAction) + applicationsAlternateAddress Address? @relation("applications_alternate_address", fields: [alternateAddressId], references: [id], onDelete: NoAction, onUpdate: NoAction) + userAccounts UserAccounts? @relation(fields: [userId], references: [id], onUpdate: NoAction) + applicationsMailingAddress Address? @relation("applications_mailing_address", fields: [mailingAddressId], references: [id], onDelete: NoAction, onUpdate: NoAction) + listings Listings? @relation(fields: [listingId], references: [id], onDelete: NoAction, onUpdate: NoAction) + demographics Demographics? @relation(fields: [demographicsId], references: [id], onDelete: NoAction, onUpdate: NoAction) + preferredUnitTypes UnitTypes[] + householdMember HouseholdMember[] + + @@unique([listingId, confirmationCode]) + @@index([listingId]) + @@map("applications") +} + +// Note: [file_id, label] formerly max length 128 +model Assets { + id String @id() @default(dbgenerated("uuid_generate_v4()")) @db.Uuid + createdAt DateTime @default(now()) @map("created_at") @db.Timestamp(6) + updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamp(6) + fileId String @map("file_id") + label String + listingEvents ListingEvents[] + listingImages ListingImages[] + buildingSelectionCriteriaFile Listings[] @relation("building_selection_criteria_file") + listingsResult Listings[] @relation("listings_result") + paperApplications PaperApplications[] + + @@map("assets") +} + +// Note: [name] formerly max length 64 +model CronJob { + id String @id() @default(dbgenerated("uuid_generate_v4()")) @db.Uuid + name String? @unique() + lastRunDate DateTime? @map("last_run_date") @db.Timestamptz(6) + createdAt DateTime @default(now()) @map("created_at") @db.Timestamp(6) + updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamp(6) + + @@map("cron_job") +} + +// Note: [ethnicity, gender, sexual_orientation, how_did_you_hear] formerly max length 64 +// Note: [how_did_you_hear, race] formerly max array length 64 +model Demographics { + id String @id() @default(dbgenerated("uuid_generate_v4()")) @db.Uuid + createdAt DateTime @default(now()) @map("created_at") @db.Timestamp(6) + updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamp(6) + ethnicity String? + gender String? + sexualOrientation String? @map("sexual_orientation") + howDidYouHear String[] @map("how_did_you_hear") + race String[] + applications Applications? + + @@map("demographics") +} + +model GeneratedListingTranslations { + id String @id() @default(dbgenerated("uuid_generate_v4()")) @db.Uuid + createdAt DateTime @default(now()) @map("created_at") @db.Timestamp(6) + updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamp(6) + listingId String @map("listing_id") @db.VarChar + jurisdictionId String @map("jurisdiction_id") @db.VarChar + language LanguagesEnum + translations Json + timestamp DateTime @db.Timestamp(6) + + @@map("generated_listing_translations") +} + +// Note: [birth_month, birth_day, birth_year] formerly max length 8; +// [phone_number, phone_number_type] formerly max length 16 +// [first_name, middle_name, last_name, relationship] formerly max length 64; +// Note: [email_address] formerly enforced lower case +model HouseholdMember { + id String @id() @default(dbgenerated("uuid_generate_v4()")) @db.Uuid + createdAt DateTime @default(now()) @map("created_at") @db.Timestamp(6) + updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamp(6) + orderId Int? @map("order_id") + firstName String? @map("first_name") + middleName String? @map("middle_name") + lastName String? @map("last_name") + birthMonth String? @map("birth_month") + birthDay String? @map("birth_day") + birthYear String? @map("birth_year") + sameAddress YesNoEnum? @map("same_address") + relationship String? + workInRegion YesNoEnum? @map("work_in_region") + addressId String? @unique() @map("address_id") @db.Uuid + workAddressId String? @unique() @map("work_address_id") @db.Uuid + applicationId String? @map("application_id") @db.Uuid + applications Applications? @relation(fields: [applicationId], references: [id], onDelete: NoAction, onUpdate: NoAction) + householdMemberAddress Address? @relation("household_member_address", fields: [addressId], references: [id], onDelete: NoAction, onUpdate: NoAction) + householdMemberWorkAddress Address? @relation("household_member_work_address", fields: [workAddressId], references: [id], onDelete: NoAction, onUpdate: NoAction) + + @@index([applicationId]) + @@map("household_member") +} + +// Note: [name] formerly max length 256 +model Jurisdictions { + id String @id() @default(dbgenerated("uuid_generate_v4()")) @db.Uuid + createdAt DateTime @default(now()) @map("created_at") @db.Timestamp(6) + updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamp(6) + name String @unique() + notificationsSignUpUrl String? @map("notifications_sign_up_url") + languages LanguagesEnum[] @default([en]) + partnerTerms String? @map("partner_terms") + publicUrl String @default("") @map("public_url") + emailFromAddress String? @map("email_from_address") + rentalAssistanceDefault String @map("rental_assistance_default") + enablePartnerSettings Boolean @default(false) @map("enable_partner_settings") + enableAccessibilityFeatures Boolean @default(false) @map("enable_accessibility_features") + enableUtilitiesIncluded Boolean @default(false) @map("enable_utilities_included") + enableGeocodingPreferences Boolean @default(false) @map("enable_geocoding_preferences") + amiChart AmiChart[] + multiselectQuestions MultiselectQuestions[] + listings Listings[] + reservedCommunityTypes ReservedCommunityTypes[] + translations Translations[] + user_accounts UserAccounts[] + listingApprovalPermissions UserRoleEnum[] @map("listing_approval_permission") + + @@map("jurisdictions") +} + +enum UserRoleEnum { + user + partner + admin + jurisdictionAdmin + + @@map("user_role_enum") +} + +model ListingEvents { + id String @id() @default(dbgenerated("uuid_generate_v4()")) @db.Uuid + createdAt DateTime @default(now()) @map("created_at") @db.Timestamp(6) + updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamp(6) + type ListingEventsTypeEnum + startDate DateTime? @map("start_date") @db.Timestamptz(6) + startTime DateTime? @map("start_time") @db.Timestamptz(6) + endTime DateTime? @map("end_time") @db.Timestamptz(6) + url String? + note String? + label String? + listingId String? @map("listing_id") @db.Uuid + fileId String? @map("file_id") @db.Uuid + assets Assets? @relation(fields: [fileId], references: [id], onDelete: NoAction, onUpdate: NoAction) + listings Listings? @relation(fields: [listingId], references: [id], onDelete: NoAction, onUpdate: NoAction) + + @@map("listing_events") +} + +model ListingFeatures { + id String @id() @default(dbgenerated("uuid_generate_v4()")) @db.Uuid + createdAt DateTime @default(now()) @map("created_at") @db.Timestamp(6) + updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamp(6) + elevator Boolean? + wheelchairRamp Boolean? @map("wheelchair_ramp") + serviceAnimalsAllowed Boolean? @map("service_animals_allowed") + accessibleParking Boolean? @map("accessible_parking") + parkingOnSite Boolean? @map("parking_on_site") + inUnitWasherDryer Boolean? @map("in_unit_washer_dryer") + laundryInBuilding Boolean? @map("laundry_in_building") + barrierFreeEntrance Boolean? @map("barrier_free_entrance") + rollInShower Boolean? @map("roll_in_shower") + grabBars Boolean? @map("grab_bars") + heatingInUnit Boolean? @map("heating_in_unit") + acInUnit Boolean? @map("ac_in_unit") + hearing Boolean? + visual Boolean? + mobility Boolean? + barrierFreeUnitEntrance Boolean? @map("barrier_free_unit_entrance") + loweredLightSwitch Boolean? @map("lowered_light_switch") + barrierFreeBathroom Boolean? @map("barrier_free_bathroom") + wideDoorways Boolean? @map("wide_doorways") + loweredCabinets Boolean? @map("lowered_cabinets") + listings Listings? + + @@map("listing_features") +} + +model ListingImages { + ordinal Int? + listingId String @map("listing_id") @db.Uuid + imageId String @map("image_id") @db.Uuid + assets Assets @relation(fields: [imageId], references: [id], onDelete: NoAction, onUpdate: NoAction) + listings Listings @relation(fields: [listingId], references: [id], onDelete: NoAction, onUpdate: NoAction) + + @@id([listingId, imageId]) + @@index([listingId]) + @@map("listing_images") +} + +model ListingMultiselectQuestions { + ordinal Int? + listingId String @map("listing_id") @db.Uuid + multiselectQuestionId String @map("multiselect_question_id") @db.Uuid + multiselectQuestions MultiselectQuestions @relation(fields: [multiselectQuestionId], references: [id], onDelete: NoAction, onUpdate: NoAction) + listings Listings @relation(fields: [listingId], references: [id], onDelete: NoAction, onUpdate: NoAction) + + @@id([listingId, multiselectQuestionId]) + @@map("listing_multiselect_questions") +} + +model ListingUtilities { + id String @id() @default(dbgenerated("uuid_generate_v4()")) @db.Uuid + createdAt DateTime @default(now()) @map("created_at") @db.Timestamp(6) + updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamp(6) + water Boolean? + gas Boolean? + trash Boolean? + sewer Boolean? + electricity Boolean? + cable Boolean? + phone Boolean? + internet Boolean? + listings Listings? + + @@map("listing_utilities") +} + +// Note: missing [referralApplication, applicationConfig, showWaitlist, unitsSummarized] virtual property +// Note: [reserved_community_description, result_link] formerly max length 4096; +// Note: [leasing_agent_email] formerly had an isEmail validator +model Listings { + id String @id() @default(dbgenerated("uuid_generate_v4()")) @db.Uuid + createdAt DateTime @default(now()) @map("created_at") @db.Timestamp(6) + updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamp(6) + additionalApplicationSubmissionNotes String? @map("additional_application_submission_notes") + digitalApplication Boolean? @map("digital_application") + commonDigitalApplication Boolean? @map("common_digital_application") + paperApplication Boolean? @map("paper_application") + referralOpportunity Boolean? @map("referral_opportunity") + assets Json + accessibility String? + amenities String? + buildingTotalUnits Int? @map("building_total_units") + developer String? + householdSizeMax Int? @map("household_size_max") + householdSizeMin Int? @map("household_size_min") + neighborhood String? + petPolicy String? @map("pet_policy") + smokingPolicy String? @map("smoking_policy") + unitsAvailable Int? @map("units_available") + unitAmenities String? @map("unit_amenities") + servicesOffered String? @map("services_offered") + yearBuilt Int? @map("year_built") + applicationDueDate DateTime? @map("application_due_date") @db.Timestamptz(6) + applicationOpenDate DateTime? @map("application_open_date") @db.Timestamptz(6) + applicationFee String? @map("application_fee") + applicationOrganization String? @map("application_organization") + applicationPickUpAddressOfficeHours String? @map("application_pick_up_address_office_hours") + applicationPickUpAddressType ApplicationAddressTypeEnum? @map("application_pick_up_address_type") + applicationDropOffAddressOfficeHours String? @map("application_drop_off_address_office_hours") + applicationDropOffAddressType ApplicationAddressTypeEnum? @map("application_drop_off_address_type") + applicationMailingAddressType ApplicationAddressTypeEnum? @map("application_mailing_address_type") + buildingSelectionCriteria String? @map("building_selection_criteria") + costsNotIncluded String? @map("costs_not_included") + creditHistory String? @map("credit_history") + criminalBackground String? @map("criminal_background") + depositMin String? @map("deposit_min") + depositMax String? @map("deposit_max") + depositHelperText String? @map("deposit_helper_text") + disableUnitsAccordion Boolean? @map("disable_units_accordion") + leasingAgentEmail String? @map("leasing_agent_email") + leasingAgentName String? @map("leasing_agent_name") + leasingAgentOfficeHours String? @map("leasing_agent_office_hours") + leasingAgentPhone String? @map("leasing_agent_phone") + leasingAgentTitle String? @map("leasing_agent_title") + name String + postmarkedApplicationsReceivedByDate DateTime? @map("postmarked_applications_received_by_date") @db.Timestamptz(6) + programRules String? @map("program_rules") + rentalAssistance String? @map("rental_assistance") + rentalHistory String? @map("rental_history") + requiredDocuments String? @map("required_documents") + specialNotes String? @map("special_notes") + waitlistCurrentSize Int? @map("waitlist_current_size") + waitlistMaxSize Int? @map("waitlist_max_size") + whatToExpect String? @map("what_to_expect") + status ListingsStatusEnum @default(pending) + reviewOrderType ReviewOrderTypeEnum? @map("review_order_type") + displayWaitlistSize Boolean @map("display_waitlist_size") + reservedCommunityDescription String? @map("reserved_community_description") + reservedCommunityMinAge Int? @map("reserved_community_min_age") + resultLink String? @map("result_link") + isWaitlistOpen Boolean? @map("is_waitlist_open") + waitlistOpenSpots Int? @map("waitlist_open_spots") + customMapPin Boolean? @map("custom_map_pin") + publishedAt DateTime? @map("published_at") @db.Timestamptz(6) + closedAt DateTime? @map("closed_at") @db.Timestamptz(6) + afsLastRunAt DateTime? @default(dbgenerated("'1970-01-01 00:00:00-07'::timestamp with time zone")) @map("afs_last_run_at") @db.Timestamptz(6) + lastApplicationUpdateAt DateTime? @default(dbgenerated("'1970-01-01 00:00:00-07'::timestamp with time zone")) @map("last_application_update_at") @db.Timestamptz(6) + buildingAddressId String? @map("building_address_id") @db.Uuid + applicationPickUpAddressId String? @map("application_pick_up_address_id") @db.Uuid + applicationDropOffAddressId String? @map("application_drop_off_address_id") @db.Uuid + applicationMailingAddressId String? @map("application_mailing_address_id") @db.Uuid + buildingSelectionCriteriaFileId String? @map("building_selection_criteria_file_id") @db.Uuid + jurisdictionId String? @map("jurisdiction_id") @db.Uuid + leasingAgentAddressId String? @map("leasing_agent_address_id") @db.Uuid + reservedCommunityTypeId String? @map("reserved_community_type_id") @db.Uuid + resultId String? @map("result_id") @db.Uuid + featuresId String? @unique() @map("features_id") @db.Uuid + utilitiesId String? @unique() @map("utilities_id") @db.Uuid + // START DETROIT SPECIFIC + hrdId String? @map("hrd_id") + ownerCompany String? @map("owner_company") + managementCompany String? @map("management_company") + managementWebsite String? @map("management_website") + amiPercentageMin Int? @map("ami_percentage_min") + amiPercentageMax Int? @map("ami_percentage_max") + phoneNumber String? @map("phone_number") + temporaryListingId Int? @map("temporary_listing_id") + isVerified Boolean? @default(false) @map("is_verified") + marketingType MarketingTypeEnum @default(marketing) @map("marketing_type") + marketingDate DateTime? @map("marketing_date") @db.Timestamptz(6) + marketingSeason MarketingSeasonEnum? @map("marketing_season") + whatToExpectAdditionalText String? @map("what_to_expect_additional_text") + section8Acceptance Boolean? @map("section8_acceptance") + neighborhoodAmenitiesId String? @unique() @map("neighborhood_amenities_id") @db.Uuid + verifiedAt DateTime? @map("verified_at") @db.Timestamptz(6) + homeType HomeTypeEnum? @map("home_type") + listingNeighborhoodAmenities ListingNeighborhoodAmenities? @relation(fields: [neighborhoodAmenitiesId], references: [id], onDelete: NoAction, onUpdate: NoAction) + region RegionEnum? + // END DETROIT SPECIFIC + applicationFlaggedSet ApplicationFlaggedSet[] + applicationMethods ApplicationMethods[] + applications Applications[] + listingEvents ListingEvents[] + listingImages ListingImages[] + listingMultiselectQuestions ListingMultiselectQuestions[] + listingsApplicationDropOffAddress Address? @relation("application_drop_off_address", fields: [applicationDropOffAddressId], references: [id], onDelete: NoAction, onUpdate: NoAction) + reservedCommunityTypes ReservedCommunityTypes? @relation(fields: [reservedCommunityTypeId], references: [id], onDelete: NoAction, onUpdate: NoAction) + listingsBuildingSelectionCriteriaFile Assets? @relation("building_selection_criteria_file", fields: [buildingSelectionCriteriaFileId], references: [id], onDelete: NoAction, onUpdate: NoAction) + listingsResult Assets? @relation("listings_result", fields: [resultId], references: [id], onDelete: NoAction, onUpdate: NoAction) + listingUtilities ListingUtilities? @relation(fields: [utilitiesId], references: [id], onDelete: NoAction, onUpdate: NoAction) + listingsApplicationMailingAddress Address? @relation("application_mailing_address", fields: [applicationMailingAddressId], references: [id], onDelete: NoAction, onUpdate: NoAction) + listingsLeasingAgentAddress Address? @relation("leasing_agent_address", fields: [leasingAgentAddressId], references: [id], onDelete: NoAction, onUpdate: NoAction) + listingFeatures ListingFeatures? @relation(fields: [featuresId], references: [id], onDelete: NoAction, onUpdate: NoAction) + jurisdictions Jurisdictions? @relation(fields: [jurisdictionId], references: [id], onDelete: NoAction, onUpdate: NoAction) + listingsApplicationPickUpAddress Address? @relation("application_pick_up_address", fields: [applicationPickUpAddressId], references: [id], onDelete: NoAction, onUpdate: NoAction) + listingsBuildingAddress Address? @relation("building_address", fields: [buildingAddressId], references: [id], onDelete: NoAction, onUpdate: NoAction) + userAccounts UserAccounts[] + units Units[] + unitGroup UnitGroup[] + userPreferences UserPreferences[] + unitsSummary UnitsSummary[] + requestedChanges String? @map("requested_changes") + requestedChangesDate DateTime? @default(dbgenerated("'1970-01-01 00:00:00-07'::timestamp with time zone")) @map("requested_changes_date") @db.Timestamptz(6) + requestedChangesUserId String? @map("requested_changes_user_id") @db.Uuid + + @@index([jurisdictionId]) + @@map("listings") +} + +model MapLayers { + id String @id() @default(dbgenerated("uuid_generate_v4()")) @db.Uuid + name String + jurisdictionId String @map("jurisdiction_id") + featureCollection Json @map("feature_collection") @default("{}") + + @@map("map_layers") +} + +// Note: hold over from TypeORM +model Migrations { + id Int @id() @default(autoincrement()) + timestamp BigInt + name String @db.VarChar + + @@map("migrations") +} + +// Note: missing [untranslatedText, untranslatedOptOutText] virtual fields +// Note: [options] formerly had array max length 64 +model MultiselectQuestions { + id String @id() @default(dbgenerated("uuid_generate_v4()")) @db.Uuid + createdAt DateTime @default(now()) @map("created_at") @db.Timestamp(6) + updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamp(6) + text String + subText String? @map("sub_text") + description String? + links Json? + options Json? + optOutText String? @map("opt_out_text") + hideFromListing Boolean? @map("hide_from_listing") + applicationSection MultiselectQuestionsApplicationSectionEnum @map("application_section") + jurisdictions Jurisdictions[] + listings ListingMultiselectQuestions[] + + @@map("multiselect_questions") +} + +model PaperApplications { + id String @id() @default(dbgenerated("uuid_generate_v4()")) @db.Uuid + createdAt DateTime @default(now()) @map("created_at") @db.Timestamp(6) + updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamp(6) + language LanguagesEnum + fileId String? @map("file_id") @db.Uuid + applicationMethodId String? @map("application_method_id") @db.Uuid + assets Assets? @relation(fields: [fileId], references: [id], onDelete: Cascade, onUpdate: NoAction) + applicationMethods ApplicationMethods? @relation(fields: [applicationMethodId], references: [id], onDelete: NoAction, onUpdate: NoAction) + + @@map("paper_applications") +} + +// Note: [name] formerly max length 256; [description] formerly max length 2048 +model ReservedCommunityTypes { + id String @id() @default(dbgenerated("uuid_generate_v4()")) @db.Uuid + createdAt DateTime @default(now()) @map("created_at") @db.Timestamp(6) + updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamp(6) + name String + description String? + jurisdictionId String @map("jurisdiction_id") @db.Uuid + listings Listings[] + jurisdictions Jurisdictions @relation(fields: [jurisdictionId], references: [id], onDelete: NoAction, onUpdate: NoAction) + + @@map("reserved_community_types") +} + +model Translations { + id String @id() @default(dbgenerated("uuid_generate_v4()")) @db.Uuid + createdAt DateTime @default(now()) @map("created_at") @db.Timestamp(6) + updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamp(6) + language LanguagesEnum + translations Json + jurisdictionId String? @map("jurisdiction_id") @db.Uuid + jurisdictions Jurisdictions? @relation(fields: [jurisdictionId], references: [id], onDelete: NoAction, onUpdate: NoAction) + + @@unique([jurisdictionId, language]) + @@map("translations") +} + +// Note: [name] formerly max length 256 +model UnitAccessibilityPriorityTypes { + id String @id() @default(dbgenerated("uuid_generate_v4()")) @db.Uuid + createdAt DateTime @default(now()) @map("created_at") @db.Timestamp(6) + updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamp(6) + name String + units Units[] + unitGroup UnitGroup[] + unitsSummary UnitsSummary[] + + @@map("unit_accessibility_priority_types") +} + +model UnitAmiChartOverrides { + id String @id() @default(dbgenerated("uuid_generate_v4()")) @db.Uuid + createdAt DateTime @default(now()) @map("created_at") @db.Timestamp(6) + updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamp(6) + items Json + units Units? + + @@map("unit_ami_chart_overrides") +} + +// Note: [name] formerly max length 256 +model UnitRentTypes { + id String @id() @default(dbgenerated("uuid_generate_v4()")) @db.Uuid + createdAt DateTime @default(now()) @map("created_at") @db.Timestamp(6) + updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamp(6) + name UnitRentTypeEnum + units Units[] + + @@map("unit_rent_types") +} + +// Note: [name] formerly max length 256 +model UnitTypes { + id String @id() @default(dbgenerated("uuid_generate_v4()")) @db.Uuid + createdAt DateTime @default(now()) @map("created_at") @db.Timestamp(6) + updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamp(6) + name UnitTypeEnum + numBedrooms Int @map("num_bedrooms") + applications Applications[] + units Units[] + unitGroups UnitGroup[] + unitsSummary UnitsSummary[] + + @@map("unit_types") +} + +model Units { + id String @id() @default(dbgenerated("uuid_generate_v4()")) @db.Uuid + createdAt DateTime @default(now()) @map("created_at") @db.Timestamp(6) + updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamp(6) + amiPercentage String? @map("ami_percentage") + annualIncomeMin String? @map("annual_income_min") + monthlyIncomeMin String? @map("monthly_income_min") + floor Int? + annualIncomeMax String? @map("annual_income_max") + maxOccupancy Int? @map("max_occupancy") + minOccupancy Int? @map("min_occupancy") + monthlyRent String? @map("monthly_rent") + numBathrooms Int? @map("num_bathrooms") + numBedrooms Int? @map("num_bedrooms") + number String? + sqFeet Decimal? @map("sq_feet") @db.Decimal(8, 2) + monthlyRentAsPercentOfIncome Decimal? @map("monthly_rent_as_percent_of_income") @db.Decimal(8, 2) + bmrProgramChart Boolean? @map("bmr_program_chart") + amiChartId String? @map("ami_chart_id") @db.Uuid + listingId String? @map("listing_id") @db.Uuid + unitTypeId String? @map("unit_type_id") @db.Uuid + unitRentTypeId String? @map("unit_rent_type_id") @db.Uuid + priorityTypeId String? @map("priority_type_id") @db.Uuid + amiChartOverrideId String? @unique() @map("ami_chart_override_id") @db.Uuid + unitTypes UnitTypes? @relation(fields: [unitTypeId], references: [id], onDelete: NoAction, onUpdate: NoAction) + amiChart AmiChart? @relation(fields: [amiChartId], references: [id], onDelete: NoAction, onUpdate: NoAction) + unitAmiChartOverrides UnitAmiChartOverrides? @relation(fields: [amiChartOverrideId], references: [id], onDelete: NoAction, onUpdate: NoAction) + unitAccessibilityPriorityTypes UnitAccessibilityPriorityTypes? @relation(fields: [priorityTypeId], references: [id], onDelete: NoAction, onUpdate: NoAction) + listings Listings? @relation(fields: [listingId], references: [id], onDelete: Cascade) + unitRentTypes UnitRentTypes? @relation(fields: [unitRentTypeId], references: [id], onDelete: NoAction, onUpdate: NoAction) + status UnitsStatusEnum @default(unknown) + + @@map("units") +} + +model UnitsSummary { + id String @id() @default(dbgenerated("uuid_generate_v4()")) @db.Uuid + monthlyRentMin Int? @map("monthly_rent_min") + monthlyRentMax Int? @map("monthly_rent_max") + monthlyRentAsPercentOfIncome Decimal? @map("monthly_rent_as_percent_of_income") @db.Decimal(8, 2) + amiPercentage Int? @map("ami_percentage") + minimumIncomeMin String? @map("minimum_income_min") + minimumIncomeMax String? @map("minimum_income_max") + maxOccupancy Int? @map("max_occupancy") + minOccupancy Int? @map("min_occupancy") + floorMin Int? @map("floor_min") + floorMax Int? @map("floor_max") + sqFeetMin Decimal? @map("sq_feet_min") @db.Decimal(8, 2) + sqFeetMax Decimal? @map("sq_feet_max") @db.Decimal(8, 2) + totalCount Int? @map("total_count") + totalAvailable Int? @map("total_available") + unitTypeId String? @map("unit_type_id") @db.Uuid + listingId String? @map("listing_id") @db.Uuid + priorityTypeId String? @map("priority_type_id") @db.Uuid + unitTypes UnitTypes? @relation(fields: [unitTypeId], references: [id], onDelete: NoAction, onUpdate: NoAction) + unitAccessibilityPriorityTypes UnitAccessibilityPriorityTypes? @relation(fields: [priorityTypeId], references: [id], onDelete: NoAction, onUpdate: NoAction) + listings Listings? @relation(fields: [listingId], references: [id], onDelete: NoAction, onUpdate: NoAction) + + @@map("units_summary") +} + +// Note: [mfa_code] formerly max length 16; [first_name, middle_name, last_name] formerly max length 64 +// Note: [email] formerly lower case enforced formerly had an isEmail validator +// Note: [phone_number] formerly had a phone number validator +model UserAccounts { + id String @id() @default(dbgenerated("uuid_generate_v4()")) @db.Uuid + passwordHash String @map("password_hash") @db.VarChar + passwordUpdatedAt DateTime @default(now()) @map("password_updated_at") @db.Timestamp(6) + passwordValidForDays Int @default(180) @map("password_valid_for_days") + resetToken String? @map("reset_token") @db.VarChar + confirmationToken String? @map("confirmation_token") @db.VarChar + confirmedAt DateTime? @map("confirmed_at") @db.Timestamptz(6) + email String @unique() @db.VarChar + firstName String @map("first_name") @db.VarChar + middleName String? @map("middle_name") @db.VarChar + lastName String @map("last_name") @db.VarChar + dob DateTime? @db.Timestamp(6) + phoneNumber String? @map("phone_number") @db.VarChar + createdAt DateTime @default(now()) @map("created_at") @db.Timestamp(6) + updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamp(6) + language LanguagesEnum? + mfaEnabled Boolean @default(false) @map("mfa_enabled") + mfaCode String? @map("mfa_code") @db.VarChar + mfaCodeUpdatedAt DateTime? @map("mfa_code_updated_at") @db.Timestamptz(6) + lastLoginAt DateTime @default(now()) @map("last_login_at") @db.Timestamp(6) + failedLoginAttemptsCount Int @default(0) @map("failed_login_attempts_count") + phoneNumberVerified Boolean? @default(false) @map("phone_number_verified") + agreedToTermsOfService Boolean @default(false) @map("agreed_to_terms_of_service") + hitConfirmationUrl DateTime? @map("hit_confirmation_url") @db.Timestamptz(6) + activeAccessToken String? @map("active_access_token") @db.VarChar + activeRefreshToken String? @map("active_refresh_token") @db.VarChar + activityLogs ActivityLog[] + applicationFlaggedSet ApplicationFlaggedSet[] + applications Applications[] + listings Listings[] + jurisdictions Jurisdictions[] + userPreferences UserPreferences? + userRoles UserRoles? + + @@map("user_accounts") +} + +model UserRoles { + isAdmin Boolean @default(false) @map("is_admin") + isJurisdictionalAdmin Boolean @default(false) @map("is_jurisdictional_admin") + isPartner Boolean @default(false) @map("is_partner") + userId String @id() @map("user_id") @db.Uuid + userAccounts UserAccounts @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@map("user_roles") +} + + +// START DETROIT SPECIFIC +model ListingNeighborhoodAmenities { + id String @id() @default(dbgenerated("uuid_generate_v4()")) @db.Uuid + createdAt DateTime @default(now()) @map("created_at") @db.Timestamp(6) + updatedAt DateTime @default(now()) @map("updated_at") @db.Timestamp(6) + groceryStores String? @map("grocery_stores") + pharmacies String? + healthCareResources String? @map("health_care_resources") + parksAndCommunityCenters String? @map("parks_and_community_centers") + schools String? + publicTransportation String? @map("public_transportation") + listings Listings? + + @@map("listing_neighborhood_amenities") +} + +model UnitGroup { + maxOccupancy Int? @map("max_occupancy") + minOccupancy Int? @map("min_occupancy") + floorMin Int? @map("floor_min") + floorMax Int? @map("floor_max") + totalCount Int? @map("total_count") + totalAvailable Int? @map("total_available") + priorityTypeId String? @map("priority_type_id") @db.Uuid + id String @id() @default(dbgenerated("uuid_generate_v4()")) @db.Uuid + listingId String? @map("listing_id") @db.Uuid + bathroomMin Decimal? @map("bathroom_min") @db.Decimal + bathroomMax Decimal? @map("bathroom_max") @db.Decimal + openWaitlist Boolean @default(true) @map("open_waitlist") + sqFeetMin Decimal? @map("sq_feet_min") @db.Decimal + sqFeetMax Decimal? @map("sq_feet_max") @db.Decimal + listings Listings? @relation(fields: [listingId], references: [id], onDelete: NoAction, onUpdate: NoAction) + unitAccessibilityPriorityTypes UnitAccessibilityPriorityTypes? @relation(fields: [priorityTypeId], references: [id], onDelete: NoAction, onUpdate: NoAction) + unitGroupAmiLevels UnitGroupAmiLevels[] + unitTypes UnitTypes[] + + @@map("unit_group") +} + +model UnitGroupAmiLevels { + id String @id() @default(dbgenerated("uuid_generate_v4()")) @db.Uuid + amiPercentage Int? @map("ami_percentage") + monthlyRentDeterminationType MonthlyRentDeterminationTypeEnum @map("monthly_rent_determination_type") + percentageOfIncomeValue Decimal? @map("percentage_of_income_value") @db.Decimal + amiChartId String? @map("ami_chart_id") @db.Uuid + unitGroupId String? @map("unit_group_id") @db.Uuid + flatRentValue Decimal? @map("flat_rent_value") @db.Decimal + unitGroup UnitGroup? @relation(fields: [unitGroupId], references: [id], onDelete: NoAction, onUpdate: NoAction) + amiChart AmiChart? @relation(fields: [amiChartId], references: [id], onDelete: NoAction, onUpdate: NoAction) + + @@map("unit_group_ami_levels") +} + +model UserPreferences { + sendEmailNotifications Boolean @default(false) @map("send_email_notifications") + sendSmsNotifications Boolean @default(false) @map("send_sms_notifications") + userId String @id() @unique() @map("user_id") @db.Uuid + favoriteIds String[] @default([]) @map("favorite_ids") + userAccounts UserAccounts @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: NoAction) + favoritesListings Listings[] + + @@map("user_preferences") +} + +// END DETROIT SPECIFIC + +enum ApplicationMethodsTypeEnum { + Internal + FileDownload + ExternalLink + PaperPickup + POBox + LeasingAgent + Referral + + @@map("application_methods_type_enum") +} + +enum LanguagesEnum { + en + es + vi + zh + tl + + @@map("languages_enum") +} + +enum ListingEventsTypeEnum { + openHouse + publicLottery + lotteryResults + + @@map("listing_events_type_enum") +} + +enum ApplicationAddressTypeEnum { + leasingAgent + + @@map("listings_application_address_type_enum") +} + +enum ReviewOrderTypeEnum { + lottery + firstComeFirstServe + waitlist + + @@map("listings_review_order_type_enum") +} + +enum ListingsStatusEnum { + active + pending + closed + pendingReview + changesRequested + + @@map("listings_status_enum") +} + +enum MultiselectQuestionsApplicationSectionEnum { + programs + preferences + + @@map("multiselect_questions_application_section_enum") +} + +enum YesNoEnum { + yes + no + + @@map("yes_no_enum") +} + +enum RuleEnum { + nameAndDOB + email + + @@map("rule_enum") +} + +enum FlaggedSetStatusEnum { + flagged + pending + resolved + + @@map("flagged_set_status_enum") +} + +enum IncomePeriodEnum { + perMonth + perYear + + @@map("income_period_enum") +} + +enum ApplicationStatusEnum { + draft + submitted + removed + + @@map("application_status_enum") +} + +enum ApplicationSubmissionTypeEnum { + paper + electronical + + @@map("application_submission_type_enum") +} + +enum ApplicationReviewStatusEnum { + pending + pendingAndValid + valid + duplicate + + @@map("application_review_status_enum") +} + +enum UnitsStatusEnum { + unknown + available + occupied + unavailable + + @@map("units_status_enum") +} + +enum HomeTypeEnum { + apartment + duplex + house + townhome + + @@map("listings_home_type_enum") +} + +enum MarketingSeasonEnum { + spring + summer + fall + winter + + @@map("listings_marketing_season_enum") +} + +enum MarketingTypeEnum { + marketing + comingSoon + + @@map("listings_marketing_type_enum") +} + +enum RegionEnum { + Greater_Downtown + Eastside + Southwest + Westside + + @@map("property_region_enum") +} + +enum MonthlyRentDeterminationTypeEnum { + flatRent + percentageOfIncome + + @@map("monthly_rent_determination_type_enum") +} + +enum UnitRentTypeEnum { + fixed + percentageOfIncome + + @@map("unit_rent_type_enum") +} + +enum UnitTypeEnum { + studio + oneBdrm + twoBdrm + threeBdrm + fourBdrm + SRO + fiveBdrm + + @@map("unit_type_enum") +} diff --git a/api/prisma/seed-dev.ts b/api/prisma/seed-dev.ts new file mode 100644 index 0000000000..9ee139fcb6 --- /dev/null +++ b/api/prisma/seed-dev.ts @@ -0,0 +1,115 @@ +import { + ListingsStatusEnum, + MultiselectQuestionsApplicationSectionEnum, + PrismaClient, +} from '@prisma/client'; +import { userFactory } from './seed-helpers/user-factory'; +import { jurisdictionFactory } from './seed-helpers/jurisdiction-factory'; +import { amiChartFactory } from './seed-helpers/ami-chart-factory'; +import { multiselectQuestionFactory } from './seed-helpers/multiselect-question-factory'; +import { listingFactory } from './seed-helpers/listing-factory'; +import { unitTypeFactoryAll } from './seed-helpers/unit-type-factory'; +import { randomName } from './seed-helpers/word-generator'; +import { randomInt } from 'node:crypto'; +import { applicationFactory } from './seed-helpers/application-factory'; +import { translationFactory } from './seed-helpers/translation-factory'; +import { reservedCommunityTypeFactoryAll } from './seed-helpers/reserved-community-type-factory'; +import { householdMemberFactoryMany } from './seed-helpers/household-member-factory'; +import { APPLICATIONS_PER_LISTINGS, LISTINGS_TO_SEED } from './constants'; + +const listingStatusEnumArray = Object.values(ListingsStatusEnum); + +const createMultiselect = async ( + jurisdictionId: string, + prismaClient: PrismaClient, +) => { + const multiSelectQuestions = [...new Array(4)].map(async (_, index) => { + return await prismaClient.multiselectQuestions.create({ + data: multiselectQuestionFactory(jurisdictionId, { + multiselectQuestion: { + text: randomName(), + applicationSection: + index % 2 + ? MultiselectQuestionsApplicationSectionEnum.preferences + : MultiselectQuestionsApplicationSectionEnum.programs, + }, + optOut: index > 1, + }), + }); + }); + return multiSelectQuestions; +}; + +export const devSeeding = async ( + prismaClient: PrismaClient, + jurisdictionName?: string, +) => { + const jurisdiction = await prismaClient.jurisdictions.create({ + data: jurisdictionFactory(jurisdictionName), + }); + await prismaClient.userAccounts.create({ + data: await userFactory({ + roles: { isAdmin: true }, + email: 'admin@example.com', + confirmedAt: new Date(), + jurisdictionIds: [jurisdiction.id], + acceptedTerms: true, + }), + }); + await prismaClient.userAccounts.create({ + data: await userFactory({ + roles: { isJurisdictionalAdmin: true }, + email: 'jurisdiction-admin@example.com', + confirmedAt: new Date(), + jurisdictionIds: [jurisdiction.id], + }), + }); + // add jurisdiction specific translations and default ones + await prismaClient.translations.create({ + data: translationFactory(jurisdiction.id, jurisdiction.name), + }); + await prismaClient.translations.create({ + data: translationFactory(), + }); + const unitTypes = await unitTypeFactoryAll(prismaClient); + const amiChart = await prismaClient.amiChart.create({ + data: amiChartFactory(10, jurisdiction.id), + }); + const multiselectQuestions = await Promise.all( + await createMultiselect(jurisdiction.id, prismaClient), + ); + + await reservedCommunityTypeFactoryAll(jurisdiction.id, prismaClient); + + const householdSize = randomInt(0, 6); + for (let index = 0; index < LISTINGS_TO_SEED; index++) { + const householdMembers = await householdMemberFactoryMany(householdSize); + + const applications = []; + + for (let j = 0; j <= APPLICATIONS_PER_LISTINGS; j++) { + applications.push( + await applicationFactory({ + householdSize, + unitTypeId: unitTypes[randomInt(0, 5)].id, + householdMember: householdMembers, + multiselectQuestions, + }), + ); + } + + const listing = await listingFactory(jurisdiction.id, prismaClient, { + amiChart: amiChart, + numberOfUnits: index, + includeBuildingFeatures: index > 1, + includeEligibilityRules: index > 2, + status: listingStatusEnumArray[randomInt(listingStatusEnumArray.length)], + multiselectQuestions: + index > 0 ? multiselectQuestions.slice(0, index - 1) : [], + applications, + }); + await prismaClient.listings.create({ + data: listing, + }); + } +}; diff --git a/api/prisma/seed-helpers/address-factory.ts b/api/prisma/seed-helpers/address-factory.ts new file mode 100644 index 0000000000..1cafe72e49 --- /dev/null +++ b/api/prisma/seed-helpers/address-factory.ts @@ -0,0 +1,72 @@ +import { Prisma } from '@prisma/client'; +import { randomInt } from 'node:crypto'; + +export const addressFactory = + (): Prisma.AddressCreateWithoutBuildingAddressInput => + [ + whiteHouse, + yellowstone, + goldenGateBridge, + washingtonMonument, + lincolnMemorial, + ][randomInt(5)]; + +export const whiteHouse = { + placeName: 'White House', + city: 'Washington', + county: null, + state: 'DC', + street: '1600 Pennsylvania Avenue', + street2: null, + zipCode: '20500', + latitude: 38.8977, + longitude: -77.0365, +}; + +export const yellowstone = { + placeName: 'Yellowstone National Park', + city: 'Yellowstone National Park', + county: null, + state: 'WY', + street: '3200 Old Faithful Inn Rd', + street2: null, + zipCode: '82190', + latitude: 44.459928576661824, + longitude: -110.83109211487681, +}; + +export const goldenGateBridge = { + placeName: 'Golden Gate Bridge', + city: 'San Francisco', + county: null, + state: 'CA', + street: 'Golden Gate Brg', + street2: null, + zipCode: '94129', + latitude: 37.820589659186425, + longitude: -122.47842676136818, +}; + +export const washingtonMonument = { + placeName: 'Washington Monument', + city: 'Washington', + county: null, + state: 'DC', + street: '2 15th St NW', + street2: null, + zipCode: '20024', + latitude: 38.88983672842871, + longitude: -77.03522750134796, +}; + +export const lincolnMemorial = { + placeName: 'Lincoln Memorial', + city: 'Washington', + county: null, + state: 'DC', + street: '2 Lincoln Memorial Cir NW', + street2: null, + zipCode: '20002', + latitude: 38.88958323798129, + longitude: -77.05024900814298, +}; diff --git a/api/prisma/seed-helpers/ami-chart-factory.ts b/api/prisma/seed-helpers/ami-chart-factory.ts new file mode 100644 index 0000000000..78cee63b1f --- /dev/null +++ b/api/prisma/seed-helpers/ami-chart-factory.ts @@ -0,0 +1,27 @@ +import { Prisma } from '@prisma/client'; +import { randomName } from './word-generator'; + +export const amiChartFactory = ( + numberToCreate: number, + jurisdictionId: string, +): Prisma.AmiChartCreateInput => ({ + name: randomName(), + items: amiChartItemsFactory(numberToCreate), + jurisdictions: { + connect: { + id: jurisdictionId, + }, + }, +}); + +const amiChartItemsFactory = (numberToCreate: number): Prisma.JsonArray => + [...Array(numberToCreate)].flatMap((_, index) => { + const baseValue = index + 1; + return [...Array(8)].map((_, index2) => { + return { + percentOfAmi: baseValue * 10, + householdSize: index2 + 1, + income: (baseValue + index2) * 12_000, + }; + }); + }); diff --git a/api/prisma/seed-helpers/application-factory.ts b/api/prisma/seed-helpers/application-factory.ts new file mode 100644 index 0000000000..0e1bbe6f29 --- /dev/null +++ b/api/prisma/seed-helpers/application-factory.ts @@ -0,0 +1,119 @@ +import { + Prisma, + IncomePeriodEnum, + ApplicationStatusEnum, + ApplicationSubmissionTypeEnum, + MultiselectQuestions, + YesNoEnum, + MultiselectQuestionsApplicationSectionEnum, +} from '@prisma/client'; +import { generateConfirmationCode } from '../../src/utilities/applications-utilities'; +import { addressFactory } from './address-factory'; +import { randomNoun } from './word-generator'; +import { + randomBirthDay, + randomBirthMonth, + randomBirthYear, +} from './number-generator'; +import { preferenceFactory } from './application-preference-factory'; +import { demographicsFactory } from './demographic-factory'; + +export const applicationFactory = async (optionalParams?: { + householdSize?: number; + unitTypeId?: string; + applicant?: Prisma.ApplicantCreateWithoutApplicationsInput; + overrides?: Prisma.ApplicationsCreateInput; + listingId?: string; + householdMember?: Prisma.HouseholdMemberCreateWithoutApplicationsInput[]; + demographics?: Prisma.DemographicsCreateWithoutApplicationsInput; + multiselectQuestions?: Partial[]; +}): Promise => { + let preferredUnitTypes: Prisma.UnitTypesCreateNestedManyWithoutApplicationsInput; + if (optionalParams?.unitTypeId) { + preferredUnitTypes = { + connect: [ + { + id: optionalParams.unitTypeId, + }, + ], + }; + } + const demographics = await demographicsFactory(); + return { + confirmationCode: generateConfirmationCode(), + applicant: { create: applicantFactory(optionalParams?.applicant) }, + appUrl: '', + status: ApplicationStatusEnum.submitted, + submissionType: ApplicationSubmissionTypeEnum.electronical, + submissionDate: new Date(), + householdSize: optionalParams?.householdSize ?? 1, + income: '40000', + incomePeriod: IncomePeriodEnum.perYear, + preferences: preferenceFactory( + optionalParams?.multiselectQuestions + ? optionalParams.multiselectQuestions.filter( + (question) => + question.applicationSection === + MultiselectQuestionsApplicationSectionEnum.preferences, + ) + : [], + ), + programs: preferenceFactory( + optionalParams?.multiselectQuestions + ? optionalParams.multiselectQuestions.filter( + (question) => + question.applicationSection === + MultiselectQuestionsApplicationSectionEnum.programs, + ) + : [], + ), + preferredUnitTypes, + sendMailToMailingAddress: true, + applicationsMailingAddress: { + create: addressFactory(), + }, + listings: optionalParams?.listingId + ? { + connect: { + id: optionalParams?.listingId, + }, + } + : undefined, + ...optionalParams?.overrides, + householdMember: optionalParams?.householdMember + ? { + create: optionalParams.householdMember, + } + : undefined, + demographics: { + create: demographics, + }, + }; +}; + +export const applicantFactory = ( + overrides?: Prisma.ApplicantCreateWithoutApplicationsInput, +): Prisma.ApplicantCreateWithoutApplicationsInput => { + const firstName = randomNoun(); + const lastName = randomNoun(); + return { + firstName: firstName, + lastName: lastName, + emailAddress: `${firstName}.${lastName}@example.com`, + noEmail: false, + phoneNumber: '(123) 123-1231', + phoneNumberType: 'home', + noPhone: false, + workInRegion: YesNoEnum.no, + birthDay: `${randomBirthDay()}`, // no zeros + birthMonth: `${randomBirthMonth()}`, // no zeros + birthYear: `${randomBirthYear()}`, + applicantAddress: { + create: addressFactory(), + }, + applicantWorkAddress: { + create: addressFactory(), + }, + ...overrides, + }; +}; diff --git a/api/prisma/seed-helpers/application-preference-factory.ts b/api/prisma/seed-helpers/application-preference-factory.ts new file mode 100644 index 0000000000..2297a003ec --- /dev/null +++ b/api/prisma/seed-helpers/application-preference-factory.ts @@ -0,0 +1,35 @@ +import { MultiselectQuestions, Prisma } from '@prisma/client'; +import { randomNoun } from './word-generator'; +import { randomBoolean } from './boolean-generator'; +import { InputType } from '../../src/enums/shared/input-type-enum'; + +export const preferenceFactory = ( + multiselectQuestions: Partial[], +): Prisma.InputJsonValue => { + return multiselectQuestions.map((question) => ({ + multiselectQuestionId: question.id, + key: question.text, + claimed: randomBoolean(), + options: JSON.parse(JSON.stringify(question.options)).map((option) => { + return { + key: option.key, + checked: randomBoolean(), + extraData: option.collectAddress + ? [ + { + key: 'Address', + type: InputType.address, + value: { + city: randomNoun(), + state: randomNoun(), + street: '123 4th St', + street2: 'Apt 5', + zipCode: '67890', + }, + }, + ] + : [], + }; + }), + })); +}; diff --git a/api/prisma/seed-helpers/boolean-generator.ts b/api/prisma/seed-helpers/boolean-generator.ts new file mode 100644 index 0000000000..3d6f553398 --- /dev/null +++ b/api/prisma/seed-helpers/boolean-generator.ts @@ -0,0 +1,3 @@ +export function randomBoolean(): boolean { + return Math.random() < 0.5; +} diff --git a/api/prisma/seed-helpers/demographic-factory.ts b/api/prisma/seed-helpers/demographic-factory.ts new file mode 100644 index 0000000000..025ed37a41 --- /dev/null +++ b/api/prisma/seed-helpers/demographic-factory.ts @@ -0,0 +1,36 @@ +import { Prisma } from '@prisma/client'; +import { randomAdjective, randomName } from './word-generator'; +import { randomInt } from 'crypto'; + +const race = [ + 'americanIndianAlaskanNative', + 'asian', + 'asian-asianIndian', + 'asian-otherAsian', + 'blackAfricanAmerican', + 'asian-chinese', + 'declineToRespond', + 'asian-filipino', + 'nativeHawaiianOtherPacificIslander-guamanianOrChamorro', + 'asian-japanese', + 'asian-korean', + 'nativeHawaiianOtherPacificIslander-nativeHawaiian', + 'nativeHawaiianOtherPacificIslander', + 'nativeHawaiianOtherPacificIslander:Fijian', + 'otherMultiracial: Black African American and white', + 'nativeHawaiianOtherPacificIslander-otherPacificIslander', + 'nativeHawaiianOtherPacificIslander-samoan', + 'asian-vietnamese', + 'white', +]; + +const randomRace = () => { + return race[randomInt(race.length - 1)]; +}; + +export const demographicsFactory = + async (): Promise => ({ + ethnicity: randomAdjective(), + howDidYouHear: [randomName()], + race: [randomRace()], + }); diff --git a/api/prisma/seed-helpers/household-member-factory.ts b/api/prisma/seed-helpers/household-member-factory.ts new file mode 100644 index 0000000000..b73f4ed61d --- /dev/null +++ b/api/prisma/seed-helpers/household-member-factory.ts @@ -0,0 +1,45 @@ +import { Prisma, YesNoEnum } from '@prisma/client'; +import { addressFactory } from './address-factory'; +import { randomAdjective, randomNoun } from './word-generator'; +import { + randomBirthDay, + randomBirthMonth, + randomBirthYear, +} from './number-generator'; +import { randomBoolean } from './boolean-generator'; + +export const householdMemberFactorySingle = + (): Prisma.HouseholdMemberCreateWithoutApplicationsInput => { + const firstName = randomNoun(); + const lastName = randomNoun(); + const randomYesNo = randomBoolean() === true ? YesNoEnum.yes : YesNoEnum.no; + return { + firstName: firstName, + middleName: randomNoun(), + lastName: lastName, + // Question: why are these strings? + birthMonth: randomBirthMonth().toString(), + birthDay: randomBirthDay().toString(), + birthYear: randomBirthYear().toString(), + sameAddress: randomYesNo, + // Question: should this be an enum? + relationship: randomAdjective(), + workInRegion: randomYesNo, + householdMemberAddress: randomBoolean + ? undefined + : { create: addressFactory() }, + householdMemberWorkAddress: { + create: addressFactory(), + }, + }; + }; + +export const householdMemberFactoryMany = async ( + numberToMake: number, +): Promise => { + const createArray: Promise[] = + [...new Array(numberToMake)].map(async () => + householdMemberFactorySingle(), + ); + return await Promise.all(createArray); +}; diff --git a/api/prisma/seed-helpers/jurisdiction-factory.ts b/api/prisma/seed-helpers/jurisdiction-factory.ts new file mode 100644 index 0000000000..a7e70567ca --- /dev/null +++ b/api/prisma/seed-helpers/jurisdiction-factory.ts @@ -0,0 +1,21 @@ +import { LanguagesEnum, Prisma, UserRoleEnum } from '@prisma/client'; +import { randomName } from './word-generator'; + +export const jurisdictionFactory = ( + jurisdictionName = randomName(), + listingApprovalPermissions?: UserRoleEnum[], +): Prisma.JurisdictionsCreateInput => ({ + name: jurisdictionName, + notificationsSignUpUrl: null, + languages: [LanguagesEnum.en], + partnerTerms: 'Example Terms', + publicUrl: 'http://localhost:3000', + emailFromAddress: 'Bloom ', + rentalAssistanceDefault: + 'Housing Choice Vouchers, Section 8 and other valid rental assistance programs will be considered for this property. In the case of a valid rental subsidy, the required minimum income will be based on the portion of the rent that the tenant pays after use of the subsidy.', + enablePartnerSettings: true, + enableAccessibilityFeatures: true, + enableUtilitiesIncluded: true, + enableGeocodingPreferences: true, + listingApprovalPermissions: listingApprovalPermissions || [], +}); diff --git a/api/prisma/seed-helpers/listing-factory.ts b/api/prisma/seed-helpers/listing-factory.ts new file mode 100644 index 0000000000..c5778294f1 --- /dev/null +++ b/api/prisma/seed-helpers/listing-factory.ts @@ -0,0 +1,176 @@ +import { + Prisma, + AmiChart, + MultiselectQuestions, + PrismaClient, + ListingsStatusEnum, +} from '@prisma/client'; +import { randomName } from './word-generator'; +import { addressFactory } from './address-factory'; +import { unitFactoryMany } from './unit-factory'; +import { reservedCommunityTypeFactoryGet } from './reserved-community-type-factory'; + +export const listingFactory = async ( + jurisdictionId: string, + prismaClient: PrismaClient, + optionalParams?: { + amiChart?: AmiChart; + numberOfUnits?: number; + status?: ListingsStatusEnum; + units?: Prisma.UnitsCreateWithoutListingsInput[]; + listing?: Prisma.ListingsCreateInput; + includeBuildingFeatures?: boolean; + includeEligibilityRules?: boolean; + multiselectQuestions?: Partial[]; + applications?: Prisma.ApplicationsCreateInput[]; + applicationDueDate?: Date; + afsLastRunSetInPast?: boolean; + }, +): Promise => { + const previousListing = optionalParams?.listing || {}; + let units = optionalParams?.units; + if (!units && optionalParams?.numberOfUnits) { + units = await unitFactoryMany(optionalParams.numberOfUnits, prismaClient, { + randomizePriorityType: true, + amiChart: optionalParams?.amiChart, + }); + } + const reservedCommunityType = await reservedCommunityTypeFactoryGet( + prismaClient, + jurisdictionId, + ); + return { + createdAt: new Date(), + assets: [], + name: randomName(), + status: optionalParams?.status || ListingsStatusEnum.active, + displayWaitlistSize: Math.random() < 0.5, + listingsBuildingAddress: { + create: addressFactory(), + }, + listingsApplicationMailingAddress: { + create: addressFactory(), + }, + listingsApplicationPickUpAddress: { + create: addressFactory(), + }, + listingsLeasingAgentAddress: { + create: addressFactory(), + }, + listingsApplicationDropOffAddress: { + create: addressFactory(), + }, + reservedCommunityTypes: { + connect: { + id: reservedCommunityType.id, + }, + }, + // For application flagged set tests the date needs to be before the updated timestamp + // All others should be a newer timestamp so that they are not picked up by AFS tests + afsLastRunAt: optionalParams?.afsLastRunSetInPast + ? new Date(0) + : new Date(), + listingMultiselectQuestions: optionalParams?.multiselectQuestions + ? { + create: optionalParams.multiselectQuestions.map( + (question, index) => ({ + multiselectQuestions: { + connect: { + id: question.id, + }, + }, + ordinal: index + 1, + }), + ), + } + : undefined, + applications: optionalParams?.applications + ? { + create: optionalParams.applications, + } + : undefined, + unitsAvailable: units?.length || 0, + ...featuresAndUtilites(), + ...buildingFeatures(optionalParams?.includeBuildingFeatures), + ...additionalEligibilityRules(optionalParams?.includeEligibilityRules), + ...previousListing, + jurisdictions: { + connect: { + id: jurisdictionId, + }, + }, + units: units + ? { + create: units, + } + : undefined, + applicationDueDate: optionalParams?.applicationDueDate ?? undefined, + }; +}; + +const buildingFeatures = (includeBuildingFeatures: boolean) => { + if (!includeBuildingFeatures) return {}; + return { + amenities: + 'Laundry facilities, Elevators, Beautifully landscaped garden, walkways', + unitAmenities: 'All-electric kitchen, Dishwasher', + petPolicy: 'Allow pets with a deposit of $500', + accessibility: 'ADA units available', + smokingPolicy: 'Non-smoking building', + servicesOffered: 'Resident services on-site.', + }; +}; + +const additionalEligibilityRules = (includeEligibilityRules: boolean) => { + if (!includeEligibilityRules) return {}; + return { + rentalHistory: 'Two years of rental history will be verified', + rentalAssistance: 'additional rental assistance', + creditHistory: + 'A poor credit history may be grounds to deem an applicant ineligible for housing.', + 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. ', + }; +}; + +export const featuresAndUtilites = (): { + listingFeatures: Prisma.ListingFeaturesCreateNestedOneWithoutListingsInput; + listingUtilities: Prisma.ListingUtilitiesCreateNestedOneWithoutListingsInput; +} => ({ + listingFeatures: { + create: { + elevator: true, + wheelchairRamp: true, + serviceAnimalsAllowed: true, + accessibleParking: true, + parkingOnSite: true, + inUnitWasherDryer: true, + laundryInBuilding: true, + barrierFreeEntrance: true, + rollInShower: true, + grabBars: true, + heatingInUnit: true, + acInUnit: true, + hearing: true, + visual: true, + mobility: true, + barrierFreeUnitEntrance: true, + loweredLightSwitch: true, + barrierFreeBathroom: true, + wideDoorways: true, + loweredCabinets: true, + }, + }, + listingUtilities: { + create: { + water: true, + gas: true, + trash: true, + sewer: true, + electricity: true, + cable: true, + phone: true, + internet: true, + }, + }, +}); diff --git a/api/prisma/seed-helpers/map-layer-factory.ts b/api/prisma/seed-helpers/map-layer-factory.ts new file mode 100644 index 0000000000..e571c4625a --- /dev/null +++ b/api/prisma/seed-helpers/map-layer-factory.ts @@ -0,0 +1,362 @@ +import { Prisma } from '@prisma/client'; +import { randomName } from './word-generator'; + +export const mapLayerFactory = ( + jurisdictionId: string, + name?: string, + data?: Prisma.InputJsonValue, +): Prisma.MapLayersCreateInput => { + return { + name: name || randomName(), + jurisdictionId: jurisdictionId, + featureCollection: data || redlinedMap, + }; +}; + +export const simplifiedDCMap = { + type: 'FeatureCollection', + features: [ + { + type: 'Feature', + properties: {}, + geometry: { + coordinates: [ + [ + [ + [-77.0392589333301, 38.79186072967565], + [-76.90981025809415, 38.89293952026222], + [-77.04122027689426, 38.996161202682146], + [-77.12000091005532, 38.93465307055658], + [-77.10561772391833, 38.91990351952725], + [-77.09123453778136, 38.90565966392609], + [-77.06802530560486, 38.9015894658674], + [-77.06181438431805, 38.889377471720564], + [-77.03697069917165, 38.870801038935525], + [-77.03043288729134, 38.850437727576235], + [-77.03435557441966, 38.80816525459605], + [-77.0392589333301, 38.79186072967565], + ], + ], + ], + type: 'Polygon', + }, + }, + ], +}; + +export const redlinedMap = { + type: 'FeatureCollection', + features: [ + { + type: 'Feature', + geometry: { + type: 'MultiPolygon', + coordinates: [ + [ + [ + [-122.26591, 37.89001], + [-122.26565, 37.88796], + [-122.26533, 37.88531], + [-122.26311, 37.88555], + [-122.26276, 37.88617], + [-122.2626, 37.88673], + [-122.2626, 37.88705], + [-122.26255, 37.88735], + [-122.26236, 37.8875], + [-122.26211, 37.88761], + [-122.26177, 37.88773], + [-122.26153, 37.88782], + [-122.26144, 37.88802], + [-122.26145, 37.88821], + [-122.2616, 37.88848], + [-122.26208, 37.88886], + [-122.2623, 37.8891], + [-122.26241, 37.88967], + [-122.26188, 37.88994], + [-122.2609, 37.89018], + [-122.26052, 37.89016], + [-122.2602, 37.89014], + [-122.25989, 37.89016], + [-122.25931, 37.89032], + [-122.25876, 37.89063], + [-122.25887, 37.89067], + [-122.25919, 37.89067], + [-122.25943, 37.8907], + [-122.25976, 37.89081], + [-122.25983, 37.89091], + [-122.25991, 37.89104], + [-122.25969, 37.8914], + [-122.25976, 37.89166], + [-122.26018, 37.89202], + [-122.26051, 37.89218], + [-122.26087, 37.89218], + [-122.26223, 37.89188], + [-122.26268, 37.8917], + [-122.26314, 37.89137], + [-122.26353, 37.89106], + [-122.26407, 37.89062], + [-122.2649, 37.89022], + [-122.26535, 37.89002], + [-122.26591, 37.89001], + ], + ], + ], + }, + properties: { + area_id: 6664, + city_id: 17, + grade: 'D', + fill: '#d9838d', + label: 'D1', + name: ' ', + category_id: 4, + sheets: 1, + area: 0.0000230966496717784, + bounds: [ + [37.88531, -122.26591], + [37.89218, -122.25876], + ], + label_coords: [37.888, -122.264], + residential: true, + commercial: false, + industrial: false, + }, + }, + { + type: 'Feature', + geometry: { + type: 'MultiPolygon', + coordinates: [ + [ + [ + [-122.27239, 37.86322], + [-122.27236, 37.86524], + [-122.27271, 37.8653], + [-122.27275, 37.86581], + [-122.27328, 37.86584], + [-122.27332, 37.86662], + [-122.27303, 37.86696], + [-122.27253, 37.86696], + [-122.27268, 37.86879], + [-122.27371, 37.86876], + [-122.27392, 37.87025], + [-122.27296, 37.87062], + [-122.27303, 37.87119], + [-122.29152, 37.86896], + [-122.29132, 37.86812], + [-122.28897, 37.86111], + [-122.27239, 37.86322], + ], + [ + [-122.28572, 37.86679], + [-122.28455, 37.86695], + [-122.28449, 37.86636], + [-122.2857, 37.86622], + [-122.28572, 37.86679], + ], + ], + [ + [ + [-122.2732, 37.87251], + [-122.27479, 37.87235], + [-122.27526, 37.8765], + [-122.28269, 37.87569], + [-122.28235, 37.87301], + [-122.29281, 37.87163], + [-122.29201, 37.86973], + [-122.27314, 37.87202], + [-122.2732, 37.87251], + ], + ], + ], + }, + properties: { + area_id: 6774, + city_id: 17, + grade: 'D', + fill: '#d9838d', + label: 'D2', + name: ' ', + category_id: 4, + sheets: 1, + area: 0.000197049068477924, + bounds: [ + [37.86111, -122.29281], + [37.8765, -122.27236], + ], + label_coords: [37.866, -122.279], + residential: true, + commercial: false, + industrial: false, + }, + id: 10, + }, + { + type: 'Feature', + geometry: { + type: 'MultiPolygon', + coordinates: [ + [ + [ + [-122.28911, 37.84974], + [-122.28665, 37.85025], + [-122.28947, 37.85919], + [-122.28987, 37.86059], + [-122.29112, 37.86446], + [-122.29249, 37.86893], + [-122.29947, 37.86731], + [-122.30288, 37.86649], + [-122.30249, 37.86552], + [-122.3012, 37.86364], + [-122.30055, 37.86264], + [-122.30056, 37.86192], + [-122.30041, 37.86166], + [-122.29993, 37.86082], + [-122.29948, 37.85998], + [-122.29893, 37.85862], + [-122.29834, 37.85694], + [-122.2977, 37.85497], + [-122.29654, 37.85182], + [-122.29601, 37.85002], + [-122.29601, 37.8496], + [-122.29559, 37.84842], + [-122.28911, 37.84974], + ], + ], + [ + [ + [-122.29704, 37.88312], + [-122.29804, 37.88301], + [-122.29894, 37.88281], + [-122.29949, 37.88271], + [-122.30042, 37.88277], + [-122.30115, 37.88271], + [-122.30215, 37.88249], + [-122.30306, 37.88239], + [-122.30348, 37.8822], + [-122.3055, 37.88209], + [-122.30612, 37.882], + [-122.30691, 37.88181], + [-122.30741, 37.88175], + [-122.30783, 37.88187], + [-122.30812, 37.88211], + [-122.30831, 37.8822], + [-122.30959, 37.88196], + [-122.30698, 37.87299], + [-122.30479, 37.86635], + [-122.30298, 37.86678], + [-122.30306, 37.86731], + [-122.30244, 37.86759], + [-122.29284, 37.86967], + [-122.29704, 37.88312], + ], + [ + [-122.29787, 37.8724], + [-122.29692, 37.87264], + [-122.29651, 37.87144], + [-122.29752, 37.87126], + [-122.29787, 37.8724], + ], + ], + ], + }, + properties: { + area_id: 6672, + city_id: 17, + grade: 'D', + fill: '#d9838d', + label: 'D3', + name: ' ', + category_id: 4, + sheets: 1, + area: 0.000366050178227747, + bounds: [ + [37.84842, -122.30959], + [37.88312, -122.28665], + ], + label_coords: [37.863, -122.296], + residential: true, + commercial: false, + industrial: false, + }, + id: 12, + }, + { + type: 'Feature', + geometry: { + type: 'MultiPolygon', + coordinates: [ + [ + [ + [-122.27165, 37.85605], + [-122.2719, 37.85796], + [-122.27262, 37.85796], + [-122.27276, 37.85959], + [-122.27201, 37.85973], + [-122.27215, 37.86142], + [-122.26838, 37.86198], + [-122.26869, 37.8637], + [-122.27239, 37.86322], + [-122.28897, 37.86111], + [-122.28559, 37.85014], + [-122.28381, 37.8505], + [-122.28313, 37.84867], + [-122.28271, 37.84876], + [-122.28185, 37.84681], + [-122.27675, 37.84746], + [-122.27625, 37.84566], + [-122.27314, 37.846], + [-122.27265, 37.84605], + [-122.27232, 37.84679], + [-122.27511, 37.84639], + [-122.27545, 37.8482], + [-122.27146, 37.84869], + [-122.27037, 37.85112], + [-122.2708, 37.85382], + [-122.27169, 37.85388], + [-122.27212, 37.85582], + [-122.27165, 37.85605], + ], + [ + [-122.28512, 37.85717], + [-122.28352, 37.85733], + [-122.283, 37.85442], + [-122.28464, 37.85417], + [-122.28512, 37.85717], + ], + ], + [ + [ + [-122.26623, 37.8623], + [-122.26474, 37.86252], + [-122.26498, 37.86432], + [-122.26545, 37.86711], + [-122.26688, 37.86696], + [-122.26623, 37.8623], + ], + ], + ], + }, + properties: { + area_id: 6675, + city_id: 17, + grade: 'D', + fill: '#d9838d', + label: 'D4', + name: ' ', + category_id: 4, + sheets: 1, + area: 0.000231566437344764, + bounds: [ + [37.84566, -122.28897], + [37.86711, -122.26474], + ], + label_coords: [37.853, -122.277], + residential: true, + commercial: false, + industrial: false, + }, + id: 13, + }, + ], +}; diff --git a/api/prisma/seed-helpers/multiselect-question-factory.ts b/api/prisma/seed-helpers/multiselect-question-factory.ts new file mode 100644 index 0000000000..e894257c27 --- /dev/null +++ b/api/prisma/seed-helpers/multiselect-question-factory.ts @@ -0,0 +1,52 @@ +import { + MultiselectQuestionsApplicationSectionEnum, + Prisma, +} from '@prisma/client'; +import { randomName, randomNoun } from './word-generator'; +import { randomInt } from 'crypto'; + +const multiselectAppSectionAsArray = Object.values( + MultiselectQuestionsApplicationSectionEnum, +); + +export const multiselectQuestionFactory = ( + jurisdictionId: string, + optionalParams?: { + optOut?: boolean; + multiselectQuestion?: Partial; + }, +): Prisma.MultiselectQuestionsCreateInput => { + const previousMultiselectQuestion = optionalParams?.multiselectQuestion || {}; + const text = optionalParams?.multiselectQuestion?.text || randomName(); + return { + text: text, + subText: `sub text for ${text}`, + description: `description of ${text}`, + links: [], + options: multiselectOptionFactory(randomInt(1, 3)), + optOutText: optionalParams?.optOut ? "I don't want this preference" : null, + hideFromListing: false, + applicationSection: + optionalParams?.multiselectQuestion?.applicationSection || + multiselectAppSectionAsArray[ + randomInt(multiselectAppSectionAsArray.length) + ], + ...previousMultiselectQuestion, + jurisdictions: { + connect: { + id: jurisdictionId, + }, + }, + }; +}; + +const multiselectOptionFactory = ( + numberToMake: number, +): Prisma.InputJsonValue => { + if (!numberToMake) return []; + return [...new Array(numberToMake)].map((_, index) => ({ + text: randomNoun(), + ordinal: index, + collectAddress: index % 2 === 0, + })); +}; diff --git a/api/prisma/seed-helpers/number-generator.ts b/api/prisma/seed-helpers/number-generator.ts new file mode 100644 index 0000000000..614609939c --- /dev/null +++ b/api/prisma/seed-helpers/number-generator.ts @@ -0,0 +1,13 @@ +import { randomInt } from 'crypto'; + +export function randomBirthDay(): number { + return randomInt(31) + 1; +} + +export function randomBirthMonth(): number { + return randomInt(12) + 1; +} + +export function randomBirthYear(): number { + return randomInt(80) + 1930; +} diff --git a/api/prisma/seed-helpers/reserved-community-type-factory.ts b/api/prisma/seed-helpers/reserved-community-type-factory.ts new file mode 100644 index 0000000000..75c58e06cf --- /dev/null +++ b/api/prisma/seed-helpers/reserved-community-type-factory.ts @@ -0,0 +1,68 @@ +import { Prisma, PrismaClient, ReservedCommunityTypes } from '@prisma/client'; +import { randomInt } from 'crypto'; + +const reservedCommunityTypeOptions = [ + 'specialNeeds', + 'senior', + 'senior62', + 'developmentalDisability', + 'veteran', +]; + +export const reservedCommunityTypeFactory = ( + jurisdictionId: string, + name: string, +): Prisma.ReservedCommunityTypesCreateWithoutListingsInput => { + return { + name: name, + description: `reservedCommunityTypes of ${name}`, + jurisdictions: { + connect: { + id: jurisdictionId, + }, + }, + }; +}; + +export const reservedCommunityTypeFactoryAll = async ( + jurisdictionId: string, + prismaClient: PrismaClient, +) => { + await prismaClient.reservedCommunityTypes.createMany({ + data: reservedCommunityTypeOptions.map((value) => ({ + name: value, + jurisdictionId: jurisdictionId, + })), + }); +}; + +export const reservedCommunityTypeFactoryGet = async ( + prismaClient: PrismaClient, + jurisdictionId: string, + name?: string, +): Promise => { + // if name is not given pick one randomly from the above list + const chosenName = + name || + reservedCommunityTypeOptions[ + randomInt(reservedCommunityTypeOptions.length) + ]; + const reservedCommunityType = + await prismaClient.reservedCommunityTypes.findFirst({ + where: { + name: { + equals: chosenName, + }, + jurisdictionId: { + equals: jurisdictionId, + }, + }, + }); + + if (!reservedCommunityType) { + console.warn( + `reserved community type ${chosenName} was not created, run reservedCommunityTypeFactoryAll first`, + ); + } + return reservedCommunityType; +}; diff --git a/api/prisma/seed-helpers/translation-factory.ts b/api/prisma/seed-helpers/translation-factory.ts new file mode 100644 index 0000000000..752886b98b --- /dev/null +++ b/api/prisma/seed-helpers/translation-factory.ts @@ -0,0 +1,166 @@ +import { LanguagesEnum, Prisma } from '@prisma/client'; + +const translations = (jurisdictionName?: string) => ({ + t: { + hello: 'Hello', + seeListing: 'See Listing', + partnersPortal: 'Partners Portal', + viewListing: 'View Listing', + editListing: 'Edit Listing', + reviewListing: 'Review Listing', + }, + footer: { + line1: `${jurisdictionName || 'Bloom'}`, + line2: '', + thankYou: 'Thank you', + footer: `${jurisdictionName || 'Bloom Housing'}`, + }, + header: { + logoUrl: + 'https://res.cloudinary.com/exygy/image/upload/v1692118607/core/bloom_housing_logo.png', + logoTitle: 'Bloom Housing Portal', + }, + invite: { + hello: 'Welcome to the Partners Portal', + confirmMyAccount: 'Confirm my account', + inviteManageListings: + 'You will now be able to manage listings and applications that you are a part of from one centralized location.', + inviteWelcomeMessage: 'Welcome to the Partners Portal at %{appUrl}.', + toCompleteAccountCreation: + 'To complete your account creation, please click the link below:', + }, + register: { + 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.', + confirmMyAccount: 'Confirm my account', + toConfirmAccountMessage: + 'To complete your account creation, please click the link below:', + }, + changeEmail: { + message: 'An email address change has been requested for your account.', + changeMyEmail: 'Confirm email change', + onChangeEmailMessage: + 'To confirm the change to your email address, please click the link below:', + }, + confirmation: { + subject: 'Your Application Confirmation', + eligible: { + fcfs: 'Eligible applicants will be contacted on a first come first serve basis until vacancies are filled.', + lottery: + 'Once the application period closes, eligible applicants will be placed in order based on lottery rank order.', + waitlist: + 'Eligible applicants will be placed on the waitlist on a first come first serve basis until waitlist spots are filled.', + fcfsPreference: + 'Housing preferences, if applicable, will affect first come first serve order.', + waitlistContact: + 'You may be contacted while on the waitlist to confirm that you wish to remain on the waitlist.', + lotteryPreference: + 'Housing preferences, if applicable, will affect lottery rank order.', + waitlistPreference: + 'Housing preferences, if applicable, will affect waitlist order.', + }, + interview: + 'If you are contacted for an interview, you will be asked to fill out a more detailed application and provide supporting documents.', + 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.', + }, + whileYouWait: + 'While you wait, there are things you can do to prepare for potential next steps and future opportunities.', + shouldBeChosen: + 'Should your application be chosen, be prepared to fill out a more detailed application and provide required supporting documents.', + whatHappensNext: 'What happens next?', + whatToExpectNext: 'What to expect next:', + needToMakeUpdates: 'Need to make updates?', + applicationsClosed: 'Application
closed', + applicationsRanked: 'Application
ranked', + eligibleApplicants: { + FCFS: 'Eligible applicants will be placed in order based on first come first serve basis.', + lottery: + 'Eligible applicants will be placed in order based on preference and lottery rank.', + lotteryDate: 'The lottery will be held on %{lotteryDate}.', + }, + applicationReceived: 'Application
received', + prepareForNextSteps: 'Prepare for next steps', + thankYouForApplying: + 'Thanks for applying. We have received your application for', + readHowYouCanPrepare: 'Read about how you can prepare for next steps', + yourConfirmationNumber: 'Your Confirmation Number', + applicationPeriodCloses: + 'Once the application period closes, the property manager will begin processing applications.', + contactedForAnInterview: + 'If you are contacted for an interview, you will need to fill out a more detailed application and provide supporting documents.', + gotYourConfirmationNumber: 'We got your application for', + }, + leasingAgent: { + officeHours: 'Office Hours:', + propertyManager: 'Property Manager', + contactAgentToUpdateInfo: + 'If you need to update information on your application, do not apply again. Instead, contact the agent for this listing.', + }, + mfaCodeEmail: { + message: 'Access code for your account has been requested.', + mfaCode: 'Your access code is: %{mfaCode}', + }, + forgotPassword: { + subject: 'Forgot your password?', + callToAction: + 'If you did make this request, please click on the link below to reset your password:', + 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.', + ignoreRequest: "If you didn't request this, please ignore this email.", + changePassword: 'Change my password', + }, + requestApproval: { + header: 'Listing approval requested', + partnerRequest: + 'A Partner has submitted an approval request to publish the %{listingName} listing.', + logInToReviewStart: 'Please log into the', + logInToReviewEnd: + 'and navigate to the listing detail page to review and publish.', + accessListing: + 'To access the listing after logging in, please click the link below', + }, + changesRequested: { + header: 'Listing changes requested', + adminRequestStart: + 'An administrator is requesting changes to the %{listingName} listing. Please log into the', + adminRequestEnd: + 'and navigate to the listing detail page to view the request and edit the listing.', + }, + listingApproved: { + header: 'New published listing', + adminApproved: + 'The %{listingName} listing has been approved and published by an administrator.', + viewPublished: + 'To view the published listing, please click on the link below', + }, + csvExport: { + body: 'The attached file is %{fileDescription}. If you have any questions, please reach out to your administrator.', + hello: 'Hello,', + title: '%{title}', + }, +}); + +export const translationFactory = ( + jurisdictionId?: string, + jurisdictionName?: string, +): Prisma.TranslationsCreateInput => { + return { + language: LanguagesEnum.en, + translations: translations(jurisdictionName), + jurisdictions: jurisdictionId + ? { + connect: { + id: jurisdictionId, + }, + } + : undefined, + }; +}; diff --git a/api/prisma/seed-helpers/unit-accessibility-priority-type-factory.ts b/api/prisma/seed-helpers/unit-accessibility-priority-type-factory.ts new file mode 100644 index 0000000000..63c978376b --- /dev/null +++ b/api/prisma/seed-helpers/unit-accessibility-priority-type-factory.ts @@ -0,0 +1,41 @@ +import { PrismaClient, UnitAccessibilityPriorityTypes } from '@prisma/client'; +import { randomInt } from 'crypto'; + +export const unitAccessibilityPriorityTypeFactorySingle = async ( + prismaClient: PrismaClient, + type?: string, +): Promise => { + const chosenType = + type || + unitAccesibilityPriorityTypeAsArray[ + randomInt(unitAccesibilityPriorityTypeAsArray.length) + ]; + + const priorityType = + await prismaClient.unitAccessibilityPriorityTypes.findFirst({ + where: { + name: { + equals: chosenType, + }, + }, + }); + return priorityType; +}; + +export const unitAccessibilityPriorityTypeFactoryAll = async ( + prismaClient: PrismaClient, +) => { + await prismaClient.unitAccessibilityPriorityTypes.createMany({ + data: unitAccesibilityPriorityTypeAsArray.map((value) => ({ name: value })), + }); +}; + +export const unitAccesibilityPriorityTypeAsArray = [ + 'Mobility', + 'Hearing', + 'Visual', + 'Hearing and Visual', + 'Mobility and Hearing', + 'Mobility and Visual', + 'Mobility, Hearing and Visual', +]; diff --git a/api/prisma/seed-helpers/unit-factory.ts b/api/prisma/seed-helpers/unit-factory.ts new file mode 100644 index 0000000000..788c02bfa3 --- /dev/null +++ b/api/prisma/seed-helpers/unit-factory.ts @@ -0,0 +1,90 @@ +import { + AmiChart, + Prisma, + PrismaClient, + UnitTypeEnum, + UnitTypes, +} from '@prisma/client'; +import { unitTypeFactorySingle } from './unit-type-factory'; +import { unitAccessibilityPriorityTypeFactorySingle } from './unit-accessibility-priority-type-factory'; +import { unitRentTypeFactory } from './unit-rent-type-factory'; +import { randomInt } from 'crypto'; + +const unitTypes = Object.values(UnitTypeEnum); + +export const unitFactorySingle = ( + unitType: UnitTypes, + optionalParams?: { + amiChart?: AmiChart; + unitRentTypeId?: string; + otherFields?: Prisma.UnitsCreateWithoutListingsInput; + }, +): Prisma.UnitsCreateWithoutListingsInput => { + const bedrooms = unitType.numBedrooms || randomInt(6); + return { + amiChart: optionalParams?.amiChart + ? { connect: { id: optionalParams.amiChart.id } } + : undefined, + unitTypes: { + connect: { + id: unitType.id, + }, + }, + amiPercentage: optionalParams?.amiChart + ? (Math.ceil((Math.random() * 100) / 10) * 10).toString() // get an integer divisible by 10 + : undefined, + numBathrooms: randomInt(4), + floor: randomInt(3), + numBedrooms: bedrooms, + minOccupancy: bedrooms, + maxOccupancy: bedrooms + 2, + monthlyIncomeMin: randomInt(3500).toString(), + monthlyRent: (randomInt(2500) * (bedrooms || 1)).toString(), + unitRentTypes: optionalParams?.unitRentTypeId + ? { connect: { id: optionalParams?.unitRentTypeId } } + : { + create: unitRentTypeFactory(), + }, + ...optionalParams?.otherFields, + }; +}; + +export const unitFactoryMany = async ( + numberToMake: number, + prismaClient: PrismaClient, + optionalParams?: { + randomizePriorityType?: boolean; + amiChart?: AmiChart; + unitAccessibilityPriorityTypeId?: string; + }, +): Promise => { + const createArray: Promise[] = [ + ...new Array(numberToMake), + ].map(async (_, index) => { + const unitType = await unitTypeFactorySingle( + prismaClient, + unitTypes[randomInt(unitTypes.length)], + ); + + // create a random priority type with roughly half being null + const unitAccessibilityPriorityTypes = + optionalParams?.randomizePriorityType && Math.random() > 0.5 + ? await unitAccessibilityPriorityTypeFactorySingle(prismaClient) + : undefined; + + return unitFactorySingle(unitType, { + ...optionalParams, + otherFields: { + unitAccessibilityPriorityTypes: unitAccessibilityPriorityTypes + ? { + connect: { + id: unitAccessibilityPriorityTypes.id, + }, + } + : undefined, + numBathrooms: index, + }, + }); + }); + return await Promise.all(createArray); +}; diff --git a/api/prisma/seed-helpers/unit-rent-type-factory.ts b/api/prisma/seed-helpers/unit-rent-type-factory.ts new file mode 100644 index 0000000000..9b51189fa1 --- /dev/null +++ b/api/prisma/seed-helpers/unit-rent-type-factory.ts @@ -0,0 +1,10 @@ +import { Prisma, UnitRentTypeEnum } from '@prisma/client'; +import { randomInt } from 'crypto'; + +export const unitRentTypeFactory = ( + type?: UnitRentTypeEnum, +): Prisma.UnitRentTypesCreateInput => ({ + name: type || unitRentTypeArray[randomInt(unitRentTypeArray.length)], +}); + +export const unitRentTypeArray = Object.values(UnitRentTypeEnum); diff --git a/api/prisma/seed-helpers/unit-type-factory.ts b/api/prisma/seed-helpers/unit-type-factory.ts new file mode 100644 index 0000000000..443ade292d --- /dev/null +++ b/api/prisma/seed-helpers/unit-type-factory.ts @@ -0,0 +1,46 @@ +import { PrismaClient, UnitTypeEnum, UnitTypes } from '@prisma/client'; + +export const unitTypeFactorySingle = async ( + prismaClient: PrismaClient, + type: UnitTypeEnum, +): Promise => { + const unitType = await prismaClient.unitTypes.findFirst({ + where: { + name: { + equals: type, + }, + }, + }); + if (!unitType) { + console.warn(`Unit type ${type} was not created, run unitTypeFactoryAll`); + } + return unitType; +}; + +// All unit types should only be created once. This function checks if they have been created +// before putting all types in the database +export const unitTypeFactoryAll = async ( + prismaClient: PrismaClient, +): Promise => { + const all = await prismaClient.unitTypes.findMany({}); + const unitTypes = Object.values(UnitTypeEnum); + if (all.length !== unitTypes.length) { + await prismaClient.unitTypes.createMany({ + data: Object.values(UnitTypeEnum).map((value) => ({ + name: value, + numBedrooms: unitTypeMapping[value], + })), + }); + } + return await prismaClient.unitTypes.findMany({}); +}; + +export const unitTypeMapping = { + [UnitTypeEnum.studio]: 0, + [UnitTypeEnum.SRO]: 0, + [UnitTypeEnum.oneBdrm]: 1, + [UnitTypeEnum.twoBdrm]: 2, + [UnitTypeEnum.threeBdrm]: 3, + [UnitTypeEnum.fourBdrm]: 4, + [UnitTypeEnum.fiveBdrm]: 5, +}; diff --git a/api/prisma/seed-helpers/user-factory.ts b/api/prisma/seed-helpers/user-factory.ts new file mode 100644 index 0000000000..f081035d24 --- /dev/null +++ b/api/prisma/seed-helpers/user-factory.ts @@ -0,0 +1,56 @@ +import { Prisma } from '@prisma/client'; +import { randomAdjective, randomNoun } from './word-generator'; +import { passwordToHash } from '../../src/utilities/password-helpers'; + +export const userFactory = async (optionalParams?: { + roles?: Prisma.UserRolesUncheckedCreateWithoutUserAccountsInput; + firstName?: string; + lastName?: string; + email?: string; + mfaCode?: string; + mfaEnabled?: boolean; + confirmedAt?: Date; + phoneNumber?: string; + phoneNumberVerified?: boolean; + jurisdictionIds?: string[]; + listings?: string[]; + acceptedTerms?: boolean; +}): Promise => ({ + email: + optionalParams?.email?.toLocaleLowerCase() || + `${randomNoun().toLowerCase()}${randomNoun().toLowerCase()}@${randomAdjective().toLowerCase()}.com`, + firstName: optionalParams?.firstName || 'First', + lastName: optionalParams?.lastName || 'Last', + passwordHash: await passwordToHash('abcdef'), + userRoles: { + create: { + isAdmin: optionalParams?.roles?.isAdmin || false, + isJurisdictionalAdmin: + optionalParams?.roles?.isJurisdictionalAdmin || false, + isPartner: optionalParams?.roles?.isPartner || false, + }, + }, + mfaCode: optionalParams?.mfaCode || null, + mfaEnabled: optionalParams?.mfaEnabled || false, + confirmedAt: optionalParams?.confirmedAt || null, + mfaCodeUpdatedAt: optionalParams?.mfaEnabled ? new Date() : undefined, + phoneNumber: optionalParams?.phoneNumber || null, + phoneNumberVerified: optionalParams?.phoneNumberVerified || null, + agreedToTermsOfService: optionalParams?.acceptedTerms || false, + listings: optionalParams?.listings + ? { + connect: optionalParams.listings.map((listing) => { + return { id: listing }; + }), + } + : undefined, + jurisdictions: optionalParams?.jurisdictionIds + ? { + connect: optionalParams?.jurisdictionIds.map((jurisdiction) => { + return { + id: jurisdiction, + }; + }), + } + : undefined, +}); diff --git a/api/prisma/seed-helpers/word-generator.ts b/api/prisma/seed-helpers/word-generator.ts new file mode 100644 index 0000000000..f933104f4c --- /dev/null +++ b/api/prisma/seed-helpers/word-generator.ts @@ -0,0 +1,470 @@ +import { randomInt } from 'crypto'; + +// Random list of nouns +const nouns = [ + 'Serenity', + 'Urbanity', + 'Harmony', + 'Oasis', + 'Zenith', + 'Uptown', + 'Haven', + 'Crestview', + 'Cascade', + 'Meadowview', + 'Riverside', + 'Lakeside', + 'Parkside', + 'Arborwood', + 'Autumnwood', + 'Birchwood', + 'Cedarwood', + 'Sunscape', + 'Ridgepoint', + 'Skyline', + 'Horizon', + 'Brookside', + 'Willowcrest', + 'Whispering', + 'Sunflower', + 'Rosewood', + 'Mapleside', + 'Crystal', + 'Emerald', + 'Diamond', + 'Sapphire', + 'Topaz', + 'Garnet', + 'Onyx', + 'Opal', + 'Azure', + 'Alpine', + 'Summit', + 'Breeze', + 'Bluebell', + 'Sunburst', + 'Starlight', + 'Moonbeam', + 'Timberland', + 'Waterfront', + 'Creekside', + 'Streamside', + 'Trailside', + 'Woodland', + 'Meadowland', + 'Springbrook', + 'Ivywood', + 'Oakridge', + 'Pinebrook', + 'Hillcrest', + 'Glenwood', + 'Lakeview', + 'Valleyview', + 'Fieldstone', + 'Parkview', + 'Gardenview', + 'Lakeshore', + 'Evergreen', + 'Forestview', + 'Highland', + 'Sunset', + 'Sunrise', + 'Willowbend', + 'Riverwalk', + 'Urbanlife', + 'Metropolis', + 'Hearthstone', + 'Coastal', + 'Pineview', + 'Brookhaven', + 'Birchhill', + 'Silverleaf', + 'Oakwood', + 'Greenscape', + 'Blueharbor', + 'Lakesound', + 'Riverbend', + 'Pondside', + 'Creekside', + 'Springhill', + 'Countryview', + 'Townscape', + 'Lakeshine', + 'Meadowlands', + 'Ridgefield', + 'Cedarbreeze', + 'Havenridge', + 'Sunhaven', + 'Starbreeze', + 'Meadowbrook', + 'Briarwood', + 'Walnut', + 'Magnolia', + 'Pinecone', + 'Birchgrove', + 'Cedarhill', + 'Sunridge', + 'Starcrest', + 'Sunkiss', + 'Autumnvale', + 'Redwood', + 'Willowfield', + 'Orchard', + 'Sunvalley', + 'Starlight', + 'Moonshadow', + 'Oceanview', + 'Seaside', + 'Rosehill', + 'Heather', + 'Heatherwood', + 'Lavender', + 'Lavenderfield', + 'Heatherbloom', + 'Amber', + 'Amberwood', + 'Honeywood', + 'Lavendergrove', + 'Lavenderblossom', + 'Lavenderglade', + 'Lavenderhill', + 'Lavenderwood', + 'Saffron', + 'Chestnut', + 'Chestnutgrove', + 'Chestnutfield', + 'Chestnutridge', + 'Chestnuthaven', + 'Pecanwood', + 'Pecangrove', + 'Maplewood', + 'Maplefield', + 'Maplebend', + 'Mapleridge', + 'Maplecrest', + 'Mapleside', + 'Maplehaven', + 'Elderwood', + 'Elderberry', + 'Cherrywood', + 'Cherrygrove', + 'Cherryfield', + 'Cherrycrest', + 'Cherryblossom', + 'Cherryview', + 'Cherryhaven', + 'Berrybush', + 'Berryfield', + 'Berryridge', + 'Berrygrove', + 'Berryvale', + 'Berrybend', + 'Blossom', + 'Bloomfield', + 'Bloomingdale', + 'Bloomridge', + 'Bloomhaven', + 'Blossomvale', + 'Blossomwood', + 'Blossomgrove', + 'Blossomhill', + 'Bloomhill', + 'Elmwood', + 'Elmfield', + 'Elmridge', + 'Elmshade', + 'Elmglade', + 'Elmbrook', + 'Elmhaven', + 'Elmcrest', + 'Elmside', + 'Walnutwood', + 'Walnutfield', + 'Walnutgrove', + 'Walnuthaven', + 'Walnutridge', + 'Walnutvale', + 'Walnutview', + 'Walnutcrest', + 'Walnutshade', + 'Walnutglade', + 'Walnutblossom', + 'Willowwood', + 'Willowfield', + 'Willowridge', + 'Willowvale', + 'Willowview', + 'Willowscape', + 'Willowhaven', + 'Willowside', + 'Sycamorewood', + 'Sycamorefield', + 'Sycamoregrove', + 'Sycamorehill', + 'Sycamorevale', + 'Sycamorebend', + 'Sycamoreblossom', + 'Sycamoreglade', + 'Sycamoreridge', + 'Sycamoreside', + 'Juniperwood', + 'Juniperfield', + 'Juniperblossom', + 'Juniperbend', + 'Juniperhill', + 'Junipergrove', + 'Juniperglade', + 'Juniperview', + 'Junipercrest', + 'Juniperside', + 'Blossomfield', + 'Blossomgrove', + 'Blossomhill', + 'Blossomvale', + 'Blossomview', + 'Blossomshade', + 'Blossomglade', + 'Residences', + 'Towers', + 'Mirage', + 'Estates', + 'Plaza', + 'Villas', + 'Apartments', + 'Suites', + 'Manor', + 'Courtyard', + 'Retreat', + 'Homes', + 'Gardens', + 'Lofts', + 'Terrace', +]; + +// Random list of adjectives +const adjectives = [ + 'Serene', + 'Urban', + 'Harmonious', + 'Tranquil', + 'Elegant', + 'Modern', + 'Luxurious', + 'Cozy', + 'Chic', + 'Stylish', + 'Spacious', + 'Sleek', + 'Inviting', + 'Upscale', + 'Contemporary', + 'Comfortable', + 'Peaceful', + 'Scenic', + 'Serendipitous', + 'Vibrant', + 'Radiant', + 'Glorious', + 'Majestic', + 'Sunny', + 'Bright', + 'Airy', + 'Lively', + 'Exquisite', + 'Captivating', + 'Posh', + 'Grand', + 'Regal', + 'Dazzling', + 'Swanky', + 'Sophisticated', + 'Unique', + 'Exotic', + 'Charming', + 'Whimsical', + 'Enchanting', + 'Alluring', + 'Quaint', + 'Delightful', + 'Enchanted', + 'Enraptured', + 'Eclectic', + 'Remarkable', + 'Stunning', + 'Graceful', + 'Beautiful', + 'Delicate', + 'Breathtaking', + 'Striking', + 'Picturesque', + 'Magnificent', + 'Awe-Inspiring', + 'Incredible', + 'Extraordinary', + 'Mesmerizing', + 'Mesmeric', + 'Aesthetic', + 'Enthralling', + 'Spellbinding', + 'Enigmatic', + 'Mysterious', + 'Mystical', + 'Euphoric', + 'Luminous', + 'Dashing', + 'Gleaming', + 'Glowing', + 'Shimmering', + 'Sparkling', + 'Glistening', + 'Illuminating', + 'Glittering', + 'Bedazzling', + 'Opulent', + 'Plush', + 'Lavish', + 'Sumptuous', + 'Swish', + 'Classy', + 'Refined', + 'Aristocratic', + 'Imposing', + 'Illustrious', + 'Noble', + 'Royal', + 'Kingly', + 'Sovereign', + 'Supreme', + 'Pinnacle', + 'Peak', + 'Paramount', + 'Utmost', + 'Optimal', + 'Ideal', + 'Exemplary', + 'Superb', + 'Outstanding', + 'Fantastic', + 'Wonderful', + 'Terrific', + 'Amazing', + 'Astonishing', + 'Astounding', + 'Marvelous', + 'Spectacular', + 'Breathtaking', + 'Impressive', + 'Remarkable', + 'Notable', + 'Memorable', + 'Unforgettable', + 'Exceptional', + 'Fabulous', + 'Delightful', + 'Enjoyable', + 'Fulfilling', + 'Enriching', + 'Positive', + 'Happy', + 'Joyful', + 'Blissful', + 'Heavenly', + 'Pleasurable', + 'Satisfying', + 'Gratifying', + 'Uplifting', + 'Inspiring', + 'Thrilling', + 'Invigorating', + 'Refreshing', + 'Rejuvenating', + 'Revitalizing', + 'Nourishing', + 'Relaxing', + 'Calming', + 'Laid-Back', + 'Riveting', + 'Mesmerizing', + 'Hypnotic', + 'Charismatic', + 'Attractive', + 'Compelling', + 'Exciting', + 'Fascinating', + 'Entertaining', + 'Engaging', + 'Attractive', + 'Magnetic', + 'Charming', + 'Warm', + 'Cosy', + 'Hospitable', + 'Friendly', + 'Relaxing', + 'Delightful', + 'Elegant', + 'Refined', + 'Regal', + 'Lavish', + 'Abundant', + 'Prosperous', + 'Flourishing', + 'Bountiful', + 'Select', + 'Exclusive', + 'High-End', + 'Trendy', + 'Swanky', + 'Cozy', + 'Hospitable', + 'Relaxing', + 'Refreshing', + 'Invigorating', + 'Nourishing', + 'Superlative', + 'Divine', + 'Distinguished', + 'Luxurious', + 'Opulent', + 'Illustrious', + 'Regal', + 'Royal', + 'Palatial', + 'Splendid', + 'Sublime', + 'Gorgeous', + 'Lovely', + 'Elegant', + 'Stylish', + 'Fashionable', + 'Chic', + 'Classy', + 'Graceful', + 'Wonderful', + 'Positive', + 'Exquisite', + 'Superior', + 'Prolific', + 'Satisfying', + 'Gratifying', + 'Enjoyable', + 'Fabulous', + 'Terrific', + 'Fantastic', + 'Phenomenal', + 'Celestial', + 'Vintage', + 'Cosmopolitan', +]; + +export const randomNoun = () => { + return nouns[randomInt(nouns.length)]; +}; + +export const randomAdjective = () => { + return adjectives[randomInt(adjectives.length)]; +}; + +export const randomName = () => { + return `${randomAdjective()} ${randomNoun()}`; +}; diff --git a/api/prisma/seed-staging.ts b/api/prisma/seed-staging.ts new file mode 100644 index 0000000000..6359675828 --- /dev/null +++ b/api/prisma/seed-staging.ts @@ -0,0 +1,890 @@ +import { + ApplicationAddressTypeEnum, + ApplicationMethodsTypeEnum, + ListingsStatusEnum, + MultiselectQuestions, + MultiselectQuestionsApplicationSectionEnum, + Prisma, + PrismaClient, + ReviewOrderTypeEnum, + UserRoleEnum, +} from '@prisma/client'; +import dayjs from 'dayjs'; +import { jurisdictionFactory } from './seed-helpers/jurisdiction-factory'; +import { + featuresAndUtilites, + listingFactory, +} from './seed-helpers/listing-factory'; +import { amiChartFactory } from './seed-helpers/ami-chart-factory'; +import { userFactory } from './seed-helpers/user-factory'; +import { unitTypeFactoryAll } from './seed-helpers/unit-type-factory'; +import { unitAccessibilityPriorityTypeFactoryAll } from './seed-helpers/unit-accessibility-priority-type-factory'; +import { multiselectQuestionFactory } from './seed-helpers/multiselect-question-factory'; +import { + lincolnMemorial, + washingtonMonument, + whiteHouse, +} from './seed-helpers/address-factory'; +import { applicationFactory } from './seed-helpers/application-factory'; +import { translationFactory } from './seed-helpers/translation-factory'; +import { reservedCommunityTypeFactoryAll } from './seed-helpers/reserved-community-type-factory'; +import { + mapLayerFactory, + redlinedMap, + simplifiedDCMap, +} from './seed-helpers/map-layer-factory'; +import { ValidationMethod } from '../src/enums/multiselect-questions/validation-method-enum'; + +export const stagingSeed = async ( + prismaClient: PrismaClient, + jurisdictionName: string, +) => { + // create main jurisdiction + const jurisdiction = await prismaClient.jurisdictions.create({ + data: jurisdictionFactory(jurisdictionName, [UserRoleEnum.admin]), + }); + // add another jurisdiction + const additionalJurisdiction = await prismaClient.jurisdictions.create({ + data: jurisdictionFactory(), + }); + // create admin user + await prismaClient.userAccounts.create({ + data: await userFactory({ + roles: { isAdmin: true }, + email: 'admin@example.com', + confirmedAt: new Date(), + jurisdictionIds: [jurisdiction.id, additionalJurisdiction.id], + acceptedTerms: true, + }), + }); + // create a jurisdictional admin + await prismaClient.userAccounts.create({ + data: await userFactory({ + roles: { isJurisdictionalAdmin: true }, + email: 'jurisdiction-admin@example.com', + confirmedAt: new Date(), + jurisdictionIds: [jurisdiction.id], + acceptedTerms: true, + }), + }); + await prismaClient.userAccounts.create({ + data: await userFactory({ + roles: { isJurisdictionalAdmin: true }, + email: 'unverified@example.com', + confirmedAt: new Date(), + jurisdictionIds: [jurisdiction.id], + acceptedTerms: false, + }), + }); + await prismaClient.userAccounts.create({ + data: await userFactory({ + roles: { isJurisdictionalAdmin: true }, + email: 'mfauser@bloom.com', + confirmedAt: new Date(), + jurisdictionIds: [jurisdiction.id], + acceptedTerms: true, + mfaEnabled: true, + mfaCode: '12345', + }), + }); + // add jurisdiction specific translations and default ones + await prismaClient.translations.create({ + data: translationFactory(jurisdiction.id, jurisdiction.name), + }); + await prismaClient.translations.create({ + data: translationFactory(), + }); + // build ami charts + const amiChart = await prismaClient.amiChart.create({ + data: amiChartFactory(10, jurisdiction.id), + }); + await prismaClient.mapLayers.create({ + data: mapLayerFactory(jurisdiction.id, 'Redlined Districts', redlinedMap), + }); + const mapLayer = await prismaClient.mapLayers.create({ + data: mapLayerFactory(jurisdiction.id, 'Washington DC', simplifiedDCMap), + }); + const multiselectQuestion1 = await prismaClient.multiselectQuestions.create({ + data: multiselectQuestionFactory(jurisdiction.id, { + multiselectQuestion: { + text: 'City Employees', + description: 'Employees of the local city.', + applicationSection: + MultiselectQuestionsApplicationSectionEnum.preferences, + options: [ + { + text: 'At least one member of my household is a city employee', + collectAddress: false, + ordinal: 0, + }, + ], + }, + }), + }); + const multiselectQuestion2 = await prismaClient.multiselectQuestions.create({ + data: multiselectQuestionFactory(jurisdiction.id, { + multiselectQuestion: { + text: 'Work in the city', + description: 'At least one member of my household works in the city', + applicationSection: + MultiselectQuestionsApplicationSectionEnum.preferences, + options: [ + { + text: 'At least one member of my household works in the city', + ordinal: 0, + collectAddress: true, + collectName: true, + collectRelationship: true, + mapLayerId: mapLayer.id, + validationMethod: ValidationMethod.map, + }, + { + text: 'All members of the household work in the city', + ordinal: 1, + collectAddress: true, + ValidationMethod: ValidationMethod.none, + collectName: false, + collectRelationship: false, + }, + ], + }, + }), + }); + const multiselectQuestionPrograms = + await prismaClient.multiselectQuestions.create({ + data: multiselectQuestionFactory(jurisdiction.id, { + multiselectQuestion: { + text: 'Housing Situation', + description: + 'Thinking about the past 30 days, do either of these describe your housing situation?', + applicationSection: + MultiselectQuestionsApplicationSectionEnum.programs, + options: [ + { + text: 'Not Permanent', + ordinal: 0, + }, + { + text: 'Homeless', + ordinal: 1, + }, + { + text: 'Do Not Consider', + ordinal: 2, + }, + { + text: 'Prefer not to say', + ordinal: 3, + }, + ], + }, + }), + }); + // create pre-determined values + const unitTypes = await unitTypeFactoryAll(prismaClient); + await unitAccessibilityPriorityTypeFactoryAll(prismaClient); + await reservedCommunityTypeFactoryAll(jurisdiction.id, prismaClient); + // list of predefined listings WARNING: images only work if image setup is cloudinary on exygy account + [ + { + listing: { + additionalApplicationSubmissionNotes: null, + digitalApplication: true, + commonDigitalApplication: true, + paperApplication: false, + referralOpportunity: false, + assets: [], + accessibility: null, + amenities: null, + buildingTotalUnits: 0, + developer: 'Bloom', + householdSizeMax: 0, + householdSizeMin: 0, + neighborhood: 'Hollywood', + petPolicy: null, + smokingPolicy: null, + unitAmenities: null, + servicesOffered: null, + yearBuilt: null, + applicationDueDate: null, + applicationOpenDate: dayjs(new Date()).subtract(70, 'days').toDate(), + applicationFee: null, + applicationOrganization: null, + applicationPickUpAddressOfficeHours: null, + applicationPickUpAddressType: null, + applicationDropOffAddressOfficeHours: null, + applicationDropOffAddressType: null, + applicationMailingAddressType: null, + buildingSelectionCriteria: null, + costsNotIncluded: null, + creditHistory: null, + criminalBackground: null, + depositMin: '0', + depositMax: '0', + depositHelperText: + "or one month's rent may be higher for lower credit scores", + disableUnitsAccordion: false, + leasingAgentEmail: 'bloom@exygy.com', + leasingAgentName: 'Bloom Bloomington', + leasingAgentOfficeHours: null, + leasingAgentPhone: '(555) 555-5555', + leasingAgentTitle: null, + name: 'Hollywood Hills Heights', + postmarkedApplicationsReceivedByDate: null, + programRules: null, + rentalAssistance: + 'Housing Choice Vouchers, Section 8 and other valid rental assistance programs will be considered for this property. In the case of a valid rental subsidy, the required minimum income will be based on the portion of the rent that the tenant pays after use of the subsidy.', + rentalHistory: null, + requiredDocuments: null, + specialNotes: null, + waitlistCurrentSize: null, + waitlistMaxSize: null, + whatToExpect: + '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.', + status: ListingsStatusEnum.active, + reviewOrderType: ReviewOrderTypeEnum.waitlist, + unitsAvailable: 0, + displayWaitlistSize: false, + reservedCommunityDescription: null, + reservedCommunityMinAge: null, + resultLink: null, + isWaitlistOpen: false, + waitlistOpenSpots: null, + customMapPin: false, + publishedAt: new Date(), + listingsBuildingAddress: { + create: whiteHouse, + }, + listingsApplicationPickUpAddress: undefined, + listingsLeasingAgentAddress: undefined, + listingsApplicationDropOffAddress: undefined, + listingsApplicationMailingAddress: undefined, + reservedCommunityTypes: undefined, + listingImages: { + create: { + ordinal: 0, + assets: { + create: { + label: 'cloudinaryBuilding', + fileId: 'dev/apartment_building_2_b7ujdd', + }, + }, + }, + }, + }, + units: [ + { + amiPercentage: '30', + monthlyIncomeMin: '2000', + floor: 1, + maxOccupancy: 3, + minOccupancy: 1, + monthlyRent: '1200', + numBathrooms: 1, + numBedrooms: 1, + number: '101', + sqFeet: '750.00', + amiChart: { connect: { id: amiChart.id } }, + unitTypes: { + connect: { + id: unitTypes[0].id, + }, + }, + }, + { + amiPercentage: '30', + monthlyIncomeMin: '2000', + floor: 1, + maxOccupancy: 3, + minOccupancy: 1, + numBathrooms: 1, + numBedrooms: 1, + number: '101', + sqFeet: '750.00', + amiChart: { connect: { id: amiChart.id } }, + unitTypes: { + connect: { + id: unitTypes[1].id, + }, + }, + }, + ], + multiselectQuestions: [ + multiselectQuestion1, + multiselectQuestion2, + multiselectQuestionPrograms, + ], + applications: [await applicationFactory(), await applicationFactory()], + }, + { + listing: { + additionalApplicationSubmissionNotes: null, + digitalApplication: true, + commonDigitalApplication: true, + paperApplication: false, + referralOpportunity: false, + assets: [], + accessibility: null, + amenities: null, + buildingTotalUnits: 0, + developer: 'ABS Housing', + householdSizeMax: 0, + householdSizeMin: 0, + neighborhood: null, + petPolicy: 'Pets are not permitted on the property. ', + smokingPolicy: null, + unitAmenities: 'Each unit comes with included central AC.', + servicesOffered: null, + yearBuilt: 2021, + applicationDueDate: dayjs(new Date()).add(30, 'days').toDate(), + applicationOpenDate: dayjs(new Date()).subtract(7, 'days').toDate(), + applicationFee: '35', + applicationOrganization: null, + applicationPickUpAddressOfficeHours: null, + applicationPickUpAddressType: null, + applicationDropOffAddressOfficeHours: null, + applicationDropOffAddressType: null, + applicationMailingAddressType: null, + buildingSelectionCriteria: null, + costsNotIncluded: null, + creditHistory: null, + criminalBackground: null, + depositMin: '500', + depositMax: '0', + depositHelperText: + "or one month's rent may be higher for lower credit scores", + disableUnitsAccordion: false, + leasingAgentEmail: 'sgates@abshousing.com', + leasingAgentName: 'Samuel Gates', + leasingAgentOfficeHours: null, + leasingAgentPhone: '(888) 888-8888', + leasingAgentTitle: 'Property Manager', + name: 'District View Apartments', + postmarkedApplicationsReceivedByDate: null, + programRules: null, + rentalAssistance: + 'Housing Choice Vouchers, Section 8 and other valid rental assistance programs will be considered for this property. In the case of a valid rental subsidy, the required minimum income will be based on the portion of the rent that the tenant pays after use of the subsidy.', + rentalHistory: null, + requiredDocuments: null, + specialNotes: null, + waitlistCurrentSize: null, + waitlistMaxSize: null, + whatToExpect: + '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.', + status: ListingsStatusEnum.active, + reviewOrderType: ReviewOrderTypeEnum.lottery, + displayWaitlistSize: false, + reservedCommunityDescription: null, + reservedCommunityMinAge: null, + resultLink: null, + isWaitlistOpen: false, + waitlistOpenSpots: null, + customMapPin: false, + publishedAt: new Date(), + listingsApplicationPickUpAddress: undefined, + listingsApplicationDropOffAddress: undefined, + listingsApplicationMailingAddress: undefined, + reservedCommunityTypes: undefined, + }, + units: [ + { + amiPercentage: '30', + annualIncomeMin: null, + monthlyIncomeMin: '1985', + floor: 2, + maxOccupancy: 5, + minOccupancy: 2, + monthlyRent: '800', + numBathrooms: 2, + numBedrooms: 2, + number: '', + amiChart: { connect: { id: amiChart.id } }, + unitTypes: { + connect: { + id: unitTypes[2].id, + }, + }, + }, + { + amiPercentage: '30', + annualIncomeMin: null, + monthlyIncomeMin: '2020', + floor: 2, + maxOccupancy: 5, + minOccupancy: 2, + monthlyRent: '800', + numBathrooms: 2, + numBedrooms: 2, + number: '', + amiChart: { connect: { id: amiChart.id } }, + unitTypes: { + connect: { + id: unitTypes[2].id, + }, + }, + }, + { + amiPercentage: '30', + annualIncomeMin: null, + monthlyIncomeMin: '1985', + floor: 2, + maxOccupancy: 5, + minOccupancy: 2, + monthlyRent: '800', + numBathrooms: 2, + numBedrooms: 2, + amiChart: { connect: { id: amiChart.id } }, + unitTypes: { + connect: { + id: unitTypes[2].id, + }, + }, + }, + ], + multiselectQuestions: [multiselectQuestion1], + // has applications that are the same email + applications: [ + await applicationFactory({ + applicant: { emailAddress: 'user1@example.com' }, + }), + await applicationFactory({ + applicant: { emailAddress: 'user1@example.com' }, + }), + await applicationFactory(), + await applicationFactory(), + ], + }, + { + listing: { + additionalApplicationSubmissionNotes: null, + digitalApplication: true, + commonDigitalApplication: true, + paperApplication: true, + referralOpportunity: false, + assets: [], + accessibility: null, + amenities: null, + buildingTotalUnits: 0, + developer: 'Cielo Housing', + householdSizeMax: 0, + householdSizeMin: 0, + neighborhood: 'North End', + petPolicy: null, + smokingPolicy: null, + unitAmenities: null, + servicesOffered: null, + yearBuilt: 1900, + applicationDueDate: null, + applicationOpenDate: dayjs(new Date()).subtract(1, 'days').toDate(), + applicationFee: '60', + applicationOrganization: null, + applicationPickUpAddressOfficeHours: null, + applicationPickUpAddressType: ApplicationAddressTypeEnum.leasingAgent, + applicationDropOffAddressOfficeHours: null, + applicationDropOffAddressType: ApplicationAddressTypeEnum.leasingAgent, + applicationMailingAddressType: ApplicationAddressTypeEnum.leasingAgent, + applicationMethods: { + create: { + type: ApplicationMethodsTypeEnum.Internal, + }, + }, + buildingSelectionCriteria: null, + costsNotIncluded: null, + creditHistory: null, + criminalBackground: null, + depositMin: '0', + depositMax: '50', + depositHelperText: + "or one month's rent may be higher for lower credit scores", + disableUnitsAccordion: false, + leasingAgentEmail: 'joe@smithrealty.com', + leasingAgentName: 'Joe Smith', + leasingAgentOfficeHours: '9:00am - 5:00pm, Monday-Friday', + leasingAgentPhone: '(773) 580-5897', + leasingAgentTitle: 'Senior Leasing Agent', + name: 'Blue Sky Apartments', + postmarkedApplicationsReceivedByDate: '2025-06-06T23:00:00.000Z', + programRules: null, + rentalAssistance: + 'Housing Choice Vouchers, Section 8 and other valid rental assistance programs will be considered for this property. ', + rentalHistory: null, + requiredDocuments: null, + specialNotes: null, + waitlistCurrentSize: null, + waitlistMaxSize: null, + whatToExpect: + '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.', + status: ListingsStatusEnum.active, + reviewOrderType: ReviewOrderTypeEnum.firstComeFirstServe, + displayWaitlistSize: false, + reservedCommunityDescription: + 'Seniors over 55 are eligible for this property ', + reservedCommunityMinAge: null, + resultLink: null, + isWaitlistOpen: false, + waitlistOpenSpots: null, + customMapPin: false, + publishedAt: new Date(), + listingsBuildingAddress: { + create: whiteHouse, + }, + listingsApplicationMailingAddress: { + create: lincolnMemorial, + }, + listingsApplicationPickUpAddress: { + create: washingtonMonument, + }, + listingsLeasingAgentAddress: { + create: lincolnMemorial, + }, + listingsApplicationDropOffAddress: { + create: washingtonMonument, + }, + reservedCommunityTypes: undefined, + listingImages: { + create: [ + { + ordinal: 0, + assets: { + create: { + label: 'cloudinaryBuilding', + fileId: 'dev/trayan-xIOYJSVEZ8c-unsplash_f1axsg', + }, + }, + }, + ], + }, + }, + units: [ + { + amiPercentage: '30', + monthlyIncomeMin: '2000', + floor: 1, + maxOccupancy: 3, + minOccupancy: 1, + monthlyRent: '1200', + numBathrooms: 1, + numBedrooms: 1, + number: '101', + sqFeet: '750.00', + amiChart: { connect: { id: amiChart.id } }, + unitTypes: { + connect: { + id: unitTypes[2].id, + }, + }, + }, + ], + }, + { + listing: { + additionalApplicationSubmissionNotes: null, + digitalApplication: true, + commonDigitalApplication: true, + paperApplication: false, + referralOpportunity: false, + assets: [], + accessibility: null, + amenities: 'Includes handicap accessible entry and parking spots. ', + buildingTotalUnits: 17, + developer: 'ABS Housing', + householdSizeMax: 0, + householdSizeMin: 0, + neighborhood: null, + petPolicy: null, + smokingPolicy: 'No smoking is allowed on the property.', + unitAmenities: null, + servicesOffered: null, + yearBuilt: 2019, + applicationDueDate: null, + applicationOpenDate: dayjs(new Date()).subtract(100, 'days').toDate(), + applicationFee: '50', + applicationOrganization: null, + applicationPickUpAddressOfficeHours: null, + applicationPickUpAddressType: null, + applicationDropOffAddressOfficeHours: null, + applicationDropOffAddressType: null, + applicationMailingAddressType: null, + buildingSelectionCriteria: null, + costsNotIncluded: 'Residents are responsible for gas and electric. ', + creditHistory: null, + criminalBackground: null, + depositMin: '0', + depositMax: '0', + depositHelperText: + "or one month's rent may be higher for lower credit scores", + disableUnitsAccordion: false, + leasingAgentEmail: 'valleysenior@vpm.com', + leasingAgentName: 'Valley Property Management', + leasingAgentOfficeHours: '10 am - 6 pm Monday through Friday', + leasingAgentPhone: '(919) 999-9999', + leasingAgentTitle: 'Property Manager', + name: 'Valley Heights Senior Community', + postmarkedApplicationsReceivedByDate: null, + programRules: null, + rentalAssistance: + 'Housing Choice Vouchers, Section 8 and other valid rental assistance programs will be considered for this property. In the case of a valid rental subsidy, the required minimum income will be based on the portion of the rent that the tenant pays after use of the subsidy.', + rentalHistory: null, + requiredDocuments: null, + specialNotes: null, + waitlistCurrentSize: null, + waitlistMaxSize: null, + whatToExpect: + '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.', + status: ListingsStatusEnum.closed, + reviewOrderType: ReviewOrderTypeEnum.waitlist, + displayWaitlistSize: false, + reservedCommunityDescription: + 'Residents must be over the age of 55 at the time of move in.', + reservedCommunityMinAge: null, + resultLink: null, + isWaitlistOpen: false, + waitlistOpenSpots: null, + customMapPin: false, + publishedAt: dayjs(new Date()).subtract(3, 'days').toDate(), + closedAt: dayjs(new Date()).subtract(1, 'days').toDate(), + listingsApplicationPickUpAddress: undefined, + listingsLeasingAgentAddress: undefined, + listingsApplicationDropOffAddress: undefined, + listingsApplicationMailingAddress: undefined, + listingImages: { + create: [ + { + ordinal: 0, + assets: { + create: { + label: 'cloudinaryBuilding', + fileId: 'dev/apartment_ez3yyz', + }, + }, + }, + { + ordinal: 1, + assets: { + create: { + label: 'cloudinaryBuilding', + fileId: 'dev/interior_mc9erd', + }, + }, + }, + { + ordinal: 2, + assets: { + create: { + label: 'cloudinaryBuilding', + fileId: 'dev/inside_qo9wre', + }, + }, + }, + ], + }, + }, + }, + { + listing: { + additionalApplicationSubmissionNotes: null, + digitalApplication: true, + commonDigitalApplication: false, + paperApplication: false, + referralOpportunity: false, + assets: [], + accessibility: null, + amenities: null, + buildingTotalUnits: 0, + developer: 'La Villita Listings', + householdSizeMax: 0, + householdSizeMin: 0, + neighborhood: 'Koreatown', + petPolicy: null, + smokingPolicy: null, + unitAmenities: null, + servicesOffered: null, + yearBuilt: 1996, + applicationDueDate: null, + applicationOpenDate: dayjs(new Date()).subtract(30, 'days').toDate(), + applicationFee: null, + applicationOrganization: null, + applicationPickUpAddressOfficeHours: null, + applicationPickUpAddressType: null, + applicationDropOffAddressOfficeHours: null, + applicationDropOffAddressType: null, + applicationMailingAddressType: null, + buildingSelectionCriteria: null, + costsNotIncluded: null, + creditHistory: null, + criminalBackground: null, + depositMin: '0', + depositMax: '0', + depositHelperText: + "or one month's rent may be higher for lower credit scores", + disableUnitsAccordion: false, + leasingAgentEmail: 'joe@smith.com', + leasingAgentName: 'Joe Smith', + leasingAgentOfficeHours: null, + leasingAgentPhone: '(619) 591-5987', + leasingAgentTitle: null, + name: 'Little Village Apartments', + postmarkedApplicationsReceivedByDate: null, + programRules: null, + rentalAssistance: + 'Housing Choice Vouchers, Section 8 and other valid rental assistance programs will be considered for this property. In the case of a valid rental subsidy, the required minimum income will be based on the portion of the rent that the tenant pays after use of the subsidy.', + rentalHistory: null, + requiredDocuments: null, + specialNotes: null, + waitlistCurrentSize: null, + waitlistMaxSize: null, + whatToExpect: + '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.', + status: ListingsStatusEnum.pending, + reviewOrderType: ReviewOrderTypeEnum.waitlist, + displayWaitlistSize: false, + reservedCommunityDescription: null, + reservedCommunityMinAge: null, + resultLink: null, + isWaitlistOpen: true, + waitlistOpenSpots: 6, + customMapPin: false, + publishedAt: new Date(), + listingsApplicationPickUpAddress: undefined, + listingsApplicationDropOffAddress: undefined, + listingsApplicationMailingAddress: undefined, + listingImages: { + create: [ + { + ordinal: 0, + assets: { + create: { + label: 'cloudinaryBuilding', + fileId: 'dev/dillon-kydd-2keCPb73aQY-unsplash_lm7krp', + }, + }, + }, + ], + }, + }, + multiselectQuestions: [multiselectQuestion2], + }, + { + listing: { + additionalApplicationSubmissionNotes: null, + digitalApplication: true, + commonDigitalApplication: true, + paperApplication: false, + referralOpportunity: false, + assets: [], + accessibility: null, + amenities: null, + buildingTotalUnits: 25, + developer: 'Johnson Realtors', + householdSizeMax: 0, + householdSizeMin: 0, + neighborhood: 'Hyde Park', + petPolicy: null, + smokingPolicy: null, + unitAmenities: null, + servicesOffered: null, + yearBuilt: 1988, + applicationMethods: { + create: { + type: ApplicationMethodsTypeEnum.Internal, + }, + }, + applicationDueDate: dayjs(new Date()).add(6, 'months').toDate(), + applicationOpenDate: dayjs(new Date()).subtract(1, 'days').toDate(), + applicationFee: null, + applicationOrganization: null, + applicationPickUpAddressOfficeHours: null, + applicationPickUpAddressType: null, + applicationDropOffAddressOfficeHours: null, + applicationDropOffAddressType: null, + applicationMailingAddressType: null, + buildingSelectionCriteria: null, + costsNotIncluded: null, + creditHistory: null, + criminalBackground: null, + depositMin: '0', + depositMax: '0', + depositHelperText: + "or one month's rent may be higher for lower credit scores", + disableUnitsAccordion: true, + leasingAgentEmail: 'jenny@gold.com', + leasingAgentName: 'Jenny Gold', + leasingAgentOfficeHours: null, + leasingAgentPhone: '(208) 772-2856', + leasingAgentTitle: 'Lead Agent', + name: 'Elm Village', + postmarkedApplicationsReceivedByDate: null, + programRules: null, + rentalAssistance: + 'Housing Choice Vouchers, Section 8 and other valid rental assistance programs will be considered for this property. In the case of a valid rental subsidy, the required minimum income will be based on the portion of the rent that the tenant pays after use of the subsidy.', + rentalHistory: null, + requiredDocuments: 'Please bring proof of income and a recent paystub.', + specialNotes: null, + waitlistCurrentSize: null, + waitlistMaxSize: null, + whatToExpect: + '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.', + status: ListingsStatusEnum.active, + reviewOrderType: ReviewOrderTypeEnum.lottery, + displayWaitlistSize: false, + reservedCommunityDescription: null, + reservedCommunityMinAge: null, + resultLink: null, + isWaitlistOpen: false, + waitlistOpenSpots: null, + customMapPin: false, + publishedAt: new Date(), + listingsApplicationPickUpAddress: undefined, + listingsApplicationDropOffAddress: undefined, + reservedCommunityTypes: undefined, + ...featuresAndUtilites(), + listingImages: { + create: [ + { + ordinal: 0, + assets: { + create: { + label: 'cloudinaryBuilding', + fileId: 'dev/krzysztof-hepner-V7Q0Oh3Az-c-unsplash_xoj7sr', + }, + }, + }, + { + ordinal: 1, + assets: { + create: { + label: 'cloudinaryBuilding', + fileId: 'dev/blake-wheeler-zBHU08hdzhY-unsplash_swqash ', + }, + }, + }, + ], + }, + }, + multiselectQuestions: [multiselectQuestion2, multiselectQuestion1], + }, + ].map( + async ( + value: { + listing: Prisma.ListingsCreateInput; + units?: Prisma.UnitsCreateWithoutListingsInput[]; + multiselectQuestions?: MultiselectQuestions[]; + applications?: Prisma.ApplicationsCreateInput[]; + }, + index, + ) => { + const listing = await listingFactory(jurisdiction.id, prismaClient, { + amiChart: amiChart, + numberOfUnits: index, + listing: value.listing, + units: value.units, + multiselectQuestions: value.multiselectQuestions, + applications: value.applications, + afsLastRunSetInPast: true, + }); + await prismaClient.listings.create({ + data: listing, + }); + }, + ); +}; diff --git a/api/prisma/seed.ts b/api/prisma/seed.ts new file mode 100644 index 0000000000..a7baca9fec --- /dev/null +++ b/api/prisma/seed.ts @@ -0,0 +1,51 @@ +import { PrismaClient } from '@prisma/client'; +import { parseArgs } from 'node:util'; +import { jurisdictionFactory } from './seed-helpers/jurisdiction-factory'; +import { stagingSeed } from './seed-staging'; +import { devSeeding } from './seed-dev'; +import { unitTypeFactoryAll } from './seed-helpers/unit-type-factory'; +import { unitAccessibilityPriorityTypeFactoryAll } from './seed-helpers/unit-accessibility-priority-type-factory'; +import { reservedCommunityTypeFactoryAll } from './seed-helpers/reserved-community-type-factory'; + +const options: { [name: string]: { type: 'string' | 'boolean' } } = { + environment: { type: 'string' }, + jurisdictionName: { type: 'string' }, +}; + +const prisma = new PrismaClient(); +async function main() { + const { + values: { environment, jurisdictionName }, + } = parseArgs({ options }); + switch (environment) { + case 'production': + // Setting up a production database we would just need the bare minimum such as jurisdiction + const jurisdictionId = await prisma.jurisdictions.create({ + data: jurisdictionFactory(jurisdictionName as string), + }); + await unitTypeFactoryAll(prisma); + await unitAccessibilityPriorityTypeFactoryAll(prisma); + await reservedCommunityTypeFactoryAll(jurisdictionId.id, prisma); + break; + case 'staging': + // Staging setup should have realistic looking data with a preset list of listings + // along with all of the required tables (ami, users, etc) + stagingSeed(prisma, jurisdictionName as string); + break; + case 'development': + default: + // Development is less realistic data, but can be more experimental and also should + // be partially randomized so we cover all bases + devSeeding(prisma, jurisdictionName as string); + break; + } +} +main() + .then(async () => { + await prisma.$disconnect(); + }) + .catch(async (e) => { + console.error(e); + await prisma.$disconnect(); + process.exit(1); + }); diff --git a/api/scripts/generate-axios-client.ts b/api/scripts/generate-axios-client.ts new file mode 100644 index 0000000000..00f7ef36c0 --- /dev/null +++ b/api/scripts/generate-axios-client.ts @@ -0,0 +1,22 @@ +import { codegen } from 'swagger-axios-codegen'; +import * as fs from 'fs'; +import 'dotenv/config'; + +async function codeGen() { + await codegen({ + methodNameMode: 'operationId', + remoteUrl: `http://localhost:${process.env.PORT}/api-json`, + outputDir: '../shared-helpers/src/types', + useStaticMethod: false, + fileName: 'backend-swagger.ts', + useHeaderParameters: false, + strictNullChecks: true, + }); + let content = fs.readFileSync( + '../shared-helpers/src/types/backend-swagger.ts', + 'utf-8', + ); + content = content.replace(/(\w+)Dto/g, '$1'); + fs.writeFileSync('../shared-helpers/src/types/backend-swagger.ts', content); +} +void codeGen(); diff --git a/api/src/controllers/ami-chart.controller.ts b/api/src/controllers/ami-chart.controller.ts new file mode 100644 index 0000000000..e5047d4565 --- /dev/null +++ b/api/src/controllers/ami-chart.controller.ts @@ -0,0 +1,75 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + Post, + Put, + Query, + UseGuards, + UsePipes, + ValidationPipe, +} from '@nestjs/common'; +import { + ApiExtraModels, + ApiOkResponse, + ApiOperation, + ApiTags, +} from '@nestjs/swagger'; +import { AmiChartService } from '../services/ami-chart.service'; +import { AmiChart } from '../dtos/ami-charts/ami-chart.dto'; +import { AmiChartCreate } from '../dtos/ami-charts/ami-chart-create.dto'; +import { AmiChartUpdate } from '../dtos/ami-charts/ami-chart-update.dto'; +import { defaultValidationPipeOptions } from '../utilities/default-validation-pipe-options'; +import { AmiChartQueryParams } from '../dtos/ami-charts/ami-chart-query-params.dto'; +import { IdDTO } from '../dtos/shared/id.dto'; +import { SuccessDTO } from '../dtos/shared/success.dto'; +import { PermissionTypeDecorator } from '../decorators/permission-type.decorator'; +import { JwtAuthGuard } from '../guards/jwt.guard'; +import { PermissionGuard } from '../guards/permission.guard'; + +@Controller('/amiCharts') +@ApiTags('amiCharts') +@UsePipes(new ValidationPipe(defaultValidationPipeOptions)) +@PermissionTypeDecorator('amiChart') +@UseGuards(JwtAuthGuard, PermissionGuard) +@ApiExtraModels(AmiChartQueryParams) +export class AmiChartController { + constructor(private readonly AmiChartService: AmiChartService) {} + + @Get() + @ApiOperation({ summary: 'List amiCharts', operationId: 'list' }) + @ApiOkResponse({ type: AmiChart, isArray: true }) + async list(@Query() queryParams: AmiChartQueryParams): Promise { + return await this.AmiChartService.list(queryParams); + } + + @Get(`:amiChartId`) + @ApiOperation({ summary: 'Get amiChart by id', operationId: 'retrieve' }) + @ApiOkResponse({ type: AmiChart }) + async retrieve(@Param('amiChartId') amiChartId: string): Promise { + return this.AmiChartService.findOne(amiChartId); + } + + @Post() + @ApiOperation({ summary: 'Create amiChart', operationId: 'create' }) + @ApiOkResponse({ type: AmiChart }) + async create(@Body() amiChart: AmiChartCreate): Promise { + return await this.AmiChartService.create(amiChart); + } + + @Put(`:amiChartId`) + @ApiOperation({ summary: 'Update amiChart', operationId: 'update' }) + @ApiOkResponse({ type: AmiChart }) + async update(@Body() amiChart: AmiChartUpdate): Promise { + return await this.AmiChartService.update(amiChart); + } + + @Delete() + @ApiOperation({ summary: 'Delete amiChart by id', operationId: 'delete' }) + @ApiOkResponse({ type: SuccessDTO }) + async delete(@Body() dto: IdDTO): Promise { + return await this.AmiChartService.delete(dto.id); + } +} diff --git a/api/src/controllers/app.controller.ts b/api/src/controllers/app.controller.ts new file mode 100644 index 0000000000..7f5c557e80 --- /dev/null +++ b/api/src/controllers/app.controller.ts @@ -0,0 +1,50 @@ +import { + Controller, + Get, + Put, + UseGuards, + UseInterceptors, +} from '@nestjs/common'; +import { + ApiExtraModels, + ApiOperation, + ApiTags, + ApiOkResponse, +} from '@nestjs/swagger'; +import { SuccessDTO } from '../dtos/shared/success.dto'; +import { OptionalAuthGuard } from '../guards/optional.guard'; +import { ActivityLogInterceptor } from '../interceptors/activity-log.interceptor'; +import { PermissionAction } from '../decorators/permission-action.decorator'; +import { permissionActions } from '../enums/permissions/permission-actions-enum'; +import { AdminOrJurisdictionalAdminGuard } from '../guards/admin-or-jurisdiction-admin.guard'; +import { AppService } from '../services/app.service'; + +@Controller() +@ApiExtraModels(SuccessDTO) +@ApiTags('root') +export class AppController { + constructor(private readonly appService: AppService) {} + + @Get() + @ApiOperation({ + summary: 'Health check endpoint', + operationId: 'healthCheck', + }) + @ApiOkResponse({ type: SuccessDTO }) + async healthCheck(): Promise { + return await this.appService.healthCheck(); + } + + @Put('clearTempFiles') + @ApiOperation({ + summary: 'Trigger the removal of CSVs job', + operationId: 'clearTempFiles', + }) + @ApiOkResponse({ type: SuccessDTO }) + @PermissionAction(permissionActions.submit) + @UseInterceptors(ActivityLogInterceptor) + @UseGuards(OptionalAuthGuard, AdminOrJurisdictionalAdminGuard) + async clearTempFiles(): Promise { + return await this.appService.clearTempFiles(); + } +} diff --git a/api/src/controllers/application-flagged-set.controller.ts b/api/src/controllers/application-flagged-set.controller.ts new file mode 100644 index 0000000000..761a0e98a6 --- /dev/null +++ b/api/src/controllers/application-flagged-set.controller.ts @@ -0,0 +1,118 @@ +import { + Body, + Controller, + Get, + Param, + Post, + Put, + Query, + Request, + UseGuards, + UsePipes, + ValidationPipe, +} from '@nestjs/common'; +import { + ApiExtraModels, + ApiOkResponse, + ApiOperation, + ApiTags, +} from '@nestjs/swagger'; +import { Request as ExpressRequest } from 'express'; +import { defaultValidationPipeOptions } from '../utilities/default-validation-pipe-options'; +import { SuccessDTO } from '../dtos/shared/success.dto'; +import { ApplicationFlaggedSetService } from '../services/application-flagged-set.service'; +import { IdDTO } from '../dtos/shared/id.dto'; +import { PaginatedAfsDto } from '../dtos/application-flagged-sets/paginated-afs.dto'; +import { ApplicationFlaggedSet } from '../dtos/application-flagged-sets/application-flagged-set.dto'; +import { AfsResolve } from '../dtos/application-flagged-sets/afs-resolve.dto'; +import { AfsMeta } from '../dtos/application-flagged-sets/afs-meta.dto'; +import { AfsQueryParams } from '../dtos/application-flagged-sets/afs-query-params.dto'; +import { User } from '../dtos/users/user.dto'; +import { mapTo } from '../utilities/mapTo'; +import { OptionalAuthGuard } from '../guards/optional.guard'; +import { PermissionGuard } from '../guards/permission.guard'; +import { PermissionTypeDecorator } from '../decorators/permission-type.decorator'; + +@Controller('/applicationFlaggedSets') +@ApiExtraModels(SuccessDTO) +@ApiTags('applicationFlaggedSets') +@UseGuards(OptionalAuthGuard, PermissionGuard) +@PermissionTypeDecorator('applicationFlaggedSet') +@UsePipes( + new ValidationPipe({ + ...defaultValidationPipeOptions, + }), +) +export class ApplicationFlaggedSetController { + constructor( + private readonly applicationFlaggedSetService: ApplicationFlaggedSetService, + ) {} + + @Get() + @ApiOperation({ + summary: 'List application flagged sets', + operationId: 'list', + }) + @ApiOkResponse({ type: PaginatedAfsDto }) + async list(@Query() params: AfsQueryParams): Promise { + return await this.applicationFlaggedSetService.list(params); + } + + @Get('meta') + @ApiOperation({ + summary: 'Meta information for application flagged sets', + operationId: 'meta', + }) + @ApiOkResponse({ type: AfsMeta }) + async meta(@Query() params: AfsQueryParams): Promise { + return await this.applicationFlaggedSetService.meta(params); + } + + @Get(`:afsId`) + @ApiOperation({ + summary: 'Retrieve application flagged set by id', + operationId: 'retrieve', + }) + @ApiOkResponse({ type: ApplicationFlaggedSet }) + async retrieve(@Param('afsId') id: string): Promise { + return await this.applicationFlaggedSetService.findOne(id); + } + + @Post('resolve') + @ApiOperation({ + summary: 'Resolve application flagged set', + operationId: 'resolve', + }) + @ApiOkResponse({ type: ApplicationFlaggedSet }) + async resolve( + @Body() dto: AfsResolve, + @Request() req: ExpressRequest, + ): Promise { + return await this.applicationFlaggedSetService.resolve( + dto, + mapTo(User, req['user']), + ); + } + + @Put('process') + @ApiOperation({ + summary: 'Trigger the duplicate check process', + operationId: 'process', + }) + @ApiOkResponse({ type: SuccessDTO }) + async process(): Promise { + return await this.applicationFlaggedSetService.process(); + } + + @Put(':id') + @ApiOperation({ + summary: 'Reset flagged set confirmation alert', + operationId: 'resetConfirmationAlert', + }) + @ApiOkResponse({ type: SuccessDTO }) + async resetConfirmationAlert(@Body() dto: IdDTO): Promise { + return await this.applicationFlaggedSetService.resetConfirmationAlert( + dto.id, + ); + } +} diff --git a/api/src/controllers/application.controller.ts b/api/src/controllers/application.controller.ts new file mode 100644 index 0000000000..81ab1dcc83 --- /dev/null +++ b/api/src/controllers/application.controller.ts @@ -0,0 +1,191 @@ +import { + Body, + Controller, + Delete, + Get, + Header, + Param, + Post, + Put, + Query, + Request, + Res, + StreamableFile, + UseGuards, + UseInterceptors, + UsePipes, + ValidationPipe, +} from '@nestjs/common'; +import { + ApiExtraModels, + ApiOkResponse, + ApiOperation, + ApiTags, +} from '@nestjs/swagger'; +import { Request as ExpressRequest, Response } from 'express'; +import { ApplicationService } from '../services/application.service'; +import { Application } from '../dtos/applications/application.dto'; +import { defaultValidationPipeOptions } from '../utilities/default-validation-pipe-options'; +import { IdDTO } from '../dtos/shared/id.dto'; +import { PaginatedApplicationDto } from '../dtos/applications/paginated-application.dto'; +import { ApplicationQueryParams } from '../dtos/applications/application-query-params.dto'; +import { SuccessDTO } from '../dtos/shared/success.dto'; +import { ApplicationUpdate } from '../dtos/applications/application-update.dto'; +import { ApplicationCreate } from '../dtos/applications/application-create.dto'; +import { + AddressInput, + BooleanInput, + TextInput, +} from '../dtos/applications/application-multiselect-question-option.dto'; +import { ValidationsGroupsEnum } from '../enums/shared/validation-groups-enum'; +import { OptionalAuthGuard } from '../guards/optional.guard'; +import { mapTo } from '../utilities/mapTo'; +import { User } from '../dtos/users/user.dto'; +import { ActivityLogInterceptor } from '../interceptors/activity-log.interceptor'; +import { PermissionTypeDecorator } from '../decorators/permission-type.decorator'; +import { permissionActions } from '../enums/permissions/permission-actions-enum'; +import { PermissionAction } from '../decorators/permission-action.decorator'; +import { ApplicationCsvExporterService } from '../services/application-csv-export.service'; +import { ApplicationCsvQueryParams } from '../dtos/applications/application-csv-query-params.dto'; + +@Controller('applications') +@ApiTags('applications') +@UsePipes( + new ValidationPipe({ + ...defaultValidationPipeOptions, + groups: [ValidationsGroupsEnum.default, ValidationsGroupsEnum.partners], + }), +) +@ApiExtraModels(IdDTO, AddressInput, BooleanInput, TextInput) +@UseGuards(OptionalAuthGuard) +@PermissionTypeDecorator('application') +@UseInterceptors(ActivityLogInterceptor) +export class ApplicationController { + constructor( + private readonly applicationService: ApplicationService, + private readonly applicationCsvExportService: ApplicationCsvExporterService, + ) {} + + @Get() + @ApiOperation({ + summary: 'Get a paginated set of applications', + operationId: 'list', + }) + @ApiOkResponse({ type: PaginatedApplicationDto }) + async list(@Query() queryParams: ApplicationQueryParams) { + return await this.applicationService.list(queryParams); + } + + @Get(`csv`) + @ApiOperation({ + summary: 'Get applications as csv', + operationId: 'listAsCsv', + }) + @Header('Content-Type', 'text/csv') + async listAsCsv( + @Request() req: ExpressRequest, + @Res({ passthrough: true }) res: Response, + @Query(new ValidationPipe(defaultValidationPipeOptions)) + queryParams: ApplicationCsvQueryParams, + ): Promise { + return await this.applicationCsvExportService.exportFile( + req, + res, + queryParams, + ); + } + + @Get(`:applicationId`) + @ApiOperation({ + summary: 'Get application by id', + operationId: 'retrieve', + }) + @ApiOkResponse({ type: Application }) + async retrieve(@Param('applicationId') applicationId: string) { + return this.applicationService.findOne(applicationId); + } + + @Post() + @ApiOperation({ + summary: + 'Create application (used by partners to hand create an application)', + operationId: 'create', + }) + @ApiOkResponse({ type: Application }) + async create( + @Body() dto: ApplicationCreate, + @Request() req: ExpressRequest, + ): Promise { + return await this.applicationService.create( + dto, + false, + mapTo(User, req['user']), + ); + } + + @Post(`submit`) + @ApiOperation({ + summary: 'Submit application (used by applicants applying to a listing)', + operationId: 'submit', + }) + @ApiOkResponse({ type: Application }) + @UsePipes( + new ValidationPipe({ + ...defaultValidationPipeOptions, + groups: [ValidationsGroupsEnum.default, ValidationsGroupsEnum.applicants], + }), + ) + @UseGuards(OptionalAuthGuard) + @PermissionAction(permissionActions.submit) + @UseInterceptors(ActivityLogInterceptor) + async submit( + @Request() req: ExpressRequest, + @Body() dto: ApplicationCreate, + ): Promise { + const user = mapTo(User, req['user']); + return await this.applicationService.create(dto, true, user); + } + + @Post('verify') + @ApiOperation({ + summary: 'Verify application can be saved', + operationId: 'submissionValidation', + }) + @ApiOkResponse({ type: SuccessDTO }) + @UsePipes( + new ValidationPipe({ + ...defaultValidationPipeOptions, + groups: [ValidationsGroupsEnum.default, ValidationsGroupsEnum.applicants], + }), + ) + @PermissionAction(permissionActions.submit) + @UseInterceptors(ActivityLogInterceptor) + // eslint-disable-next-line @typescript-eslint/no-unused-vars + submissionValidation(@Body() dto: ApplicationCreate): SuccessDTO { + // if we succeeded then the record is good to submit + return { + success: true, + }; + } + + @Put(`:id`) + @ApiOperation({ summary: 'Update application by id', operationId: 'update' }) + @ApiOkResponse({ type: Application }) + async update( + @Param('id') applicationId: string, + @Body() dto: ApplicationUpdate, + @Request() req: ExpressRequest, + ): Promise { + return await this.applicationService.update(dto, mapTo(User, req['user'])); + } + + @Delete() + @ApiOperation({ summary: 'Delete application by id', operationId: 'delete' }) + @ApiOkResponse({ type: SuccessDTO }) + async delete(@Body() dto: IdDTO, @Request() req: ExpressRequest) { + return await this.applicationService.delete( + dto.id, + mapTo(User, req['user']), + ); + } +} diff --git a/api/src/controllers/asset.controller.ts b/api/src/controllers/asset.controller.ts new file mode 100644 index 0000000000..dbb66c04f7 --- /dev/null +++ b/api/src/controllers/asset.controller.ts @@ -0,0 +1,48 @@ +import { + Body, + Controller, + Post, + UseGuards, + UsePipes, + ValidationPipe, +} from '@nestjs/common'; +import { + ApiExtraModels, + ApiOkResponse, + ApiOperation, + ApiTags, +} from '@nestjs/swagger'; +import { PermissionTypeDecorator } from '../decorators/permission-type.decorator'; +import { JwtAuthGuard } from '../guards/jwt.guard'; +import { PermissionGuard } from '../guards/permission.guard'; +import { CreatePresignedUploadMetadataResponse } from '../dtos/assets/create-presign-upload-meta-response.dto'; +import { CreatePresignedUploadMetadata } from '../dtos/assets/create-presigned-upload-meta.dto'; +import { AssetService } from '../services/asset.service'; +import { defaultValidationPipeOptions } from '../utilities/default-validation-pipe-options'; + +@Controller('assets') +@ApiTags('assets') +@UsePipes(new ValidationPipe(defaultValidationPipeOptions)) +@ApiExtraModels( + CreatePresignedUploadMetadata, + CreatePresignedUploadMetadataResponse, +) +@PermissionTypeDecorator('asset') +@UseGuards(JwtAuthGuard, PermissionGuard) +export class AssetController { + constructor(private readonly assetService: AssetService) {} + + @Post('/presigned-upload-metadata') + @ApiOperation({ + summary: 'Create presigned upload metadata', + operationId: 'createPresignedUploadMetadata', + }) + @ApiOkResponse({ type: CreatePresignedUploadMetadataResponse }) + async create( + @Body() createPresignedUploadMetadata: CreatePresignedUploadMetadata, + ): Promise { + return await this.assetService.createPresignedUploadMetadata( + createPresignedUploadMetadata, + ); + } +} diff --git a/api/src/controllers/auth.controller.ts b/api/src/controllers/auth.controller.ts new file mode 100644 index 0000000000..eafea40c1d --- /dev/null +++ b/api/src/controllers/auth.controller.ts @@ -0,0 +1,114 @@ +import { + Controller, + Get, + Request, + Response, + Post, + UsePipes, + ValidationPipe, + Body, + BadRequestException, + Put, + UseGuards, +} from '@nestjs/common'; +import { ApiBody, ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { + Response as ExpressResponse, + Request as ExpressRequest, +} from 'express'; +import { defaultValidationPipeOptions } from '../utilities/default-validation-pipe-options'; +import { SuccessDTO } from '../dtos/shared/success.dto'; +import { AuthService, REFRESH_COOKIE_NAME } from '../services/auth.service'; +import { RequestMfaCode } from '../dtos/mfa/request-mfa-code.dto'; +import { RequestMfaCodeResponse } from '../dtos/mfa/request-mfa-code-response.dto'; +import { Confirm } from '../dtos/auth/confirm.dto'; +import { UpdatePassword } from '../dtos/auth/update-password.dto'; +import { MfaAuthGuard } from '../guards/mfa.guard'; +import { JwtAuthGuard } from '../guards/jwt.guard'; +import { OptionalAuthGuard } from '../guards/optional.guard'; +import { Login } from '../dtos/auth/login.dto'; +import { mapTo } from '../utilities/mapTo'; +import { User } from '../dtos/users/user.dto'; + +@Controller('auth') +@ApiTags('auth') +@UsePipes(new ValidationPipe(defaultValidationPipeOptions)) +export class AuthController { + constructor(private readonly authService: AuthService) {} + + @Post('login') + @ApiOperation({ summary: 'Login', operationId: 'login' }) + @ApiOkResponse({ type: SuccessDTO }) + @ApiBody({ type: Login }) + @UseGuards(MfaAuthGuard) + async login( + @Request() req: ExpressRequest, + @Response({ passthrough: true }) res: ExpressResponse, + ): Promise { + return await this.authService.setCredentials(res, mapTo(User, req['user'])); + } + + @Get('logout') + @ApiOperation({ summary: 'Logout', operationId: 'logout' }) + @ApiOkResponse({ type: SuccessDTO }) + @UseGuards(JwtAuthGuard) + async logout( + @Request() req: ExpressRequest, + @Response({ passthrough: true }) res: ExpressResponse, + ): Promise { + return await this.authService.clearCredentials( + res, + mapTo(User, req['user']), + ); + } + + @Post('request-mfa-code') + @ApiOperation({ summary: 'Request mfa code', operationId: 'requestMfaCode' }) + @ApiOkResponse({ type: RequestMfaCodeResponse }) + async requestMfaCode( + @Body() dto: RequestMfaCode, + ): Promise { + return await this.authService.requestMfaCode(dto); + } + + @Get('requestNewToken') + @ApiOperation({ + summary: 'Requests a new token given a refresh token', + operationId: 'requestNewToken', + }) + @ApiOkResponse({ type: SuccessDTO }) + @UseGuards(OptionalAuthGuard) + async requestNewToken( + @Request() req: ExpressRequest, + @Response({ passthrough: true }) res: ExpressResponse, + ): Promise { + if (!req?.cookies[REFRESH_COOKIE_NAME]) { + throw new BadRequestException('No refresh token sent with request'); + } + return await this.authService.setCredentials( + res, + mapTo(User, req['user']), + req.cookies[REFRESH_COOKIE_NAME], + ); + } + + @Put('update-password') + @ApiOperation({ summary: 'Update Password', operationId: 'update-password' }) + @ApiOkResponse({ type: SuccessDTO }) + async updatePassword( + @Body() dto: UpdatePassword, + @Response({ passthrough: true }) res: ExpressResponse, + ): Promise { + return await this.authService.updatePassword(dto, res); + } + + @Put('confirm') + @ApiOperation({ summary: 'Confirm email', operationId: 'confirm' }) + @ApiOkResponse({ type: SuccessDTO }) + async confirm( + @Body() dto: Confirm, + @Response({ passthrough: true }) res: ExpressResponse, + ): Promise { + return await this.authService.confirmUser(dto, res); + } +} diff --git a/api/src/controllers/jurisdiction.controller.ts b/api/src/controllers/jurisdiction.controller.ts new file mode 100644 index 0000000000..e7263240c3 --- /dev/null +++ b/api/src/controllers/jurisdiction.controller.ts @@ -0,0 +1,107 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + ParseUUIDPipe, + Post, + Put, + UseGuards, + UsePipes, + ValidationPipe, +} from '@nestjs/common'; +import { + ApiExtraModels, + ApiOkResponse, + ApiOperation, + ApiTags, +} from '@nestjs/swagger'; +import { JurisdictionService } from '../services/jurisdiction.service'; +import { Jurisdiction } from '../dtos/jurisdictions/jurisdiction.dto'; +import { JurisdictionCreate } from '../dtos/jurisdictions/jurisdiction-create.dto'; +import { JurisdictionUpdate } from '../dtos/jurisdictions/jurisdiction-update.dto'; +import { defaultValidationPipeOptions } from '../utilities/default-validation-pipe-options'; +import { IdDTO } from '../dtos/shared/id.dto'; +import { SuccessDTO } from '../dtos/shared/success.dto'; +import { PermissionTypeDecorator } from '../decorators/permission-type.decorator'; +import { OptionalAuthGuard } from '../guards/optional.guard'; +import { PermissionGuard } from '../guards/permission.guard'; + +@Controller('jurisdictions') +@ApiTags('jurisdictions') +@UsePipes(new ValidationPipe(defaultValidationPipeOptions)) +@ApiExtraModels(JurisdictionCreate, JurisdictionUpdate, IdDTO) +@PermissionTypeDecorator('jurisdiction') +@UseGuards(OptionalAuthGuard, PermissionGuard) +export class JurisdictionController { + constructor(private readonly jurisdictionService: JurisdictionService) {} + + @Get() + @ApiOperation({ summary: 'List jurisdictions', operationId: 'list' }) + @ApiOkResponse({ type: Jurisdiction, isArray: true }) + async list(): Promise { + return await this.jurisdictionService.list(); + } + + @Get(`:jurisdictionId`) + @ApiOperation({ + summary: 'Get jurisdiction by id', + operationId: 'retrieve', + }) + @ApiOkResponse({ type: Jurisdiction }) + async retrieve( + @Param('jurisdictionId', new ParseUUIDPipe({ version: '4' })) + jurisdictionId: string, + ): Promise { + return this.jurisdictionService.findOne({ jurisdictionId }); + } + + @Get(`byName/:jurisdictionName`) + @ApiOperation({ + summary: 'Get jurisdiction by name', + operationId: 'retrieveByName', + }) + @ApiOkResponse({ type: Jurisdiction }) + async retrieveByName( + @Param('jurisdictionName') jurisdictionName: string, + ): Promise { + return await this.jurisdictionService.findOne({ + jurisdictionName, + }); + } + + @Post() + @ApiOperation({ + summary: 'Create jurisdiction', + operationId: 'create', + }) + @ApiOkResponse({ type: Jurisdiction }) + async create( + @Body() jurisdiction: JurisdictionCreate, + ): Promise { + return await this.jurisdictionService.create(jurisdiction); + } + + @Put(`:jurisdictionId`) + @ApiOperation({ + summary: 'Update jurisdiction', + operationId: 'update', + }) + @ApiOkResponse({ type: Jurisdiction }) + async update( + @Body() jurisdiction: JurisdictionUpdate, + ): Promise { + return await this.jurisdictionService.update(jurisdiction); + } + + @Delete() + @ApiOperation({ + summary: 'Delete jurisdiction by id', + operationId: 'delete', + }) + @ApiOkResponse({ type: SuccessDTO }) + async delete(@Body() dto: IdDTO): Promise { + return await this.jurisdictionService.delete(dto.id); + } +} diff --git a/api/src/controllers/listing.controller.ts b/api/src/controllers/listing.controller.ts new file mode 100644 index 0000000000..fe70510902 --- /dev/null +++ b/api/src/controllers/listing.controller.ts @@ -0,0 +1,182 @@ +import { + Body, + ClassSerializerInterceptor, + Controller, + Delete, + Get, + Header, + Headers, + Param, + ParseUUIDPipe, + Post, + Put, + Query, + Request, + Res, + StreamableFile, + UseGuards, + UseInterceptors, + UsePipes, + ValidationPipe, +} from '@nestjs/common'; +import { + ApiExtraModels, + ApiOperation, + ApiTags, + ApiOkResponse, +} from '@nestjs/swagger'; +import { Request as ExpressRequest, Response } from 'express'; +import { ListingService } from '../services/listing.service'; +import { defaultValidationPipeOptions } from '../utilities/default-validation-pipe-options'; +import { ListingsQueryParams } from '../dtos/listings/listings-query-params.dto'; +import { LanguagesEnum } from '@prisma/client'; +import { ListingsRetrieveParams } from '../dtos/listings/listings-retrieve-params.dto'; +import { PaginationAllowsAllQueryParams } from '../dtos/shared/pagination.dto'; +import { ListingFilterParams } from '../dtos/listings/listings-filter-params.dto'; +import { PaginatedListingDto } from '../dtos/listings/paginated-listing.dto'; +import Listing from '../dtos/listings/listing.dto'; +import { IdDTO } from '../dtos/shared/id.dto'; +import { ListingCreate } from '../dtos/listings/listing-create.dto'; +import { SuccessDTO } from '../dtos/shared/success.dto'; +import { ListingUpdate } from '../dtos/listings/listing-update.dto'; +import { ListingCreateUpdateValidationPipe } from '../validation-pipes/listing-create-update-pipe'; +import { mapTo } from '../utilities/mapTo'; +import { User } from '../dtos/users/user.dto'; +import { OptionalAuthGuard } from '../guards/optional.guard'; +import { ActivityLogInterceptor } from '../interceptors/activity-log.interceptor'; +import { ActivityLogMetadata } from '../decorators/activity-log-metadata.decorator'; +import { PermissionTypeDecorator } from '../decorators/permission-type.decorator'; +import { PermissionAction } from '../decorators/permission-action.decorator'; +import { permissionActions } from '../enums/permissions/permission-actions-enum'; +import { AdminOrJurisdictionalAdminGuard } from '../guards/admin-or-jurisdiction-admin.guard'; +import { ListingCsvExporterService } from '../services/listing-csv-export.service'; +import { ListingCsvQueryParams } from '../dtos/listings/listing-csv-query-params.dto'; +import { PermissionGuard } from '../guards/permission.guard'; + +@Controller('listings') +@ApiTags('listings') +@ApiExtraModels( + ListingsQueryParams, + ListingFilterParams, + ListingsRetrieveParams, + PaginationAllowsAllQueryParams, + IdDTO, +) +@UseGuards(OptionalAuthGuard) +@PermissionTypeDecorator('listing') +@ActivityLogMetadata([{ targetPropertyName: 'status', propertyPath: 'status' }]) +@UseInterceptors(ActivityLogInterceptor) +export class ListingController { + constructor( + private readonly listingService: ListingService, + private readonly listingCsvExportService: ListingCsvExporterService, + ) {} + + @Get() + @ApiOperation({ + summary: 'Get a paginated set of listings', + operationId: 'list', + }) + @UsePipes(new ValidationPipe(defaultValidationPipeOptions)) + @UseInterceptors(ClassSerializerInterceptor) + @ApiOkResponse({ type: PaginatedListingDto }) + public async getPaginatedSet(@Query() queryParams: ListingsQueryParams) { + return await this.listingService.list(queryParams); + } + + @Get(`csv`) + @ApiOperation({ + summary: 'Get listings and units as zip', + operationId: 'listAsCsv', + }) + @Header('Content-Type', 'application/zip') + @UseGuards(OptionalAuthGuard, PermissionGuard) + async listAsCsv( + @Request() req: ExpressRequest, + @Res({ passthrough: true }) res: Response, + @Query(new ValidationPipe(defaultValidationPipeOptions)) + queryParams: ListingCsvQueryParams, + ): Promise { + return await this.listingCsvExportService.exportFile(req, res, queryParams); + } + + @Get(`:id`) + @ApiOperation({ summary: 'Get listing by id', operationId: 'retrieve' }) + @UseInterceptors(ClassSerializerInterceptor) + @UsePipes(new ValidationPipe(defaultValidationPipeOptions)) + @ApiOkResponse({ type: Listing }) + async retrieve( + @Headers('language') language: LanguagesEnum, + @Param('id', new ParseUUIDPipe({ version: '4' })) listingId: string, + @Query() queryParams: ListingsRetrieveParams, + ) { + return await this.listingService.findOne( + listingId, + language, + queryParams.view, + ); + } + + @Post() + @ApiOperation({ summary: 'Create listing', operationId: 'create' }) + @UseInterceptors(ClassSerializerInterceptor) + @UsePipes(new ListingCreateUpdateValidationPipe(defaultValidationPipeOptions)) + @ApiOkResponse({ type: Listing }) + async create( + @Request() req: ExpressRequest, + @Body() listingDto: ListingCreate, + ): Promise { + return await this.listingService.create( + listingDto, + mapTo(User, req['user']), + ); + } + + @Delete() + @ApiOperation({ summary: 'Delete listing by id', operationId: 'delete' }) + @UsePipes(new ValidationPipe(defaultValidationPipeOptions)) + async delete( + @Body() dto: IdDTO, + @Request() req: ExpressRequest, + ): Promise { + return await this.listingService.delete(dto.id, mapTo(User, req['user'])); + } + + @Put('process') + @ApiOperation({ + summary: 'Trigger the listing process job', + operationId: 'process', + }) + @ApiOkResponse({ type: SuccessDTO }) + @PermissionAction(permissionActions.submit) + @UseInterceptors(ActivityLogInterceptor) + @UseGuards(OptionalAuthGuard, AdminOrJurisdictionalAdminGuard) + async process(): Promise { + return await this.listingService.process(); + } + + @Put(':id') + @ApiOperation({ summary: 'Update listing by id', operationId: 'update' }) + @UsePipes(new ListingCreateUpdateValidationPipe(defaultValidationPipeOptions)) + async update( + @Request() req: ExpressRequest, + @Param('id') listingId: string, + @Body() dto: ListingUpdate, + ): Promise { + return await this.listingService.update(dto, mapTo(User, req['user'])); + } + + @Get(`byMultiselectQuestion/:multiselectQuestionId`) + @ApiOperation({ + summary: 'Get listings by multiselect question id', + operationId: 'retrieveListings', + }) + @ApiOkResponse({ type: IdDTO, isArray: true }) + async retrieveListings( + @Param('multiselectQuestionId') multiselectQuestionId: string, + ) { + return await this.listingService.findListingsWithMultiSelectQuestion( + multiselectQuestionId, + ); + } +} diff --git a/api/src/controllers/map-layer.controller.ts b/api/src/controllers/map-layer.controller.ts new file mode 100644 index 0000000000..205e53dcb9 --- /dev/null +++ b/api/src/controllers/map-layer.controller.ts @@ -0,0 +1,34 @@ +import { + Controller, + Get, + Query, + UseGuards, + UsePipes, + ValidationPipe, +} from '@nestjs/common'; +import { ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { MapLayersQueryParams } from '../dtos/map-layers/map-layers-query-params.dto'; +import { MapLayersService } from '../services/map-layers.service'; +import { MapLayerDto } from '../dtos/map-layers/map-layer.dto'; +import { defaultValidationPipeOptions } from '../utilities/default-validation-pipe-options'; +import { OptionalAuthGuard } from '../guards/optional.guard'; +import { PermissionGuard } from '../guards/permission.guard'; +import { PermissionTypeDecorator } from '../decorators/permission-type.decorator'; + +@Controller('/mapLayers') +@ApiTags('mapLayers') +@UseGuards(OptionalAuthGuard, PermissionGuard) +@PermissionTypeDecorator('mapLayers') +@UsePipes(new ValidationPipe(defaultValidationPipeOptions)) +export class MapLayersController { + constructor(private readonly mapLayerService: MapLayersService) {} + + @Get() + @ApiOperation({ summary: 'List map layers', operationId: 'list' }) + @ApiOkResponse({ type: MapLayerDto, isArray: true }) + async list( + @Query() queryParams: MapLayersQueryParams, + ): Promise { + return await this.mapLayerService.list(queryParams); + } +} diff --git a/api/src/controllers/multiselect-question.controller.ts b/api/src/controllers/multiselect-question.controller.ts new file mode 100644 index 0000000000..4a578f30bf --- /dev/null +++ b/api/src/controllers/multiselect-question.controller.ts @@ -0,0 +1,113 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + Post, + Put, + Query, + UseGuards, + UseInterceptors, + UsePipes, + ValidationPipe, +} from '@nestjs/common'; +import { + ApiExtraModels, + ApiOkResponse, + ApiOperation, + ApiTags, +} from '@nestjs/swagger'; +import { MultiselectQuestionService } from '../services/multiselect-question.service'; +import { MultiselectQuestion } from '../dtos/multiselect-questions/multiselect-question.dto'; +import { MultiselectQuestionCreate } from '../dtos/multiselect-questions/multiselect-question-create.dto'; +import { MultiselectQuestionUpdate } from '../dtos/multiselect-questions/multiselect-question-update.dto'; +import { MultiselectQuestionQueryParams } from '../dtos/multiselect-questions/multiselect-question-query-params.dto'; +import { defaultValidationPipeOptions } from '../utilities/default-validation-pipe-options'; +import { IdDTO } from '../dtos/shared/id.dto'; +import { SuccessDTO } from '../dtos/shared/success.dto'; +import { MultiselectQuestionFilterParams } from '../dtos/multiselect-questions/multiselect-question-filter-params.dto'; +import { PaginationMeta } from '../dtos/shared/pagination.dto'; +import { PermissionTypeDecorator } from '../decorators/permission-type.decorator'; +import { OptionalAuthGuard } from '../guards/optional.guard'; +import { PermissionGuard } from '../guards/permission.guard'; +import { AdminOrJurisdictionalAdminGuard } from '../guards/admin-or-jurisdiction-admin.guard'; +import { ActivityLogInterceptor } from '../interceptors/activity-log.interceptor'; + +@Controller('multiselectQuestions') +@ApiTags('multiselectQuestions') +@UsePipes(new ValidationPipe(defaultValidationPipeOptions)) +@ApiExtraModels( + MultiselectQuestionCreate, + MultiselectQuestionUpdate, + MultiselectQuestionQueryParams, + MultiselectQuestionFilterParams, + PaginationMeta, + IdDTO, +) +@PermissionTypeDecorator('multiselectQuestion') +@UseGuards(OptionalAuthGuard, PermissionGuard) +export class MultiselectQuestionController { + constructor( + private readonly multiselectQuestionService: MultiselectQuestionService, + ) {} + + @Get() + @ApiOperation({ summary: 'List multiselect questions', operationId: 'list' }) + @ApiOkResponse({ type: MultiselectQuestion, isArray: true }) + async list( + @Query() queryParams: MultiselectQuestionQueryParams, + ): Promise { + return await this.multiselectQuestionService.list(queryParams); + } + + @Get(`:multiselectQuestionId`) + @ApiOperation({ + summary: 'Get multiselect question by id', + operationId: 'retrieve', + }) + @ApiOkResponse({ type: MultiselectQuestion }) + async retrieve( + @Param('multiselectQuestionId') multiselectQuestionId: string, + ): Promise { + return this.multiselectQuestionService.findOne(multiselectQuestionId); + } + + @Post() + @ApiOperation({ + summary: 'Create multiselect question', + operationId: 'create', + }) + @ApiOkResponse({ type: MultiselectQuestion }) + @UseGuards(OptionalAuthGuard, AdminOrJurisdictionalAdminGuard) + async create( + @Body() multiselectQuestion: MultiselectQuestionCreate, + ): Promise { + return await this.multiselectQuestionService.create(multiselectQuestion); + } + + @Put(`:multiselectQuestionId`) + @ApiOperation({ + summary: 'Update multiselect question', + operationId: 'update', + }) + @ApiOkResponse({ type: MultiselectQuestion }) + @UseGuards(OptionalAuthGuard, AdminOrJurisdictionalAdminGuard) + async update( + @Body() multiselectQuestion: MultiselectQuestionUpdate, + ): Promise { + return await this.multiselectQuestionService.update(multiselectQuestion); + } + + @Delete() + @ApiOperation({ + summary: 'Delete multiselect question by id', + operationId: 'delete', + }) + @ApiOkResponse({ type: SuccessDTO }) + @UseGuards(OptionalAuthGuard, AdminOrJurisdictionalAdminGuard) + @UseInterceptors(ActivityLogInterceptor) + async delete(@Body() dto: IdDTO): Promise { + return await this.multiselectQuestionService.delete(dto.id); + } +} diff --git a/api/src/controllers/reserved-community-type.controller.ts b/api/src/controllers/reserved-community-type.controller.ts new file mode 100644 index 0000000000..a8d936e2e4 --- /dev/null +++ b/api/src/controllers/reserved-community-type.controller.ts @@ -0,0 +1,101 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + Post, + Put, + Query, + UseGuards, + UsePipes, + ValidationPipe, +} from '@nestjs/common'; +import { + ApiExtraModels, + ApiOkResponse, + ApiOperation, + ApiTags, +} from '@nestjs/swagger'; +import { ReservedCommunityTypeService } from '../services/reserved-community-type.service'; +import { ReservedCommunityType } from '../dtos/reserved-community-types/reserved-community-type.dto'; +import { ReservedCommunityTypeCreate } from '../dtos/reserved-community-types/reserved-community-type-create.dto'; +import { ReservedCommunityTypeUpdate } from '../dtos/reserved-community-types/reserved-community-type-update.dto'; +import { defaultValidationPipeOptions } from '../utilities/default-validation-pipe-options'; +import { ReservedCommunityTypeQueryParams } from '../dtos/reserved-community-types/reserved-community-type-query-params.dto'; +import { IdDTO } from '../dtos/shared/id.dto'; +import { SuccessDTO } from '../dtos/shared/success.dto'; +import { PermissionTypeDecorator } from '../decorators/permission-type.decorator'; +import { JwtAuthGuard } from '../guards/jwt.guard'; +import { PermissionGuard } from '../guards/permission.guard'; + +@Controller('reservedCommunityTypes') +@ApiTags('reservedCommunityTypes') +@UsePipes(new ValidationPipe(defaultValidationPipeOptions)) +@ApiExtraModels(ReservedCommunityTypeQueryParams) +@PermissionTypeDecorator('reservedCommunityType') +@UseGuards(JwtAuthGuard, PermissionGuard) +export class ReservedCommunityTypeController { + constructor( + private readonly ReservedCommunityTypeService: ReservedCommunityTypeService, + ) {} + + @Get() + @ApiOperation({ summary: 'List reservedCommunityTypes', operationId: 'list' }) + @ApiOkResponse({ type: ReservedCommunityType, isArray: true }) + async list( + @Query() queryParams: ReservedCommunityTypeQueryParams, + ): Promise { + return await this.ReservedCommunityTypeService.list(queryParams); + } + + @Get(`:reservedCommunityTypeId`) + @ApiOperation({ + summary: 'Get reservedCommunityType by id', + operationId: 'retrieve', + }) + @ApiOkResponse({ type: ReservedCommunityType }) + async retrieve( + @Param('reservedCommunityTypeId') reservedCommunityTypeId: string, + ): Promise { + return this.ReservedCommunityTypeService.findOne(reservedCommunityTypeId); + } + + @Post() + @ApiOperation({ + summary: 'Create reservedCommunityType', + operationId: 'create', + }) + @ApiOkResponse({ type: ReservedCommunityType }) + async create( + @Body() reservedCommunityType: ReservedCommunityTypeCreate, + ): Promise { + return await this.ReservedCommunityTypeService.create( + reservedCommunityType, + ); + } + + @Put(`:reservedCommunityTypeId`) + @ApiOperation({ + summary: 'Update reservedCommunityType', + operationId: 'update', + }) + @ApiOkResponse({ type: ReservedCommunityType }) + async update( + @Body() reservedCommunityType: ReservedCommunityTypeUpdate, + ): Promise { + return await this.ReservedCommunityTypeService.update( + reservedCommunityType, + ); + } + + @Delete() + @ApiOperation({ + summary: 'Delete reservedCommunityType by id', + operationId: 'delete', + }) + @ApiOkResponse({ type: SuccessDTO }) + async delete(@Body() dto: IdDTO): Promise { + return await this.ReservedCommunityTypeService.delete(dto.id); + } +} diff --git a/api/src/controllers/unit-accessibility-priority-type.controller.ts b/api/src/controllers/unit-accessibility-priority-type.controller.ts new file mode 100644 index 0000000000..a185a240ab --- /dev/null +++ b/api/src/controllers/unit-accessibility-priority-type.controller.ts @@ -0,0 +1,103 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + Post, + Put, + UseGuards, + UsePipes, + ValidationPipe, +} from '@nestjs/common'; +import { + ApiExtraModels, + ApiOkResponse, + ApiOperation, + ApiTags, +} from '@nestjs/swagger'; +import { UnitAccessibilityPriorityTypeService } from '../services/unit-accessibility-priority-type.service'; +import { UnitAccessibilityPriorityType } from '../dtos/unit-accessibility-priority-types/unit-accessibility-priority-type.dto'; +import { UnitAccessibilityPriorityTypeCreate } from '../dtos/unit-accessibility-priority-types/unit-accessibility-priority-type-create.dto'; +import { UnitAccessibilityPriorityTypeUpdate } from '../dtos/unit-accessibility-priority-types/unit-accessibility-priority-type-update.dto'; +import { defaultValidationPipeOptions } from '../utilities/default-validation-pipe-options'; +import { IdDTO } from '../dtos/shared/id.dto'; +import { SuccessDTO } from '../dtos/shared/success.dto'; +import { PermissionTypeDecorator } from '../decorators/permission-type.decorator'; +import { JwtAuthGuard } from '../guards/jwt.guard'; +import { PermissionGuard } from '../guards/permission.guard'; + +@Controller('unitAccessibilityPriorityTypes') +@ApiTags('unitAccessibilityPriorityTypes') +@UsePipes(new ValidationPipe(defaultValidationPipeOptions)) +@ApiExtraModels(IdDTO) +@PermissionTypeDecorator('unitAccessibilityPriorityType') +@UseGuards(JwtAuthGuard, PermissionGuard) +export class UnitAccessibilityPriorityTypeController { + constructor( + private readonly unitAccessibilityPriorityTypeService: UnitAccessibilityPriorityTypeService, + ) {} + + @Get() + @ApiOperation({ + summary: 'List unitAccessibilityPriorityTypes', + operationId: 'list', + }) + @ApiOkResponse({ type: UnitAccessibilityPriorityType, isArray: true }) + async list(): Promise { + return await this.unitAccessibilityPriorityTypeService.list(); + } + + @Get(`:unitAccessibilityPriorityTypeId`) + @ApiOperation({ + summary: 'Get unitAccessibilityPriorityType by id', + operationId: 'retrieve', + }) + @ApiOkResponse({ type: UnitAccessibilityPriorityType }) + async retrieve( + @Param('unitAccessibilityPriorityTypeId') + unitAccessibilityPriorityTypeId: string, + ): Promise { + return this.unitAccessibilityPriorityTypeService.findOne( + unitAccessibilityPriorityTypeId, + ); + } + + @Post() + @ApiOperation({ + summary: 'Create unitAccessibilityPriorityType', + operationId: 'create', + }) + @ApiOkResponse({ type: UnitAccessibilityPriorityType }) + async create( + @Body() unitAccessibilityPriorityType: UnitAccessibilityPriorityTypeCreate, + ): Promise { + return await this.unitAccessibilityPriorityTypeService.create( + unitAccessibilityPriorityType, + ); + } + + @Put(`:unitAccessibilityPriorityTypeId`) + @ApiOperation({ + summary: 'Update unitAccessibilityPriorityType', + operationId: 'update', + }) + @ApiOkResponse({ type: UnitAccessibilityPriorityType }) + async update( + @Body() unitAccessibilityPriorityType: UnitAccessibilityPriorityTypeUpdate, + ): Promise { + return await this.unitAccessibilityPriorityTypeService.update( + unitAccessibilityPriorityType, + ); + } + + @Delete() + @ApiOperation({ + summary: 'Delete unitAccessibilityPriorityType by id', + operationId: 'delete', + }) + @ApiOkResponse({ type: SuccessDTO }) + async delete(@Body() dto: IdDTO): Promise { + return await this.unitAccessibilityPriorityTypeService.delete(dto.id); + } +} diff --git a/api/src/controllers/unit-rent-type.controller.ts b/api/src/controllers/unit-rent-type.controller.ts new file mode 100644 index 0000000000..b7035ec142 --- /dev/null +++ b/api/src/controllers/unit-rent-type.controller.ts @@ -0,0 +1,91 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + Post, + Put, + UseGuards, + UsePipes, + ValidationPipe, +} from '@nestjs/common'; +import { + ApiExtraModels, + ApiOkResponse, + ApiOperation, + ApiTags, +} from '@nestjs/swagger'; +import { UnitRentTypeService } from '../services/unit-rent-type.service'; +import { UnitRentType } from '../dtos/unit-rent-types/unit-rent-type.dto'; +import { UnitRentTypeCreate } from '../dtos/unit-rent-types/unit-rent-type-create.dto'; +import { UnitRentTypeUpdate } from '../dtos/unit-rent-types/unit-rent-type-update.dto'; +import { defaultValidationPipeOptions } from '../utilities/default-validation-pipe-options'; +import { IdDTO } from '../dtos/shared/id.dto'; +import { SuccessDTO } from '../dtos/shared/success.dto'; +import { PermissionTypeDecorator } from '../decorators/permission-type.decorator'; +import { JwtAuthGuard } from '../guards/jwt.guard'; +import { PermissionGuard } from '../guards/permission.guard'; + +@Controller('unitRentTypes') +@ApiTags('unitRentTypes') +@UsePipes(new ValidationPipe(defaultValidationPipeOptions)) +@ApiExtraModels(UnitRentTypeCreate, UnitRentTypeUpdate, IdDTO) +@PermissionTypeDecorator('unitRentType') +@UseGuards(JwtAuthGuard, PermissionGuard) +export class UnitRentTypeController { + constructor(private readonly unitRentTypeService: UnitRentTypeService) {} + + @Get() + @ApiOperation({ summary: 'List unitRentTypes', operationId: 'list' }) + @ApiOkResponse({ type: UnitRentType, isArray: true }) + async list(): Promise { + return await this.unitRentTypeService.list(); + } + + @Get(`:unitRentTypeId`) + @ApiOperation({ + summary: 'Get unitRentType by id', + operationId: 'retrieve', + }) + @ApiOkResponse({ type: UnitRentType }) + async retrieve( + @Param('unitRentTypeId') unitRentTypeId: string, + ): Promise { + return this.unitRentTypeService.findOne(unitRentTypeId); + } + + @Post() + @ApiOperation({ + summary: 'Create unitRentType', + operationId: 'create', + }) + @ApiOkResponse({ type: UnitRentType }) + async create( + @Body() unitRentType: UnitRentTypeCreate, + ): Promise { + return await this.unitRentTypeService.create(unitRentType); + } + + @Put(`:unitRentTypeId`) + @ApiOperation({ + summary: 'Update unitRentType', + operationId: 'update', + }) + @ApiOkResponse({ type: UnitRentType }) + async update( + @Body() unitRentType: UnitRentTypeUpdate, + ): Promise { + return await this.unitRentTypeService.update(unitRentType); + } + + @Delete() + @ApiOperation({ + summary: 'Delete unitRentType by id', + operationId: 'delete', + }) + @ApiOkResponse({ type: SuccessDTO }) + async delete(@Body() dto: IdDTO): Promise { + return await this.unitRentTypeService.delete(dto.id); + } +} diff --git a/api/src/controllers/unit-type.controller.ts b/api/src/controllers/unit-type.controller.ts new file mode 100644 index 0000000000..ac23f9c3fa --- /dev/null +++ b/api/src/controllers/unit-type.controller.ts @@ -0,0 +1,79 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + Post, + Put, + UseGuards, + UsePipes, + ValidationPipe, +} from '@nestjs/common'; +import { ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { UnitTypeService } from '../services/unit-type.service'; +import { UnitType } from '../dtos/unit-types/unit-type.dto'; +import { UnitTypeCreate } from '../dtos/unit-types/unit-type-create.dto'; +import { UnitTypeUpdate } from '../dtos/unit-types/unit-type-update.dto'; +import { defaultValidationPipeOptions } from '../utilities/default-validation-pipe-options'; +import { IdDTO } from '../dtos/shared/id.dto'; +import { SuccessDTO } from '../dtos/shared/success.dto'; +import { PermissionTypeDecorator } from '../decorators/permission-type.decorator'; +import { JwtAuthGuard } from '../guards/jwt.guard'; +import { PermissionGuard } from '../guards/permission.guard'; + +@Controller('unitTypes') +@ApiTags('unitTypes') +@UsePipes(new ValidationPipe(defaultValidationPipeOptions)) +@PermissionTypeDecorator('unitType') +@UseGuards(JwtAuthGuard, PermissionGuard) +export class UnitTypeController { + constructor(private readonly unitTypeService: UnitTypeService) {} + + @Get() + @ApiOperation({ summary: 'List unitTypes', operationId: 'list' }) + @ApiOkResponse({ type: UnitType, isArray: true }) + async list(): Promise { + return await this.unitTypeService.list(); + } + + @Get(`:unitTypeId`) + @ApiOperation({ + summary: 'Get unitType by id', + operationId: 'retrieve', + }) + @ApiOkResponse({ type: UnitType }) + async retrieve(@Param('unitTypeId') unitTypeId: string): Promise { + return this.unitTypeService.findOne(unitTypeId); + } + + @Post() + @ApiOperation({ + summary: 'Create unitType', + operationId: 'create', + }) + @ApiOkResponse({ type: UnitType }) + async create(@Body() unitType: UnitTypeCreate): Promise { + return await this.unitTypeService.create(unitType); + } + + @Put(`:unitTypeId`) + @ApiOperation({ + summary: 'Update unitType', + operationId: 'update', + }) + @ApiOkResponse({ type: UnitType }) + async update(@Body() unitType: UnitTypeUpdate): Promise { + return await this.unitTypeService.update(unitType); + } + + @Delete() + @ApiOperation({ + summary: 'Delete unitType by id', + operationId: 'delete', + }) + @ApiOkResponse({ type: SuccessDTO }) + async delete(@Body() dto: IdDTO): Promise { + return await this.unitTypeService.delete(dto.id); + } +} diff --git a/api/src/controllers/user.controller.ts b/api/src/controllers/user.controller.ts new file mode 100644 index 0000000000..d3b613ceab --- /dev/null +++ b/api/src/controllers/user.controller.ts @@ -0,0 +1,222 @@ +import { + Body, + ClassSerializerInterceptor, + Controller, + Delete, + Get, + Param, + ParseUUIDPipe, + Post, + Put, + Query, + Request, + UseGuards, + UseInterceptors, + UsePipes, + ValidationPipe, +} from '@nestjs/common'; +import { + ApiExtraModels, + ApiOkResponse, + ApiOperation, + ApiTags, +} from '@nestjs/swagger'; +import { UserService } from '../services/user.service'; +import { User } from '../dtos/users/user.dto'; +import { defaultValidationPipeOptions } from '../utilities/default-validation-pipe-options'; +import { IdDTO } from '../dtos/shared/id.dto'; +import { mapTo } from '../utilities/mapTo'; +import { PaginatedUserDto } from '../dtos/users/paginated-user.dto'; +import { UserQueryParams } from '../dtos/users/user-query-param.dto'; +import { Request as ExpressRequest } from 'express'; +import { UserUpdate } from '../dtos/users/user-update.dto'; +import { SuccessDTO } from '../dtos/shared/success.dto'; +import { UserCreate } from '../dtos/users/user-create.dto'; +import { UserCreateParams } from '../dtos/users/user-create-params.dto'; +import { EmailAndAppUrl } from '../dtos/users/email-and-app-url.dto'; +import { ConfirmationRequest } from '../dtos/users/confirmation-request.dto'; +import { UserInvite } from '../dtos/users/user-invite.dto'; +import { JwtAuthGuard } from '../guards/jwt.guard'; +import { UserProfilePermissionGuard } from '../guards/user-profile-permission-guard'; +import { OptionalAuthGuard } from '../guards/optional.guard'; +import { PermissionGuard } from '../guards/permission.guard'; +import { AdminOrJurisdictionalAdminGuard } from '../guards/admin-or-jurisdiction-admin.guard'; +import { ActivityLogInterceptor } from '../interceptors/activity-log.interceptor'; +import { PermissionTypeDecorator } from '../decorators/permission-type.decorator'; +import { UserFilterParams } from '../dtos/users/user-filter-params.dto'; + +@Controller('user') +@ApiTags('user') +@PermissionTypeDecorator('user') +@UsePipes(new ValidationPipe(defaultValidationPipeOptions)) +@ApiExtraModels(IdDTO, EmailAndAppUrl) +export class UserController { + constructor(private readonly userService: UserService) {} + + @Get() + @UseGuards(JwtAuthGuard, UserProfilePermissionGuard) + @ApiOkResponse({ type: User }) + @ApiOperation({ + summary: 'Get a user from cookies', + operationId: 'profile', + }) + profile(@Request() req: ExpressRequest): User { + return mapTo(User, req['user']); + } + + @Get('/list') + @UseGuards(JwtAuthGuard) + @UsePipes(new ValidationPipe(defaultValidationPipeOptions)) + @UseInterceptors(ClassSerializerInterceptor) + @ApiOkResponse({ type: PaginatedUserDto }) + @ApiOperation({ + summary: 'Get a paginated set of users', + operationId: 'list', + }) + @UseGuards(OptionalAuthGuard, AdminOrJurisdictionalAdminGuard) + @ApiExtraModels(UserFilterParams) + async list( + @Request() req: ExpressRequest, + @Query() queryParams: UserQueryParams, + ): Promise { + return await this.userService.list(queryParams, mapTo(User, req['user'])); + } + + @Get('/csv') + @UsePipes(new ValidationPipe(defaultValidationPipeOptions)) + @UseInterceptors(ClassSerializerInterceptor) + @ApiOkResponse({ type: SuccessDTO }) + @ApiOperation({ + summary: 'List users in CSV', + operationId: 'listAsCsv', + }) + @UseGuards(OptionalAuthGuard, AdminOrJurisdictionalAdminGuard) + async listAsCsv(@Request() req: ExpressRequest): Promise { + return await this.userService.export(mapTo(User, req['user'])); + } + + @Get(`:id`) + @ApiOperation({ + summary: 'Get user by id', + operationId: 'retrieve', + }) + @ApiOkResponse({ type: User }) + @UseGuards(JwtAuthGuard, PermissionGuard) + async retrieve( + @Param('id', new ParseUUIDPipe({ version: '4' })) userId: string, + ): Promise { + return this.userService.findOne(userId); + } + + @Put('forgot-password') + @ApiOperation({ summary: 'Forgot Password', operationId: 'forgotPassword' }) + @ApiOkResponse({ type: SuccessDTO }) + async forgotPassword(@Body() dto: EmailAndAppUrl): Promise { + return await this.userService.forgotPassword(dto); + } + + @Put(':id') + @ApiOperation({ summary: 'Update user', operationId: 'update' }) + @ApiOkResponse({ type: User }) + @UseGuards(JwtAuthGuard, PermissionGuard) + @UseInterceptors(ActivityLogInterceptor) + async update( + @Request() req: ExpressRequest, + @Body() dto: UserUpdate, + ): Promise { + const jurisdictionName = req.headers['jurisdictionname'] || ''; + return await this.userService.update( + dto, + mapTo(User, req['user']), + jurisdictionName as string, + ); + } + + @Delete() + @ApiOperation({ summary: 'Delete user by id', operationId: 'delete' }) + @ApiOkResponse({ type: SuccessDTO }) + @UseGuards(OptionalAuthGuard, PermissionGuard) + @UseInterceptors(ActivityLogInterceptor) + async delete( + @Body() dto: IdDTO, + @Request() req: ExpressRequest, + ): Promise { + return await this.userService.delete(dto.id, mapTo(User, req['user'])); + } + + @Post() + @ApiOperation({ + summary: 'Creates a public only user', + operationId: 'create', + }) + @ApiOkResponse({ type: User }) + @UseGuards(OptionalAuthGuard, PermissionGuard) + async create( + @Request() req: ExpressRequest, + @Body() dto: UserCreate, + @Query() queryParams: UserCreateParams, + ): Promise { + const jurisdictionName = req.headers['jurisdictionname'] || ''; + return await this.userService.create( + dto, + false, + queryParams.noWelcomeEmail !== true, + mapTo(User, req['user']), + jurisdictionName as string, + ); + } + + @Post('/invite') + @ApiOperation({ summary: 'Invite partner user', operationId: 'invite' }) + @ApiOkResponse({ type: User }) + @UseGuards(OptionalAuthGuard) + @UseInterceptors(ActivityLogInterceptor) + async invite( + @Body() dto: UserInvite, + @Request() req: ExpressRequest, + ): Promise { + return await this.userService.create( + dto, + true, + undefined, + mapTo(User, req['user']), + ); + } + + @Post('resend-confirmation') + @ApiOperation({ + summary: 'Resend public confirmation', + operationId: 'resendConfirmation', + }) + @ApiOkResponse({ type: SuccessDTO }) + @UseGuards(OptionalAuthGuard, PermissionGuard) + async confirmation(@Body() dto: EmailAndAppUrl): Promise { + return await this.userService.resendConfirmation(dto, true); + } + + @Post('resend-partner-confirmation') + @ApiOperation({ + summary: 'Resend partner confirmation', + operationId: 'resendPartnerConfirmation', + }) + @ApiOkResponse({ type: SuccessDTO }) + @UseGuards(OptionalAuthGuard, PermissionGuard) + async requestConfirmationResend( + @Body() dto: EmailAndAppUrl, + ): Promise { + return await this.userService.resendConfirmation(dto, false); + } + + @Post('is-confirmation-token-valid') + @ApiOperation({ + summary: 'Verifies token is valid', + operationId: 'isUserConfirmationTokenValid', + }) + @ApiOkResponse({ type: SuccessDTO }) + @UseGuards(OptionalAuthGuard, PermissionGuard) + async isUserConfirmationTokenValid( + @Body() dto: ConfirmationRequest, + ): Promise { + return await this.userService.isUserConfirmationTokenValid(dto); + } +} diff --git a/api/src/decorators/activity-log-metadata.decorator.ts b/api/src/decorators/activity-log-metadata.decorator.ts new file mode 100644 index 0000000000..f006c59a94 --- /dev/null +++ b/api/src/decorators/activity-log-metadata.decorator.ts @@ -0,0 +1,9 @@ +import { SetMetadata } from '@nestjs/common'; + +export type ActivityLogMetadataType = Array<{ + targetPropertyName: string; + propertyPath: string; +}>; + +export const ActivityLogMetadata = (metadata: ActivityLogMetadataType) => + SetMetadata('activity_log_metadata', metadata); diff --git a/api/src/decorators/enforce-lower-case.decorator.ts b/api/src/decorators/enforce-lower-case.decorator.ts new file mode 100644 index 0000000000..48b46f4e5c --- /dev/null +++ b/api/src/decorators/enforce-lower-case.decorator.ts @@ -0,0 +1,7 @@ +import { Transform, TransformFnParams } from 'class-transformer'; + +export function EnforceLowerCase() { + return Transform((param: TransformFnParams) => + param?.value ? param.value.toLowerCase() : param.value, + ); +} diff --git a/api/src/decorators/match-decorator.ts b/api/src/decorators/match-decorator.ts new file mode 100644 index 0000000000..385d77c74f --- /dev/null +++ b/api/src/decorators/match-decorator.ts @@ -0,0 +1,33 @@ +import { + registerDecorator, + ValidationArguments, + ValidationOptions, + ValidatorConstraint, + ValidatorConstraintInterface, +} from 'class-validator'; + +/* + This creates a validation decorator. + It requires that the current field's value matches related property supplied to it as an argument + e.g. password's and passwordConfirmation's values should match +*/ +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/api/src/decorators/permission-action.decorator.ts b/api/src/decorators/permission-action.decorator.ts new file mode 100644 index 0000000000..d83682582c --- /dev/null +++ b/api/src/decorators/permission-action.decorator.ts @@ -0,0 +1,4 @@ +import { SetMetadata } from '@nestjs/common'; + +export const PermissionAction = (action: string) => + SetMetadata('permission_action', action); diff --git a/api/src/decorators/permission-type.decorator.ts b/api/src/decorators/permission-type.decorator.ts new file mode 100644 index 0000000000..8d43f98454 --- /dev/null +++ b/api/src/decorators/permission-type.decorator.ts @@ -0,0 +1,4 @@ +import { SetMetadata } from '@nestjs/common'; + +export const PermissionTypeDecorator = (type: string) => + SetMetadata('permission_type', type); diff --git a/api/src/decorators/search-string-length-check.decorator.ts b/api/src/decorators/search-string-length-check.decorator.ts new file mode 100644 index 0000000000..66427df87d --- /dev/null +++ b/api/src/decorators/search-string-length-check.decorator.ts @@ -0,0 +1,28 @@ +import { + registerDecorator, + ValidationOptions, + ValidatorConstraint, + ValidatorConstraintInterface, +} from 'class-validator'; + +export function SearchStringLengthCheck( + property: string, + validationOptions?: ValidationOptions, +) { + return (object: unknown, propertyName: string) => { + registerDecorator({ + target: object.constructor, + propertyName, + options: validationOptions, + constraints: [property], + validator: LengthConstraint, + }); + }; +} + +@ValidatorConstraint({ name: 'SearchStringLengthCheck' }) +export class LengthConstraint implements ValidatorConstraintInterface { + validate(value: string) { + return value.length >= 3 || value.length === 0; + } +} diff --git a/api/src/dtos/addresses/address-create.dto.ts b/api/src/dtos/addresses/address-create.dto.ts new file mode 100644 index 0000000000..e040876165 --- /dev/null +++ b/api/src/dtos/addresses/address-create.dto.ts @@ -0,0 +1,8 @@ +import { OmitType } from '@nestjs/swagger'; +import { Address } from './address.dto'; + +export class AddressCreate extends OmitType(Address, [ + 'id', + 'createdAt', + 'updatedAt', +]) {} diff --git a/api/src/dtos/addresses/address.dto.ts b/api/src/dtos/addresses/address.dto.ts new file mode 100644 index 0000000000..9b03e83f1e --- /dev/null +++ b/api/src/dtos/addresses/address.dto.ts @@ -0,0 +1,66 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Expose, Type } from 'class-transformer'; +import { IsNumber, IsDefined, IsString, MaxLength } from 'class-validator'; +import { ValidationsGroupsEnum } from '../../enums/shared/validation-groups-enum'; +import { AbstractDTO } from '../shared/abstract.dto'; + +export class Address extends AbstractDTO { + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @MaxLength(64, { groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + placeName?: string; + + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @MaxLength(64, { groups: [ValidationsGroupsEnum.default] }) + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ApiProperty() + city: string; + + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @MaxLength(64, { groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + county?: string; + + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @MaxLength(64, { groups: [ValidationsGroupsEnum.default] }) + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ApiProperty() + state: string; + + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @MaxLength(64, { groups: [ValidationsGroupsEnum.default] }) + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ApiProperty() + street: string; + + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @MaxLength(64, { groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + street2?: string; + + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @MaxLength(10, { groups: [ValidationsGroupsEnum.default] }) + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ApiProperty() + zipCode: string; + + @Expose() + @IsNumber({}, { groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + @Type(() => Number) + @ApiPropertyOptional() + latitude?: number; + + @Expose() + @IsNumber({}, { groups: [ValidationsGroupsEnum.default] }) + @Type(() => Number) + @ApiPropertyOptional() + longitude?: number; +} diff --git a/api/src/dtos/ami-charts/ami-chart-create.dto.ts b/api/src/dtos/ami-charts/ami-chart-create.dto.ts new file mode 100644 index 0000000000..29fe706714 --- /dev/null +++ b/api/src/dtos/ami-charts/ami-chart-create.dto.ts @@ -0,0 +1,8 @@ +import { OmitType } from '@nestjs/swagger'; +import { AmiChart } from './ami-chart.dto'; + +export class AmiChartCreate extends OmitType(AmiChart, [ + 'id', + 'createdAt', + 'updatedAt', +]) {} diff --git a/api/src/dtos/ami-charts/ami-chart-query-params.dto.ts b/api/src/dtos/ami-charts/ami-chart-query-params.dto.ts new file mode 100644 index 0000000000..bc6b04db00 --- /dev/null +++ b/api/src/dtos/ami-charts/ami-chart-query-params.dto.ts @@ -0,0 +1,14 @@ +import { Expose } from 'class-transformer'; +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsString } from 'class-validator'; +import { ValidationsGroupsEnum } from '../../enums/shared/validation-groups-enum'; + +export class AmiChartQueryParams { + @Expose() + @ApiPropertyOptional({ + name: 'jurisdictionId', + type: String, + }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + jurisdictionId?: string; +} diff --git a/api/src/dtos/ami-charts/ami-chart-update.dto.ts b/api/src/dtos/ami-charts/ami-chart-update.dto.ts new file mode 100644 index 0000000000..1a00c6f223 --- /dev/null +++ b/api/src/dtos/ami-charts/ami-chart-update.dto.ts @@ -0,0 +1,8 @@ +import { OmitType } from '@nestjs/swagger'; +import { AmiChart } from './ami-chart.dto'; + +export class AmiChartUpdate extends OmitType(AmiChart, [ + 'createdAt', + 'updatedAt', + 'jurisdictions', +]) {} diff --git a/api/src/dtos/ami-charts/ami-chart.dto.ts b/api/src/dtos/ami-charts/ami-chart.dto.ts new file mode 100644 index 0000000000..71e7f29379 --- /dev/null +++ b/api/src/dtos/ami-charts/ami-chart.dto.ts @@ -0,0 +1,32 @@ +import { IsDefined, IsString, ValidateNested } from 'class-validator'; +import { Expose, Type } from 'class-transformer'; +import { ValidationsGroupsEnum } from '../../enums/shared/validation-groups-enum'; +import { AbstractDTO } from '../shared/abstract.dto'; +import { AmiChartItem } from '../units/ami-chart-item.dto'; +import { IdDTO } from '../shared/id.dto'; +import { ApiProperty } from '@nestjs/swagger'; + +export class AmiChart extends AbstractDTO { + @Expose() + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default], each: true }) + @Type(() => AmiChartItem) + @ApiProperty({ + type: AmiChartItem, + isArray: true, + }) + items: AmiChartItem[]; + + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ApiProperty() + name: string; + + @Expose() + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => IdDTO) + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ApiProperty() + jurisdictions: IdDTO; +} diff --git a/api/src/dtos/application-flagged-sets/afs-meta.dto.ts b/api/src/dtos/application-flagged-sets/afs-meta.dto.ts new file mode 100644 index 0000000000..795c73ba66 --- /dev/null +++ b/api/src/dtos/application-flagged-sets/afs-meta.dto.ts @@ -0,0 +1,35 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { Expose } from 'class-transformer'; +import { IsNumber, IsOptional } from 'class-validator'; + +export class AfsMeta { + @Expose() + @IsNumber() + @IsOptional() + @ApiPropertyOptional() + totalCount?: number; + + @Expose() + @IsNumber() + @IsOptional() + @ApiPropertyOptional() + totalResolvedCount?: number; + + @Expose() + @IsNumber() + @IsOptional() + @ApiPropertyOptional() + totalPendingCount?: number; + + @Expose() + @IsNumber() + @IsOptional() + @ApiPropertyOptional() + totalNamePendingCount?: number; + + @Expose() + @IsNumber() + @IsOptional() + @ApiPropertyOptional() + totalEmailPendingCount?: number; +} diff --git a/api/src/dtos/application-flagged-sets/afs-pagination-meta.dto.ts b/api/src/dtos/application-flagged-sets/afs-pagination-meta.dto.ts new file mode 100644 index 0000000000..5a2560da6e --- /dev/null +++ b/api/src/dtos/application-flagged-sets/afs-pagination-meta.dto.ts @@ -0,0 +1,9 @@ +import { PaginationMeta } from '../shared/pagination.dto'; +import { Expose } from 'class-transformer'; +import { ApiProperty } from '@nestjs/swagger'; + +export class ApplicationFlaggedSetPaginationMeta extends PaginationMeta { + @Expose() + @ApiProperty() + totalFlagged: number; +} diff --git a/api/src/dtos/application-flagged-sets/afs-query-params.dto.ts b/api/src/dtos/application-flagged-sets/afs-query-params.dto.ts new file mode 100644 index 0000000000..a741219648 --- /dev/null +++ b/api/src/dtos/application-flagged-sets/afs-query-params.dto.ts @@ -0,0 +1,22 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Expose } from 'class-transformer'; +import { IsEnum, IsUUID } from 'class-validator'; +import { View } from '../../enums/application-flagged-sets/view'; +import { ValidationsGroupsEnum } from '../../enums/shared/validation-groups-enum'; +import { PaginationAllowsAllQueryParams } from '../shared/pagination.dto'; + +export class AfsQueryParams extends PaginationAllowsAllQueryParams { + @Expose() + @ApiProperty() + @IsUUID(4, { groups: [ValidationsGroupsEnum.default] }) + listingId: string; + + @Expose() + @ApiPropertyOptional({ + enum: View, + enumName: 'AfsView', + example: 'pending', + }) + @IsEnum(View, { groups: [ValidationsGroupsEnum.default] }) + view?: View; +} diff --git a/api/src/dtos/application-flagged-sets/afs-resolve.dto.ts b/api/src/dtos/application-flagged-sets/afs-resolve.dto.ts new file mode 100644 index 0000000000..2e5227fcc9 --- /dev/null +++ b/api/src/dtos/application-flagged-sets/afs-resolve.dto.ts @@ -0,0 +1,36 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { Expose, Type } from 'class-transformer'; +import { + ArrayMaxSize, + IsArray, + IsDefined, + IsEnum, + IsUUID, + ValidateNested, +} from 'class-validator'; +import { FlaggedSetStatusEnum } from '@prisma/client'; +import { ValidationsGroupsEnum } from '../../enums/shared/validation-groups-enum'; +import { IdDTO } from '../shared/id.dto'; + +export class AfsResolve { + @Expose() + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @IsUUID(4, { groups: [ValidationsGroupsEnum.default] }) + @ApiProperty() + afsId: string; + + @Expose() + @IsEnum(FlaggedSetStatusEnum, { groups: [ValidationsGroupsEnum.default] }) + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ApiProperty({ enum: FlaggedSetStatusEnum, enumName: 'FlaggedSetStatusEnum' }) + status: FlaggedSetStatusEnum; + + @Expose() + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @IsArray({ groups: [ValidationsGroupsEnum.default] }) + @ArrayMaxSize(512, { groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default], each: true }) + @Type(() => IdDTO) + @ApiProperty({ type: IdDTO, isArray: true }) + applications: IdDTO[]; +} diff --git a/api/src/dtos/application-flagged-sets/application-flagged-set.dto.ts b/api/src/dtos/application-flagged-sets/application-flagged-set.dto.ts new file mode 100644 index 0000000000..cac4ed17b3 --- /dev/null +++ b/api/src/dtos/application-flagged-sets/application-flagged-set.dto.ts @@ -0,0 +1,69 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Expose, Type } from 'class-transformer'; +import { + IsBoolean, + IsDate, + IsDefined, + IsEnum, + IsString, + ValidateNested, +} from 'class-validator'; +import { FlaggedSetStatusEnum, RuleEnum } from '@prisma/client'; +import { ValidationsGroupsEnum } from '../../enums/shared/validation-groups-enum'; +import { AbstractDTO } from '../shared/abstract.dto'; +import { IdDTO } from '../shared/id.dto'; +import { Application } from '../applications/application.dto'; + +export class ApplicationFlaggedSet extends AbstractDTO { + @Expose() + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @ApiProperty({ type: IdDTO }) + @Type(() => IdDTO) + resolvingUser: IdDTO; + + @Expose() + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @ApiProperty({ type: IdDTO }) + @Type(() => IdDTO) + listing: IdDTO; + + @Expose() + @IsEnum(RuleEnum, { groups: [ValidationsGroupsEnum.default] }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @ApiProperty({ enum: RuleEnum, enumName: 'RuleEnum' }) + rule: RuleEnum; + + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @ApiProperty() + ruleKey: string; + + @Expose() + @IsDate({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => Date) + @ApiPropertyOptional() + resolvedTime?: Date | null; + + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @ApiProperty() + listingId: string; + + @Expose() + @IsBoolean({ groups: [ValidationsGroupsEnum.default] }) + @ApiProperty() + showConfirmationAlert: boolean; + + @Expose() + @IsEnum(FlaggedSetStatusEnum, { groups: [ValidationsGroupsEnum.default] }) + @ApiProperty({ enum: FlaggedSetStatusEnum, enumName: 'FlaggedSetStatusEnum' }) + status: FlaggedSetStatusEnum; + + @Expose() + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => Application) + @ApiProperty({ type: Application, isArray: true }) + applications: Application[]; +} diff --git a/api/src/dtos/application-flagged-sets/paginated-afs.dto.ts b/api/src/dtos/application-flagged-sets/paginated-afs.dto.ts new file mode 100644 index 0000000000..dcd3e4cc3b --- /dev/null +++ b/api/src/dtos/application-flagged-sets/paginated-afs.dto.ts @@ -0,0 +1,13 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { Expose } from 'class-transformer'; +import { PaginationFactory } from '../shared/pagination.dto'; +import { ApplicationFlaggedSetPaginationMeta } from './afs-pagination-meta.dto'; +import { ApplicationFlaggedSet } from './application-flagged-set.dto'; + +export class PaginatedAfsDto extends PaginationFactory( + ApplicationFlaggedSet, +) { + @Expose() + @ApiProperty({ type: ApplicationFlaggedSetPaginationMeta }) + meta: ApplicationFlaggedSetPaginationMeta; +} diff --git a/api/src/dtos/application-methods/application-method-create.dto.ts b/api/src/dtos/application-methods/application-method-create.dto.ts new file mode 100644 index 0000000000..05f557059e --- /dev/null +++ b/api/src/dtos/application-methods/application-method-create.dto.ts @@ -0,0 +1,19 @@ +import { OmitType, ApiPropertyOptional } from '@nestjs/swagger'; +import { Expose, Type } from 'class-transformer'; +import { ValidateNested } from 'class-validator'; +import { ValidationsGroupsEnum } from '../../enums/shared/validation-groups-enum'; +import { PaperApplicationCreate } from '../paper-applications/paper-application-create.dto'; +import { ApplicationMethod } from './application-method.dto'; + +export class ApplicationMethodCreate extends OmitType(ApplicationMethod, [ + 'id', + 'createdAt', + 'updatedAt', + 'paperApplications', +]) { + @Expose() + @ValidateNested({ groups: [ValidationsGroupsEnum.default], each: true }) + @Type(() => PaperApplicationCreate) + @ApiPropertyOptional({ type: PaperApplicationCreate, isArray: true }) + paperApplications?: PaperApplicationCreate[]; +} diff --git a/api/src/dtos/application-methods/application-method.dto.ts b/api/src/dtos/application-methods/application-method.dto.ts new file mode 100644 index 0000000000..4726e6d239 --- /dev/null +++ b/api/src/dtos/application-methods/application-method.dto.ts @@ -0,0 +1,57 @@ +import { Expose, Type } from 'class-transformer'; +import { + IsBoolean, + IsEnum, + IsDefined, + IsString, + MaxLength, + ValidateNested, +} from 'class-validator'; +import { ValidationsGroupsEnum } from '../../enums/shared/validation-groups-enum'; +import { AbstractDTO } from '../shared/abstract.dto'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { ApplicationMethodsTypeEnum } from '@prisma/client'; +import { PaperApplication } from '../paper-applications/paper-application.dto'; + +export class ApplicationMethod extends AbstractDTO { + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @IsEnum(ApplicationMethodsTypeEnum, { + groups: [ValidationsGroupsEnum.default], + }) + @ApiProperty({ + enum: ApplicationMethodsTypeEnum, + enumName: 'ApplicationMethodsTypeEnum', + }) + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + type: ApplicationMethodsTypeEnum; + + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @MaxLength(256, { groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + label?: string; + + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @MaxLength(4096, { groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + externalReference?: string; + + @Expose() + @IsBoolean({ groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + acceptsPostmarkedApplications?: boolean; + + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @MaxLength(16, { groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + phoneNumber?: string; + + @Expose() + @ValidateNested({ groups: [ValidationsGroupsEnum.default], each: true }) + @Type(() => PaperApplication) + @ApiPropertyOptional({ type: PaperApplication, isArray: true }) + paperApplications?: PaperApplication[]; +} diff --git a/api/src/dtos/applications/accessibility-update.dto.ts b/api/src/dtos/applications/accessibility-update.dto.ts new file mode 100644 index 0000000000..043c502d31 --- /dev/null +++ b/api/src/dtos/applications/accessibility-update.dto.ts @@ -0,0 +1,8 @@ +import { OmitType } from '@nestjs/swagger'; +import { Accessibility } from './accessibility.dto'; + +export class AccessibilityUpdate extends OmitType(Accessibility, [ + 'id', + 'createdAt', + 'updatedAt', +]) {} diff --git a/api/src/dtos/applications/accessibility.dto.ts b/api/src/dtos/applications/accessibility.dto.ts new file mode 100644 index 0000000000..50de91ab31 --- /dev/null +++ b/api/src/dtos/applications/accessibility.dto.ts @@ -0,0 +1,22 @@ +import { Expose } from 'class-transformer'; +import { IsBoolean } from 'class-validator'; +import { ValidationsGroupsEnum } from '../../enums/shared/validation-groups-enum'; +import { AbstractDTO } from '../shared/abstract.dto'; +import { ApiPropertyOptional } from '@nestjs/swagger'; + +export class Accessibility extends AbstractDTO { + @Expose() + @IsBoolean({ groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + mobility?: boolean; + + @Expose() + @IsBoolean({ groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + vision?: boolean; + + @Expose() + @IsBoolean({ groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + hearing?: boolean; +} diff --git a/api/src/dtos/applications/alternate-contact-update.dto.ts b/api/src/dtos/applications/alternate-contact-update.dto.ts new file mode 100644 index 0000000000..317da14ef7 --- /dev/null +++ b/api/src/dtos/applications/alternate-contact-update.dto.ts @@ -0,0 +1,19 @@ +import { ApiProperty, OmitType } from '@nestjs/swagger'; +import { Expose, Type } from 'class-transformer'; +import { ValidateNested } from 'class-validator'; +import { ValidationsGroupsEnum } from '../../enums/shared/validation-groups-enum'; +import { AddressCreate } from '../addresses/address-create.dto'; +import { AlternateContact } from './alternate-contact.dto'; + +export class AlternateContactUpdate extends OmitType(AlternateContact, [ + 'id', + 'createdAt', + 'updatedAt', + 'address', +]) { + @Expose() + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => AddressCreate) + @ApiProperty({ type: AddressCreate }) + address: AddressCreate; +} diff --git a/api/src/dtos/applications/alternate-contact.dto.ts b/api/src/dtos/applications/alternate-contact.dto.ts new file mode 100644 index 0000000000..fea3a86314 --- /dev/null +++ b/api/src/dtos/applications/alternate-contact.dto.ts @@ -0,0 +1,69 @@ +import { Expose, Type } from 'class-transformer'; +import { + IsDefined, + IsEmail, + IsString, + MaxLength, + ValidateNested, +} from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { ValidationsGroupsEnum } from '../../enums/shared/validation-groups-enum'; +import { AbstractDTO } from '../shared/abstract.dto'; +import { Address } from '../addresses/address.dto'; +import { EnforceLowerCase } from '../../decorators/enforce-lower-case.decorator'; + +export class AlternateContact extends AbstractDTO { + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @MaxLength(16, { groups: [ValidationsGroupsEnum.default] }) + @IsDefined({ groups: [ValidationsGroupsEnum.applicants] }) + @ApiPropertyOptional() + type?: string; + + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @MaxLength(64, { groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + otherType?: string; + + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @MaxLength(64, { groups: [ValidationsGroupsEnum.default] }) + @IsDefined({ groups: [ValidationsGroupsEnum.applicants] }) + @ApiPropertyOptional() + firstName?: string; + + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @MaxLength(64, { groups: [ValidationsGroupsEnum.default] }) + @IsDefined({ groups: [ValidationsGroupsEnum.applicants] }) + @ApiPropertyOptional() + lastName?: string; + + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @MaxLength(128, { groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + agency?: string; + + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @MaxLength(16, { groups: [ValidationsGroupsEnum.default] }) + @IsDefined({ groups: [ValidationsGroupsEnum.applicants] }) + @ApiPropertyOptional() + phoneNumber?: string; + + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @IsEmail({}, { groups: [ValidationsGroupsEnum.default] }) + @EnforceLowerCase() + @ApiPropertyOptional() + emailAddress?: string; + + @Expose() + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => Address) + @ApiProperty({ type: Address }) + address: Address; +} diff --git a/api/src/dtos/applications/applicant-update.dto.ts b/api/src/dtos/applications/applicant-update.dto.ts new file mode 100644 index 0000000000..4fcdec5700 --- /dev/null +++ b/api/src/dtos/applications/applicant-update.dto.ts @@ -0,0 +1,26 @@ +import { ApiProperty, OmitType } from '@nestjs/swagger'; +import { Expose, Type } from 'class-transformer'; +import { ValidateNested } from 'class-validator'; +import { ValidationsGroupsEnum } from '../../enums/shared/validation-groups-enum'; +import { AddressCreate } from '../addresses/address-create.dto'; +import { Applicant } from './applicant.dto'; + +export class ApplicantUpdate extends OmitType(Applicant, [ + 'id', + 'createdAt', + 'updatedAt', + 'applicantAddress', + 'applicantWorkAddress', +]) { + @Expose() + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => AddressCreate) + @ApiProperty({ type: AddressCreate }) + applicantAddress: AddressCreate; + + @Expose() + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => AddressCreate) + @ApiProperty({ type: AddressCreate }) + applicantWorkAddress: AddressCreate; +} diff --git a/api/src/dtos/applications/applicant.dto.ts b/api/src/dtos/applications/applicant.dto.ts new file mode 100644 index 0000000000..eb352de454 --- /dev/null +++ b/api/src/dtos/applications/applicant.dto.ts @@ -0,0 +1,112 @@ +import { Expose, Type } from 'class-transformer'; +import { + IsBoolean, + IsDefined, + IsEmail, + IsEnum, + IsString, + MaxLength, + MinLength, + ValidateNested, +} from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { YesNoEnum } from '@prisma/client'; +import { ValidationsGroupsEnum } from '../../enums/shared/validation-groups-enum'; +import { AbstractDTO } from '../shared/abstract.dto'; +import { Address } from '../addresses/address.dto'; +import { EnforceLowerCase } from '../../decorators/enforce-lower-case.decorator'; + +export class Applicant extends AbstractDTO { + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @MaxLength(64, { groups: [ValidationsGroupsEnum.default] }) + @MinLength(1, { groups: [ValidationsGroupsEnum.default] }) + @IsDefined({ groups: [ValidationsGroupsEnum.applicants] }) + @ApiPropertyOptional() + firstName?: string; + + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @MaxLength(64, { groups: [ValidationsGroupsEnum.default] }) + @IsDefined({ groups: [ValidationsGroupsEnum.applicants] }) + @ApiPropertyOptional() + middleName?: string; + + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @MaxLength(64, { groups: [ValidationsGroupsEnum.default] }) + @MinLength(1, { groups: [ValidationsGroupsEnum.default] }) + @IsDefined({ groups: [ValidationsGroupsEnum.applicants] }) + @ApiPropertyOptional() + lastName?: string; + + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @MaxLength(8, { groups: [ValidationsGroupsEnum.default] }) + @IsDefined({ groups: [ValidationsGroupsEnum.applicants] }) + @ApiPropertyOptional() + birthMonth?: string; + + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @MaxLength(8, { groups: [ValidationsGroupsEnum.default] }) + @IsDefined({ groups: [ValidationsGroupsEnum.applicants] }) + @ApiPropertyOptional() + birthDay?: string; + + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @MaxLength(8, { groups: [ValidationsGroupsEnum.default] }) + @IsDefined({ groups: [ValidationsGroupsEnum.applicants] }) + @ApiPropertyOptional() + birthYear?: string; + + @Expose() + @IsEmail({}, { groups: [ValidationsGroupsEnum.default] }) + @EnforceLowerCase() + @ApiPropertyOptional() + emailAddress?: string; + + @Expose() + @IsBoolean({ groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + noEmail?: boolean; + + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @MaxLength(16, { groups: [ValidationsGroupsEnum.default] }) + @IsDefined({ groups: [ValidationsGroupsEnum.applicants] }) + @ApiPropertyOptional() + phoneNumber?: string; + + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @MaxLength(16, { groups: [ValidationsGroupsEnum.default] }) + @IsDefined({ groups: [ValidationsGroupsEnum.applicants] }) + @ApiPropertyOptional() + phoneNumberType?: string; + + @Expose() + @IsBoolean({ groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + noPhone?: boolean; + + @Expose() + @IsEnum(YesNoEnum, { groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional({ enum: YesNoEnum, enumName: 'YesNoEnum' }) + workInRegion?: YesNoEnum; + + @Expose() + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => Address) + @ApiProperty({ type: Address }) + applicantWorkAddress: Address; + + @Expose() + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => Address) + @ApiProperty({ type: Address }) + applicantAddress: Address; +} diff --git a/api/src/dtos/applications/application-create.dto.ts b/api/src/dtos/applications/application-create.dto.ts new file mode 100644 index 0000000000..32b5dc1b76 --- /dev/null +++ b/api/src/dtos/applications/application-create.dto.ts @@ -0,0 +1,4 @@ +import { OmitType } from '@nestjs/swagger'; +import { ApplicationUpdate } from './application-update.dto'; + +export class ApplicationCreate extends OmitType(ApplicationUpdate, ['id']) {} diff --git a/api/src/dtos/applications/application-csv-query-params.dto.ts b/api/src/dtos/applications/application-csv-query-params.dto.ts new file mode 100644 index 0000000000..106befa4cb --- /dev/null +++ b/api/src/dtos/applications/application-csv-query-params.dto.ts @@ -0,0 +1,35 @@ +import { Expose, Transform } from 'class-transformer'; +import { ApiProperty, ApiPropertyOptional, OmitType } from '@nestjs/swagger'; +import { IsBoolean, IsOptional, IsUUID } from 'class-validator'; +import { AbstractDTO } from '../shared/abstract.dto'; +import { ValidationsGroupsEnum } from '../../enums/shared/validation-groups-enum'; + +export class ApplicationCsvQueryParams extends OmitType(AbstractDTO, [ + 'id', + 'createdAt', + 'updatedAt', +]) { + @Expose() + @ApiProperty({ + type: String, + required: true, + }) + @IsUUID() + listingId: string; + + @Expose() + @ApiPropertyOptional({ + type: Boolean, + example: true, + required: false, + }) + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsBoolean({ groups: [ValidationsGroupsEnum.default] }) + @Transform( + (obj: any) => { + return obj.value === 'true' || obj.value === true; + }, + { toClassOnly: true }, + ) + includeDemographics?: boolean; +} diff --git a/api/src/dtos/applications/application-multiselect-question-option.dto.ts b/api/src/dtos/applications/application-multiselect-question-option.dto.ts new file mode 100644 index 0000000000..fea23d422a --- /dev/null +++ b/api/src/dtos/applications/application-multiselect-question-option.dto.ts @@ -0,0 +1,97 @@ +import { + ApiProperty, + ApiPropertyOptional, + getSchemaPath, +} from '@nestjs/swagger'; +import { Expose, Type } from 'class-transformer'; +import { + ArrayMaxSize, + IsBoolean, + IsDefined, + IsEnum, + IsString, + MaxLength, + ValidateNested, +} from 'class-validator'; +import { InputType } from '../../enums/shared/input-type-enum'; +import { ValidationsGroupsEnum } from '../../enums/shared/validation-groups-enum'; +import { AddressCreate } from '../addresses/address-create.dto'; + +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] }) + @ApiProperty() + key: string; +} +export class AddressInput extends FormMetadataExtraData { + @Expose() + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => AddressCreate) + @ApiProperty({ type: AddressCreate }) + value: AddressCreate; +} + +export class BooleanInput extends FormMetadataExtraData { + @Expose() + @IsBoolean({ groups: [ValidationsGroupsEnum.default] }) + @ApiProperty() + value: boolean; +} + +export class TextInput extends FormMetadataExtraData { + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @MaxLength(4096, { groups: [ValidationsGroupsEnum.default] }) + @ApiProperty() + value: string; +} + +export class ApplicationMultiselectQuestionOption { + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @ApiProperty() + key: string; + + @Expose() + @IsBoolean({ groups: [ValidationsGroupsEnum.default] }) + @ApiProperty() + checked: boolean; + + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + mapPinPosition?: string; + + @Expose() + @ApiPropertyOptional({ + type: 'array', + 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/api/src/dtos/applications/application-multiselect-question.dto.ts b/api/src/dtos/applications/application-multiselect-question.dto.ts new file mode 100644 index 0000000000..9e95795690 --- /dev/null +++ b/api/src/dtos/applications/application-multiselect-question.dto.ts @@ -0,0 +1,38 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { Expose, Type } from 'class-transformer'; +import { + ArrayMaxSize, + IsBoolean, + IsDefined, + IsString, + IsUUID, + ValidateNested, +} from 'class-validator'; +import { ValidationsGroupsEnum } from '../../enums/shared/validation-groups-enum'; +import { ApplicationMultiselectQuestionOption } from './application-multiselect-question-option.dto'; + +export class ApplicationMultiselectQuestion { + @Expose() + @IsUUID(4, { groups: [ValidationsGroupsEnum.default] }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ApiProperty() + multiselectQuestionId: string; + + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @ApiProperty() + key: string; + + @Expose() + @IsBoolean({ groups: [ValidationsGroupsEnum.default] }) + @ApiProperty() + claimed: boolean; + + @Expose() + @ArrayMaxSize(64, { groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default], each: true }) + @Type(() => ApplicationMultiselectQuestionOption) + @ApiProperty({ type: ApplicationMultiselectQuestionOption, isArray: true }) + options: ApplicationMultiselectQuestionOption[]; +} diff --git a/api/src/dtos/applications/application-query-params.dto.ts b/api/src/dtos/applications/application-query-params.dto.ts new file mode 100644 index 0000000000..837ebfac44 --- /dev/null +++ b/api/src/dtos/applications/application-query-params.dto.ts @@ -0,0 +1,94 @@ +import { Expose, Transform, TransformFnParams } from 'class-transformer'; +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsBoolean, IsEnum, IsString } from 'class-validator'; +import { ValidationsGroupsEnum } from '../../enums/shared/validation-groups-enum'; +import { ApplicationOrderByKeys } from '../../enums/applications/order-by-enum'; +import { OrderByEnum } from '../../enums/shared/order-by-enum'; +import { SearchStringLengthCheck } from '../../decorators/search-string-length-check.decorator'; +import { PaginationAllowsAllQueryParams } from '../shared/pagination.dto'; +export class ApplicationQueryParams extends PaginationAllowsAllQueryParams { + @Expose() + @ApiPropertyOptional({ + type: String, + example: 'listingId', + }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + listingId?: string; + + @Expose() + @ApiPropertyOptional({ + type: String, + example: 'search', + }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @SearchStringLengthCheck('search', { + message: 'Search must be at least 3 characters', + groups: [ValidationsGroupsEnum.default], + }) + search?: string; + + @Expose() + @ApiPropertyOptional({ + type: String, + example: 'userId', + }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + userId?: string; + + @Expose() + @ApiPropertyOptional({ + enum: ApplicationOrderByKeys, + enumName: 'ApplicationOrderByKeys', + example: 'createdAt', + default: 'createdAt', + }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @IsEnum(ApplicationOrderByKeys, { + groups: [ValidationsGroupsEnum.default], + }) + @Transform((value: TransformFnParams) => + value?.value + ? ApplicationOrderByKeys[value.value] + ? ApplicationOrderByKeys[value.value] + : value + : ApplicationOrderByKeys.createdAt, + ) + orderBy?: ApplicationOrderByKeys; + + @Expose() + @ApiPropertyOptional({ + enum: OrderByEnum, + enumName: 'OrderByEnum', + example: 'DESC', + default: 'DESC', + }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @IsEnum(OrderByEnum, { + groups: [ValidationsGroupsEnum.default], + }) + @Transform((value: TransformFnParams) => + value?.value ? value.value.toLowerCase() : OrderByEnum.DESC, + ) + order?: OrderByEnum; + + @Expose() + @ApiPropertyOptional({ + type: Boolean, + example: true, + }) + @IsBoolean({ groups: [ValidationsGroupsEnum.default] }) + @Transform( + (value: TransformFnParams) => { + switch (value?.value) { + case 'true': + return true; + case 'false': + return false; + default: + return undefined; + } + }, + { toClassOnly: true }, + ) + markedAsDuplicate?: boolean; +} diff --git a/api/src/dtos/applications/application-update.dto.ts b/api/src/dtos/applications/application-update.dto.ts new file mode 100644 index 0000000000..fa4e82d7a1 --- /dev/null +++ b/api/src/dtos/applications/application-update.dto.ts @@ -0,0 +1,78 @@ +import { ApiProperty, OmitType } from '@nestjs/swagger'; +import { Expose, Type } from 'class-transformer'; +import { ArrayMaxSize, ValidateNested } from 'class-validator'; +import { ValidationsGroupsEnum } from '../../enums/shared/validation-groups-enum'; +import { AddressCreate } from '../addresses/address-create.dto'; +import { AccessibilityUpdate } from './accessibility-update.dto'; +import { AlternateContactUpdate } from './alternate-contact-update.dto'; +import { ApplicantUpdate } from './applicant-update.dto'; +import { Application } from './application.dto'; +import { DemographicUpdate } from './demographic-update.dto'; +import { HouseholdMemberUpdate } from './household-member-update.dto'; +import { IdDTO } from '../shared/id.dto'; + +export class ApplicationUpdate extends OmitType(Application, [ + 'createdAt', + 'updatedAt', + 'deletedAt', + 'applicant', + 'applicationsMailingAddress', + 'applicationsAlternateAddress', + 'alternateContact', + 'accessibility', + 'demographics', + 'householdMember', + 'markedAsDuplicate', + 'flagged', + 'confirmationCode', + 'preferredUnitTypes', +]) { + @Expose() + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => ApplicantUpdate) + @ApiProperty({ type: ApplicantUpdate }) + applicant: ApplicantUpdate; + + @Expose() + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => AddressCreate) + @ApiProperty({ type: AddressCreate }) + applicationsMailingAddress: AddressCreate; + + @Expose() + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => AddressCreate) + @ApiProperty({ type: AddressCreate }) + applicationsAlternateAddress: AddressCreate; + + @Expose() + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => AlternateContactUpdate) + @ApiProperty({ type: AlternateContactUpdate }) + alternateContact: AlternateContactUpdate; + + @Expose() + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => AccessibilityUpdate) + @ApiProperty({ type: AccessibilityUpdate }) + accessibility: AccessibilityUpdate; + + @Expose() + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => DemographicUpdate) + @ApiProperty({ type: DemographicUpdate }) + demographics: DemographicUpdate; + + @Expose() + @ValidateNested({ groups: [ValidationsGroupsEnum.default], each: true }) + @ArrayMaxSize(32, { groups: [ValidationsGroupsEnum.default] }) + @Type(() => HouseholdMemberUpdate) + @ApiProperty({ type: HouseholdMemberUpdate, isArray: true }) + householdMember: HouseholdMemberUpdate[]; + + @Expose() + @ValidateNested({ groups: [ValidationsGroupsEnum.default], each: true }) + @Type(() => IdDTO) + @ApiProperty({ type: IdDTO, isArray: true }) + preferredUnitTypes: IdDTO[]; +} diff --git a/api/src/dtos/applications/application.dto.ts b/api/src/dtos/applications/application.dto.ts new file mode 100644 index 0000000000..9af1b9902f --- /dev/null +++ b/api/src/dtos/applications/application.dto.ts @@ -0,0 +1,268 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { + ApplicationReviewStatusEnum, + ApplicationStatusEnum, + ApplicationSubmissionTypeEnum, + IncomePeriodEnum, + LanguagesEnum, +} from '@prisma/client'; +import { Expose, Type } from 'class-transformer'; +import { + ArrayMaxSize, + IsBoolean, + IsDate, + IsDefined, + IsEnum, + IsNumber, + IsString, + MaxLength, + ValidateNested, +} from 'class-validator'; +import { ValidationsGroupsEnum } from '../../enums/shared/validation-groups-enum'; +import { Address } from '../addresses/address.dto'; +import { AbstractDTO } from '../shared/abstract.dto'; +import { IdDTO } from '../shared/id.dto'; +import { Accessibility } from './accessibility.dto'; +import { AlternateContact } from './alternate-contact.dto'; +import { Applicant } from './applicant.dto'; +import { ApplicationMultiselectQuestion } from './application-multiselect-question.dto'; +import { Demographic } from './demographic.dto'; +import { HouseholdMember } from './household-member.dto'; +import { UnitType } from '../unit-types/unit-type.dto'; + +export class Application extends AbstractDTO { + @Expose() + @IsDate({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => Date) + @ApiPropertyOptional() + deletedAt?: Date; + + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @MaxLength(256, { groups: [ValidationsGroupsEnum.default] }) + @IsDefined({ groups: [ValidationsGroupsEnum.applicants] }) + @ApiPropertyOptional() + appUrl?: string; + + @Expose() + @IsBoolean({ groups: [ValidationsGroupsEnum.default] }) + @IsDefined({ groups: [ValidationsGroupsEnum.applicants] }) + @ApiPropertyOptional() + additionalPhone?: boolean; + + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @MaxLength(16, { groups: [ValidationsGroupsEnum.default] }) + @IsDefined({ groups: [ValidationsGroupsEnum.applicants] }) + @ApiPropertyOptional() + additionalPhoneNumber?: string; + + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @MaxLength(16, { groups: [ValidationsGroupsEnum.default] }) + @IsDefined({ groups: [ValidationsGroupsEnum.applicants] }) + @ApiPropertyOptional() + additionalPhoneNumberType?: string; + + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default], each: true }) + @ArrayMaxSize(8, { groups: [ValidationsGroupsEnum.default] }) + @MaxLength(64, { groups: [ValidationsGroupsEnum.default], each: true }) + @ApiProperty() + contactPreferences: string[]; + + @Expose() + @IsNumber({}, { groups: [ValidationsGroupsEnum.default] }) + @ApiProperty() + householdSize?: number; + + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @MaxLength(16, { groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + housingStatus?: string; + + @Expose() + @IsBoolean({ groups: [ValidationsGroupsEnum.default] }) + @IsDefined({ groups: [ValidationsGroupsEnum.applicants] }) + @ApiPropertyOptional() + sendMailToMailingAddress?: boolean; + + @Expose() + @IsBoolean({ groups: [ValidationsGroupsEnum.default] }) + @IsDefined({ groups: [ValidationsGroupsEnum.applicants] }) + @ApiPropertyOptional() + householdExpectingChanges?: boolean; + + @Expose() + @IsBoolean({ groups: [ValidationsGroupsEnum.default] }) + @IsDefined({ groups: [ValidationsGroupsEnum.applicants] }) + @ApiPropertyOptional() + householdStudent?: boolean; + + @Expose() + @IsBoolean({ groups: [ValidationsGroupsEnum.default] }) + @IsDefined({ groups: [ValidationsGroupsEnum.applicants] }) + @ApiPropertyOptional() + incomeVouchers?: boolean; + + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @MaxLength(64, { groups: [ValidationsGroupsEnum.default] }) + @IsDefined({ groups: [ValidationsGroupsEnum.applicants] }) + @ApiPropertyOptional() + income?: string; + + @Expose() + @IsEnum(IncomePeriodEnum, { groups: [ValidationsGroupsEnum.default] }) + @IsDefined({ groups: [ValidationsGroupsEnum.applicants] }) + @ApiPropertyOptional({ enum: IncomePeriodEnum, enumName: 'IncomePeriodEnum' }) + incomePeriod?: IncomePeriodEnum; + + @Expose() + @IsEnum(ApplicationStatusEnum, { groups: [ValidationsGroupsEnum.default] }) + @ApiProperty({ + enum: ApplicationStatusEnum, + enumName: 'ApplicationStatusEnum', + }) + status: ApplicationStatusEnum; + + @Expose() + @IsEnum(LanguagesEnum, { groups: [ValidationsGroupsEnum.default] }) + @IsDefined({ groups: [ValidationsGroupsEnum.applicants] }) + @ApiPropertyOptional({ enum: LanguagesEnum, enumName: 'LanguagesEnum' }) + language?: LanguagesEnum; + + @Expose() + @IsBoolean({ groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + acceptedTerms?: boolean; + + @Expose() + @IsEnum(ApplicationSubmissionTypeEnum, { + groups: [ValidationsGroupsEnum.default], + }) + @ApiProperty({ + enum: ApplicationSubmissionTypeEnum, + enumName: 'ApplicationSubmissionTypeEnum', + }) + submissionType: ApplicationSubmissionTypeEnum; + + @Expose() + @IsDate({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => Date) + @ApiPropertyOptional() + submissionDate?: Date; + + // if this field is true then the application is a confirmed duplicate + // meaning that the record in the applicaiton flagged set table has a status of duplicate + @Expose() + @IsBoolean({ groups: [ValidationsGroupsEnum.default] }) + @ApiProperty() + markedAsDuplicate: boolean; + + // This is a 'virtual field' needed for CSV export + // if this field is true then the application is a possible duplicate + // meaning there exists a record in the application_flagged_set table for this application + @Expose() + @IsBoolean({ groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + flagged?: boolean; + + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @ApiProperty() + confirmationCode: string; + + @Expose() + @IsEnum(ApplicationReviewStatusEnum, { + groups: [ValidationsGroupsEnum.default], + }) + @ApiProperty({ + enum: ApplicationReviewStatusEnum, + enumName: 'ApplicationReviewStatusEnum', + }) + @ApiPropertyOptional() + reviewStatus?: ApplicationReviewStatusEnum; + + @Expose() + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => Address) + @ApiProperty({ type: Address }) + applicationsMailingAddress: Address; + + @Expose() + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => Address) + @ApiProperty({ type: Address }) + applicationsAlternateAddress: Address; + + @Expose() + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => Accessibility) + @ApiProperty({ type: Accessibility }) + accessibility: Accessibility; + + @Expose() + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => Demographic) + @ApiProperty({ type: Demographic }) + demographics: Demographic; + + @Expose() + @ValidateNested({ groups: [ValidationsGroupsEnum.default], each: true }) + @Type(() => UnitType) + @ApiProperty({ type: UnitType, isArray: true }) + preferredUnitTypes: UnitType[]; + + @Expose() + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => Applicant) + @ApiProperty({ type: Applicant }) + applicant: Applicant; + + @Expose() + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => AlternateContact) + @ApiProperty({ type: AlternateContact }) + alternateContact: AlternateContact; + + @Expose() + @ValidateNested({ groups: [ValidationsGroupsEnum.default], each: true }) + @ArrayMaxSize(32, { groups: [ValidationsGroupsEnum.default] }) + @Type(() => HouseholdMember) + @ApiProperty({ type: HouseholdMember, isArray: true }) + householdMember: HouseholdMember[]; + + @Expose() + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ArrayMaxSize(64, { groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default], each: true }) + @Type(() => ApplicationMultiselectQuestion) + @ApiPropertyOptional({ + type: ApplicationMultiselectQuestion, + isArray: true, + }) + preferences?: ApplicationMultiselectQuestion[]; + + @Expose() + @ArrayMaxSize(64, { groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default], each: true }) + @Type(() => ApplicationMultiselectQuestion) + @ApiPropertyOptional({ + type: ApplicationMultiselectQuestion, + isArray: true, + }) + programs?: ApplicationMultiselectQuestion[]; + + @Expose() + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => IdDTO) + @ApiProperty({ type: IdDTO }) + listings: IdDTO; +} diff --git a/api/src/dtos/applications/demographic-update.dto.ts b/api/src/dtos/applications/demographic-update.dto.ts new file mode 100644 index 0000000000..cce6ccb577 --- /dev/null +++ b/api/src/dtos/applications/demographic-update.dto.ts @@ -0,0 +1,8 @@ +import { OmitType } from '@nestjs/swagger'; +import { Demographic } from './demographic.dto'; + +export class DemographicUpdate extends OmitType(Demographic, [ + 'id', + 'createdAt', + 'updatedAt', +]) {} diff --git a/api/src/dtos/applications/demographic.dto.ts b/api/src/dtos/applications/demographic.dto.ts new file mode 100644 index 0000000000..fd31688d5d --- /dev/null +++ b/api/src/dtos/applications/demographic.dto.ts @@ -0,0 +1,42 @@ +import { Expose } from 'class-transformer'; +import { ArrayMaxSize, IsDefined, IsString, MaxLength } from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { ValidationsGroupsEnum } from '../../enums/shared/validation-groups-enum'; +import { AbstractDTO } from '../shared/abstract.dto'; + +export class Demographic extends AbstractDTO { + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @MaxLength(64, { groups: [ValidationsGroupsEnum.default] }) + @IsDefined({ groups: [ValidationsGroupsEnum.applicants] }) + @ApiPropertyOptional() + ethnicity?: string; + + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @MaxLength(64, { groups: [ValidationsGroupsEnum.default] }) + @IsDefined({ groups: [ValidationsGroupsEnum.applicants] }) + @ApiPropertyOptional() + gender?: string; + + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @MaxLength(64, { groups: [ValidationsGroupsEnum.default] }) + @IsDefined({ groups: [ValidationsGroupsEnum.applicants] }) + @ApiPropertyOptional() + sexualOrientation?: string; + + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default], each: true }) + @ArrayMaxSize(64, { groups: [ValidationsGroupsEnum.default] }) + @MaxLength(64, { groups: [ValidationsGroupsEnum.default], each: true }) + @ApiProperty() + howDidYouHear: string[]; + + @Expose() + @ArrayMaxSize(64, { groups: [ValidationsGroupsEnum.default] }) + @IsString({ groups: [ValidationsGroupsEnum.default], each: true }) + @IsDefined({ groups: [ValidationsGroupsEnum.applicants] }) + @ApiProperty() + race?: string[]; +} diff --git a/api/src/dtos/applications/household-member-update.dto.ts b/api/src/dtos/applications/household-member-update.dto.ts new file mode 100644 index 0000000000..a892f2404b --- /dev/null +++ b/api/src/dtos/applications/household-member-update.dto.ts @@ -0,0 +1,30 @@ +import { ApiProperty, ApiPropertyOptional, OmitType } from '@nestjs/swagger'; +import { Expose, Type } from 'class-transformer'; +import { ValidateNested } from 'class-validator'; +import { ValidationsGroupsEnum } from '../../enums/shared/validation-groups-enum'; +import { AddressCreate } from '../addresses/address-create.dto'; +import { HouseholdMember } from './household-member.dto'; + +export class HouseholdMemberUpdate extends OmitType(HouseholdMember, [ + 'id', + 'createdAt', + 'updatedAt', + 'householdMemberAddress', + 'householdMemberWorkAddress', +]) { + @Expose() + @ApiPropertyOptional() + id?: string; + + @Expose() + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => AddressCreate) + @ApiProperty({ type: AddressCreate }) + householdMemberAddress: AddressCreate; + + @Expose() + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => AddressCreate) + @ApiPropertyOptional({ type: AddressCreate }) + householdMemberWorkAddress?: AddressCreate; +} diff --git a/api/src/dtos/applications/household-member.dto.ts b/api/src/dtos/applications/household-member.dto.ts new file mode 100644 index 0000000000..b723de0411 --- /dev/null +++ b/api/src/dtos/applications/household-member.dto.ts @@ -0,0 +1,92 @@ +import { Expose, Type } from 'class-transformer'; +import { + IsDefined, + IsEnum, + IsNumber, + IsString, + MaxLength, + ValidateNested, +} from 'class-validator'; +import { YesNoEnum } from '@prisma/client'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { ValidationsGroupsEnum } from '../../enums/shared/validation-groups-enum'; +import { AbstractDTO } from '../shared/abstract.dto'; +import { Address } from '../addresses/address.dto'; + +export class HouseholdMember extends AbstractDTO { + @Expose() + @IsNumber({}, { groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + orderId?: number; + + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @MaxLength(64, { groups: [ValidationsGroupsEnum.default] }) + @IsDefined({ groups: [ValidationsGroupsEnum.applicants] }) + @ApiPropertyOptional() + firstName?: string; + + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @MaxLength(64, { groups: [ValidationsGroupsEnum.default] }) + @IsDefined({ groups: [ValidationsGroupsEnum.applicants] }) + @ApiPropertyOptional() + middleName?: string; + + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @MaxLength(64, { groups: [ValidationsGroupsEnum.default] }) + @IsDefined({ groups: [ValidationsGroupsEnum.applicants] }) + @ApiPropertyOptional() + lastName?: string; + + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @MaxLength(8, { groups: [ValidationsGroupsEnum.default] }) + @IsDefined({ groups: [ValidationsGroupsEnum.applicants] }) + @ApiPropertyOptional() + birthMonth?: string; + + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @MaxLength(8, { groups: [ValidationsGroupsEnum.default] }) + @IsDefined({ groups: [ValidationsGroupsEnum.applicants] }) + @ApiPropertyOptional() + birthDay?: string; + + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @MaxLength(8, { groups: [ValidationsGroupsEnum.default] }) + @IsDefined({ groups: [ValidationsGroupsEnum.applicants] }) + @ApiPropertyOptional() + birthYear?: string; + + @Expose() + @IsEnum(YesNoEnum, { groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional({ enum: YesNoEnum, enumName: 'YesNoEnum' }) + sameAddress?: YesNoEnum; + + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @MaxLength(64, { groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + relationship?: string; + + @Expose() + @IsEnum(YesNoEnum, { groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional({ enum: YesNoEnum, enumName: 'YesNoEnum' }) + workInRegion?: YesNoEnum; + + @Expose() + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => Address) + @ApiPropertyOptional({ type: Address }) + householdMemberWorkAddress?: Address; + + @Expose() + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => Address) + @ApiProperty({ type: Address }) + householdMemberAddress: Address; +} diff --git a/api/src/dtos/applications/paginated-application.dto.ts b/api/src/dtos/applications/paginated-application.dto.ts new file mode 100644 index 0000000000..50dbe7304c --- /dev/null +++ b/api/src/dtos/applications/paginated-application.dto.ts @@ -0,0 +1,6 @@ +import { PaginationFactory } from '../shared/pagination.dto'; +import { Application } from './application.dto'; + +export class PaginatedApplicationDto extends PaginationFactory( + Application, +) {} diff --git a/api/src/dtos/assets/asset-create.dto.ts b/api/src/dtos/assets/asset-create.dto.ts new file mode 100644 index 0000000000..8540dcde2e --- /dev/null +++ b/api/src/dtos/assets/asset-create.dto.ts @@ -0,0 +1,19 @@ +import { ApiPropertyOptional, OmitType } from '@nestjs/swagger'; +import { Asset } from './asset.dto'; +import { ValidationsGroupsEnum } from '../../enums/shared/validation-groups-enum'; +import { IsString, IsUUID } from 'class-validator'; +import { Expose } from 'class-transformer'; + +export class AssetCreate extends OmitType(Asset, [ + 'id', + 'createdAt', + 'updatedAt', +]) { + // This field is optional since on update assets like images can be either new or an + // existing asset and we need to know the id in order to not recreate the object in the db + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @IsUUID(4, { groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + id?: string; +} diff --git a/api/src/dtos/assets/asset.dto.ts b/api/src/dtos/assets/asset.dto.ts new file mode 100644 index 0000000000..1d20bfd363 --- /dev/null +++ b/api/src/dtos/assets/asset.dto.ts @@ -0,0 +1,21 @@ +import { AbstractDTO } from '../shared/abstract.dto'; +import { Expose } from 'class-transformer'; +import { IsString, IsDefined, MaxLength } from 'class-validator'; +import { ValidationsGroupsEnum } from '../../enums/shared/validation-groups-enum'; +import { ApiProperty } from '@nestjs/swagger'; + +export class Asset extends AbstractDTO { + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @MaxLength(128, { groups: [ValidationsGroupsEnum.default] }) + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ApiProperty() + fileId: string; + + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @MaxLength(128, { groups: [ValidationsGroupsEnum.default] }) + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ApiProperty() + label: string; +} diff --git a/api/src/dtos/assets/create-presign-upload-meta-response.dto.ts b/api/src/dtos/assets/create-presign-upload-meta-response.dto.ts new file mode 100644 index 0000000000..e618414b0c --- /dev/null +++ b/api/src/dtos/assets/create-presign-upload-meta-response.dto.ts @@ -0,0 +1,8 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { Expose } from 'class-transformer'; + +export class CreatePresignedUploadMetadataResponse { + @Expose() + @ApiProperty() + signature: string; +} diff --git a/api/src/dtos/assets/create-presigned-upload-meta.dto.ts b/api/src/dtos/assets/create-presigned-upload-meta.dto.ts new file mode 100644 index 0000000000..59d6a2b8f7 --- /dev/null +++ b/api/src/dtos/assets/create-presigned-upload-meta.dto.ts @@ -0,0 +1,11 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { Expose } from 'class-transformer'; +import { IsDefined } from 'class-validator'; +import { ValidationsGroupsEnum } from '../../enums/shared/validation-groups-enum'; + +export class CreatePresignedUploadMetadata { + @Expose() + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ApiProperty() + parametersToSign: Record; +} diff --git a/api/src/dtos/auth/confirm.dto.ts b/api/src/dtos/auth/confirm.dto.ts new file mode 100644 index 0000000000..bd35d87da0 --- /dev/null +++ b/api/src/dtos/auth/confirm.dto.ts @@ -0,0 +1,22 @@ +import { IsString, Matches, MaxLength } from 'class-validator'; +import { Expose } from 'class-transformer'; +import { ValidationsGroupsEnum } from '../../enums/shared/validation-groups-enum'; +import { passwordRegex } from '../../utilities/password-regex'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export class Confirm { + @Expose() + @ApiProperty() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @MaxLength(256, { groups: [ValidationsGroupsEnum.default] }) + token: string; + + @Expose() + @ApiPropertyOptional() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @Matches(passwordRegex, { + message: 'passwordTooWeak', + groups: [ValidationsGroupsEnum.default], + }) + password?: string; +} diff --git a/api/src/dtos/auth/login.dto.ts b/api/src/dtos/auth/login.dto.ts new file mode 100644 index 0000000000..7765e91614 --- /dev/null +++ b/api/src/dtos/auth/login.dto.ts @@ -0,0 +1,30 @@ +import { IsEmail, IsString, MaxLength, IsEnum } from 'class-validator'; +import { Expose } from 'class-transformer'; +import { EnforceLowerCase } from '../../decorators/enforce-lower-case.decorator'; +import { ValidationsGroupsEnum } from '../../enums/shared/validation-groups-enum'; +import { MfaType } from '../../enums/mfa/mfa-type-enum'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export class Login { + @Expose() + @IsEmail({}, { groups: [ValidationsGroupsEnum.default] }) + @EnforceLowerCase() + @ApiProperty() + email: string; + + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @ApiProperty() + password: string; + + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @MaxLength(16, { groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + mfaCode?: string; + + @Expose() + @IsEnum(MfaType, { groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional({ enum: MfaType, enumName: 'MfaType' }) + mfaType?: MfaType; +} diff --git a/api/src/dtos/auth/update-password.dto.ts b/api/src/dtos/auth/update-password.dto.ts new file mode 100644 index 0000000000..20a52ca19f --- /dev/null +++ b/api/src/dtos/auth/update-password.dto.ts @@ -0,0 +1,30 @@ +import { IsString, Matches, MaxLength } from 'class-validator'; +import { Expose } from 'class-transformer'; +import { ValidationsGroupsEnum } from '../../enums/shared/validation-groups-enum'; +import { passwordRegex } from '../../utilities/password-regex'; +import { Match } from '../../decorators/match-decorator'; +import { ApiProperty } from '@nestjs/swagger'; + +export class UpdatePassword { + @Expose() + @ApiProperty() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @Matches(passwordRegex, { + message: 'passwordTooWeak', + groups: [ValidationsGroupsEnum.default], + }) + password: string; + + @Expose() + @ApiProperty() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @MaxLength(64, { groups: [ValidationsGroupsEnum.default] }) + @Match('password') + passwordConfirmation: string; + + @Expose() + @ApiProperty() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @MaxLength(256, { groups: [ValidationsGroupsEnum.default] }) + token: string; +} diff --git a/api/src/dtos/jurisdictions/jurisdiction-create.dto.ts b/api/src/dtos/jurisdictions/jurisdiction-create.dto.ts new file mode 100644 index 0000000000..3ccde8b9d5 --- /dev/null +++ b/api/src/dtos/jurisdictions/jurisdiction-create.dto.ts @@ -0,0 +1,4 @@ +import { OmitType } from '@nestjs/swagger'; +import { JurisdictionUpdate } from './jurisdiction-update.dto'; + +export class JurisdictionCreate extends OmitType(JurisdictionUpdate, ['id']) {} diff --git a/api/src/dtos/jurisdictions/jurisdiction-update.dto.ts b/api/src/dtos/jurisdictions/jurisdiction-update.dto.ts new file mode 100644 index 0000000000..a2a2f4077e --- /dev/null +++ b/api/src/dtos/jurisdictions/jurisdiction-update.dto.ts @@ -0,0 +1,8 @@ +import { OmitType } from '@nestjs/swagger'; +import { Jurisdiction } from './jurisdiction.dto'; + +export class JurisdictionUpdate extends OmitType(Jurisdiction, [ + 'createdAt', + 'updatedAt', + 'multiselectQuestions', +]) {} diff --git a/api/src/dtos/jurisdictions/jurisdiction.dto.ts b/api/src/dtos/jurisdictions/jurisdiction.dto.ts new file mode 100644 index 0000000000..a298872f3b --- /dev/null +++ b/api/src/dtos/jurisdictions/jurisdiction.dto.ts @@ -0,0 +1,111 @@ +import { AbstractDTO } from '../shared/abstract.dto'; +import { + IsString, + MaxLength, + IsDefined, + IsEnum, + ArrayMaxSize, + IsArray, + ValidateNested, + IsBoolean, +} from 'class-validator'; +import { ValidationsGroupsEnum } from '../../enums/shared/validation-groups-enum'; +import { LanguagesEnum, UserRoleEnum } from '@prisma/client'; +import { Expose, Type } from 'class-transformer'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IdDTO } from '../shared/id.dto'; + +export class Jurisdiction extends AbstractDTO { + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @MaxLength(256, { groups: [ValidationsGroupsEnum.default] }) + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ApiProperty() + name: string; + + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + notificationsSignUpUrl?: string; + + @Expose() + @IsArray({ groups: [ValidationsGroupsEnum.default] }) + @ArrayMaxSize(256, { groups: [ValidationsGroupsEnum.default] }) + @IsEnum(LanguagesEnum, { + groups: [ValidationsGroupsEnum.default], + each: true, + }) + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ApiProperty({ + enum: LanguagesEnum, + enumName: 'LanguagesEnum', + isArray: true, + }) + languages: LanguagesEnum[]; + + @Expose() + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => IdDTO) + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ApiProperty({ type: IdDTO, isArray: true }) + multiselectQuestions: IdDTO[]; + + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + partnerTerms?: string; + + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ApiProperty() + publicUrl: string; + + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ApiProperty() + emailFromAddress: string; + + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ApiProperty() + rentalAssistanceDefault: string; + + @Expose() + @IsBoolean({ groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + enablePartnerSettings?: boolean; + + @Expose() + @IsBoolean({ groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + enableGeocodingPreferences?: boolean; + + @Expose() + @IsBoolean({ groups: [ValidationsGroupsEnum.default] }) + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ApiProperty() + enableAccessibilityFeatures: boolean; + + @Expose() + @IsBoolean({ groups: [ValidationsGroupsEnum.default] }) + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ApiProperty() + enableUtilitiesIncluded: boolean; + + @Expose() + @IsArray({ groups: [ValidationsGroupsEnum.default] }) + @IsEnum(UserRoleEnum, { + groups: [ValidationsGroupsEnum.default], + each: true, + }) + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ApiProperty({ + enum: UserRoleEnum, + example: [UserRoleEnum.admin], + isArray: true, + }) + listingApprovalPermissions: UserRoleEnum[]; +} diff --git a/api/src/dtos/listings/listing-create.dto.ts b/api/src/dtos/listings/listing-create.dto.ts new file mode 100644 index 0000000000..9adbd0ebb2 --- /dev/null +++ b/api/src/dtos/listings/listing-create.dto.ts @@ -0,0 +1,4 @@ +import { OmitType } from '@nestjs/swagger'; +import { ListingUpdate } from './listing-update.dto'; + +export class ListingCreate extends OmitType(ListingUpdate, ['id']) {} diff --git a/api/src/dtos/listings/listing-csv-query-params.dto.ts b/api/src/dtos/listings/listing-csv-query-params.dto.ts new file mode 100644 index 0000000000..413a0db122 --- /dev/null +++ b/api/src/dtos/listings/listing-csv-query-params.dto.ts @@ -0,0 +1,16 @@ +import { Expose } from 'class-transformer'; +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsOptional, IsString } from 'class-validator'; +import { ValidationsGroupsEnum } from '../../enums/shared/validation-groups-enum'; + +export class ListingCsvQueryParams { + @Expose() + @ApiPropertyOptional({ + type: String, + example: 'America/Los_Angeles', + required: false, + }) + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + timeZone?: string; +} diff --git a/api/src/dtos/listings/listing-event-create.dto.ts b/api/src/dtos/listings/listing-event-create.dto.ts new file mode 100644 index 0000000000..76b61e8777 --- /dev/null +++ b/api/src/dtos/listings/listing-event-create.dto.ts @@ -0,0 +1,19 @@ +import { ApiPropertyOptional, OmitType } from '@nestjs/swagger'; +import { Expose, Type } from 'class-transformer'; +import { ValidateNested } from 'class-validator'; +import { ValidationsGroupsEnum } from '../../enums/shared/validation-groups-enum'; +import { AssetCreate } from '../assets/asset-create.dto'; +import { ListingEvent } from './listing-event.dto'; + +export class ListingEventCreate extends OmitType(ListingEvent, [ + 'id', + 'createdAt', + 'updatedAt', + 'assets', +]) { + @Expose() + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => AssetCreate) + @ApiPropertyOptional({ type: AssetCreate }) + assets?: AssetCreate; +} diff --git a/api/src/dtos/listings/listing-event.dto.ts b/api/src/dtos/listings/listing-event.dto.ts new file mode 100644 index 0000000000..494b54c76f --- /dev/null +++ b/api/src/dtos/listings/listing-event.dto.ts @@ -0,0 +1,63 @@ +import { Expose, Type } from 'class-transformer'; +import { + IsDate, + IsEnum, + IsDefined, + IsString, + ValidateNested, +} from 'class-validator'; +import { ValidationsGroupsEnum } from '../../enums/shared/validation-groups-enum'; +import { ListingEventsTypeEnum } from '@prisma/client'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { AbstractDTO } from '../shared/abstract.dto'; +import { Asset } from '../assets/asset.dto'; + +export class ListingEvent extends AbstractDTO { + @Expose() + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @IsEnum(ListingEventsTypeEnum, { groups: [ValidationsGroupsEnum.default] }) + @ApiProperty({ + enum: ListingEventsTypeEnum, + enumName: 'ListingEventsTypeEnum', + }) + type: ListingEventsTypeEnum; + + @Expose() + @IsDate({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => Date) + @ApiPropertyOptional() + startDate?: Date; + + @Expose() + @IsDate({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => Date) + @ApiPropertyOptional() + startTime?: Date; + + @Expose() + @IsDate({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => Date) + @ApiPropertyOptional() + endTime?: Date; + + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + url?: string; + + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + note?: string; + + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + label?: string; + + @Expose() + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => Asset) + @ApiPropertyOptional({ type: Asset }) + assets?: Asset; +} diff --git a/api/src/dtos/listings/listing-feature.dto.ts b/api/src/dtos/listings/listing-feature.dto.ts new file mode 100644 index 0000000000..27085834f3 --- /dev/null +++ b/api/src/dtos/listings/listing-feature.dto.ts @@ -0,0 +1,81 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { Expose } from 'class-transformer'; +import { IsBoolean } from 'class-validator'; +import { ValidationsGroupsEnum } from '../../enums/shared/validation-groups-enum'; + +export class ListingFeatures { + @Expose() + @IsBoolean({ groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + elevator?: boolean; + + @Expose() + @IsBoolean({ groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + wheelchairRamp?: boolean; + + @Expose() + @IsBoolean({ groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + serviceAnimalsAllowed?: boolean; + + @Expose() + @IsBoolean({ groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + accessibleParking?: boolean; + + @Expose() + @IsBoolean({ groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + parkingOnSite?: boolean; + + @Expose() + @IsBoolean({ groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + inUnitWasherDryer?: boolean; + + @Expose() + @IsBoolean({ groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + laundryInBuilding?: boolean; + + @Expose() + @IsBoolean({ groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + barrierFreeEntrance?: boolean; + + @Expose() + @IsBoolean({ groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + rollInShower?: boolean; + + @Expose() + @IsBoolean({ groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + grabBars?: boolean; + + @Expose() + @IsBoolean({ groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + heatingInUnit?: boolean; + + @Expose() + @IsBoolean({ groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + acInUnit?: boolean; + + @Expose() + @IsBoolean({ groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + hearing?: boolean; + + @Expose() + @IsBoolean({ groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + visual?: boolean; + + @Expose() + @IsBoolean({ groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + mobility?: boolean; +} diff --git a/api/src/dtos/listings/listing-image-create.dto.ts b/api/src/dtos/listings/listing-image-create.dto.ts new file mode 100644 index 0000000000..9390ea5979 --- /dev/null +++ b/api/src/dtos/listings/listing-image-create.dto.ts @@ -0,0 +1,15 @@ +import { ApiProperty, OmitType } from '@nestjs/swagger'; +import { Expose, Type } from 'class-transformer'; +import { IsDefined, ValidateNested } from 'class-validator'; +import { ValidationsGroupsEnum } from '../../enums/shared/validation-groups-enum'; +import { AssetCreate } from '../assets/asset-create.dto'; +import { ListingImage } from './listing-image.dto'; + +export class ListingImageCreate extends OmitType(ListingImage, ['assets']) { + @Expose() + @ValidateNested({ groups: [ValidationsGroupsEnum.default], each: true }) + @Type(() => AssetCreate) + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ApiProperty({ type: AssetCreate }) + assets: AssetCreate; +} diff --git a/api/src/dtos/listings/listing-image.dto.ts b/api/src/dtos/listings/listing-image.dto.ts new file mode 100644 index 0000000000..73d90e8143 --- /dev/null +++ b/api/src/dtos/listings/listing-image.dto.ts @@ -0,0 +1,18 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Expose, Type } from 'class-transformer'; +import { IsNumber, IsDefined } from 'class-validator'; +import { ValidationsGroupsEnum } from '../../enums/shared/validation-groups-enum'; +import { Asset } from '../assets/asset.dto'; + +export class ListingImage { + @Expose() + @Type(() => Asset) + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ApiProperty({ type: Asset }) + assets: Asset; + + @Expose() + @IsNumber({}, { groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + ordinal?: number; +} diff --git a/api/src/dtos/listings/listing-multiselect-question.dto.ts b/api/src/dtos/listings/listing-multiselect-question.dto.ts new file mode 100644 index 0000000000..44be13d1f7 --- /dev/null +++ b/api/src/dtos/listings/listing-multiselect-question.dto.ts @@ -0,0 +1,18 @@ +import { MultiselectQuestion } from '../multiselect-questions/multiselect-question.dto'; +import { Expose, Type } from 'class-transformer'; +import { IsNumber, IsDefined } from 'class-validator'; +import { ValidationsGroupsEnum } from '../../enums/shared/validation-groups-enum'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export class ListingMultiselectQuestion { + @Expose() + @Type(() => MultiselectQuestion) + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ApiProperty({ type: MultiselectQuestion }) + multiselectQuestions: MultiselectQuestion; + + @Expose() + @IsNumber({}, { groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + ordinal?: number; +} diff --git a/api/src/dtos/listings/listing-published-create.dto.ts b/api/src/dtos/listings/listing-published-create.dto.ts new file mode 100644 index 0000000000..6afee389ac --- /dev/null +++ b/api/src/dtos/listings/listing-published-create.dto.ts @@ -0,0 +1,6 @@ +import { OmitType } from '@nestjs/swagger'; +import { ListingPublishedUpdate } from './listing-published-update.dto'; + +export class ListingPublishedCreate extends OmitType(ListingPublishedUpdate, [ + 'id', +]) {} diff --git a/api/src/dtos/listings/listing-published-update.dto.ts b/api/src/dtos/listings/listing-published-update.dto.ts new file mode 100644 index 0000000000..445759f9b6 --- /dev/null +++ b/api/src/dtos/listings/listing-published-update.dto.ts @@ -0,0 +1,153 @@ +import { OmitType } from '@nestjs/swagger'; +import { Expose, Type } from 'class-transformer'; +import { + ArrayMaxSize, + ArrayMinSize, + IsBoolean, + IsDefined, + IsEmail, + IsEnum, + IsPhoneNumber, + IsString, + MaxLength, + ValidateNested, +} from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; +import { ValidationsGroupsEnum } from '../../enums/shared/validation-groups-enum'; +import { ListingUpdate } from './listing-update.dto'; +import { UnitCreate } from '../units/unit-create.dto'; +import { AssetCreate } from '../assets/asset-create.dto'; +import { ListingImageCreate } from './listing-image-create.dto'; +import { AddressCreate } from '../addresses/address-create.dto'; +import { EnforceLowerCase } from '../../decorators/enforce-lower-case.decorator'; +import { ReviewOrderTypeEnum } from '@prisma/client'; + +export class ListingPublishedUpdate extends OmitType(ListingUpdate, [ + 'assets', + 'depositMax', + 'depositMin', + 'developer', + 'digitalApplication', + 'listingImages', + 'leasingAgentEmail', + 'leasingAgentName', + 'leasingAgentPhone', + 'name', + 'paperApplication', + 'referralOpportunity', + 'rentalAssistance', + 'reviewOrderType', + 'units', + 'listingsBuildingAddress', +]) { + @Expose() + @ValidateNested({ groups: [ValidationsGroupsEnum.default], each: true }) + @Type(() => AssetCreate) + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ApiProperty({ type: AssetCreate, isArray: true }) + assets: AssetCreate[]; + + @Expose() + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => AddressCreate) + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ApiProperty({ type: AddressCreate }) + listingsBuildingAddress: AddressCreate; + + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @MaxLength(64, { groups: [ValidationsGroupsEnum.default] }) + @ApiProperty() + depositMin: string; + + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @MaxLength(64, { groups: [ValidationsGroupsEnum.default] }) + @ApiProperty() + depositMax: string; + + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @MaxLength(256, { groups: [ValidationsGroupsEnum.default] }) + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ApiProperty() + developer: string; + + @Expose() + @IsBoolean({ groups: [ValidationsGroupsEnum.default] }) + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ApiProperty() + digitalApplication: boolean; + + @Expose() + @ValidateNested({ groups: [ValidationsGroupsEnum.default], each: true }) + @Type(() => ListingImageCreate) + @ArrayMinSize(1, { groups: [ValidationsGroupsEnum.default] }) + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ApiProperty({ type: ListingImageCreate, isArray: true }) + listingImages: ListingImageCreate[]; + + @Expose() + @IsEmail({}, { groups: [ValidationsGroupsEnum.default] }) + @EnforceLowerCase() + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ApiProperty() + leasingAgentEmail: string; + + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @MaxLength(256, { groups: [ValidationsGroupsEnum.default] }) + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ApiProperty() + leasingAgentName: string; + + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @IsPhoneNumber('US', { groups: [ValidationsGroupsEnum.default] }) + @MaxLength(64, { groups: [ValidationsGroupsEnum.default] }) + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ApiProperty() + leasingAgentPhone: string; + + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @MaxLength(256, { groups: [ValidationsGroupsEnum.default] }) + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ApiProperty() + name: string; + + @Expose() + @IsBoolean({ groups: [ValidationsGroupsEnum.default] }) + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ApiProperty() + paperApplication: boolean; + + @Expose() + @IsBoolean({ groups: [ValidationsGroupsEnum.default] }) + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ApiProperty() + referralOpportunity: boolean; + + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @MaxLength(4096, { groups: [ValidationsGroupsEnum.default] }) + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ApiProperty() + rentalAssistance: string; + + @Expose() + @IsEnum(ReviewOrderTypeEnum, { groups: [ValidationsGroupsEnum.default] }) + @ApiProperty({ + enum: ReviewOrderTypeEnum, + enumName: 'ReviewOrderTypeEnum', + }) + reviewOrderType: ReviewOrderTypeEnum; + + @Expose() + @ApiProperty({ isArray: true, type: UnitCreate }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default], each: true }) + @ArrayMinSize(1, { groups: [ValidationsGroupsEnum.default] }) + @ArrayMaxSize(256, { groups: [ValidationsGroupsEnum.default] }) + @Type(() => UnitCreate) + units: UnitCreate[]; +} diff --git a/api/src/dtos/listings/listing-update.dto.ts b/api/src/dtos/listings/listing-update.dto.ts new file mode 100644 index 0000000000..b3229813fb --- /dev/null +++ b/api/src/dtos/listings/listing-update.dto.ts @@ -0,0 +1,155 @@ +import { ApiPropertyOptional, OmitType } from '@nestjs/swagger'; +import { Expose, Type } from 'class-transformer'; +import { IsDefined, ValidateNested } from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; +import { ValidationsGroupsEnum } from '../../enums/shared/validation-groups-enum'; +import { IdDTO } from '../shared/id.dto'; +import { Listing } from './listing.dto'; +import { UnitCreate } from '../units/unit-create.dto'; +import { ApplicationMethodCreate } from '../application-methods/application-method-create.dto'; +import { AssetCreate } from '../assets/asset-create.dto'; +import { UnitsSummaryCreate } from '../units/units-summary-create.dto'; +import { ListingImageCreate } from './listing-image-create.dto'; +import { AddressCreate } from '../addresses/address-create.dto'; +import { ListingEventCreate } from './listing-event-create.dto'; +import { ListingFeatures } from './listing-feature.dto'; +import { ListingUtilities } from './listing-utility.dto'; + +export class ListingUpdate extends OmitType(Listing, [ + // fields get their type changed + 'listingMultiselectQuestions', + 'units', + 'applicationMethods', + 'assets', + 'unitsSummary', + 'listingImages', + 'listingsResult', + 'listingsApplicationPickUpAddress', + 'listingsApplicationMailingAddress', + 'listingsApplicationDropOffAddress', + 'listingsLeasingAgentAddress', + 'listingsBuildingAddress', + 'listingsBuildingSelectionCriteriaFile', + 'listingEvents', + 'listingFeatures', + 'listingUtilities', + 'requestedChangesUser', + + // fields removed entirely + 'createdAt', + 'updatedAt', + 'referralApplication', + 'publishedAt', + 'showWaitlist', + 'unitsSummarized', + 'closedAt', + 'afsLastRunAt', + 'urlSlug', + 'applicationConfig', +]) { + @Expose() + @ValidateNested({ groups: [ValidationsGroupsEnum.default], each: true }) + @Type(() => IdDTO) + @ApiPropertyOptional({ type: IdDTO, isArray: true }) + listingMultiselectQuestions?: IdDTO[]; + + @Expose() + @ValidateNested({ groups: [ValidationsGroupsEnum.default], each: true }) + @Type(() => UnitCreate) + @ApiPropertyOptional({ type: UnitCreate, isArray: true }) + units?: UnitCreate[]; + + @Expose() + @ValidateNested({ groups: [ValidationsGroupsEnum.default], each: true }) + @Type(() => ApplicationMethodCreate) + @ApiPropertyOptional({ + type: ApplicationMethodCreate, + isArray: true, + }) + applicationMethods?: ApplicationMethodCreate[]; + + @Expose() + @ValidateNested({ groups: [ValidationsGroupsEnum.default], each: true }) + @Type(() => AssetCreate) + @ApiPropertyOptional({ type: AssetCreate, isArray: true }) + assets?: AssetCreate[]; + + @Expose() + @ValidateNested({ groups: [ValidationsGroupsEnum.default], each: true }) + @ApiProperty({ type: UnitsSummaryCreate, isArray: true }) + @Type(() => UnitsSummaryCreate) + unitsSummary: UnitsSummaryCreate[]; + + @Expose() + @ValidateNested({ groups: [ValidationsGroupsEnum.default], each: true }) + @Type(() => ListingImageCreate) + @ApiPropertyOptional({ type: ListingImageCreate, isArray: true }) + listingImages?: ListingImageCreate[]; + + @Expose() + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => AddressCreate) + @ApiPropertyOptional({ type: AddressCreate }) + listingsApplicationPickUpAddress?: AddressCreate; + + @Expose() + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => AddressCreate) + @ApiPropertyOptional({ type: AddressCreate }) + listingsApplicationMailingAddress?: AddressCreate; + + @Expose() + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => AddressCreate) + @ApiPropertyOptional({ type: AddressCreate }) + listingsApplicationDropOffAddress?: AddressCreate; + + @Expose() + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => AddressCreate) + @ApiPropertyOptional({ type: AddressCreate }) + listingsLeasingAgentAddress?: AddressCreate; + + @Expose() + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => AddressCreate) + @ApiPropertyOptional({ type: AddressCreate }) + listingsBuildingAddress?: AddressCreate; + + @Expose() + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => AssetCreate) + @ApiPropertyOptional({ type: AssetCreate }) + listingsBuildingSelectionCriteriaFile?: AssetCreate; + + @Expose() + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => AssetCreate) + @ApiPropertyOptional({ type: AssetCreate }) + listingsResult?: AssetCreate; + + @Expose() + @ValidateNested({ groups: [ValidationsGroupsEnum.default], each: true }) + @Type(() => ListingEventCreate) + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ApiProperty({ type: ListingEventCreate, isArray: true }) + listingEvents: ListingEventCreate[]; + + @Expose() + @ValidateNested({ groups: [ValidationsGroupsEnum.default], each: true }) + @Type(() => ListingFeatures) + @ApiPropertyOptional({ type: ListingFeatures }) + listingFeatures?: ListingFeatures; + + @Expose() + @ValidateNested({ groups: [ValidationsGroupsEnum.default], each: true }) + @Type(() => ListingUtilities) + @ApiPropertyOptional({ type: ListingUtilities }) + listingUtilities?: ListingUtilities; + + @Expose() + @ApiPropertyOptional() + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => IdDTO) + requestedChangesUser?: IdDTO; +} diff --git a/api/src/dtos/listings/listing-utility.dto.ts b/api/src/dtos/listings/listing-utility.dto.ts new file mode 100644 index 0000000000..714938cd01 --- /dev/null +++ b/api/src/dtos/listings/listing-utility.dto.ts @@ -0,0 +1,46 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { Expose } from 'class-transformer'; +import { IsBoolean } from 'class-validator'; +import { ValidationsGroupsEnum } from '../../enums/shared/validation-groups-enum'; + +export class ListingUtilities { + @Expose() + @IsBoolean({ groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + water?: boolean; + + @Expose() + @IsBoolean({ groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + gas?: boolean; + + @Expose() + @IsBoolean({ groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + trash?: boolean; + + @Expose() + @IsBoolean({ groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + sewer?: boolean; + + @Expose() + @IsBoolean({ groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + electricity?: boolean; + + @Expose() + @IsBoolean({ groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + cable?: boolean; + + @Expose() + @IsBoolean({ groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + phone?: boolean; + + @Expose() + @IsBoolean({ groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + internet?: boolean; +} diff --git a/api/src/dtos/listings/listing.dto.ts b/api/src/dtos/listings/listing.dto.ts new file mode 100644 index 0000000000..307084d9d1 --- /dev/null +++ b/api/src/dtos/listings/listing.dto.ts @@ -0,0 +1,554 @@ +import { Expose, Transform, TransformFnParams, Type } from 'class-transformer'; +import { + IsBoolean, + IsDate, + IsDefined, + IsEmail, + IsEnum, + IsNumber, + IsString, + MaxLength, + ValidateNested, +} from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { ValidationsGroupsEnum } from '../../enums/shared/validation-groups-enum'; +import { AbstractDTO } from '../shared/abstract.dto'; +import { + ApplicationAddressTypeEnum, + ApplicationMethodsTypeEnum, + ListingsStatusEnum, + ReviewOrderTypeEnum, +} from '@prisma/client'; +import { EnforceLowerCase } from '../../decorators/enforce-lower-case.decorator'; +import { ListingMultiselectQuestion } from './listing-multiselect-question.dto'; +import { ApplicationMethod } from '../application-methods/application-method.dto'; +import { Asset } from '../assets/asset.dto'; +import { ListingEvent } from './listing-event.dto'; +import { Address } from '../addresses/address.dto'; +import { ListingImage } from './listing-image.dto'; +import { ListingFeatures } from './listing-feature.dto'; +import { ListingUtilities } from './listing-utility.dto'; +import { Unit } from '../units/unit.dto'; +import { UnitsSummarized } from '../units/unit-summarized.dto'; +import { UnitsSummary } from '../units/units-summary.dto'; +import { IdDTO } from '../shared/id.dto'; +import { listingUrlSlug } from '../../utilities/listing-url-slug'; +import { User } from '../users/user.dto'; + +class Listing extends AbstractDTO { + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + additionalApplicationSubmissionNotes?: string; + + @Expose() + @IsBoolean({ groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + digitalApplication?: boolean; + + @Expose() + @IsBoolean({ groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + commonDigitalApplication?: boolean; + + @Expose() + @IsBoolean({ groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + paperApplication?: boolean; + + @Expose() + @IsBoolean({ groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + referralOpportunity?: boolean; + + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + accessibility?: string; + + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + amenities?: string; + + @Expose() + @IsNumber({}, { groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + buildingTotalUnits?: number; + + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + developer?: string; + + @Expose() + @IsNumber({}, { groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + householdSizeMax?: number; + + @Expose() + @IsNumber({}, { groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + householdSizeMin?: number; + + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + neighborhood?: string; + + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + petPolicy?: string; + + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + smokingPolicy?: string; + + @Expose() + @IsNumber({}, { groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + unitsAvailable?: number; + + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + unitAmenities?: string; + + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + servicesOffered?: string; + + @Expose() + @IsNumber({}, { groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + yearBuilt?: number; + + @Expose() + @IsDate({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => Date) + @ApiPropertyOptional() + applicationDueDate?: Date; + + @Expose() + @IsDate({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => Date) + @ApiPropertyOptional() + applicationOpenDate?: Date; + + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + applicationFee?: string; + + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + applicationOrganization?: string; + + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + applicationPickUpAddressOfficeHours?: string; + + @Expose() + @IsEnum(ApplicationAddressTypeEnum, { + groups: [ValidationsGroupsEnum.default], + }) + @ApiPropertyOptional({ + enum: ApplicationAddressTypeEnum, + enumName: 'ApplicationAddressTypeEnum', + }) + applicationPickUpAddressType?: ApplicationAddressTypeEnum; + + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + applicationDropOffAddressOfficeHours?: string; + + @Expose() + @IsEnum(ApplicationAddressTypeEnum, { + groups: [ValidationsGroupsEnum.default], + }) + @ApiPropertyOptional({ + enum: ApplicationAddressTypeEnum, + enumName: 'ApplicationAddressTypeEnum', + }) + applicationDropOffAddressType?: ApplicationAddressTypeEnum; + + @Expose() + @IsEnum(ApplicationAddressTypeEnum, { + groups: [ValidationsGroupsEnum.default], + }) + @ApiPropertyOptional({ + enum: ApplicationAddressTypeEnum, + enumName: 'ApplicationAddressTypeEnum', + }) + applicationMailingAddressType?: ApplicationAddressTypeEnum; + + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + buildingSelectionCriteria?: string; + + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + costsNotIncluded?: string; + + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + creditHistory?: string; + + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + criminalBackground?: string; + + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + depositMin?: string; + + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + depositMax?: string; + + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + depositHelperText?: string; + + @Expose() + @IsBoolean({ groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + disableUnitsAccordion?: boolean; + + @Expose() + @IsEmail({}, { groups: [ValidationsGroupsEnum.default] }) + @EnforceLowerCase() + @ApiPropertyOptional() + leasingAgentEmail?: string; + + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + leasingAgentName?: string; + + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + leasingAgentOfficeHours?: string; + + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + leasingAgentPhone?: string; + + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + leasingAgentTitle?: string; + + @Expose() + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @ApiProperty() + name: string; + + @Expose() + @IsDate({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => Date) + @ApiPropertyOptional() + postmarkedApplicationsReceivedByDate?: Date; + + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + programRules?: string; + + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + rentalAssistance?: string; + + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + rentalHistory?: string; + + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + requiredDocuments?: string; + + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + specialNotes?: string; + + @Expose() + @IsNumber({}, { groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + waitlistCurrentSize?: number; + + @Expose() + @IsNumber({}, { groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + waitlistMaxSize?: number; + + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + whatToExpect?: string; + + @Expose() + @IsEnum(ListingsStatusEnum, { groups: [ValidationsGroupsEnum.default] }) + @ApiProperty({ + enum: ListingsStatusEnum, + enumName: 'ListingsStatusEnum', + }) + status: ListingsStatusEnum; + + @Expose() + @IsEnum(ReviewOrderTypeEnum, { groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional({ + enum: ReviewOrderTypeEnum, + enumName: 'ReviewOrderTypeEnum', + }) + reviewOrderType?: ReviewOrderTypeEnum; + + @Expose() + @ApiPropertyOptional() + applicationConfig?: Record; + + @Expose() + @IsBoolean({ groups: [ValidationsGroupsEnum.default] }) + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ApiProperty() + displayWaitlistSize: boolean; + + @Expose() + @ApiPropertyOptional() + get showWaitlist(): boolean { + return ( + this.waitlistMaxSize !== null && + this.waitlistCurrentSize !== null && + this.waitlistCurrentSize < this.waitlistMaxSize + ); + } + + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @MaxLength(4096, { groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + reservedCommunityDescription?: string; + + @Expose() + @IsNumber({}, { groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + reservedCommunityMinAge?: number; + + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @MaxLength(4096, { groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + resultLink?: string; + + @Expose() + @IsBoolean({ groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + isWaitlistOpen?: boolean; + + @Expose() + @IsNumber({}, { groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + waitlistOpenSpots?: number; + + @Expose() + @IsBoolean({ groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + customMapPin?: boolean; + + @Expose() + @IsDate({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => Date) + @ApiPropertyOptional() + publishedAt?: Date; + + @Expose() + @IsDate({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => Date) + @ApiPropertyOptional() + closedAt?: Date; + + @Expose() + @IsDate({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => Date) + @ApiPropertyOptional() + afsLastRunAt?: Date; + + @Expose() + @IsDate({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => Date) + @ApiPropertyOptional() + lastApplicationUpdateAt?: Date; + + @Expose() + @ValidateNested({ groups: [ValidationsGroupsEnum.default], each: true }) + @Type(() => ListingMultiselectQuestion) + @ApiPropertyOptional({ + type: ListingMultiselectQuestion, + isArray: true, + }) + listingMultiselectQuestions?: ListingMultiselectQuestion[]; + + @Expose() + @ValidateNested({ groups: [ValidationsGroupsEnum.default], each: true }) + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => ApplicationMethod) + @ApiProperty({ type: ApplicationMethod, isArray: true }) + applicationMethods: ApplicationMethod[]; + + @Expose() + @ApiPropertyOptional() + get referralApplication(): ApplicationMethod | undefined { + return this.applicationMethods?.find( + (method) => method.type === ApplicationMethodsTypeEnum.Referral, + ); + } + + // This is no longer needed and should be removed https://github.com/bloom-housing/bloom/issues/3747 + @Expose() + @ValidateNested({ groups: [ValidationsGroupsEnum.default], each: true }) + @Type(() => Asset) + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ApiProperty({ type: Asset, isArray: true }) + assets: Asset[]; + + @Expose() + @ValidateNested({ groups: [ValidationsGroupsEnum.default], each: true }) + @Type(() => ListingEvent) + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ApiProperty({ type: ListingEvent, isArray: true }) + listingEvents: ListingEvent[]; + + @Expose() + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => Address) + @ApiProperty({ type: Address }) + listingsBuildingAddress: Address; + + @Expose() + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => Address) + @ApiPropertyOptional({ type: Address }) + listingsApplicationPickUpAddress?: Address; + + @Expose() + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => Address) + @ApiPropertyOptional({ type: Address }) + listingsApplicationDropOffAddress?: Address; + + @Expose() + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => Address) + @ApiPropertyOptional({ type: Address }) + listingsApplicationMailingAddress?: Address; + + @Expose() + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => Address) + @ApiPropertyOptional({ type: Address }) + listingsLeasingAgentAddress?: Address; + + @Expose() + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => Asset) + @ApiPropertyOptional({ type: Asset }) + listingsBuildingSelectionCriteriaFile?: Asset; + + @Expose() + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => IdDTO) + @ApiProperty({ type: IdDTO }) + jurisdictions: IdDTO; + + @Expose() + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => Asset) + @ApiPropertyOptional({ type: Asset }) + listingsResult?: Asset; + + @Expose() + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => IdDTO) + @ApiPropertyOptional({ type: IdDTO }) + reservedCommunityTypes?: IdDTO; + + @Expose() + @ValidateNested({ groups: [ValidationsGroupsEnum.default], each: true }) + @Type(() => ListingImage) + @ApiPropertyOptional({ type: ListingImage, isArray: true }) + listingImages?: ListingImage[]; + + @Expose() + @ValidateNested({ groups: [ValidationsGroupsEnum.default], each: true }) + @Type(() => ListingFeatures) + @ApiPropertyOptional({ type: ListingFeatures }) + listingFeatures?: ListingFeatures; + + @Expose() + @ValidateNested({ groups: [ValidationsGroupsEnum.default], each: true }) + @Type(() => ListingUtilities) + @ApiPropertyOptional({ type: ListingUtilities }) + listingUtilities?: ListingUtilities; + + @Expose() + @ValidateNested({ groups: [ValidationsGroupsEnum.default], each: true }) + @Type(() => Unit) + @ApiProperty({ type: Unit, isArray: true }) + units: Unit[]; + + @Expose() + @ApiPropertyOptional({ type: UnitsSummarized }) + unitsSummarized?: UnitsSummarized; + + @Expose() + @ValidateNested({ groups: [ValidationsGroupsEnum.default], each: true }) + @ApiPropertyOptional({ type: UnitsSummary, isArray: true }) + @Type(() => UnitsSummary) + unitsSummary?: UnitsSummary[]; + + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + @Transform((value: TransformFnParams) => listingUrlSlug(value.obj as Listing)) + urlSlug?: string; + + @Expose() + @ApiPropertyOptional() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + requestedChanges?: string; + + @Expose() + @ApiPropertyOptional() + @IsDate({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => Date) + requestedChangesDate?: Date; + + @Expose() + @ApiPropertyOptional() + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => User) + requestedChangesUser?: User; +} + +export { Listing as default, Listing }; diff --git a/api/src/dtos/listings/listings-filter-params.dto.ts b/api/src/dtos/listings/listings-filter-params.dto.ts new file mode 100644 index 0000000000..54ea0ba69e --- /dev/null +++ b/api/src/dtos/listings/listings-filter-params.dto.ts @@ -0,0 +1,59 @@ +import { BaseFilter } from '../shared/base-filter.dto'; +import { Expose } from 'class-transformer'; +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsEnum, IsNumberString, IsString } from 'class-validator'; +import { ValidationsGroupsEnum } from '../../enums/shared/validation-groups-enum'; +import { ListingFilterKeys } from '../../enums/listings/filter-key-enum'; +import { ListingsStatusEnum } from '@prisma/client'; + +export class ListingFilterParams extends BaseFilter { + @Expose() + @ApiPropertyOptional({ + example: 'Coliseum', + }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + [ListingFilterKeys.name]?: string; + + @Expose() + @ApiPropertyOptional({ + enum: ListingsStatusEnum, + enumName: 'ListingsStatusEnum', + example: 'active', + }) + @IsEnum(ListingsStatusEnum, { groups: [ValidationsGroupsEnum.default] }) + [ListingFilterKeys.status]?: ListingsStatusEnum; + + @Expose() + @ApiPropertyOptional({ + example: 'Fox Creek', + }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + [ListingFilterKeys.neighborhood]?: string; + + @Expose() + @ApiPropertyOptional({ + example: '3', + }) + @IsNumberString({}, { groups: [ValidationsGroupsEnum.default] }) + [ListingFilterKeys.bedrooms]?: number; + + @Expose() + @ApiPropertyOptional({ + example: '48211', + }) + [ListingFilterKeys.zipcode]?: string; + + @Expose() + @ApiPropertyOptional({ + example: 'FAB1A3C6-965E-4054-9A48-A282E92E9426', + }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + [ListingFilterKeys.leasingAgents]?: string; + + @Expose() + @ApiPropertyOptional({ + example: 'bab6cb4f-7a5a-4ee5-b327-0c2508807780', + }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + [ListingFilterKeys.jurisdiction]?: string; +} diff --git a/api/src/dtos/listings/listings-query-params.dto.ts b/api/src/dtos/listings/listings-query-params.dto.ts new file mode 100644 index 0000000000..dfed815015 --- /dev/null +++ b/api/src/dtos/listings/listings-query-params.dto.ts @@ -0,0 +1,92 @@ +import { PaginationAllowsAllQueryParams } from '../shared/pagination.dto'; +import { Expose, Transform, Type } from 'class-transformer'; +import { ApiPropertyOptional, getSchemaPath } from '@nestjs/swagger'; +import { ListingFilterParams } from './listings-filter-params.dto'; +import { + ArrayMaxSize, + IsArray, + IsEnum, + IsString, + MinLength, + Validate, + ValidateNested, +} from 'class-validator'; +import { ValidationsGroupsEnum } from '../../enums/shared/validation-groups-enum'; +import { ListingOrderByKeys } from '../../enums/listings/order-by-enum'; +import { ListingViews } from '../../enums/listings/view-enum'; +import { OrderByEnum } from '../../enums/shared/order-by-enum'; +import { OrderQueryParamValidator } from '../../utilities/order-by-validator'; + +export class ListingsQueryParams extends PaginationAllowsAllQueryParams { + @Expose() + @ApiPropertyOptional({ + type: [String], + items: { + $ref: getSchemaPath(ListingFilterParams), + }, + example: { $comparison: '=', status: 'active' }, + }) + @IsArray({ groups: [ValidationsGroupsEnum.default] }) + @ArrayMaxSize(16, { groups: [ValidationsGroupsEnum.default] }) + @Type(() => ListingFilterParams) + @ValidateNested({ groups: [ValidationsGroupsEnum.default], each: true }) + filter?: ListingFilterParams[]; + + @Expose() + @ApiPropertyOptional({ + enum: ListingViews, + enumName: 'ListingViews', + example: 'full', + }) + @IsEnum(ListingViews, { + groups: [ValidationsGroupsEnum.default], + }) + view?: ListingViews; + + @Expose() + @ApiPropertyOptional({ + enum: ListingOrderByKeys, + enumName: 'ListingOrderByKeys', + example: '["updatedAt"]', + }) + @IsArray({ groups: [ValidationsGroupsEnum.default] }) + @ArrayMaxSize(16, { groups: [ValidationsGroupsEnum.default] }) + @Type(() => ListingFilterParams) + @IsEnum(ListingOrderByKeys, { + groups: [ValidationsGroupsEnum.default], + each: true, + }) + @Validate(OrderQueryParamValidator, { + groups: [ValidationsGroupsEnum.default], + }) + orderBy?: ListingOrderByKeys[]; + + @Expose() + @ApiPropertyOptional({ + enum: OrderByEnum, + enumName: 'OrderByEnum', + example: '["desc"]', + default: '["desc"]', + }) + @IsArray({ groups: [ValidationsGroupsEnum.default] }) + @ArrayMaxSize(16, { groups: [ValidationsGroupsEnum.default] }) + @Transform(({ value }) => { + return value ? value.map((val) => val.toLowerCase()) : undefined; + }) + @IsEnum(OrderByEnum, { groups: [ValidationsGroupsEnum.default], each: true }) + @Validate(OrderQueryParamValidator, { + groups: [ValidationsGroupsEnum.default], + }) + orderDir?: OrderByEnum[]; + + @Expose() + @ApiPropertyOptional({ + example: 'search', + }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @MinLength(3, { + message: 'Search must be at least 3 characters', + groups: [ValidationsGroupsEnum.default], + }) + search?: string; +} diff --git a/api/src/dtos/listings/listings-retrieve-params.dto.ts b/api/src/dtos/listings/listings-retrieve-params.dto.ts new file mode 100644 index 0000000000..23cbdb00db --- /dev/null +++ b/api/src/dtos/listings/listings-retrieve-params.dto.ts @@ -0,0 +1,18 @@ +import { Expose } from 'class-transformer'; +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsEnum } from 'class-validator'; +import { ValidationsGroupsEnum } from '../../enums/shared/validation-groups-enum'; +import { ListingViews } from '../../enums/listings/view-enum'; + +export class ListingsRetrieveParams { + @Expose() + @ApiPropertyOptional({ + enum: ListingViews, + enumName: 'ListingViews', + example: 'full', + }) + @IsEnum(ListingViews, { + groups: [ValidationsGroupsEnum.default], + }) + view?: ListingViews; +} diff --git a/api/src/dtos/listings/paginated-listing.dto.ts b/api/src/dtos/listings/paginated-listing.dto.ts new file mode 100644 index 0000000000..d7765e9f3b --- /dev/null +++ b/api/src/dtos/listings/paginated-listing.dto.ts @@ -0,0 +1,4 @@ +import { PaginationFactory } from '../shared/pagination.dto'; +import { Listing } from './listing.dto'; + +export class PaginatedListingDto extends PaginationFactory(Listing) {} diff --git a/api/src/dtos/map-layers/map-layer.dto.ts b/api/src/dtos/map-layers/map-layer.dto.ts new file mode 100644 index 0000000000..01173c9f2a --- /dev/null +++ b/api/src/dtos/map-layers/map-layer.dto.ts @@ -0,0 +1,22 @@ +import { Expose } from 'class-transformer'; +import { IsString, IsUUID } from 'class-validator'; +import { ValidationsGroupsEnum } from '../../enums/shared/validation-groups-enum'; +import { ApiProperty } from '@nestjs/swagger'; + +export class MapLayerDto { + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @IsUUID(4, { groups: [ValidationsGroupsEnum.default] }) + @ApiProperty() + id: string; + + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @ApiProperty() + name: string; + + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @ApiProperty() + jurisdictionId: string; +} diff --git a/api/src/dtos/map-layers/map-layers-query-params.dto.ts b/api/src/dtos/map-layers/map-layers-query-params.dto.ts new file mode 100644 index 0000000000..f5eea7d055 --- /dev/null +++ b/api/src/dtos/map-layers/map-layers-query-params.dto.ts @@ -0,0 +1,15 @@ +import { Expose } from 'class-transformer'; +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsOptional, IsString } from 'class-validator'; +import { ValidationsGroupsEnum } from '../../enums/shared/validation-groups-enum'; + +export class MapLayersQueryParams { + @Expose() + @ApiPropertyOptional({ + name: 'jurisdictionId', + type: String, + }) + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + jurisdictionId?: string; +} diff --git a/api/src/dtos/mfa/request-mfa-code-response.dto.ts b/api/src/dtos/mfa/request-mfa-code-response.dto.ts new file mode 100644 index 0000000000..037b61c656 --- /dev/null +++ b/api/src/dtos/mfa/request-mfa-code-response.dto.ts @@ -0,0 +1,22 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { Expose } from 'class-transformer'; +import { IsEmail, IsPhoneNumber } from 'class-validator'; +import { EnforceLowerCase } from '../../decorators/enforce-lower-case.decorator'; +import { ValidationsGroupsEnum } from '../../enums/shared/validation-groups-enum'; + +export class RequestMfaCodeResponse { + @Expose() + @IsPhoneNumber('US', { groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + phoneNumber?: string; + + @Expose() + @IsEmail({}, { groups: [ValidationsGroupsEnum.default] }) + @EnforceLowerCase() + @ApiPropertyOptional() + email?: string; + + @Expose() + @ApiPropertyOptional() + phoneNumberVerified?: boolean; +} diff --git a/api/src/dtos/mfa/request-mfa-code.dto.ts b/api/src/dtos/mfa/request-mfa-code.dto.ts new file mode 100644 index 0000000000..21fea22e4a --- /dev/null +++ b/api/src/dtos/mfa/request-mfa-code.dto.ts @@ -0,0 +1,29 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Expose } from 'class-transformer'; +import { IsString, IsEmail, IsPhoneNumber, IsEnum } from 'class-validator'; +import { MfaType } from '../../enums/mfa/mfa-type-enum'; +import { EnforceLowerCase } from '../../decorators/enforce-lower-case.decorator'; +import { ValidationsGroupsEnum } from '../../enums/shared/validation-groups-enum'; + +export class RequestMfaCode { + @Expose() + @IsEmail({}, { groups: [ValidationsGroupsEnum.default] }) + @EnforceLowerCase() + @ApiProperty() + email: string; + + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @ApiProperty() + password: string; + + @Expose() + @IsEnum(MfaType, { groups: [ValidationsGroupsEnum.default] }) + @ApiProperty({ enum: MfaType, enumName: 'MfaType' }) + mfaType: MfaType; + + @Expose() + @IsPhoneNumber('US', { groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + phoneNumber?: string; +} diff --git a/api/src/dtos/multiselect-questions/multiselect-link.dto.ts b/api/src/dtos/multiselect-questions/multiselect-link.dto.ts new file mode 100644 index 0000000000..9b9da8446a --- /dev/null +++ b/api/src/dtos/multiselect-questions/multiselect-link.dto.ts @@ -0,0 +1,18 @@ +import { Expose } from 'class-transformer'; +import { IsString, IsDefined } from 'class-validator'; +import { ValidationsGroupsEnum } from '../../enums/shared/validation-groups-enum'; +import { ApiProperty } from '@nestjs/swagger'; + +export class MultiselectLink { + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ApiProperty() + title: string; + + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ApiProperty() + url: string; +} diff --git a/api/src/dtos/multiselect-questions/multiselect-option.dto.ts b/api/src/dtos/multiselect-questions/multiselect-option.dto.ts new file mode 100644 index 0000000000..e467f784c1 --- /dev/null +++ b/api/src/dtos/multiselect-questions/multiselect-option.dto.ts @@ -0,0 +1,85 @@ +import { Expose, Type } from 'class-transformer'; +import { + IsBoolean, + IsNumber, + IsDefined, + IsString, + ValidateNested, +} from 'class-validator'; +import { ValidationsGroupsEnum } from '../../enums/shared/validation-groups-enum'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { MultiselectLink } from './multiselect-link.dto'; +import { ValidationMethod } from '../../enums/multiselect-questions/validation-method-enum'; + +export class MultiselectOption { + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @ApiProperty() + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + text: string; + + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + untranslatedText?: string; + + @Expose() + @IsNumber({}, { groups: [ValidationsGroupsEnum.default] }) + @ApiProperty() + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + ordinal: number; + + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + description?: string; + + @Expose() + @ValidateNested({ groups: [ValidationsGroupsEnum.default], each: true }) + @Type(() => MultiselectLink) + @ApiPropertyOptional({ type: MultiselectLink, isArray: true }) + links?: MultiselectLink[]; + + @Expose() + @IsBoolean({ groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + collectAddress?: boolean; + + @Expose() + @ApiProperty({ + required: false, + enum: ValidationMethod, + enumName: 'ValidationMethodEnum', + }) + validationMethod?: ValidationMethod; + + @Expose() + @IsNumber({}, { groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + radiusSize?: number; + + @Expose() + @IsBoolean({ groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + collectName?: boolean; + + @Expose() + @IsBoolean({ groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + collectRelationship?: boolean; + + @Expose() + @IsBoolean({ groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + exclusive?: boolean; + + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + mapLayerId?: string; + + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + mapPinPosition?: string; +} diff --git a/api/src/dtos/multiselect-questions/multiselect-question-create.dto.ts b/api/src/dtos/multiselect-questions/multiselect-question-create.dto.ts new file mode 100644 index 0000000000..32b8c0ebe1 --- /dev/null +++ b/api/src/dtos/multiselect-questions/multiselect-question-create.dto.ts @@ -0,0 +1,7 @@ +import { OmitType } from '@nestjs/swagger'; +import { MultiselectQuestionUpdate } from './multiselect-question-update.dto'; + +export class MultiselectQuestionCreate extends OmitType( + MultiselectQuestionUpdate, + ['id'], +) {} diff --git a/api/src/dtos/multiselect-questions/multiselect-question-filter-params.dto.ts b/api/src/dtos/multiselect-questions/multiselect-question-filter-params.dto.ts new file mode 100644 index 0000000000..6fe92ae659 --- /dev/null +++ b/api/src/dtos/multiselect-questions/multiselect-question-filter-params.dto.ts @@ -0,0 +1,25 @@ +import { BaseFilter } from '../shared/base-filter.dto'; +import { Expose } from 'class-transformer'; +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsString } from 'class-validator'; +import { ValidationsGroupsEnum } from '../../enums/shared/validation-groups-enum'; +import { MultiselectQuestionFilterKeys } from '../../enums/multiselect-questions/filter-key-enum'; +import { MultiselectQuestionsApplicationSectionEnum } from '@prisma/client'; + +export class MultiselectQuestionFilterParams extends BaseFilter { + @Expose() + @ApiPropertyOptional({ + example: 'uuid', + }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + [MultiselectQuestionFilterKeys.jurisdiction]?: string; + + @Expose() + @ApiPropertyOptional({ + enum: MultiselectQuestionsApplicationSectionEnum, + enumName: 'MultiselectQuestionsApplicationSectionEnum', + example: 'preferences', + }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + [MultiselectQuestionFilterKeys.applicationSection]?: MultiselectQuestionsApplicationSectionEnum; +} diff --git a/api/src/dtos/multiselect-questions/multiselect-question-query-params.dto.ts b/api/src/dtos/multiselect-questions/multiselect-question-query-params.dto.ts new file mode 100644 index 0000000000..32f4877a17 --- /dev/null +++ b/api/src/dtos/multiselect-questions/multiselect-question-query-params.dto.ts @@ -0,0 +1,21 @@ +import { Expose, Type } from 'class-transformer'; +import { ApiPropertyOptional, getSchemaPath } from '@nestjs/swagger'; +import { MultiselectQuestionFilterParams } from './multiselect-question-filter-params.dto'; +import { ArrayMaxSize, IsArray, ValidateNested } from 'class-validator'; +import { ValidationsGroupsEnum } from '../../enums/shared/validation-groups-enum'; + +export class MultiselectQuestionQueryParams { + @Expose() + @ApiPropertyOptional({ + type: [String], + items: { + $ref: getSchemaPath(MultiselectQuestionFilterParams), + }, + example: { $comparison: '=', applicationSection: 'programs' }, + }) + @IsArray({ groups: [ValidationsGroupsEnum.default] }) + @ArrayMaxSize(16, { groups: [ValidationsGroupsEnum.default] }) + @Type(() => MultiselectQuestionFilterParams) + @ValidateNested({ groups: [ValidationsGroupsEnum.default], each: true }) + filter?: MultiselectQuestionFilterParams[]; +} diff --git a/api/src/dtos/multiselect-questions/multiselect-question-update.dto.ts b/api/src/dtos/multiselect-questions/multiselect-question-update.dto.ts new file mode 100644 index 0000000000..ce497c4cd6 --- /dev/null +++ b/api/src/dtos/multiselect-questions/multiselect-question-update.dto.ts @@ -0,0 +1,9 @@ +import { OmitType } from '@nestjs/swagger'; +import { MultiselectQuestion } from './multiselect-question.dto'; + +export class MultiselectQuestionUpdate extends OmitType(MultiselectQuestion, [ + 'createdAt', + 'updatedAt', + 'untranslatedText', + 'untranslatedText', +]) {} diff --git a/api/src/dtos/multiselect-questions/multiselect-question.dto.ts b/api/src/dtos/multiselect-questions/multiselect-question.dto.ts new file mode 100644 index 0000000000..3fd975fa12 --- /dev/null +++ b/api/src/dtos/multiselect-questions/multiselect-question.dto.ts @@ -0,0 +1,87 @@ +import { Expose, Type } from 'class-transformer'; +import { + IsString, + ValidateNested, + ArrayMaxSize, + IsBoolean, + IsEnum, + IsDefined, +} from 'class-validator'; +import { ValidationsGroupsEnum } from '../../enums/shared/validation-groups-enum'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { AbstractDTO } from '../shared/abstract.dto'; +import { MultiselectQuestionsApplicationSectionEnum } from '@prisma/client'; +import { MultiselectLink } from './multiselect-link.dto'; +import { MultiselectOption } from './multiselect-option.dto'; +import { IdDTO } from '../shared/id.dto'; + +class MultiselectQuestion extends AbstractDTO { + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ApiProperty() + text: string; + + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + untranslatedText?: string; + + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + untranslatedOptOutText?: string; + + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + subText?: string; + + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + description?: string; + + @Expose() + @ValidateNested({ groups: [ValidationsGroupsEnum.default], each: true }) + @Type(() => MultiselectLink) + @ApiPropertyOptional({ type: MultiselectLink, isArray: true }) + links?: MultiselectLink[]; + + @Expose() + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => IdDTO) + @ApiProperty({ type: IdDTO, isArray: true }) + jurisdictions: IdDTO[]; + + @Expose() + @ArrayMaxSize(64, { groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default], each: true }) + @Type(() => MultiselectOption) + @ApiPropertyOptional({ type: MultiselectOption, isArray: true }) + options?: MultiselectOption[]; + + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + optOutText?: string; + + @Expose() + @IsBoolean({ groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + hideFromListing?: boolean; + + @Expose() + @IsEnum(MultiselectQuestionsApplicationSectionEnum, { + groups: [ValidationsGroupsEnum.default], + }) + @ApiProperty({ + enum: MultiselectQuestionsApplicationSectionEnum, + enumName: 'MultiselectQuestionsApplicationSectionEnum', + }) + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + applicationSection: MultiselectQuestionsApplicationSectionEnum; +} + +export { MultiselectQuestion as default, MultiselectQuestion }; diff --git a/api/src/dtos/paper-applications/paper-application-create.dto.ts b/api/src/dtos/paper-applications/paper-application-create.dto.ts new file mode 100644 index 0000000000..babc6ff015 --- /dev/null +++ b/api/src/dtos/paper-applications/paper-application-create.dto.ts @@ -0,0 +1,19 @@ +import { ApiPropertyOptional, OmitType } from '@nestjs/swagger'; +import { Expose, Type } from 'class-transformer'; +import { ValidateNested } from 'class-validator'; +import { ValidationsGroupsEnum } from '../../enums/shared/validation-groups-enum'; +import { PaperApplication } from './paper-application.dto'; +import { AssetCreate } from '../assets/asset-create.dto'; + +export class PaperApplicationCreate extends OmitType(PaperApplication, [ + 'id', + 'createdAt', + 'updatedAt', + 'assets', +]) { + @Expose() + @ValidateNested({ groups: [ValidationsGroupsEnum.default], each: true }) + @Type(() => AssetCreate) + @ApiPropertyOptional({ type: AssetCreate }) + assets?: AssetCreate; +} diff --git a/api/src/dtos/paper-applications/paper-application.dto.ts b/api/src/dtos/paper-applications/paper-application.dto.ts new file mode 100644 index 0000000000..c7aba25653 --- /dev/null +++ b/api/src/dtos/paper-applications/paper-application.dto.ts @@ -0,0 +1,25 @@ +import { AbstractDTO } from '../shared/abstract.dto'; +import { LanguagesEnum } from '@prisma/client'; +import { Expose, Type } from 'class-transformer'; +import { IsEnum, IsDefined, ValidateNested } from 'class-validator'; +import { ValidationsGroupsEnum } from '../../enums/shared/validation-groups-enum'; +import { ApiProperty } from '@nestjs/swagger'; +import { Asset } from '../assets/asset.dto'; + +export class PaperApplication extends AbstractDTO { + @Expose() + @IsEnum(LanguagesEnum, { groups: [ValidationsGroupsEnum.default] }) + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ApiProperty({ + enum: LanguagesEnum, + enumName: 'LanguagesEnum', + }) + language: LanguagesEnum; + + @Expose() + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => Asset) + @ApiProperty({ type: Asset }) + assets: Asset; +} diff --git a/api/src/dtos/reserved-community-types/reserved-community-type-create.dto.ts b/api/src/dtos/reserved-community-types/reserved-community-type-create.dto.ts new file mode 100644 index 0000000000..fbc8753445 --- /dev/null +++ b/api/src/dtos/reserved-community-types/reserved-community-type-create.dto.ts @@ -0,0 +1,7 @@ +import { OmitType } from '@nestjs/swagger'; +import { ReservedCommunityType } from './reserved-community-type.dto'; + +export class ReservedCommunityTypeCreate extends OmitType( + ReservedCommunityType, + ['id', 'createdAt', 'updatedAt'], +) {} diff --git a/api/src/dtos/reserved-community-types/reserved-community-type-query-params.dto.ts b/api/src/dtos/reserved-community-types/reserved-community-type-query-params.dto.ts new file mode 100644 index 0000000000..5b2b6ae221 --- /dev/null +++ b/api/src/dtos/reserved-community-types/reserved-community-type-query-params.dto.ts @@ -0,0 +1,12 @@ +import { Expose } from 'class-transformer'; +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsString, IsUUID } from 'class-validator'; +import { ValidationsGroupsEnum } from '../../enums/shared/validation-groups-enum'; + +export class ReservedCommunityTypeQueryParams { + @Expose() + @ApiPropertyOptional() + @IsUUID(4, { groups: [ValidationsGroupsEnum.default] }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + jurisdictionId?: string; +} diff --git a/api/src/dtos/reserved-community-types/reserved-community-type-update.dto.ts b/api/src/dtos/reserved-community-types/reserved-community-type-update.dto.ts new file mode 100644 index 0000000000..3ba3de01fe --- /dev/null +++ b/api/src/dtos/reserved-community-types/reserved-community-type-update.dto.ts @@ -0,0 +1,7 @@ +import { OmitType } from '@nestjs/swagger'; +import { ReservedCommunityType } from './reserved-community-type.dto'; + +export class ReservedCommunityTypeUpdate extends OmitType( + ReservedCommunityType, + ['createdAt', 'updatedAt', 'jurisdictions'], +) {} diff --git a/api/src/dtos/reserved-community-types/reserved-community-type.dto.ts b/api/src/dtos/reserved-community-types/reserved-community-type.dto.ts new file mode 100644 index 0000000000..4c7999d1d2 --- /dev/null +++ b/api/src/dtos/reserved-community-types/reserved-community-type.dto.ts @@ -0,0 +1,34 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Expose, Type } from 'class-transformer'; +import { + IsDefined, + IsString, + MaxLength, + ValidateNested, +} from 'class-validator'; +import { ValidationsGroupsEnum } from '../../enums/shared/validation-groups-enum'; +import { AbstractDTO } from '../shared/abstract.dto'; +import { IdDTO } from '../shared/id.dto'; + +export class ReservedCommunityType extends AbstractDTO { + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @MaxLength(256, { groups: [ValidationsGroupsEnum.default] }) + @ApiProperty() + @ApiProperty() + name: string; + + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @MaxLength(2048, { groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + description?: string; + + @Expose() + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => IdDTO) + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ApiProperty({ type: IdDTO }) + jurisdictions: IdDTO; +} diff --git a/api/src/dtos/shared/abstract.dto.ts b/api/src/dtos/shared/abstract.dto.ts new file mode 100644 index 0000000000..2656a2a677 --- /dev/null +++ b/api/src/dtos/shared/abstract.dto.ts @@ -0,0 +1,27 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { Expose, Type } from 'class-transformer'; +import { IsDate, IsDefined, IsString, IsUUID } from 'class-validator'; +import { ValidationsGroupsEnum } from '../../enums/shared/validation-groups-enum'; + +export class AbstractDTO { + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @IsUUID(4, { groups: [ValidationsGroupsEnum.default] }) + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ApiProperty() + id: string; + + @Expose() + @IsDate({ groups: [ValidationsGroupsEnum.default] }) + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => Date) + @ApiProperty() + createdAt: Date; + + @Expose() + @IsDate({ groups: [ValidationsGroupsEnum.default] }) + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => Date) + @ApiProperty() + updatedAt: Date; +} diff --git a/api/src/dtos/shared/base-filter.dto.ts b/api/src/dtos/shared/base-filter.dto.ts new file mode 100644 index 0000000000..9b756adec9 --- /dev/null +++ b/api/src/dtos/shared/base-filter.dto.ts @@ -0,0 +1,25 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { Expose } from 'class-transformer'; +import { IsEnum } from 'class-validator'; +import { ValidationsGroupsEnum } from '../../enums/shared/validation-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; +} diff --git a/api/src/dtos/shared/id.dto.ts b/api/src/dtos/shared/id.dto.ts new file mode 100644 index 0000000000..bb1c36ee10 --- /dev/null +++ b/api/src/dtos/shared/id.dto.ts @@ -0,0 +1,23 @@ +import { IsDefined, IsNumber, IsString, IsUUID } from 'class-validator'; +import { Expose } from 'class-transformer'; +import { ValidationsGroupsEnum } from '../../enums/shared/validation-groups-enum'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export class IdDTO { + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @IsUUID(4, { groups: [ValidationsGroupsEnum.default] }) + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ApiProperty() + id: string; + + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + name?: string; + + @Expose() + @IsNumber({}, { groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + ordinal?: number; +} diff --git a/api/src/dtos/shared/min-max-currency.dto.ts b/api/src/dtos/shared/min-max-currency.dto.ts new file mode 100644 index 0000000000..05f0bc757d --- /dev/null +++ b/api/src/dtos/shared/min-max-currency.dto.ts @@ -0,0 +1,18 @@ +import { Expose } from 'class-transformer'; +import { IsDefined, IsString } from 'class-validator'; +import { ValidationsGroupsEnum } from '../../enums/shared/validation-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/api/src/dtos/shared/min-max.dto.ts b/api/src/dtos/shared/min-max.dto.ts new file mode 100644 index 0000000000..80b99edeb5 --- /dev/null +++ b/api/src/dtos/shared/min-max.dto.ts @@ -0,0 +1,18 @@ +import { Expose } from 'class-transformer'; +import { IsDefined, IsNumber } from 'class-validator'; +import { ValidationsGroupsEnum } from '../../enums/shared/validation-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/api/src/dtos/shared/pagination.dto.ts b/api/src/dtos/shared/pagination.dto.ts new file mode 100644 index 0000000000..fe97a9b23c --- /dev/null +++ b/api/src/dtos/shared/pagination.dto.ts @@ -0,0 +1,152 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { + Expose, + Transform, + TransformFnParams, + Type, + ClassConstructor, +} from 'class-transformer'; +import { + IsNumber, + registerDecorator, + ValidationOptions, +} from 'class-validator'; +import { ValidationsGroupsEnum } from '../../enums/shared/validation-groups-enum'; + +export class PaginationMeta { + @Expose() + @ApiProperty() + currentPage: number; + + @Expose() + @ApiProperty() + itemCount: number; + + @Expose() + @ApiProperty() + itemsPerPage: number; + + @Expose() + @ApiProperty() + totalItems: number; + + @Expose() + @ApiProperty() + totalPages: number; +} + +export interface Pagination { + items: T[]; + meta: PaginationMeta; +} + +export function PaginationFactory( + classType: ClassConstructor, +): ClassConstructor> { + class PaginationHost implements Pagination { + @ApiProperty({ type: () => classType, isArray: true }) + @Expose() + @Type(() => classType) + items: T[]; + + @Expose() + @ApiProperty() + meta: PaginationMeta; + } + return PaginationHost; +} + +export class PaginationQueryParams { + @Expose() + @ApiPropertyOptional({ + type: Number, + example: 1, + default: 1, + }) + @IsNumber({}, { groups: [ValidationsGroupsEnum.default] }) + @Transform( + (value: TransformFnParams) => (value?.value ? parseInt(value.value) : 1), + { + toClassOnly: true, + }, + ) + page?: number; + + @Expose() + @ApiPropertyOptional({ + type: Number, + example: 10, + default: 10, + }) + @IsNumber({}, { groups: [ValidationsGroupsEnum.default] }) + @Transform( + (value: TransformFnParams) => (value?.value ? parseInt(value.value) : 10), + { + toClassOnly: true, + }, + ) + limit?: number; +} + +export class PaginationAllowsAllQueryParams { + @Expose() + @ApiPropertyOptional({ + type: Number, + example: 1, + default: 1, + }) + @IsNumber({}, { groups: [ValidationsGroupsEnum.default] }) + @Transform( + (value: TransformFnParams) => (value?.value ? parseInt(value.value) : 1), + { + toClassOnly: true, + }, + ) + page?: number; + + @Expose() + @ApiPropertyOptional({ + type: "number | 'all'", + example: 10, + default: 10, + }) + @IsNumberOrAll({ + message: 'Limit must be a number or "all"', + groups: [ValidationsGroupsEnum.default], + }) + @Transform( + (value: TransformFnParams) => { + if (value?.value === 'all') { + return value.value; + } + return value?.value ? parseInt(value.value) : 10; + }, + { + toClassOnly: true, + }, + ) + limit?: number | 'all'; +} + +/* + validates if the value is either a number or the string '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/api/src/dtos/shared/success.dto.ts b/api/src/dtos/shared/success.dto.ts new file mode 100644 index 0000000000..9058bc3c74 --- /dev/null +++ b/api/src/dtos/shared/success.dto.ts @@ -0,0 +1,12 @@ +import { IsDefined, IsBoolean } from 'class-validator'; +import { Expose } from 'class-transformer'; +import { ValidationsGroupsEnum } from '../../enums/shared/validation-groups-enum'; +import { ApiProperty } from '@nestjs/swagger'; + +export class SuccessDTO { + @Expose() + @IsBoolean({ groups: [ValidationsGroupsEnum.default] }) + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ApiProperty() + success: boolean; +} diff --git a/api/src/dtos/unit-accessibility-priority-types/unit-accessibility-priority-type-create.dto.ts b/api/src/dtos/unit-accessibility-priority-types/unit-accessibility-priority-type-create.dto.ts new file mode 100644 index 0000000000..ba3eb4f46c --- /dev/null +++ b/api/src/dtos/unit-accessibility-priority-types/unit-accessibility-priority-type-create.dto.ts @@ -0,0 +1,7 @@ +import { OmitType } from '@nestjs/swagger'; +import { UnitAccessibilityPriorityTypeUpdate } from './unit-accessibility-priority-type-update.dto'; + +export class UnitAccessibilityPriorityTypeCreate extends OmitType( + UnitAccessibilityPriorityTypeUpdate, + ['id'], +) {} diff --git a/api/src/dtos/unit-accessibility-priority-types/unit-accessibility-priority-type-update.dto.ts b/api/src/dtos/unit-accessibility-priority-types/unit-accessibility-priority-type-update.dto.ts new file mode 100644 index 0000000000..9334d62687 --- /dev/null +++ b/api/src/dtos/unit-accessibility-priority-types/unit-accessibility-priority-type-update.dto.ts @@ -0,0 +1,7 @@ +import { OmitType } from '@nestjs/swagger'; +import { UnitAccessibilityPriorityType } from './unit-accessibility-priority-type.dto'; + +export class UnitAccessibilityPriorityTypeUpdate extends OmitType( + UnitAccessibilityPriorityType, + ['createdAt', 'updatedAt'], +) {} diff --git a/api/src/dtos/unit-accessibility-priority-types/unit-accessibility-priority-type.dto.ts b/api/src/dtos/unit-accessibility-priority-types/unit-accessibility-priority-type.dto.ts new file mode 100644 index 0000000000..9c9fde17d1 --- /dev/null +++ b/api/src/dtos/unit-accessibility-priority-types/unit-accessibility-priority-type.dto.ts @@ -0,0 +1,13 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { Expose } from 'class-transformer'; +import { IsDefined, IsString } from 'class-validator'; +import { ValidationsGroupsEnum } from '../../enums/shared/validation-groups-enum'; +import { AbstractDTO } from '../shared/abstract.dto'; + +export class UnitAccessibilityPriorityType extends AbstractDTO { + @Expose() + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @IsString() + @ApiProperty() + name: string; +} diff --git a/api/src/dtos/unit-rent-types/unit-rent-type-create.dto.ts b/api/src/dtos/unit-rent-types/unit-rent-type-create.dto.ts new file mode 100644 index 0000000000..905df31536 --- /dev/null +++ b/api/src/dtos/unit-rent-types/unit-rent-type-create.dto.ts @@ -0,0 +1,4 @@ +import { OmitType } from '@nestjs/swagger'; +import { UnitRentTypeUpdate } from './unit-rent-type-update.dto'; + +export class UnitRentTypeCreate extends OmitType(UnitRentTypeUpdate, ['id']) {} diff --git a/api/src/dtos/unit-rent-types/unit-rent-type-update.dto.ts b/api/src/dtos/unit-rent-types/unit-rent-type-update.dto.ts new file mode 100644 index 0000000000..8606bab64d --- /dev/null +++ b/api/src/dtos/unit-rent-types/unit-rent-type-update.dto.ts @@ -0,0 +1,7 @@ +import { OmitType } from '@nestjs/swagger'; +import { UnitRentType } from './unit-rent-type.dto'; + +export class UnitRentTypeUpdate extends OmitType(UnitRentType, [ + 'createdAt', + 'updatedAt', +]) {} diff --git a/api/src/dtos/unit-rent-types/unit-rent-type.dto.ts b/api/src/dtos/unit-rent-types/unit-rent-type.dto.ts new file mode 100644 index 0000000000..c164f24f76 --- /dev/null +++ b/api/src/dtos/unit-rent-types/unit-rent-type.dto.ts @@ -0,0 +1,19 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { Expose } from 'class-transformer'; +import { IsDefined, IsEnum } from 'class-validator'; +import { ValidationsGroupsEnum } from '../../enums/shared/validation-groups-enum'; +import { AbstractDTO } from '../shared/abstract.dto'; +import { UnitRentTypeEnum } from '@prisma/client'; + +export class UnitRentType extends AbstractDTO { + @Expose() + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @IsEnum(UnitRentTypeEnum, { + groups: [ValidationsGroupsEnum.default], + }) + @ApiProperty({ + enum: UnitRentTypeEnum, + enumName: 'UnitRentTypeEnum', + }) + name: UnitRentTypeEnum; +} diff --git a/api/src/dtos/unit-types/unit-type-create.dto.ts b/api/src/dtos/unit-types/unit-type-create.dto.ts new file mode 100644 index 0000000000..c92a54c953 --- /dev/null +++ b/api/src/dtos/unit-types/unit-type-create.dto.ts @@ -0,0 +1,4 @@ +import { OmitType } from '@nestjs/swagger'; +import { UnitTypeUpdate } from './unit-type-update.dto'; + +export class UnitTypeCreate extends OmitType(UnitTypeUpdate, ['id']) {} diff --git a/api/src/dtos/unit-types/unit-type-update.dto.ts b/api/src/dtos/unit-types/unit-type-update.dto.ts new file mode 100644 index 0000000000..08c34a9d87 --- /dev/null +++ b/api/src/dtos/unit-types/unit-type-update.dto.ts @@ -0,0 +1,7 @@ +import { OmitType } from '@nestjs/swagger'; +import { UnitType } from './unit-type.dto'; + +export class UnitTypeUpdate extends OmitType(UnitType, [ + 'createdAt', + 'updatedAt', +]) {} diff --git a/api/src/dtos/unit-types/unit-type.dto.ts b/api/src/dtos/unit-types/unit-type.dto.ts new file mode 100644 index 0000000000..a0577205bc --- /dev/null +++ b/api/src/dtos/unit-types/unit-type.dto.ts @@ -0,0 +1,25 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { Expose } from 'class-transformer'; +import { IsDefined, IsNumber, IsEnum } from 'class-validator'; +import { ValidationsGroupsEnum } from '../../enums/shared/validation-groups-enum'; +import { AbstractDTO } from '../shared/abstract.dto'; +import { UnitTypeEnum } from '@prisma/client'; + +export class UnitType extends AbstractDTO { + @Expose() + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @IsEnum(UnitTypeEnum, { + groups: [ValidationsGroupsEnum.default], + }) + @ApiProperty({ + enum: UnitTypeEnum, + enumName: 'UnitTypeEnum', + }) + name: UnitTypeEnum; + + @Expose() + @IsNumber({}, { groups: [ValidationsGroupsEnum.default] }) + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ApiProperty() + numBedrooms: number; +} diff --git a/api/src/dtos/units/ami-chart-item.dto.ts b/api/src/dtos/units/ami-chart-item.dto.ts new file mode 100644 index 0000000000..b7cc12e00d --- /dev/null +++ b/api/src/dtos/units/ami-chart-item.dto.ts @@ -0,0 +1,24 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { Expose } from 'class-transformer'; +import { IsNumber, IsDefined } from 'class-validator'; +import { ValidationsGroupsEnum } from '../../enums/shared/validation-groups-enum'; + +export class AmiChartItem { + @Expose() + @IsNumber({}, { groups: [ValidationsGroupsEnum.default] }) + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ApiProperty() + percentOfAmi: number; + + @Expose() + @IsNumber({}, { groups: [ValidationsGroupsEnum.default] }) + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ApiProperty() + householdSize: number; + + @Expose() + @IsNumber({}, { groups: [ValidationsGroupsEnum.default] }) + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ApiProperty() + income: number; +} diff --git a/api/src/dtos/units/ami-chart-override-create.dto.ts b/api/src/dtos/units/ami-chart-override-create.dto.ts new file mode 100644 index 0000000000..890e883c1d --- /dev/null +++ b/api/src/dtos/units/ami-chart-override-create.dto.ts @@ -0,0 +1,8 @@ +import { OmitType } from '@nestjs/swagger'; +import { UnitAmiChartOverride } from './ami-chart-override.dto'; + +export class UnitAmiChartOverrideCreate extends OmitType(UnitAmiChartOverride, [ + 'id', + 'createdAt', + 'updatedAt', +]) {} diff --git a/api/src/dtos/units/ami-chart-override.dto.ts b/api/src/dtos/units/ami-chart-override.dto.ts new file mode 100644 index 0000000000..c197d13b2b --- /dev/null +++ b/api/src/dtos/units/ami-chart-override.dto.ts @@ -0,0 +1,15 @@ +import { AbstractDTO } from '../shared/abstract.dto'; +import { Expose, Type } from 'class-transformer'; +import { IsDefined, ValidateNested } from 'class-validator'; +import { ValidationsGroupsEnum } from '../../enums/shared/validation-groups-enum'; +import { AmiChartItem } from './ami-chart-item.dto'; +import { ApiProperty } from '@nestjs/swagger'; + +export class UnitAmiChartOverride extends AbstractDTO { + @Expose() + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default], each: true }) + @Type(() => AmiChartItem) + @ApiProperty({ isArray: true, type: AmiChartItem }) + items: AmiChartItem[]; +} diff --git a/api/src/dtos/units/ami-chart.dto.ts b/api/src/dtos/units/ami-chart.dto.ts new file mode 100644 index 0000000000..e2e7fe2500 --- /dev/null +++ b/api/src/dtos/units/ami-chart.dto.ts @@ -0,0 +1,21 @@ +import { IsDefined, IsString, ValidateNested } from 'class-validator'; +import { Expose, Type } from 'class-transformer'; +import { ValidationsGroupsEnum } from '../../enums/shared/validation-groups-enum'; +import { AbstractDTO } from '../shared/abstract.dto'; +import { AmiChartItem } from './ami-chart-item.dto'; +import { ApiProperty } from '@nestjs/swagger'; + +export class AmiChart extends AbstractDTO { + @Expose() + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default], each: true }) + @Type(() => AmiChartItem) + @ApiProperty({ isArray: true, type: AmiChartItem }) + items: AmiChartItem[]; + + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ApiProperty() + name: string; +} diff --git a/api/src/dtos/units/hmi.dto.ts b/api/src/dtos/units/hmi.dto.ts new file mode 100644 index 0000000000..32e336d409 --- /dev/null +++ b/api/src/dtos/units/hmi.dto.ts @@ -0,0 +1,11 @@ +import { ApiProperty } from '@nestjs/swagger'; + +type AnyDict = { [key: string]: unknown }; + +export class HMI { + @ApiProperty() + columns: AnyDict; + + @ApiProperty({ type: [Object] }) + rows: AnyDict[]; +} diff --git a/api/src/dtos/units/unit-create.dto.ts b/api/src/dtos/units/unit-create.dto.ts new file mode 100644 index 0000000000..f8489d15e8 --- /dev/null +++ b/api/src/dtos/units/unit-create.dto.ts @@ -0,0 +1,48 @@ +import { ApiPropertyOptional, OmitType } from '@nestjs/swagger'; +import { Expose, Type } from 'class-transformer'; +import { ValidateNested } from 'class-validator'; +import { ValidationsGroupsEnum } from '../../enums/shared/validation-groups-enum'; +import { IdDTO } from '../shared/id.dto'; +import { Unit } from './unit.dto'; +import { UnitAmiChartOverrideCreate } from './ami-chart-override-create.dto'; + +export class UnitCreate extends OmitType(Unit, [ + 'id', + 'createdAt', + 'updatedAt', + 'amiChart', + 'unitTypes', + 'unitAccessibilityPriorityTypes', + 'unitRentTypes', + 'unitAmiChartOverrides', +]) { + @Expose() + @Type(() => IdDTO) + @ApiPropertyOptional({ type: IdDTO }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + unitTypes?: IdDTO; + + @Expose() + @Type(() => IdDTO) + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional({ type: IdDTO }) + amiChart?: IdDTO; + + @Expose() + @Type(() => IdDTO) + @ApiPropertyOptional({ type: IdDTO }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + unitAccessibilityPriorityTypes?: IdDTO; + + @Expose() + @Type(() => IdDTO) + @ApiPropertyOptional({ type: IdDTO }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + unitRentTypes?: IdDTO; + + @Expose() + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => UnitAmiChartOverrideCreate) + @ApiPropertyOptional({ type: UnitAmiChartOverrideCreate }) + unitAmiChartOverrides?: UnitAmiChartOverrideCreate; +} diff --git a/api/src/dtos/units/unit-summarized.dto.ts b/api/src/dtos/units/unit-summarized.dto.ts new file mode 100644 index 0000000000..014e410702 --- /dev/null +++ b/api/src/dtos/units/unit-summarized.dto.ts @@ -0,0 +1,48 @@ +import { Expose, Type } from 'class-transformer'; +import { IsString, ValidateNested } from 'class-validator'; +import { ValidationsGroupsEnum } from '../../enums/shared/validation-groups-enum'; +import { UnitSummary } from './unit-summary.dto'; +import { UnitSummaryByAMI } from './unit-summary-by-ami.dto'; +import { HMI } from './hmi.dto'; +import { ApiProperty } from '@nestjs/swagger'; +import { UnitType } from '../unit-types/unit-type.dto'; +import { UnitAccessibilityPriorityType } from '../unit-accessibility-priority-types/unit-accessibility-priority-type.dto'; + +export class UnitsSummarized { + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default], each: true }) + @ApiProperty({ type: [UnitType] }) + unitTypes?: UnitType[]; + + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default], each: true }) + @ApiProperty({ type: [UnitAccessibilityPriorityType] }) + priorityTypes?: UnitAccessibilityPriorityType[]; + + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default], each: true }) + @ApiProperty() + amiPercentages?: string[]; + + @Expose() + @ValidateNested({ groups: [ValidationsGroupsEnum.default], each: true }) + @Type(() => UnitSummary) + @ApiProperty({ type: [UnitSummary] }) + byUnitTypeAndRent?: UnitSummary[]; + + @Expose() + @ValidateNested({ groups: [ValidationsGroupsEnum.default], each: true }) + @Type(() => UnitSummary) + @ApiProperty({ type: [UnitSummary] }) + byUnitType?: UnitSummary[]; + + @Expose() + @ValidateNested({ groups: [ValidationsGroupsEnum.default], each: true }) + @Type(() => UnitSummaryByAMI) + @ApiProperty({ type: [UnitSummaryByAMI] }) + byAMI?: UnitSummaryByAMI[]; + + @Expose() + @ApiProperty({ type: HMI }) + hmi?: HMI; +} diff --git a/api/src/dtos/units/unit-summary-by-ami.dto.ts b/api/src/dtos/units/unit-summary-by-ami.dto.ts new file mode 100644 index 0000000000..c1477dcc95 --- /dev/null +++ b/api/src/dtos/units/unit-summary-by-ami.dto.ts @@ -0,0 +1,20 @@ +import { Expose, Type } from 'class-transformer'; +import { IsDefined, IsString, ValidateNested } from 'class-validator'; +import { ValidationsGroupsEnum } from '../../enums/shared/validation-groups-enum'; +import { UnitSummary } from './unit-summary.dto'; +import { ApiProperty } from '@nestjs/swagger'; + +export class UnitSummaryByAMI { + @Expose() + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @ApiProperty() + percent: string; + + @Expose() + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default], each: true }) + @Type(() => UnitSummary) + @ApiProperty({ type: [UnitSummary] }) + byUnitType: UnitSummary[]; +} diff --git a/api/src/dtos/units/unit-summary.dto.ts b/api/src/dtos/units/unit-summary.dto.ts new file mode 100644 index 0000000000..58918aae9d --- /dev/null +++ b/api/src/dtos/units/unit-summary.dto.ts @@ -0,0 +1,61 @@ +import { Expose, Type } from 'class-transformer'; +import { IsDefined, IsString, ValidateNested } from 'class-validator'; +import { ValidationsGroupsEnum } from '../../enums/shared/validation-groups-enum'; +import { MinMaxCurrency } from '../shared/min-max-currency.dto'; +import { MinMax } from '../shared/min-max.dto'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { UnitType } from '../unit-types/unit-type.dto'; + +export class UnitSummary { + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @ApiProperty() + unitTypes?: UnitType; + + @Expose() + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => MinMaxCurrency) + @ApiProperty() + minIncomeRange: MinMaxCurrency; + + @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() + rentAsPercentIncomeRange: MinMax; + + @Expose() + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => MinMaxCurrency) + @ApiProperty() + rentRange: MinMaxCurrency; + + @Expose() + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @ApiProperty() + totalAvailable: number; + + @Expose() + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => MinMax) + @ApiProperty() + areaRange: MinMax; + + @Expose() + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => MinMax) + @ApiPropertyOptional({ type: MinMax }) + floorRange?: MinMax; +} diff --git a/api/src/dtos/units/unit.dto.ts b/api/src/dtos/units/unit.dto.ts new file mode 100644 index 0000000000..648244fb54 --- /dev/null +++ b/api/src/dtos/units/unit.dto.ts @@ -0,0 +1,118 @@ +import { + IsBoolean, + IsNumber, + IsNumberString, + IsString, + ValidateNested, +} from 'class-validator'; +import { Expose, Type } from 'class-transformer'; +import { ValidationsGroupsEnum } from '../../enums/shared/validation-groups-enum'; +import { AbstractDTO } from '../shared/abstract.dto'; +import { AmiChart } from '../ami-charts/ami-chart.dto'; +import { UnitType } from '../unit-types/unit-type.dto'; +import { UnitRentType } from '../unit-rent-types/unit-rent-type.dto'; +import { UnitAccessibilityPriorityType } from '../unit-accessibility-priority-types/unit-accessibility-priority-type.dto'; +import { UnitAmiChartOverride } from './ami-chart-override.dto'; +import { ApiPropertyOptional } from '@nestjs/swagger'; + +class Unit extends AbstractDTO { + @Expose() + @ApiPropertyOptional({ type: AmiChart }) + amiChart?: AmiChart; + + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + amiPercentage?: string; + + @Expose() + @IsNumberString({}, { groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + annualIncomeMin?: string; + + @Expose() + @IsNumberString({}, { groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + monthlyIncomeMin?: string; + + @Expose() + @IsNumber({}, { groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + floor?: number; + + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + annualIncomeMax?: string; + + @Expose() + @IsNumber({}, { groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + maxOccupancy?: number; + + @Expose() + @IsNumber({}, { groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + minOccupancy?: number; + + @Expose() + @IsNumberString({}, { groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + monthlyRent?: string; + + @Expose() + @IsNumber({}, { groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + numBathrooms?: number; + + @Expose() + @IsNumber({}, { groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + numBedrooms?: number; + + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + number?: string; + + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + sqFeet?: string; + + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + monthlyRentAsPercentOfIncome?: string; + + @Expose() + @IsBoolean({ groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + bmrProgramChart?: boolean; + + @Expose() + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => UnitType) + @ApiPropertyOptional({ type: UnitType }) + unitTypes?: UnitType; + + @Expose() + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => UnitRentType) + @ApiPropertyOptional({ type: UnitRentType }) + unitRentTypes?: UnitRentType; + + @Expose() + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => UnitAccessibilityPriorityType) + @ApiPropertyOptional({ type: UnitAccessibilityPriorityType }) + unitAccessibilityPriorityTypes?: UnitAccessibilityPriorityType; + + @Expose() + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => UnitAmiChartOverride) + @ApiPropertyOptional({ type: UnitAmiChartOverride }) + unitAmiChartOverrides?: UnitAmiChartOverride; +} + +export { Unit as default, Unit }; diff --git a/api/src/dtos/units/units-summary-create.dto.ts b/api/src/dtos/units/units-summary-create.dto.ts new file mode 100644 index 0000000000..c200b0aab1 --- /dev/null +++ b/api/src/dtos/units/units-summary-create.dto.ts @@ -0,0 +1,4 @@ +import { OmitType } from '@nestjs/swagger'; +import { UnitsSummary } from './units-summary.dto'; + +export class UnitsSummaryCreate extends OmitType(UnitsSummary, ['id']) {} diff --git a/api/src/dtos/units/units-summary.dto.ts b/api/src/dtos/units/units-summary.dto.ts new file mode 100644 index 0000000000..e7a59cc617 --- /dev/null +++ b/api/src/dtos/units/units-summary.dto.ts @@ -0,0 +1,106 @@ +import { + IsNumber, + IsNumberString, + IsDefined, + IsString, + IsUUID, + ValidateNested, +} from 'class-validator'; +import { Expose, Type } from 'class-transformer'; +import { ValidationsGroupsEnum } from '../../enums/shared/validation-groups-enum'; +import { IdDTO } from '../shared/id.dto'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +class UnitsSummary { + @Expose() + @IsUUID(4, { groups: [ValidationsGroupsEnum.default] }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ApiProperty() + id: string; + + @Expose() + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => IdDTO) + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ApiProperty({ type: IdDTO }) + unitTypes: IdDTO; + + @Expose() + @IsNumber({}, { groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + monthlyRentMin?: number; + + @Expose() + @IsNumber({}, { groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + monthlyRentMax?: number; + + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + monthlyRentAsPercentOfIncome?: string; + + @Expose() + @IsNumber({}, { groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + amiPercentage?: number; + + @Expose() + @IsNumberString({}, { groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + minimumIncomeMin?: string; + + @Expose() + @IsNumberString({}, { groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + minimumIncomeMax?: string; + + @Expose() + @IsNumber({}, { groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + maxOccupancy?: number; + + @Expose() + @IsNumber({}, { groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + minOccupancy?: number; + + @Expose() + @IsNumber({}, { groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + floorMin?: number; + + @Expose() + @IsNumber({}, { groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + floorMax?: number; + + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + sqFeetMin?: string; + + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + sqFeetMax?: string; + + @Expose() + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => IdDTO) + @ApiPropertyOptional({ type: IdDTO }) + unitAccessibilityPriorityTypes?: IdDTO; + + @Expose() + @IsNumber({}, { groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + totalCount?: number; + + @Expose() + @IsNumber({}, { groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + totalAvailable?: number; +} + +export { UnitsSummary as default, UnitsSummary }; diff --git a/api/src/dtos/users/confirmation-request.dto.ts b/api/src/dtos/users/confirmation-request.dto.ts new file mode 100644 index 0000000000..f507cc6daf --- /dev/null +++ b/api/src/dtos/users/confirmation-request.dto.ts @@ -0,0 +1,12 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { Expose } from 'class-transformer'; +import { IsString, MaxLength } from 'class-validator'; +import { ValidationsGroupsEnum } from '../../enums/shared/validation-groups-enum'; + +export class ConfirmationRequest { + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @MaxLength(256, { groups: [ValidationsGroupsEnum.default] }) + @ApiProperty() + token: string; +} diff --git a/api/src/dtos/users/email-and-app-url.dto.ts b/api/src/dtos/users/email-and-app-url.dto.ts new file mode 100644 index 0000000000..a61a5e1b88 --- /dev/null +++ b/api/src/dtos/users/email-and-app-url.dto.ts @@ -0,0 +1,23 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Expose } from 'class-transformer'; +import { IsEmail, IsString, MaxLength } from 'class-validator'; +import { EnforceLowerCase } from '../../decorators/enforce-lower-case.decorator'; +import { ValidationsGroupsEnum } from '../../enums/shared/validation-groups-enum'; + +/* + this DTO is used to take in a user's email address and the url from which the user is sending the api request + the url is option as each endpoint handles a default case for this +*/ +export class EmailAndAppUrl { + @Expose() + @IsEmail({}, { groups: [ValidationsGroupsEnum.default] }) + @ApiProperty() + @EnforceLowerCase() + email: string; + + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + @MaxLength(256, { groups: [ValidationsGroupsEnum.default] }) + appUrl?: string; +} diff --git a/api/src/dtos/users/paginated-user.dto.ts b/api/src/dtos/users/paginated-user.dto.ts new file mode 100644 index 0000000000..72130bc13d --- /dev/null +++ b/api/src/dtos/users/paginated-user.dto.ts @@ -0,0 +1,4 @@ +import { PaginationFactory } from '../shared/pagination.dto'; +import { User } from './user.dto'; + +export class PaginatedUserDto extends PaginationFactory(User) {} diff --git a/api/src/dtos/users/user-create-params.dto.ts b/api/src/dtos/users/user-create-params.dto.ts new file mode 100644 index 0000000000..5dc014fcb1 --- /dev/null +++ b/api/src/dtos/users/user-create-params.dto.ts @@ -0,0 +1,17 @@ +import { Expose, Transform, TransformFnParams } from 'class-transformer'; +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsBoolean } from 'class-validator'; +import { ValidationsGroupsEnum } from '../../enums/shared/validation-groups-enum'; + +export class UserCreateParams { + @Expose() + @ApiPropertyOptional({ + type: Boolean, + example: true, + }) + @IsBoolean({ groups: [ValidationsGroupsEnum.default] }) + @Transform((value: TransformFnParams) => value?.value === 'true', { + toClassOnly: true, + }) + noWelcomeEmail?: boolean; +} diff --git a/api/src/dtos/users/user-create.dto.ts b/api/src/dtos/users/user-create.dto.ts new file mode 100644 index 0000000000..c8d726505d --- /dev/null +++ b/api/src/dtos/users/user-create.dto.ts @@ -0,0 +1,62 @@ +import { ApiProperty, ApiPropertyOptional, OmitType } from '@nestjs/swagger'; +import { Expose, Type } from 'class-transformer'; +import { + IsArray, + IsEmail, + IsString, + Matches, + MaxLength, + ValidateNested, +} from 'class-validator'; +import { UserUpdate } from './user-update.dto'; + +import { EnforceLowerCase } from '../../decorators/enforce-lower-case.decorator'; +import { ValidationsGroupsEnum } from '../../enums/shared/validation-groups-enum'; +import { passwordRegex } from '../../utilities/password-regex'; +import { Match } from '../../decorators/match-decorator'; +import { IdDTO } from '../shared/id.dto'; + +export class UserCreate extends OmitType(UserUpdate, [ + 'id', + 'userRoles', + 'password', + 'currentPassword', + 'email', + 'jurisdictions', +]) { + @Expose() + @ApiProperty() + @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] }) + @ApiProperty() + passwordConfirmation: string; + + @Expose() + @ApiProperty() + @IsEmail({}, { groups: [ValidationsGroupsEnum.default] }) + @EnforceLowerCase() + email: string; + + @Expose() + @ApiProperty() + @IsEmail({}, { groups: [ValidationsGroupsEnum.default] }) + @Match('email', { groups: [ValidationsGroupsEnum.default] }) + @EnforceLowerCase() + emailConfirmation: string; + + @Expose() + @Type(() => IdDTO) + @IsArray({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default], each: true }) + @ApiPropertyOptional({ type: IdDTO, isArray: true }) + jurisdictions?: IdDTO[]; +} diff --git a/api/src/dtos/users/user-filter-params.dto.ts b/api/src/dtos/users/user-filter-params.dto.ts new file mode 100644 index 0000000000..686a551084 --- /dev/null +++ b/api/src/dtos/users/user-filter-params.dto.ts @@ -0,0 +1,14 @@ +import { Expose } from 'class-transformer'; +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsString } from 'class-validator'; +import { ValidationsGroupsEnum } from '../../enums/shared/validation-groups-enum'; + +export class UserFilterParams { + @Expose() + @ApiPropertyOptional({ + type: Boolean, + example: true, + }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + isPortalUser?: boolean; +} diff --git a/api/src/dtos/users/user-invite.dto.ts b/api/src/dtos/users/user-invite.dto.ts new file mode 100644 index 0000000000..84f2d3803c --- /dev/null +++ b/api/src/dtos/users/user-invite.dto.ts @@ -0,0 +1,36 @@ +import { ApiProperty, OmitType } from '@nestjs/swagger'; +import { Expose, Type } from 'class-transformer'; +import { + ArrayMinSize, + IsArray, + IsEmail, + ValidateNested, +} from 'class-validator'; +import { UserUpdate } from './user-update.dto'; + +import { EnforceLowerCase } from '../../decorators/enforce-lower-case.decorator'; +import { ValidationsGroupsEnum } from '../../enums/shared/validation-groups-enum'; +import { IdDTO } from '../shared/id.dto'; + +export class UserInvite extends OmitType(UserUpdate, [ + 'id', + 'password', + 'currentPassword', + 'email', + 'agreedToTermsOfService', + 'jurisdictions', +]) { + @Expose() + @ApiProperty() + @IsEmail({}, { groups: [ValidationsGroupsEnum.default] }) + @EnforceLowerCase() + email: string; + + @Expose() + @Type(() => IdDTO) + @IsArray({ groups: [ValidationsGroupsEnum.default] }) + @ArrayMinSize(1, { groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default], each: true }) + @ApiProperty({ type: IdDTO, isArray: true }) + jurisdictions: IdDTO[]; +} diff --git a/api/src/dtos/users/user-query-param.dto.ts b/api/src/dtos/users/user-query-param.dto.ts new file mode 100644 index 0000000000..75bd739111 --- /dev/null +++ b/api/src/dtos/users/user-query-param.dto.ts @@ -0,0 +1,41 @@ +import { PaginationAllowsAllQueryParams } from '../shared/pagination.dto'; +import { Expose, Type } from 'class-transformer'; +import { ApiPropertyOptional, getSchemaPath } from '@nestjs/swagger'; +import { UserFilterParams } from './user-filter-params.dto'; +import { + ArrayMaxSize, + IsArray, + IsString, + MinLength, + ValidateNested, +} from 'class-validator'; +import { ValidationsGroupsEnum } from '../../enums/shared/validation-groups-enum'; + +export class UserQueryParams extends PaginationAllowsAllQueryParams { + @Expose() + @ApiPropertyOptional({ + name: 'filter', + type: [String], + items: { + $ref: getSchemaPath(UserFilterParams), + }, + example: { isPartner: true }, + }) + @IsArray({ groups: [ValidationsGroupsEnum.default] }) + @ArrayMaxSize(16, { groups: [ValidationsGroupsEnum.default] }) + @Type(() => UserFilterParams) + @ValidateNested({ groups: [ValidationsGroupsEnum.default], each: true }) + filter?: UserFilterParams[]; + + @Expose() + @ApiPropertyOptional({ + type: String, + example: 'search', + }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @MinLength(3, { + message: 'Search must be at least 3 characters', + groups: [ValidationsGroupsEnum.default], + }) + search?: string; +} diff --git a/api/src/dtos/users/user-role.dto.ts b/api/src/dtos/users/user-role.dto.ts new file mode 100644 index 0000000000..a15e2015ca --- /dev/null +++ b/api/src/dtos/users/user-role.dto.ts @@ -0,0 +1,21 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { Expose } from 'class-transformer'; +import { IsBoolean } from 'class-validator'; +import { ValidationsGroupsEnum } from '../../enums/shared/validation-groups-enum'; + +export class UserRole { + @Expose() + @IsBoolean({ groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + isAdmin?: boolean; + + @Expose() + @IsBoolean({ groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + isJurisdictionalAdmin?: boolean; + + @Expose() + @IsBoolean({ groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + isPartner?: boolean; +} diff --git a/api/src/dtos/users/user-update.dto.ts b/api/src/dtos/users/user-update.dto.ts new file mode 100644 index 0000000000..5f0ed5b255 --- /dev/null +++ b/api/src/dtos/users/user-update.dto.ts @@ -0,0 +1,74 @@ +import { ApiPropertyOptional, OmitType } from '@nestjs/swagger'; +import { Expose, Type } from 'class-transformer'; +import { + IsArray, + IsEmail, + IsNotEmpty, + IsString, + Matches, + MaxLength, + ValidateIf, +} from 'class-validator'; +import { User } from './user.dto'; + +import { EnforceLowerCase } from '../../decorators/enforce-lower-case.decorator'; +import { ValidationsGroupsEnum } from '../../enums/shared/validation-groups-enum'; +import { passwordRegex } from '../../utilities/password-regex'; +import { IdDTO } from '../shared/id.dto'; + +export class UserUpdate extends OmitType(User, [ + 'createdAt', + 'updatedAt', + 'email', + 'mfaEnabled', + 'passwordUpdatedAt', + 'passwordValidForDays', + 'lastLoginAt', + 'failedLoginAttemptsCount', + 'confirmedAt', + 'lastLoginAt', + 'phoneNumberVerified', + 'hitConfirmationURL', + 'activeAccessToken', + 'activeRefreshToken', + 'jurisdictions', +]) { + @Expose() + @ApiPropertyOptional() + @IsEmail({}, { groups: [ValidationsGroupsEnum.default] }) + @EnforceLowerCase() + email?: string; + + @Expose() + @ApiPropertyOptional() + @IsEmail({}, { groups: [ValidationsGroupsEnum.default] }) + @EnforceLowerCase() + newEmail?: string; + + @Expose() + @ApiPropertyOptional() + @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] }) + @ApiPropertyOptional() + currentPassword?: string; + + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @MaxLength(256, { groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + appUrl?: string; + + @Expose() + @Type(() => IdDTO) + @IsArray({ groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional({ type: IdDTO, isArray: true }) + jurisdictions: IdDTO[]; +} diff --git a/api/src/dtos/users/user.dto.ts b/api/src/dtos/users/user.dto.ts new file mode 100644 index 0000000000..6472308e2c --- /dev/null +++ b/api/src/dtos/users/user.dto.ts @@ -0,0 +1,145 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Expose, Type } from 'class-transformer'; +import { + ArrayMinSize, + IsArray, + IsBoolean, + IsDate, + IsEmail, + IsEnum, + IsNumber, + IsPhoneNumber, + IsString, + MaxLength, + ValidateNested, +} from 'class-validator'; +import { EnforceLowerCase } from '../../decorators/enforce-lower-case.decorator'; +import { ValidationsGroupsEnum } from '../../enums/shared/validation-groups-enum'; +import { AbstractDTO } from '../shared/abstract.dto'; +import { LanguagesEnum } from '@prisma/client'; +import { IdDTO } from '../shared/id.dto'; +import { UserRole } from './user-role.dto'; +import { Jurisdiction } from '../jurisdictions/jurisdiction.dto'; + +export class User extends AbstractDTO { + @Expose() + @Type(() => Date) + @IsDate({ groups: [ValidationsGroupsEnum.default] }) + @ApiProperty() + passwordUpdatedAt: Date; + + @Expose() + @IsNumber({}, { groups: [ValidationsGroupsEnum.default] }) + @ApiProperty() + passwordValidForDays: number; + + @Expose() + @IsDate({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => Date) + @ApiPropertyOptional() + confirmedAt?: Date; + + @Expose() + @IsEmail({}, { groups: [ValidationsGroupsEnum.default] }) + @EnforceLowerCase() + @ApiProperty() + email: string; + + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @MaxLength(64, { groups: [ValidationsGroupsEnum.default] }) + @ApiProperty() + firstName: string; + + @Expose() + @ApiPropertyOptional() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @MaxLength(64, { groups: [ValidationsGroupsEnum.default] }) + middleName?: string; + + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @MaxLength(64, { groups: [ValidationsGroupsEnum.default] }) + @ApiProperty() + lastName: string; + + @Expose() + @ApiPropertyOptional() + @IsDate({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => Date) + dob?: Date; + + @Expose() + @ApiPropertyOptional() + @IsPhoneNumber('US', { groups: [ValidationsGroupsEnum.default] }) + phoneNumber?: string; + + @Expose() + @Type(() => IdDTO) + @ApiProperty({ type: IdDTO, isArray: true, nullable: true }) + listings?: IdDTO[]; + + @Expose() + @Type(() => UserRole) + @ApiPropertyOptional({ type: UserRole }) + userRoles?: UserRole; + + @Expose() + @IsEnum(LanguagesEnum, { groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional({ + enum: LanguagesEnum, + enumName: 'LanguagesEnum', + }) + language?: LanguagesEnum; + + @Expose() + @Type(() => Jurisdiction) + @IsArray({ groups: [ValidationsGroupsEnum.default] }) + @ArrayMinSize(1, { groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default], each: true }) + @ApiProperty({ type: Jurisdiction, isArray: true }) + jurisdictions: Jurisdiction[]; + + @Expose() + @IsBoolean({ groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + mfaEnabled?: boolean; + + @Expose() + @Type(() => Date) + @ApiPropertyOptional() + lastLoginAt?: Date; + + @Expose() + @Type(() => Number) + @ApiPropertyOptional() + failedLoginAttemptsCount?: number; + + @Expose() + @ApiPropertyOptional() + @IsBoolean({ groups: [ValidationsGroupsEnum.default] }) + phoneNumberVerified?: boolean; + + @Expose() + @IsBoolean({ groups: [ValidationsGroupsEnum.default] }) + @ApiProperty() + agreedToTermsOfService: boolean; + + @Expose() + @IsDate({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => Date) + @ApiPropertyOptional() + hitConfirmationURL?: Date; + + // storing the active access token for a user + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + activeAccessToken?: string; + + // storing the active refresh token for a user + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + activeRefreshToken?: string; +} diff --git a/api/src/enums/application-flagged-sets/view.ts b/api/src/enums/application-flagged-sets/view.ts new file mode 100644 index 0000000000..18ebae5434 --- /dev/null +++ b/api/src/enums/application-flagged-sets/view.ts @@ -0,0 +1,6 @@ +export enum View { + pending = 'pending', + pendingNameAndDoB = 'pendingNameAndDoB', + pendingEmail = 'pendingEmail', + resolved = 'resolved', +} diff --git a/api/src/enums/applications/order-by-enum.ts b/api/src/enums/applications/order-by-enum.ts new file mode 100644 index 0000000000..d8c01e2dd2 --- /dev/null +++ b/api/src/enums/applications/order-by-enum.ts @@ -0,0 +1,6 @@ +export enum ApplicationOrderByKeys { + firstName = 'firstName', + lastName = 'lastName', + submissionDate = 'submissionDate', + createdAt = 'createdAt', +} diff --git a/api/src/enums/applications/view-enum.ts b/api/src/enums/applications/view-enum.ts new file mode 100644 index 0000000000..96bd355b24 --- /dev/null +++ b/api/src/enums/applications/view-enum.ts @@ -0,0 +1,6 @@ +export enum ApplicationViews { + partnerList = 'partnerList', + base = 'base', + details = 'details', + csv = 'csv', +} diff --git a/api/src/enums/listings/filter-key-enum.ts b/api/src/enums/listings/filter-key-enum.ts new file mode 100644 index 0000000000..5ff52713ed --- /dev/null +++ b/api/src/enums/listings/filter-key-enum.ts @@ -0,0 +1,9 @@ +export enum ListingFilterKeys { + status = 'status', + name = 'name', + neighborhood = 'neighborhood', + bedrooms = 'bedrooms', + zipcode = 'zipcode', + leasingAgents = 'leasingAgents', + jurisdiction = 'jurisdiction', +} diff --git a/api/src/enums/listings/order-by-enum.ts b/api/src/enums/listings/order-by-enum.ts new file mode 100644 index 0000000000..a2e668e2f4 --- /dev/null +++ b/api/src/enums/listings/order-by-enum.ts @@ -0,0 +1,11 @@ +export enum ListingOrderByKeys { + mostRecentlyUpdated = 'mostRecentlyUpdated', + applicationDates = 'applicationDates', + mostRecentlyClosed = 'mostRecentlyClosed', + mostRecentlyPublished = 'mostRecentlyPublished', + name = 'name', + waitlistOpen = 'waitlistOpen', + status = 'status', + unitsAvailable = 'unitsAvailable', + marketingType = 'marketingType', +} diff --git a/api/src/enums/listings/review-order-enum.ts b/api/src/enums/listings/review-order-enum.ts new file mode 100644 index 0000000000..9b9beea87a --- /dev/null +++ b/api/src/enums/listings/review-order-enum.ts @@ -0,0 +1,5 @@ +export enum ListingReviewOrder { + lottery = 'lottery', + firstComeFirstServe = 'firstComeFirstServe', + waitlist = 'waitlist', +} diff --git a/api/src/enums/listings/view-enum.ts b/api/src/enums/listings/view-enum.ts new file mode 100644 index 0000000000..2fc77239e1 --- /dev/null +++ b/api/src/enums/listings/view-enum.ts @@ -0,0 +1,7 @@ +export enum ListingViews { + fundamentals = 'fundamentals', + base = 'base', + full = 'full', + details = 'details', + csv = 'csv', +} diff --git a/api/src/enums/mfa/mfa-type-enum.ts b/api/src/enums/mfa/mfa-type-enum.ts new file mode 100644 index 0000000000..4b70dda762 --- /dev/null +++ b/api/src/enums/mfa/mfa-type-enum.ts @@ -0,0 +1,4 @@ +export enum MfaType { + sms = 'sms', + email = 'email', +} diff --git a/api/src/enums/multiselect-questions/filter-key-enum.ts b/api/src/enums/multiselect-questions/filter-key-enum.ts new file mode 100644 index 0000000000..398269f1eb --- /dev/null +++ b/api/src/enums/multiselect-questions/filter-key-enum.ts @@ -0,0 +1,4 @@ +export enum MultiselectQuestionFilterKeys { + jurisdiction = 'jurisdiction', + applicationSection = 'applicationSection', +} diff --git a/api/src/enums/multiselect-questions/validation-method-enum.ts b/api/src/enums/multiselect-questions/validation-method-enum.ts new file mode 100644 index 0000000000..e891f3cb7b --- /dev/null +++ b/api/src/enums/multiselect-questions/validation-method-enum.ts @@ -0,0 +1,5 @@ +export enum ValidationMethod { + radius = 'radius', + map = 'map', + none = 'none', +} diff --git a/api/src/enums/permissions/http-method-to-actions-enum.ts b/api/src/enums/permissions/http-method-to-actions-enum.ts new file mode 100644 index 0000000000..51aa23e678 --- /dev/null +++ b/api/src/enums/permissions/http-method-to-actions-enum.ts @@ -0,0 +1,9 @@ +import { permissionActions } from './permission-actions-enum'; + +export const httpMethodsToAction = { + PUT: permissionActions.update, + PATCH: permissionActions.update, + DELETE: permissionActions.delete, + POST: permissionActions.create, + GET: permissionActions.read, +}; diff --git a/api/src/enums/permissions/permission-actions-enum.ts b/api/src/enums/permissions/permission-actions-enum.ts new file mode 100644 index 0000000000..22553e5a18 --- /dev/null +++ b/api/src/enums/permissions/permission-actions-enum.ts @@ -0,0 +1,12 @@ +export enum permissionActions { + create = 'create', + read = 'read', + update = 'update', + delete = 'delete', + submit = 'submit', + confirm = 'confirm', + invite = 'invite', + invitePartner = 'invitePartner', + inviteJurisdictionalAdmin = 'inviteJurisdictionalAdmin', + inviteSuperAdmin = 'inviteSuperAdmin', +} diff --git a/api/src/enums/permissions/user-role-enum.ts b/api/src/enums/permissions/user-role-enum.ts new file mode 100644 index 0000000000..0c7324357e --- /dev/null +++ b/api/src/enums/permissions/user-role-enum.ts @@ -0,0 +1,6 @@ +export enum UserRoleEnum { + user = 'user', + partner = 'partner', + admin = 'admin', + jurisdictionAdmin = 'jurisdictionAdmin', +} diff --git a/api/src/enums/shared/input-type-enum.ts b/api/src/enums/shared/input-type-enum.ts new file mode 100644 index 0000000000..a6b45a3773 --- /dev/null +++ b/api/src/enums/shared/input-type-enum.ts @@ -0,0 +1,6 @@ +export enum InputType { + boolean = 'boolean', + text = 'text', + address = 'address', + hhMemberSelect = 'hhMemberSelect', +} diff --git a/api/src/enums/shared/order-by-enum.ts b/api/src/enums/shared/order-by-enum.ts new file mode 100644 index 0000000000..83b1952206 --- /dev/null +++ b/api/src/enums/shared/order-by-enum.ts @@ -0,0 +1,4 @@ +export enum OrderByEnum { + ASC = 'asc', + DESC = 'desc', +} diff --git a/api/src/enums/shared/validation-groups-enum.ts b/api/src/enums/shared/validation-groups-enum.ts new file mode 100644 index 0000000000..6c9c0a4560 --- /dev/null +++ b/api/src/enums/shared/validation-groups-enum.ts @@ -0,0 +1,5 @@ +export enum ValidationsGroupsEnum { + default = 'default', + partners = 'partners', + applicants = 'applicants', +} diff --git a/api/src/guards/admin-or-jurisdiction-admin.guard.ts b/api/src/guards/admin-or-jurisdiction-admin.guard.ts new file mode 100644 index 0000000000..6cccdea8f9 --- /dev/null +++ b/api/src/guards/admin-or-jurisdiction-admin.guard.ts @@ -0,0 +1,13 @@ +import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common'; +import { User } from '../dtos/users/user.dto'; + +@Injectable() +export class AdminOrJurisdictionalAdminGuard implements CanActivate { + canActivate(context: ExecutionContext) { + const req = context.switchToHttp().getRequest(); + const authUser: User = req['user']; + return ( + authUser?.userRoles?.isAdmin || authUser?.userRoles?.isJurisdictionalAdmin + ); + } +} diff --git a/api/src/guards/jwt.guard.ts b/api/src/guards/jwt.guard.ts new file mode 100644 index 0000000000..2155290ede --- /dev/null +++ b/api/src/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/api/src/guards/mfa.guard.ts b/api/src/guards/mfa.guard.ts new file mode 100644 index 0000000000..4c3ebae5e3 --- /dev/null +++ b/api/src/guards/mfa.guard.ts @@ -0,0 +1,5 @@ +import { Injectable } from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; + +@Injectable() +export class MfaAuthGuard extends AuthGuard('mfa') {} diff --git a/api/src/guards/optional.guard.ts b/api/src/guards/optional.guard.ts new file mode 100644 index 0000000000..fc5d7ad79a --- /dev/null +++ b/api/src/guards/optional.guard.ts @@ -0,0 +1,11 @@ +import { JwtAuthGuard } from './jwt.guard'; +import { Injectable } from '@nestjs/common'; + +@Injectable() +export class OptionalAuthGuard extends JwtAuthGuard { + handleRequest(err, user: User) { + // user is boolean false when not logged in + // return undefined instead + return user || undefined; + } +} diff --git a/api/src/guards/permission.guard.ts b/api/src/guards/permission.guard.ts new file mode 100644 index 0000000000..a1717b754f --- /dev/null +++ b/api/src/guards/permission.guard.ts @@ -0,0 +1,43 @@ +import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { httpMethodsToAction } from '../enums/permissions/http-method-to-actions-enum'; +import { User } from '../dtos/users/user.dto'; +import { PermissionService } from '../services/permission.service'; + +@Injectable() +export class PermissionGuard implements CanActivate { + constructor( + private permissionService: PermissionService, + private reflector: Reflector, + ) {} + + async canActivate(context: ExecutionContext): Promise { + const req = context.switchToHttp().getRequest(); + const user: User = req['user']; + + if (user?.userRoles?.isAdmin) { + return true; + } + + const type = this.reflector.getAllAndOverride('permission_type', [ + context.getClass(), + context.getHandler(), + ]); + + const action = + this.reflector.get('permission_action', context.getHandler()) || + httpMethodsToAction[req.method]; + + let resource; + + if (req.params.id) { + resource = ['GET'].includes(req.method) + ? { id: req.params.id } + : { id: req.body.id }; + } else if (req.body.id) { + resource = { id: req.body.id }; + } + + return this.permissionService.can(user, type, action, resource); + } +} diff --git a/api/src/guards/user-profile-permission-guard.ts b/api/src/guards/user-profile-permission-guard.ts new file mode 100644 index 0000000000..18d03188f8 --- /dev/null +++ b/api/src/guards/user-profile-permission-guard.ts @@ -0,0 +1,25 @@ +import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { permissionActions } from '../enums/permissions/permission-actions-enum'; +import { User } from '../dtos/users/user.dto'; +import { PermissionService } from '../services/permission.service'; + +@Injectable() +export class UserProfilePermissionGuard implements CanActivate { + constructor( + private permissionService: PermissionService, + private reflector: Reflector, + ) {} + + async canActivate(context: ExecutionContext): Promise { + const req = context.switchToHttp().getRequest(); + const user: User = req['user']; + const type = this.reflector.getAllAndOverride('permission_type', [ + context.getClass(), + context.getHandler(), + ]); + return this.permissionService.can(user, type, permissionActions.read, { + id: user.id, + }); + } +} diff --git a/api/src/interceptors/activity-log.interceptor.ts b/api/src/interceptors/activity-log.interceptor.ts new file mode 100644 index 0000000000..6222196bb7 --- /dev/null +++ b/api/src/interceptors/activity-log.interceptor.ts @@ -0,0 +1,123 @@ +import { + CallHandler, + ExecutionContext, + Injectable, + NestInterceptor, +} from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { endWith, ignoreElements, mergeMap } from 'rxjs/operators'; +import { from } from 'rxjs'; +import { User } from '../dtos/users/user.dto'; +import { httpMethodsToAction } from '../enums/permissions/http-method-to-actions-enum'; +import { permissionActions } from '../enums/permissions/permission-actions-enum'; +import { deepFind } from '../utilities/deep-find'; +import { PrismaService } from '../services/prisma.service'; + +export type ActivityLogMetadataType = Array<{ + targetPropertyName: string; + propertyPath: string; +}>; + +@Injectable() +export class ActivityLogInterceptor implements NestInterceptor { + constructor(private reflector: Reflector, private prisma: PrismaService) {} + + /* + builds the metadata that gets stored in the activity log + */ + extractMetadata(body: any, activityLogMetadata: ActivityLogMetadataType) { + let metadata; + if (activityLogMetadata) { + metadata = {}; + for (const trackPropertiesMetadata of activityLogMetadata) { + metadata[trackPropertiesMetadata.targetPropertyName] = deepFind( + body, + trackPropertiesMetadata.propertyPath, + ); + } + } + return metadata; + } + + /* + parses the request to get some of the basic info we will need during the intercept step + */ + getBasicRequestInfo(context: ExecutionContext): { + module?: string; + action?: string; + resourceId?: string; + user?: User; + activityLogMetadata: ActivityLogMetadataType; + } { + const req = context.switchToHttp().getRequest(); + const module = this.reflector.getAllAndOverride('permission_type', [ + context.getClass(), + context.getHandler(), + ]); + + const action = + this.reflector.get('permission_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 }; + } + + /* + parses and stores into the activity log table + */ + intercept(context: ExecutionContext, next: CallHandler) { + const { module, action, user, activityLogMetadata } = + this.getBasicRequestInfo(context); + if ( + action === permissionActions.read || + action === permissionActions.submit + ) { + // if the action is a read or a submit we don't need to log the activity + 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 === 'POST') { + resourceId = value?.id; + } else { + resourceId = req.body.id; + } + return from( + this.prisma.activityLog.create({ + include: { + userAccounts: true, + }, + data: { + module, + recordId: resourceId, + action, + metadata, + userAccounts: { + connect: { + id: user.id, + }, + }, + }, + }), + ).pipe(ignoreElements(), endWith(value)); + }, + ), + ); + } +} diff --git a/api/src/main.ts b/api/src/main.ts new file mode 100644 index 0000000000..982c37196a --- /dev/null +++ b/api/src/main.ts @@ -0,0 +1,62 @@ +import { NestFactory, HttpAdapterHost } from '@nestjs/core'; +import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger'; +import { ConfigService } from '@nestjs/config'; +import cookieParser from 'cookie-parser'; +import compression from 'compression'; +import { AppModule } from './modules/app.module'; +import { CustomExceptionFilter } from './utilities/custom-exception-filter'; +import { json } from 'express'; + +async function bootstrap() { + const app = await NestFactory.create(AppModule, { + logger: + process.env.NODE_ENV === 'development' + ? ['error', 'warn', 'log', 'debug'] + : ['error', 'warn'], + }); + const allowList = process.env.CORS_ORIGINS || []; + const allowListRegex = process.env.CORS_REGEX + ? JSON.parse(process.env.CORS_REGEX) + : []; + const regexAllowList = allowListRegex.map((regex) => { + return new RegExp(regex); + }); + + const { httpAdapter } = app.get(HttpAdapterHost); + app.useGlobalFilters(new CustomExceptionFilter(httpAdapter)); + app.enableCors((req, cb) => { + const options = { + credentials: true, + origin: false, + }; + + if ( + allowList.indexOf(req.header('Origin')) !== -1 || + regexAllowList.some((regex) => regex.test(req.header('Origin'))) + ) { + options.origin = true; + } + cb(null, options); + }); + app.use( + cookieParser(), + compression({ + filter: (_, res) => { + return res.req.route?.path === '/applications/csv'; + }, + }), + ); + app.use(json({ limit: '50mb' })); + const config = new DocumentBuilder() + .setTitle('Bloom API') + .setDescription('The API for Bloom') + .setVersion('2.0') + .addTag('listings') + .build(); + const document = SwaggerModule.createDocument(app, config); + SwaggerModule.setup('api', app, document); + const configService: ConfigService = app.get(ConfigService); + + await app.listen(configService.get('PORT')); +} +bootstrap(); diff --git a/api/src/modules/ami-chart.module.ts b/api/src/modules/ami-chart.module.ts new file mode 100644 index 0000000000..671aa991c8 --- /dev/null +++ b/api/src/modules/ami-chart.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { AmiChartController } from '../controllers/ami-chart.controller'; +import { AmiChartService } from '../services/ami-chart.service'; +import { PermissionModule } from './permission.module'; +import { PrismaModule } from './prisma.module'; + +@Module({ + imports: [PrismaModule, PermissionModule], + controllers: [AmiChartController], + providers: [AmiChartService], + exports: [AmiChartService], +}) +export class AmiChartModule {} diff --git a/api/src/modules/app.module.ts b/api/src/modules/app.module.ts new file mode 100644 index 0000000000..d8c57952e3 --- /dev/null +++ b/api/src/modules/app.module.ts @@ -0,0 +1,59 @@ +import { Logger, Module } from '@nestjs/common'; +import { SchedulerRegistry } from '@nestjs/schedule'; +import { AppController } from '../controllers/app.controller'; +import { AppService } from '../services/app.service'; +import { PrismaModule } from './prisma.module'; +import { AmiChartModule } from './ami-chart.module'; +import { ListingModule } from './listing.module'; +import { ReservedCommunityTypeModule } from './reserved-community-type.module'; +import { UnitAccessibilityPriorityTypeServiceModule } from './unit-accessibility-priority-type.module'; +import { UnitTypeModule } from './unit-type.module'; +import { UnitRentTypeModule } from './unit-rent-type.module'; +import { JurisdictionModule } from './jurisdiction.module'; +import { MultiselectQuestionModule } from './multiselect-question.module'; +import { ApplicationModule } from './application.module'; +import { AssetModule } from './asset.module'; +import { UserModule } from './user.module'; +import { AuthModule } from './auth.module'; +import { ApplicationFlaggedSetModule } from './application-flagged-set.module'; +import { MapLayerModule } from './map-layer.module'; + +@Module({ + imports: [ + ListingModule, + AmiChartModule, + ReservedCommunityTypeModule, + UnitTypeModule, + UnitAccessibilityPriorityTypeServiceModule, + UnitRentTypeModule, + JurisdictionModule, + MultiselectQuestionModule, + ApplicationModule, + AssetModule, + UserModule, + PrismaModule, + AuthModule, + ApplicationFlaggedSetModule, + MapLayerModule, + ], + controllers: [AppController], + providers: [AppService, Logger, SchedulerRegistry], + exports: [ + ListingModule, + AmiChartModule, + ReservedCommunityTypeModule, + UnitTypeModule, + UnitAccessibilityPriorityTypeServiceModule, + UnitRentTypeModule, + JurisdictionModule, + MultiselectQuestionModule, + ApplicationModule, + AssetModule, + UserModule, + PrismaModule, + AuthModule, + ApplicationFlaggedSetModule, + MapLayerModule, + ], +}) +export class AppModule {} diff --git a/api/src/modules/application-flagged-set.module.ts b/api/src/modules/application-flagged-set.module.ts new file mode 100644 index 0000000000..0482dd32d1 --- /dev/null +++ b/api/src/modules/application-flagged-set.module.ts @@ -0,0 +1,14 @@ +import { Logger, Module } from '@nestjs/common'; +import { SchedulerRegistry } from '@nestjs/schedule'; +import { ApplicationFlaggedSetController } from '../controllers/application-flagged-set.controller'; +import { ApplicationFlaggedSetService } from '../services/application-flagged-set.service'; +import { PrismaModule } from './prisma.module'; +import { PermissionModule } from './permission.module'; + +@Module({ + imports: [PrismaModule, PermissionModule], + controllers: [ApplicationFlaggedSetController], + providers: [ApplicationFlaggedSetService, Logger, SchedulerRegistry], + exports: [ApplicationFlaggedSetService], +}) +export class ApplicationFlaggedSetModule {} diff --git a/api/src/modules/application.module.ts b/api/src/modules/application.module.ts new file mode 100644 index 0000000000..ea471a7ce4 --- /dev/null +++ b/api/src/modules/application.module.ts @@ -0,0 +1,30 @@ +import { Module } from '@nestjs/common'; +import { ApplicationController } from '../controllers/application.controller'; +import { ApplicationService } from '../services/application.service'; +import { GeocodingService } from '../services/geocoding.service'; +import { PrismaModule } from './prisma.module'; +import { PermissionModule } from './permission.module'; +import { EmailModule } from './email.module'; +import { ListingModule } from './listing.module'; +import { MultiselectQuestionModule } from './multiselect-question.module'; +import { ApplicationCsvExporterService } from '../services/application-csv-export.service'; +import { UnitTypeModule } from './unit-type.module'; + +@Module({ + imports: [ + PrismaModule, + EmailModule, + ListingModule, + MultiselectQuestionModule, + PermissionModule, + UnitTypeModule, + ], + controllers: [ApplicationController], + providers: [ + ApplicationService, + GeocodingService, + ApplicationCsvExporterService, + ], + exports: [ApplicationService], +}) +export class ApplicationModule {} diff --git a/api/src/modules/asset.module.ts b/api/src/modules/asset.module.ts new file mode 100644 index 0000000000..5d21220a96 --- /dev/null +++ b/api/src/modules/asset.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { AssetController } from '../controllers/asset.controller'; +import { AssetService } from '../services/asset.service'; +import { PermissionModule } from './permission.module'; + +@Module({ + imports: [PermissionModule], + controllers: [AssetController], + providers: [AssetService], + exports: [AssetService], +}) +export class AssetModule {} diff --git a/api/src/modules/auth.module.ts b/api/src/modules/auth.module.ts new file mode 100644 index 0000000000..13ff160ec5 --- /dev/null +++ b/api/src/modules/auth.module.ts @@ -0,0 +1,30 @@ +import { Module } from '@nestjs/common'; +import { PassportModule } from '@nestjs/passport'; +import { JwtModule } from '@nestjs/jwt'; +import { AuthController } from '../controllers/auth.controller'; +import { AuthService } from '../services/auth.service'; +import { PermissionService } from '../services/permission.service'; +import { PrismaModule } from './prisma.module'; +import { SmsModule } from './sms-module'; +import { UserModule } from './user.module'; +import { MfaStrategy } from '../passports/mfa.strategy'; +import { JwtStrategy } from '../passports/jwt.strategy'; +import { EmailModule } from './email.module'; + +@Module({ + imports: [ + PrismaModule, + UserModule, + SmsModule, + PassportModule, + EmailModule, + JwtModule.register({ + secret: process.env.APP_SECRET, + }), + EmailModule, + ], + controllers: [AuthController], + providers: [AuthService, PermissionService, MfaStrategy, JwtStrategy], + exports: [AuthService, PermissionService], +}) +export class AuthModule {} diff --git a/api/src/modules/email.module.ts b/api/src/modules/email.module.ts new file mode 100644 index 0000000000..d1af8100d5 --- /dev/null +++ b/api/src/modules/email.module.ts @@ -0,0 +1,24 @@ +import { Module } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { MailService } from '@sendgrid/mail'; +import { EmailService } from '../services/email.service'; +import { JurisdictionService } from '../services/jurisdiction.service'; +import { TranslationService } from '../services/translation.service'; +import { GoogleTranslateService } from '../services/google-translate.service'; +import { SendGridService } from '../services/sendgrid.service'; + +@Module({ + imports: [], + controllers: [], + providers: [ + EmailService, + JurisdictionService, + TranslationService, + ConfigService, + GoogleTranslateService, + SendGridService, + MailService, + ], + exports: [EmailService], +}) +export class EmailModule {} diff --git a/api/src/modules/jurisdiction.module.ts b/api/src/modules/jurisdiction.module.ts new file mode 100644 index 0000000000..e509e8ccb5 --- /dev/null +++ b/api/src/modules/jurisdiction.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { JurisdictionController } from '../controllers/jurisdiction.controller'; +import { JurisdictionService } from '../services/jurisdiction.service'; +import { PrismaModule } from './prisma.module'; +import { PermissionModule } from './permission.module'; + +@Module({ + imports: [PrismaModule, PermissionModule], + controllers: [JurisdictionController], + providers: [JurisdictionService], + exports: [JurisdictionService], +}) +export class JurisdictionModule {} diff --git a/api/src/modules/listing.module.ts b/api/src/modules/listing.module.ts new file mode 100644 index 0000000000..8f81abc9ab --- /dev/null +++ b/api/src/modules/listing.module.ts @@ -0,0 +1,35 @@ +import { Logger, Module } from '@nestjs/common'; +import { HttpModule } from '@nestjs/axios'; +import { SchedulerRegistry } from '@nestjs/schedule'; +import { ConfigService } from '@nestjs/config'; +import { ListingController } from '../controllers/listing.controller'; +import { ListingService } from '../services/listing.service'; +import { PrismaModule } from './prisma.module'; +import { TranslationService } from '../services/translation.service'; +import { GoogleTranslateService } from '../services/google-translate.service'; +import { ApplicationFlaggedSetModule } from './application-flagged-set.module'; +import { EmailModule } from './email.module'; +import { PermissionModule } from './permission.module'; +import { ListingCsvExporterService } from '../services/listing-csv-export.service'; + +@Module({ + imports: [ + PrismaModule, + HttpModule, + EmailModule, + ApplicationFlaggedSetModule, + PermissionModule, + ], + controllers: [ListingController], + providers: [ + ListingService, + TranslationService, + GoogleTranslateService, + ConfigService, + Logger, + SchedulerRegistry, + ListingCsvExporterService, + ], + exports: [ListingService], +}) +export class ListingModule {} diff --git a/api/src/modules/map-layer.module.ts b/api/src/modules/map-layer.module.ts new file mode 100644 index 0000000000..48d6cae25c --- /dev/null +++ b/api/src/modules/map-layer.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { PrismaModule } from './prisma.module'; +import { PermissionModule } from './permission.module'; +import { MapLayersController } from '../controllers/map-layer.controller'; +import { MapLayersService } from '../services/map-layers.service'; + +@Module({ + imports: [PrismaModule, PermissionModule], + controllers: [MapLayersController], + providers: [MapLayersService], + exports: [MapLayersService], +}) +export class MapLayerModule {} diff --git a/api/src/modules/multiselect-question.module.ts b/api/src/modules/multiselect-question.module.ts new file mode 100644 index 0000000000..d4c2b0d713 --- /dev/null +++ b/api/src/modules/multiselect-question.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { MultiselectQuestionController } from '../controllers/multiselect-question.controller'; +import { MultiselectQuestionService } from '../services/multiselect-question.service'; +import { PrismaModule } from './prisma.module'; +import { PermissionModule } from './permission.module'; + +@Module({ + imports: [PrismaModule, PermissionModule], + controllers: [MultiselectQuestionController], + providers: [MultiselectQuestionService], + exports: [MultiselectQuestionService], +}) +export class MultiselectQuestionModule {} diff --git a/api/src/modules/permission.module.ts b/api/src/modules/permission.module.ts new file mode 100644 index 0000000000..bbb734fcc5 --- /dev/null +++ b/api/src/modules/permission.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { PrismaModule } from './prisma.module'; +import { PermissionService } from '../services/permission.service'; + +@Module({ + imports: [PrismaModule], + providers: [PermissionService], + exports: [PermissionService], +}) +export class PermissionModule {} diff --git a/api/src/modules/prisma.module.ts b/api/src/modules/prisma.module.ts new file mode 100644 index 0000000000..bc57b9ad55 --- /dev/null +++ b/api/src/modules/prisma.module.ts @@ -0,0 +1,9 @@ +import { Global, Module } from '@nestjs/common'; +import { PrismaService } from '../services/prisma.service'; + +@Global() +@Module({ + providers: [PrismaService], + exports: [PrismaService], +}) +export class PrismaModule {} diff --git a/api/src/modules/reserved-community-type.module.ts b/api/src/modules/reserved-community-type.module.ts new file mode 100644 index 0000000000..db4cc8da74 --- /dev/null +++ b/api/src/modules/reserved-community-type.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { ReservedCommunityTypeController } from '../controllers/reserved-community-type.controller'; +import { ReservedCommunityTypeService } from '../services/reserved-community-type.service'; +import { PrismaModule } from './prisma.module'; +import { PermissionModule } from './permission.module'; + +@Module({ + imports: [PrismaModule, PermissionModule], + controllers: [ReservedCommunityTypeController], + providers: [ReservedCommunityTypeService], + exports: [ReservedCommunityTypeService], +}) +export class ReservedCommunityTypeModule {} diff --git a/api/src/modules/sms-module.ts b/api/src/modules/sms-module.ts new file mode 100644 index 0000000000..60412a00d4 --- /dev/null +++ b/api/src/modules/sms-module.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common'; +import { SmsService } from '../services/sms.service'; + +@Module({ + providers: [SmsService], + exports: [SmsService], +}) +export class SmsModule {} diff --git a/api/src/modules/unit-accessibility-priority-type.module.ts b/api/src/modules/unit-accessibility-priority-type.module.ts new file mode 100644 index 0000000000..39a44c7d9f --- /dev/null +++ b/api/src/modules/unit-accessibility-priority-type.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { UnitAccessibilityPriorityTypeController } from '../controllers/unit-accessibility-priority-type.controller'; +import { UnitAccessibilityPriorityTypeService } from '../services/unit-accessibility-priority-type.service'; +import { PrismaModule } from './prisma.module'; +import { PermissionModule } from './permission.module'; + +@Module({ + imports: [PrismaModule, PermissionModule], + controllers: [UnitAccessibilityPriorityTypeController], + providers: [UnitAccessibilityPriorityTypeService], + exports: [UnitAccessibilityPriorityTypeService], +}) +export class UnitAccessibilityPriorityTypeServiceModule {} diff --git a/api/src/modules/unit-rent-type.module.ts b/api/src/modules/unit-rent-type.module.ts new file mode 100644 index 0000000000..9c2e5e6ac9 --- /dev/null +++ b/api/src/modules/unit-rent-type.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { UnitRentTypeController } from '../controllers/unit-rent-type.controller'; +import { UnitRentTypeService } from '../services/unit-rent-type.service'; +import { PrismaModule } from './prisma.module'; +import { PermissionModule } from './permission.module'; + +@Module({ + imports: [PrismaModule, PermissionModule], + controllers: [UnitRentTypeController], + providers: [UnitRentTypeService], + exports: [UnitRentTypeService], +}) +export class UnitRentTypeModule {} diff --git a/api/src/modules/unit-type.module.ts b/api/src/modules/unit-type.module.ts new file mode 100644 index 0000000000..af43995386 --- /dev/null +++ b/api/src/modules/unit-type.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { UnitTypeController } from '../controllers/unit-type.controller'; +import { UnitTypeService } from '../services/unit-type.service'; +import { PrismaModule } from './prisma.module'; +import { PermissionModule } from './permission.module'; + +@Module({ + imports: [PrismaModule, PermissionModule], + controllers: [UnitTypeController], + providers: [UnitTypeService], + exports: [UnitTypeService], +}) +export class UnitTypeModule {} diff --git a/api/src/modules/user.module.ts b/api/src/modules/user.module.ts new file mode 100644 index 0000000000..4598078cda --- /dev/null +++ b/api/src/modules/user.module.ts @@ -0,0 +1,15 @@ +import { Module } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { UserController } from '../controllers/user.controller'; +import { UserService } from '../services/user.service'; +import { PrismaModule } from './prisma.module'; +import { EmailModule } from './email.module'; +import { PermissionModule } from './permission.module'; + +@Module({ + imports: [PrismaModule, EmailModule, PermissionModule], + controllers: [UserController], + providers: [UserService, ConfigService], + exports: [UserService], +}) +export class UserModule {} diff --git a/api/src/passports/jwt.strategy.ts b/api/src/passports/jwt.strategy.ts new file mode 100644 index 0000000000..5e210c5ed5 --- /dev/null +++ b/api/src/passports/jwt.strategy.ts @@ -0,0 +1,89 @@ +import { Strategy } from 'passport-jwt'; +import { Request } from 'express'; +import { PassportStrategy } from '@nestjs/passport'; +import { Injectable, UnauthorizedException } from '@nestjs/common'; +import { User } from '../dtos/users/user.dto'; +import { TOKEN_COOKIE_NAME } from '../services/auth.service'; +import { PrismaService } from '../services/prisma.service'; +import { mapTo } from '../utilities/mapTo'; +import { isPasswordOutdated } from '../utilities/password-helpers'; + +type PayloadType = { + sub: string; +}; + +@Injectable() +export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') { + constructor(private prisma: PrismaService) { + super({ + jwtFromRequest: JwtStrategy.extractJwt, + passReqToCallback: true, + ignoreExpiration: false, + secretOrKey: process.env.APP_SECRET, + }); + } + + /* + verifies that the incoming jwt token is valid + returns the verified user + */ + async validate(req: Request, payload: PayloadType): Promise { + const rawToken = JwtStrategy.extractJwt(req); + const userId = payload.sub; + + const rawUser = await this.prisma.userAccounts.findFirst({ + include: { + listings: true, + userRoles: true, + jurisdictions: true, + }, + where: { + id: userId, + }, + }); + + if (!rawUser) { + // if there is no user matching the incoming id + throw new UnauthorizedException(`user ${userId} does not exist`); + } + if ( + isPasswordOutdated( + rawUser.passwordValidForDays, + rawUser.passwordUpdatedAt, + ) + ) { + // if we have a user and the user's password is outdated + throw new UnauthorizedException( + `user ${userId} attempted to log in, but password is outdated`, + ); + } else if (rawUser.activeAccessToken !== rawToken) { + // if the incoming token is not the active token for the user, clear the user's tokens + await this.prisma.userAccounts.update({ + data: { + activeAccessToken: null, + activeRefreshToken: null, + }, + where: { + id: userId, + }, + }); + throw new UnauthorizedException(` + user ${userId} attempted to log in, but token ${rawToken} didn't match their stored token ${rawUser.activeAccessToken} + `); + } + + const user = mapTo(User, rawUser); + return user; + } + + /* + grabs the token out the request's cookies + */ + static extractJwt(req: Request): string | null { + if (req.cookies?.[TOKEN_COOKIE_NAME]) { + return req.cookies[TOKEN_COOKIE_NAME]; + } + + return null; + } +} diff --git a/api/src/passports/mfa.strategy.ts b/api/src/passports/mfa.strategy.ts new file mode 100644 index 0000000000..ee37adebaf --- /dev/null +++ b/api/src/passports/mfa.strategy.ts @@ -0,0 +1,210 @@ +import { Strategy } from 'passport-local'; +import { Request } from 'express'; +import { PassportStrategy } from '@nestjs/passport'; +import { + HttpException, + HttpStatus, + Injectable, + UnauthorizedException, + ValidationPipe, +} from '@nestjs/common'; +import { User } from '../dtos/users/user.dto'; +import { PrismaService } from '../services/prisma.service'; +import { mapTo } from '../utilities/mapTo'; +import { + isPasswordOutdated, + isPasswordValid, +} from '../utilities/password-helpers'; +import { defaultValidationPipeOptions } from '../utilities/default-validation-pipe-options'; +import { Login } from '../dtos/auth/login.dto'; +import { MfaType } from '../enums/mfa/mfa-type-enum'; + +@Injectable() +export class MfaStrategy extends PassportStrategy(Strategy, 'mfa') { + constructor(private prisma: PrismaService) { + super({ + usernameField: 'email', + passReqToCallback: true, + }); + } + + /* + verifies that the incoming log in information is valid + returns the verified user + */ + async validate(req: Request): Promise { + const validationPipe = new ValidationPipe(defaultValidationPipeOptions); + const dto: Login = await validationPipe.transform(req.body, { + type: 'body', + metatype: Login, + }); + + const rawUser = await this.prisma.userAccounts.findFirst({ + include: { + userRoles: true, + listings: true, + jurisdictions: true, + }, + where: { + email: dto.email, + }, + }); + if (!rawUser) { + throw new UnauthorizedException( + `user ${dto.email} attempted to log in, but does not exist`, + ); + } else if ( + rawUser.lastLoginAt && + rawUser.failedLoginAttemptsCount >= + Number(process.env.AUTH_LOCK_LOGIN_AFTER_FAILED_ATTEMPTS) + ) { + // if a user has logged in, but has since gone over their max failed login attempts + const retryAfter = new Date( + rawUser.lastLoginAt.getTime() + + Number(process.env.AUTH_LOCK_LOGIN_COOLDOWN), + ); + if (retryAfter <= new Date()) { + // if we have passed the login lock TTL, reset login lock countdown + rawUser.failedLoginAttemptsCount = 0; + } else { + // if the login lock is still a valid lock, error + throw new HttpException( + { + statusCode: HttpStatus.TOO_MANY_REQUESTS, + error: 'Too Many Requests', + message: 'Failed login attempts exceeded.', + retryAfter, + }, + 429, + ); + } + } else if (!rawUser.confirmedAt) { + // if user is not confirmed already + throw new UnauthorizedException( + `user ${rawUser.id} attempted to login, but is not confirmed`, + ); + } else if ( + isPasswordOutdated( + rawUser.passwordValidForDays, + rawUser.passwordUpdatedAt, + ) + ) { + // if password TTL is expired + throw new UnauthorizedException( + `user ${rawUser.id} attempted to login, but password is no longer valid`, + ); + } else if (!(await isPasswordValid(rawUser.passwordHash, dto.password))) { + // if incoming password does not match + await this.updateFailedLoginCount( + rawUser.failedLoginAttemptsCount + 1, + rawUser.id, + ); + throw new UnauthorizedException({ + failureCountRemaining: + Number(process.env.AUTH_LOCK_LOGIN_AFTER_FAILED_ATTEMPTS) - + rawUser.failedLoginAttemptsCount, + }); + } + + if (!rawUser.mfaEnabled) { + // if user is not an mfaEnabled user + await this.updateStoredUser(null, null, null, 0, rawUser.id); + return mapTo(User, rawUser); + } + + let authSuccess = true; + if (!dto.mfaCode || !rawUser.mfaCode || !rawUser.mfaCodeUpdatedAt) { + // if an mfaCode was not sent, and an mfaCode wasn't stored in the db for the user + // signal to the front end to request an mfa code + await this.updateFailedLoginCount(0, rawUser.id); + throw new UnauthorizedException({ + name: 'mfaCodeIsMissing', + }); + } else if ( + new Date( + rawUser.mfaCodeUpdatedAt.getTime() + Number(process.env.MFA_CODE_VALID), + ) < new Date() || + rawUser.mfaCode !== dto.mfaCode + ) { + // if mfaCode TTL has expired, or if the mfa code input was incorrect + authSuccess = false; + } else { + // if mfaCode login was a success + rawUser.mfaCode = null; + rawUser.mfaCodeUpdatedAt = new Date(); + } + + if (!authSuccess) { + // if we failed login validation + rawUser.failedLoginAttemptsCount += 1; + await this.updateStoredUser( + rawUser.mfaCode, + rawUser.mfaCodeUpdatedAt, + rawUser.phoneNumberVerified, + rawUser.failedLoginAttemptsCount, + rawUser.id, + ); + throw new UnauthorizedException({ + message: 'mfaUnauthorized', + failureCountRemaining: + Number(process.env.AUTH_LOCK_LOGIN_AFTER_FAILED_ATTEMPTS) + + 1 - + rawUser.failedLoginAttemptsCount, + }); + } + // if the password and mfa code was valid + rawUser.failedLoginAttemptsCount = 0; + if (!rawUser.phoneNumberVerified && dto.mfaType === MfaType.sms) { + // if the phone number was not verfied, but this mfa login was done through sms + // then we should consider the phone number verified + rawUser.phoneNumberVerified = true; + } + + await this.updateStoredUser( + rawUser.mfaCode, + rawUser.mfaCodeUpdatedAt, + rawUser.phoneNumberVerified, + rawUser.failedLoginAttemptsCount, + rawUser.id, + ); + return mapTo(User, rawUser); + } + + async updateFailedLoginCount(count: number, userId: string): Promise { + let lastLoginAt = undefined; + if (count === 1) { + // if the count went from 0 -> 1 then we update the lastLoginAt so the count of failed attempts falls off properly + lastLoginAt = new Date(); + } + await this.prisma.userAccounts.update({ + data: { + failedLoginAttemptsCount: count, + lastLoginAt, + }, + where: { + id: userId, + }, + }); + } + + async updateStoredUser( + mfaCode: string, + mfaCodeUpdatedAt: Date, + phoneNumberVerified: boolean, + failedLoginAttemptsCount: number, + userId: string, + ): Promise { + await this.prisma.userAccounts.update({ + data: { + mfaCode, + mfaCodeUpdatedAt, + phoneNumberVerified, + failedLoginAttemptsCount, + lastLoginAt: new Date(), + }, + where: { + id: userId, + }, + }); + } +} diff --git a/api/src/permission-configs/permission_model.conf b/api/src/permission-configs/permission_model.conf new file mode 100644 index 0000000000..eb21cf3bf1 --- /dev/null +++ b/api/src/permission-configs/permission_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/api/src/permission-configs/permission_policy.csv b/api/src/permission-configs/permission_policy.csv new file mode 100644 index 0000000000..7f1bf80ede --- /dev/null +++ b/api/src/permission-configs/permission_policy.csv @@ -0,0 +1,88 @@ +p, admin, application, true, .* +p, user, application, true, submit +p, user, application, r.sub == r.obj.userId, read +p, anonymous, application, true, submit + +p, admin, user, true, .* +p, admin, userProfile, true, .* +p, user, user, r.sub == r.obj.id, (read|update) +p, user, userProfile, r.sub == r.obj.id, (read|update) +p, anonymous, user, true, create + +p, admin, asset, true, .* +p, jurisdictionAdmin, asset, true, .* +p, partner, asset, true, .* + +p, admin, multiselectQuestion, true, .* +p, jurisdictionAdmin, multiselectQuestion, true, .* +p, partner, multiselectQuestion, true, .* +p, anonymous, multiselectQuestion, true, read + +p, admin, applicationMethod, true, .* +p, jurisdictionAdmin, applicationMethod, true, .* +p, partner, applicationMethod, true, read + +p, admin, unit, true, .* +p, jurisdictionAdmin, unit, true, .* +p, partner, unit, true, read + +p, admin, listingEvent, true, .* +p, jurisdictionAdmin, listingEvent, true, .* +p, partner, listingEvent, true, read + +p, admin, property, true, .* +p, jurisdictionAdmin, property, true, .* +p, partner, property, true, read + +p, admin, propertyGroup, true, .* +p, jurisdictionAdmin, propertyGroup, true, .* +p, partner, propertyGroup, true, read + +p, admin, amiChart, true, .* +p, jurisdictionAdmin, amiChart, true, .* +p, anonymous, amiChart, true, read + +p, admin, applicationFlaggedSet, true, .* +p, jurisdictionAdmin, applicationFlaggedSet, true, .* +p, partner, applicationFlaggedSet, true, .* + +p, admin, translation, true, .* + +p, admin, jurisdiction, true, .* +p, jurisdictionAdmin, jurisdiction, true, read +p, anonymous, jurisdiction, true, read + +p, admin, listing, true, .* +p, anonymous, listing, true, read + +p, admin, reservedCommunityType, true, .* +p, jurisdictionAdmin, reservedCommunityType, true, read +p, anonymous, reservedCommunityType, true, read + +p, admin, unitType, true, .* +p, admin, jurisdictionAdmin, true, read +p, anonymous, unitType, true, read + +p, admin, unitRentType, true, .* +p, jurisdictionAdmin, jurisdictionAdmin, true, read +p, anonymous, unitRentType, true, read + +p, admin, unitAccessibilityPriorityType, true, .* +p, jurisdictionAdmin, jurisdictionAdmin, true, .* +p, anonymous, unitAccessibilityPriorityType, true, read + +p, admin, applicationMethod, true, .* +p, jurisdictionAdmin, applicationMethod, true, .* +p, anonymous, applicationMethod, true, read + +p, admin, paperApplication, true, .* +p, jurisdictionAdmin, paperApplication, true, .* +p, anonymous, paperApplication, true, read + +p, admin, mapLayers, true, .* +p, jurisdictionAdmin, mapLayers, true, .* + +g, admin, jurisdictionAdmin +g, jurisdictionAdmin, partner +g, partner, user +g, user, anonymous diff --git a/api/src/services/ami-chart.service.ts b/api/src/services/ami-chart.service.ts new file mode 100644 index 0000000000..48a62c9397 --- /dev/null +++ b/api/src/services/ami-chart.service.ts @@ -0,0 +1,151 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { PrismaService } from './prisma.service'; +import { Prisma } from '@prisma/client'; +import { AmiChart } from '../dtos/ami-charts/ami-chart.dto'; +import { AmiChartCreate } from '../dtos/ami-charts/ami-chart-create.dto'; +import { AmiChartUpdate } from '../dtos/ami-charts/ami-chart-update.dto'; +import { AmiChartQueryParams } from '../dtos/ami-charts/ami-chart-query-params.dto'; +import { mapTo } from '../utilities/mapTo'; +import { SuccessDTO } from '../dtos/shared/success.dto'; +import { AmiChartItem } from '../dtos/units/ami-chart-item.dto'; + +/* + this is the service for ami charts + it handles all the backend's business logic for reading/writing/deleting ami chart data +*/ + +const view: Prisma.AmiChartInclude = { + jurisdictions: true, +}; + +@Injectable() +export class AmiChartService { + constructor(private prisma: PrismaService) {} + + /* + this will get a set of ami charts given the params passed in + */ + async list(params: AmiChartQueryParams): Promise { + const rawAmiCharts = await this.prisma.amiChart.findMany({ + include: view, + where: this.buildWhereClause(params), + }); + return mapTo(AmiChart, rawAmiCharts); + } + + /* + this helps build the where clause for the list() + */ + buildWhereClause(params: AmiChartQueryParams): Prisma.AmiChartWhereInput { + const filters: Prisma.AmiChartWhereInput[] = []; + if (params && 'jurisdictionId' in params && params.jurisdictionId) { + filters.push({ + jurisdictions: { + id: params.jurisdictionId, + }, + }); + } + return { + AND: filters, + }; + } + + /* + this will return 1 ami chart or error + */ + async findOne(amiChartId: string): Promise { + const amiChartRaw = await this.prisma.amiChart.findUnique({ + include: view, + where: { + id: amiChartId, + }, + }); + + if (!amiChartRaw) { + throw new NotFoundException( + `amiChartId ${amiChartId} was requested but not found`, + ); + } + + return mapTo(AmiChart, amiChartRaw); + } + + /* + this will create an ami chart + */ + async create(incomingData: AmiChartCreate): Promise { + const rawResult = await this.prisma.amiChart.create({ + data: { + ...incomingData, + items: this.reconstructItems(incomingData.items), + jurisdictions: { + connect: { + id: incomingData.jurisdictions.id, + }, + }, + }, + include: view, + }); + + return mapTo(AmiChart, rawResult); + } + + /* + this will update an ami chart's name or items field + if no ami chart has the id of the incoming argument an error is thrown + */ + async update(incomingData: AmiChartUpdate): Promise { + await this.findOrThrow(incomingData.id); + const rawResults = await this.prisma.amiChart.update({ + include: view, + data: { + ...incomingData, + items: this.reconstructItems(incomingData.items), + jurisdictions: undefined, + id: undefined, + }, + where: { + id: incomingData.id, + }, + }); + return mapTo(AmiChart, rawResults); + } + + /* + this will delete an ami chart + */ + async delete(amiChartId: string): Promise { + await this.findOrThrow(amiChartId); + await this.prisma.amiChart.delete({ + where: { + id: amiChartId, + }, + }); + return { + success: true, + } as SuccessDTO; + } + + reconstructItems(items: AmiChartItem[]): Prisma.JsonArray { + return items.map((item) => ({ + percentOfAmi: item.percentOfAmi, + householdSize: item.householdSize, + income: item.income, + })) as Prisma.JsonArray; + } + + async findOrThrow(amiChartId: string): Promise { + const amiChart = await this.prisma.amiChart.findUnique({ + where: { + id: amiChartId, + }, + }); + + if (!amiChart) { + throw new NotFoundException( + `amiChartId ${amiChartId} was requested but not found`, + ); + } + return true; + } +} diff --git a/api/src/services/app.service.ts b/api/src/services/app.service.ts new file mode 100644 index 0000000000..9967792196 --- /dev/null +++ b/api/src/services/app.service.ts @@ -0,0 +1,114 @@ +import fs from 'fs'; +import { join } from 'path'; +import { + Inject, + Injectable, + InternalServerErrorException, + Logger, + OnModuleInit, +} from '@nestjs/common'; +import { SchedulerRegistry } from '@nestjs/schedule'; +import { startCronJob } from '../utilities/cron-job-starter'; +import { SuccessDTO } from '../dtos/shared/success.dto'; +import { PrismaService } from './prisma.service'; + +const CRON_JOB_NAME = 'TEMP_FILE_CLEAR_CRON_JOB'; + +@Injectable() +export class AppService implements OnModuleInit { + constructor( + private prisma: PrismaService, + @Inject(Logger) + private logger = new Logger(AppService.name), + private schedulerRegistry: SchedulerRegistry, + ) {} + + onModuleInit() { + startCronJob( + this.prisma, + CRON_JOB_NAME, + process.env.TEMP_FILE_CLEAR_CRON_STRING, + this.clearTempFiles.bind(this), + this.logger, + this.schedulerRegistry, + ); + } + + async healthCheck(): Promise { + await this.prisma.$queryRaw`SELECT 1`; + return { + success: true, + } as SuccessDTO; + } + + /** + runs the job to remove existing csvs and zip files + */ + async clearTempFiles(): Promise { + this.logger.warn('listing csv clear job running'); + await this.markCronJobAsStarted(); + let filesDeletedCount = 0; + await fs.readdir(join(process.cwd(), 'src/temp/'), (err, files) => { + if (err) { + throw new InternalServerErrorException(err); + } + Promise.all( + files.map((f) => { + if (!f.includes('.git')) { + filesDeletedCount++; + try { + fs.rm( + join(process.cwd(), 'src/temp/', f), + { recursive: true }, + (err) => { + if (err) { + throw new InternalServerErrorException(err); + } + }, + ); + } catch (e) { + throw new InternalServerErrorException(e); + } + } + }), + ); + this.logger.warn( + `listing csv clear job completed: ${filesDeletedCount} files were deleted`, + ); + }); + return { + success: true, + }; + } + + /** + marks the db record for this cronjob as begun or creates a cronjob that + is marked as begun if one does not already exist + */ + async markCronJobAsStarted(): Promise { + const job = await this.prisma.cronJob.findFirst({ + where: { + name: CRON_JOB_NAME, + }, + }); + if (job) { + // if a job exists then we update db entry + await this.prisma.cronJob.update({ + data: { + lastRunDate: new Date(), + }, + where: { + id: job.id, + }, + }); + } else { + // if no job we create a new entry + await this.prisma.cronJob.create({ + data: { + lastRunDate: new Date(), + name: CRON_JOB_NAME, + }, + }); + } + } +} diff --git a/api/src/services/application-csv-export.service.ts b/api/src/services/application-csv-export.service.ts new file mode 100644 index 0000000000..5ae32d2d4d --- /dev/null +++ b/api/src/services/application-csv-export.service.ts @@ -0,0 +1,738 @@ +import fs, { createReadStream } from 'fs'; +import { join } from 'path'; +import { Injectable, StreamableFile } from '@nestjs/common'; +import { Request as ExpressRequest, Response } from 'express'; +import { view } from './application.service'; +import { PrismaService } from './prisma.service'; +import { MultiselectQuestionService } from './multiselect-question.service'; +import { ApplicationCsvQueryParams } from '../dtos/applications/application-csv-query-params.dto'; +import { UnitType } from '../dtos/unit-types/unit-type.dto'; +import { Address } from '../dtos/addresses/address.dto'; +import { ApplicationMultiselectQuestion } from '../dtos/applications/application-multiselect-question.dto'; +import MultiselectQuestion from '../dtos/multiselect-questions/multiselect-question.dto'; +import { ApplicationFlaggedSet } from '../dtos/application-flagged-sets/application-flagged-set.dto'; +import { User } from '../dtos/users/user.dto'; +import { ListingService } from './listing.service'; +import { PermissionService } from './permission.service'; +import { permissionActions } from '../enums/permissions/permission-actions-enum'; +import { + CsvExporterServiceInterface, + CsvHeader, +} from '../types/CsvExportInterface'; +import { mapTo } from '../utilities/mapTo'; + +view.csv = { + ...view.details, + applicationFlaggedSet: true, + listings: false, +}; + +export const typeMap = { + SRO: 'SRO', + studio: 'Studio', + oneBdrm: 'One Bedroom', + twoBdrm: 'Two Bedroom', + threeBdrm: 'Three Bedroom', + fourBdrm: 'Four+ Bedroom', + fiveBdrm: 'Five Bedroom', +}; + +@Injectable() +export class ApplicationCsvExporterService + implements CsvExporterServiceInterface +{ + constructor( + private prisma: PrismaService, + private multiselectQuestionService: MultiselectQuestionService, + private listingService: ListingService, + private permissionService: PermissionService, + ) {} + /** + * + * @param queryParams + * @param req + * @returns a promise containing a streamable file + */ + async exportFile( + req: ExpressRequest, + res: Response, + queryParams: QueryParams, + ): Promise { + const user = mapTo(User, req['user']); + await this.authorizeCSVExport(user, queryParams.listingId); + const filename = join( + process.cwd(), + `src/temp/listing-${queryParams.listingId}-applications-${ + user.id + }-${new Date().getTime()}.csv`, + ); + await this.createCsv(filename, queryParams); + const file = createReadStream(filename); + return new StreamableFile(file); + } + + /** + * + * @param filename + * @param queryParams + * @returns a promise with SuccessDTO + */ + async createCsv( + filename: string, + queryParams: QueryParams, + ): Promise { + if (queryParams.includeDemographics) { + view.csv.demographics = true; + } + + const applications = await this.prisma.applications.findMany({ + include: view.csv, + where: { + listingId: queryParams.listingId, + deletedAt: null, + }, + }); + + // get all multiselect questions for a listing to build csv headers + const multiSelectQuestions = + await this.multiselectQuestionService.findByListingId( + queryParams.listingId, + ); + + // get maxHouseholdMembers or associated to the selected applications + const maxHouseholdMembers = await this.maxHouseholdMembers( + applications.map((application) => application.id), + ); + + const csvHeaders = await this.getCsvHeaders( + maxHouseholdMembers, + multiSelectQuestions, + queryParams.includeDemographics, + ); + + return new Promise((resolve, reject) => { + // create stream + const writableStream = fs.createWriteStream(`${filename}`); + writableStream + .on('error', (err) => { + console.log('csv writestream error'); + console.log(err); + reject(err); + }) + .on('close', () => { + resolve(); + }) + .on('open', () => { + writableStream.write( + csvHeaders + .map((header) => `"${header.label.replace(/"/g, `""`)}"`) + .join(',') + '\n', + ); + + // now loop over applications and write them to file + applications.forEach((app) => { + let row = ''; + let preferences: ApplicationMultiselectQuestion[]; + csvHeaders.forEach((header, index) => { + let multiselectQuestionValue = false; + let parsePreference = false; + let value = header.path.split('.').reduce((acc, curr) => { + // return preference/program as value for the format function to accept + if (multiselectQuestionValue) { + return acc; + } + + if (parsePreference) { + // curr should equal the preference id we're pulling from + if (!preferences) { + preferences = + app.preferences as unknown as ApplicationMultiselectQuestion[]; + } + parsePreference = false; + // there aren't typically many preferences, but if there, then a object map should be created and used + const preference = preferences.find( + (preference) => preference.multiselectQuestionId === curr, + ); + multiselectQuestionValue = true; + return preference; + } + + // sets parsePreference to true, for the next iteration + if (curr === 'preferences') { + parsePreference = true; + } + + if (acc === null || acc === undefined) { + return ''; + } + + // handles working with arrays, e.g. householdMember.0.firstName + if (!isNaN(Number(curr))) { + const index = Number(curr); + return acc[index]; + } + + return acc[curr]; + }, app); + value = value === undefined ? '' : value === null ? '' : value; + if (header.format) { + value = header.format(value); + } + + row += value ? `"${value.toString().replace(/"/g, `""`)}"` : ''; + if (index < csvHeaders.length - 1) { + row += ','; + } + }); + + try { + writableStream.write(row + '\n'); + } catch (e) { + console.log('writeStream write error = ', e); + writableStream.once('drain', () => { + console.log('drain buffer'); + writableStream.write(row + '\n'); + }); + } + }); + + writableStream.end(); + }); + }); + } + + async maxHouseholdMembers(applicationIds: string[]): Promise { + const maxHouseholdMembersRes = await this.prisma.householdMember.groupBy({ + by: ['applicationId'], + _count: { + applicationId: true, + }, + where: { + OR: applicationIds.map((id) => { + return { applicationId: id }; + }), + }, + orderBy: { + _count: { + applicationId: 'desc', + }, + }, + take: 1, + }); + + return maxHouseholdMembersRes && maxHouseholdMembersRes.length + ? maxHouseholdMembersRes[0]._count.applicationId + : 0; + } + + getHouseholdCsvHeaders(maxHouseholdMembers: number): CsvHeader[] { + const headers = []; + for (let i = 0; i < maxHouseholdMembers; i++) { + const j = i + 1; + headers.push( + { + path: `householdMember.${i}.firstName`, + label: `Household Member (${j}) First Name`, + }, + { + path: `householdMember.${i}.middleName`, + label: `Household Member (${j}) Middle Name`, + }, + { + path: `householdMember.${i}.lastName`, + label: `Household Member (${j}) Last Name`, + }, + { + path: `householdMember.${i}.firstName`, + label: `Household Member (${j}) First Name`, + }, + { + path: `householdMember.${i}.birthDay`, + label: `Household Member (${j}) Birth Day`, + }, + { + path: `householdMember.${i}.birthMonth`, + label: `Household Member (${j}) Birth Month`, + }, + { + path: `householdMember.${i}.birthYear`, + label: `Household Member (${j}) Birth Year`, + }, + { + path: `householdMember.${i}.sameAddress`, + label: `Household Member (${j}) Same as Primary Applicant`, + }, + { + path: `householdMember.${i}.relationship`, + label: `Household Member (${j}) Relationship`, + }, + { + path: `householdMember.${i}.workInRegion`, + label: `Household Member (${j}) Work in Region`, + }, + { + path: `householdMember.${i}.householdMemberAddress.street`, + label: `Household Member (${j}) Street`, + }, + { + path: `householdMember.${i}.householdMemberAddress.street2`, + label: `Household Member (${j}) Street 2`, + }, + { + path: `householdMember.${i}.householdMemberAddress.city`, + label: `Household Member (${j}) City`, + }, + { + path: `householdMember.${i}.householdMemberAddress.state`, + label: `Household Member (${j}) State`, + }, + { + path: `householdMember.${i}.householdMemberAddress.zipCode`, + label: `Household Member (${j}) Zip Code`, + }, + ); + } + + return headers; + } + + async getCsvHeaders( + maxHouseholdMembers: number, + multiSelectQuestions: MultiselectQuestion[], + includeDemographics = false, + ): Promise { + const headers: CsvHeader[] = [ + { + path: 'id', + label: 'Application Id', + }, + { + path: 'confirmationCode', + label: 'Application Confirmation Code', + }, + { + path: 'submissionType', + label: 'Application Type', + }, + { + path: 'submissionDate', + label: 'Application Submission Date', + }, + { + path: 'applicant.firstName', + label: 'Primary Applicant First Name', + }, + { + path: 'applicant.middleName', + label: 'Primary Applicant Middle Name', + }, + { + path: 'applicant.lastName', + label: 'Primary Applicant Last Name', + }, + { + path: 'applicant.birthDay', + label: 'Primary Applicant Birth Day', + }, + { + path: 'applicant.birthMonth', + label: 'Primary Applicant Birth Month', + }, + { + path: 'applicant.birthYear', + label: 'Primary Applicant Birth Year', + }, + { + path: 'applicant.emailAddress', + label: 'Primary Applicant Email Address', + }, + { + path: 'applicant.phoneNumber', + label: 'Primary Applicant Phone Number', + }, + { + path: 'applicant.phoneNumberType', + label: 'Primary Applicant Phone Type', + }, + { + path: 'additionalPhoneNumber', + label: 'Primary Applicant Additional Phone Number', + }, + { + path: 'contactPreferences', + label: 'Primary Applicant Preferred Contact Type', + }, + { + path: 'applicant.applicantAddress.street', + label: `Primary Applicant Street`, + }, + { + path: 'applicant.applicantAddress.street2', + label: `Primary Applicant Street 2`, + }, + { + path: 'applicant.applicantAddress.city', + label: `Primary Applicant City`, + }, + { + path: 'applicant.applicantAddress.state', + label: `Primary Applicant State`, + }, + { + path: 'applicant.applicantAddress.zipCode', + label: `Primary Applicant Zip Code`, + }, + { + path: 'applicationsMailingAddress.street', + label: `Primary Applicant Mailing Street`, + }, + { + path: 'applicationsMailingAddress.street2', + label: `Primary Applicant Mailing Street 2`, + }, + { + path: 'applicationsMailingAddress.city', + label: `Primary Applicant Mailing City`, + }, + { + path: 'applicationsMailingAddress.state', + label: `Primary Applicant Mailing State`, + }, + { + path: 'applicationsMailingAddress.zipCode', + label: `Primary Applicant Mailing Zip Code`, + }, + { + path: 'applicant.applicantWorkAddress.street', + label: `Primary Applicant Work Street`, + }, + { + path: 'applicant.applicantWorkAddress.street2', + label: `Primary Applicant Work Street 2`, + }, + { + path: 'applicant.applicantWorkAddress.city', + label: `Primary Applicant Work City`, + }, + { + path: 'applicant.applicantWorkAddress.state', + label: `Primary Applicant Work State`, + }, + { + path: 'applicant.applicantWorkAddress.zipCode', + label: `Primary Applicant Work Zip Code`, + }, + { + path: 'alternateContact.firstName', + label: 'Alternate Contact First Name', + }, + { + path: 'alternateContact.lastName', + label: 'Alternate Contact Last Name', + }, + { + path: 'alternateContact.type', + label: 'Alternate Contact Type', + }, + { + path: 'alternateContact.agency', + label: 'Alternate Contact Agency', + }, + { + path: 'alternateContact.otherType', + label: 'Alternate Contact Other Type', + }, + { + path: 'alternateContact.emailAddress', + label: 'Alternate Contact Email Address', + }, + { + path: 'alternateContact.phoneNumber', + label: 'Alternate Contact Phone Number', + }, + { + path: 'alternateContact.address.street', + label: `Alternate Contact Street`, + }, + { + path: 'alternateContact.address.street2', + label: `Alternate Contact Street 2`, + }, + { + path: 'alternateContact.address.city', + label: `Alternate Contact City`, + }, + { + path: 'alternateContact.address.state', + label: `Alternate Contact State`, + }, + { + path: 'alternateContact.address.zipCode', + label: `Alternate Contact Zip Code`, + }, + { + path: 'income', + label: 'Income', + }, + { + path: 'incomePeriod', + label: 'Income Period', + format: (val: string): string => + val === 'perMonth' ? 'per month' : 'per year', + }, + { + path: 'accessibility.mobility', + label: 'Accessibility Mobility', + }, + { + path: 'accessibility.vision', + label: 'Accessibility Vision', + }, + { + path: 'accessibility.hearing', + label: 'Accessibility Hearing', + }, + { + path: 'householdExpectingChanges', + label: 'Expecting Household Changes', + }, + { + path: 'householdStudent', + label: 'Household Includes Student or Member Nearing 18', + }, + { + path: 'incomeVouchers', + label: 'Vouchers or Subsidies', + }, + { + path: 'preferredUnitTypes', + label: 'Requested Unit Types', + format: (val: UnitType[]): string => { + return val + .map((unit) => this.unitTypeToReadable(unit.name)) + .join(','); + }, + }, + ]; + + // add preferences to csv headers + multiSelectQuestions + .filter((question) => question.applicationSection === 'preferences') + .forEach((question) => { + headers.push({ + path: `preferences.${question.id}.claimed`, + label: `Preference ${question.text}`, + format: (val: boolean): string => (val ? 'claimed' : ''), + }); + /** + * there are other input types for extra data besides address + * that are not used on the old backend, but could be added here + */ + question.options + ?.filter((option) => option.collectAddress) + .forEach((option) => { + headers.push({ + path: `preferences.${question.id}.address`, + label: `Preference ${question.text} - ${option.text} - Address`, + format: (val: ApplicationMultiselectQuestion): string => { + return this.multiselectQuestionFormat( + val, + option.text, + 'address', + ); + }, + }); + if (option.validationMethod) { + headers.push({ + path: `preferences.${question.id}.address`, + label: `Preference ${question.text} - ${option.text} - Passed Address Check`, + format: (val: ApplicationMultiselectQuestion): string => { + return this.multiselectQuestionFormat( + val, + option.text, + 'geocodingVerified', + ); + }, + }); + } + if (option.collectName) { + headers.push({ + path: `preferences.${question.id}.address`, + label: `Preference ${question.text} - ${option.text} - Name of Address Holder`, + format: (val: ApplicationMultiselectQuestion): string => { + return this.multiselectQuestionFormat( + val, + option.text, + 'addressHolderName', + ); + }, + }); + } + if (option.collectRelationship) { + headers.push({ + path: `preferences.${question.id}.address`, + label: `Preference ${question.text} - ${option.text} - Relationship to Address Holder`, + format: (val: ApplicationMultiselectQuestion): string => { + return this.multiselectQuestionFormat( + val, + option.text, + 'addressHolderRelationship', + ); + }, + }); + } + }); + }); + + headers.push({ + path: 'householdSize', + label: 'Household Size', + }); + + // add household member headers to csv + if (maxHouseholdMembers) { + headers.push(...this.getHouseholdCsvHeaders(maxHouseholdMembers)); + } + + headers.push( + { + path: 'markedAsDuplicate', + label: 'Marked As Duplicate', + }, + { + path: 'applicationFlaggedSet', + label: 'Flagged As Duplicate', + format: (val: ApplicationFlaggedSet[]): boolean => { + return val.length > 0; + }, + }, + ); + + if (includeDemographics) { + headers.push( + { + path: 'demographics.ethnicity', + label: 'Ethnicity', + }, + { + path: 'demographics.race', + label: 'Race', + format: (val: string[]): string => + val + .map((race) => this.convertDemographicRaceToReadable(race)) + .join(','), + }, + { + path: 'demographics.howDidYouHear', + label: 'How did you Hear?', + }, + ); + } + + return headers; + } + + addressToString(address: Address): string { + return `${address.street}${address.street2 ? ' ' + address.street2 : ''} ${ + address.city + }, ${address.state} ${address.zipCode}`; + } + + multiselectQuestionFormat( + question: ApplicationMultiselectQuestion, + optionText: string, + key: string, + ): string { + if (!question) return ''; + const selectedOption = question.options.find( + (option) => option.key === optionText, + ); + const extraData = selectedOption?.extraData.find( + (data) => data.key === key, + ); + if (!extraData) { + return ''; + } + if (key === 'address') { + return this.addressToString(extraData.value as Address); + } + if (key === 'geocodingVerified') { + return extraData.value === 'unknown' + ? 'Needs Manual Verification' + : extraData.value.toString(); + } + return extraData.value as string; + } + + // multiselectQuestionGeocodingVerifiedFormat( + // question: ApplicationMultiselectQuestion, + // optionText: string, + // ): string { + // if (!question) return ''; + // const selectedOption = question.options.find( + // (option) => option.key === optionText, + // ); + // const extraData = selectedOption.extraData.find( + // (data) => data.key === 'geocodingVerified', + // ); + // if (extraData) { + // return extraData.value === 'unknown' + // ? 'Needs Manual Verification' + // : extraData.value.toString(); + // } + // return ''; + // } + + convertDemographicRaceToReadable(type: string): string { + 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; + } + + unitTypeToReadable(type: string): string { + return typeMap[type] ?? type; + } + + async authorizeCSVExport(user, listingId): Promise { + /** + * Checking authorization for each application is very expensive. + * By making listingId 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 + */ + const jurisdictionId = + await this.listingService.getJurisdictionIdByListingId(listingId); + + await this.permissionService.canOrThrow( + user, + 'listing', + permissionActions.update, + { + id: listingId, + jurisdictionId, + }, + ); + } +} diff --git a/api/src/services/application-flagged-set.service.ts b/api/src/services/application-flagged-set.service.ts new file mode 100644 index 0000000000..848aa9b917 --- /dev/null +++ b/api/src/services/application-flagged-set.service.ts @@ -0,0 +1,952 @@ +import { + BadRequestException, + Inject, + Injectable, + Logger, + NotFoundException, + OnModuleInit, +} from '@nestjs/common'; +import { SchedulerRegistry } from '@nestjs/schedule'; +import { + ApplicationReviewStatusEnum, + ApplicationStatusEnum, + FlaggedSetStatusEnum, + ListingsStatusEnum, + Prisma, + RuleEnum, +} from '@prisma/client'; +import { PrismaService } from './prisma.service'; +import { mapTo } from '../utilities/mapTo'; +import { SuccessDTO } from '../dtos/shared/success.dto'; +import { ApplicationFlaggedSet } from '../dtos/application-flagged-sets/application-flagged-set.dto'; +import { PaginatedAfsDto } from '../dtos/application-flagged-sets/paginated-afs.dto'; +import { AfsQueryParams } from '../dtos/application-flagged-sets/afs-query-params.dto'; +import { AfsMeta } from '../dtos/application-flagged-sets/afs-meta.dto'; +import { OrderByEnum } from '../enums/shared/order-by-enum'; +import { View } from '../enums/application-flagged-sets/view'; +import { buildPaginationMetaInfo } from '../utilities/pagination-helpers'; +import { AfsResolve } from '../dtos/application-flagged-sets/afs-resolve.dto'; +import { User } from '../dtos/users/user.dto'; +import { Application } from '../dtos/applications/application.dto'; +import { IdDTO } from '../dtos/shared/id.dto'; +import { startCronJob } from '../utilities/cron-job-starter'; + +/* + this is the service for application flaged sets + it handles all the backend's business logic for managing flagged set data +*/ + +const CRON_JOB_NAME = 'AFS_CRON_JOB'; +@Injectable() +export class ApplicationFlaggedSetService implements OnModuleInit { + constructor( + private prisma: PrismaService, + @Inject(Logger) + private logger = new Logger(ApplicationFlaggedSetService.name), + private schedulerRegistry: SchedulerRegistry, + ) {} + + onModuleInit() { + startCronJob( + this.prisma, + CRON_JOB_NAME, + process.env.AFS_PROCESSING_CRON_STRING, + this.process.bind(this), + this.logger, + this.schedulerRegistry, + ); + } + + /** + this will get a set of application flagged sets given the params passed in + */ + async list(params: AfsQueryParams): Promise { + const whereClause = this.buildWhere(params); + + const count = await this.prisma.applicationFlaggedSet.count({ + where: whereClause, + }); + + const rawAfs = await this.prisma.applicationFlaggedSet.findMany({ + include: { + listings: true, + applications: { + include: { + applicant: true, + }, + }, + }, + where: whereClause, + orderBy: { + id: OrderByEnum.DESC, + }, + }); + + const totalFlagged = await this.prisma.applicationFlaggedSet.count({ + where: { + listingId: params.listingId, + status: FlaggedSetStatusEnum.pending, + }, + }); + + const afs = mapTo(ApplicationFlaggedSet, rawAfs); + + const paginationInfo = buildPaginationMetaInfo(params, count, afs.length); + + return { + items: afs, + meta: { + ...paginationInfo, + totalFlagged: totalFlagged, + }, + }; + } + + /** + builds where clause for list function + */ + buildWhere(params: AfsQueryParams): Prisma.ApplicationFlaggedSetWhereInput { + const filters: Prisma.ApplicationFlaggedSetWhereInput[] = []; + + if (params.listingId) { + filters.push({ + listingId: params.listingId, + }); + } + + if (params.view) { + switch (params.view) { + case View.pending: + filters.push({ + status: FlaggedSetStatusEnum.pending, + }); + break; + case View.pendingNameAndDoB: + filters.push({ + status: FlaggedSetStatusEnum.pending, + rule: RuleEnum.nameAndDOB, + }); + break; + case View.pendingEmail: + filters.push({ + status: FlaggedSetStatusEnum.pending, + rule: RuleEnum.email, + }); + break; + case View.resolved: + filters.push({ + status: FlaggedSetStatusEnum.resolved, + }); + break; + } + } + + return { + AND: filters, + }; + } + + /** + this will return 1 application flagged set or error + */ + async findOne(afsId: string): Promise { + const rawAfs = await this.prisma.applicationFlaggedSet.findUnique({ + where: { + id: afsId, + }, + include: { + applications: { + include: { + applicant: true, + }, + }, + listings: true, + }, + }); + + if (!rawAfs) { + throw new NotFoundException( + `applicationFlaggedSetId ${afsId} was requested but not found`, + ); + } + + return mapTo(ApplicationFlaggedSet, rawAfs); + } + + /** + this resets the showConfirmationAlert on the application flagged set + */ + async resetConfirmationAlert(afsId: string): Promise { + await this.findOrThrow(afsId); + await this.prisma.applicationFlaggedSet.update({ + data: { + showConfirmationAlert: false, + }, + where: { + id: afsId, + }, + }); + return { + success: true, + }; + } + + /** + this will return meta info for a set of application flagged sets + */ + async meta(params: AfsQueryParams): Promise { + const [ + totalCount, + totalResolvedCount, + totalNamePendingCount, + totalEmailPendingCount, + ] = await Promise.all([ + this.prisma.applications.count({ + where: { + listingId: params.listingId, + // We only should display non-deleted applications + deletedAt: null, + }, + }), + this.metaDataQueryBuilder( + params.listingId, + FlaggedSetStatusEnum.resolved, + ), + this.metaDataQueryBuilder( + params.listingId, + FlaggedSetStatusEnum.pending, + RuleEnum.nameAndDOB, + ), + this.metaDataQueryBuilder( + params.listingId, + FlaggedSetStatusEnum.pending, + RuleEnum.email, + ), + ]); + + return { + totalCount, + totalResolvedCount, + totalPendingCount: totalNamePendingCount + totalEmailPendingCount, + totalNamePendingCount, + totalEmailPendingCount, + }; + } + + /** + helper that builds the meta functions queries + */ + metaDataQueryBuilder( + listingId: string, + status?: FlaggedSetStatusEnum, + rule?: RuleEnum, + ): Promise { + return this.prisma.applicationFlaggedSet.count({ + where: { + listingId, + status, + rule, + }, + }); + } + + /** + resolves an application flagged set + */ + async resolve(dto: AfsResolve, user: User): Promise { + const filter: Prisma.ApplicationFlaggedSetWhereInput[] = [ + { + id: dto.afsId, + }, + ]; + + let applicationIds: string[] = []; + if (dto.applications?.length) { + applicationIds = dto.applications.map((app) => app.id); + filter.push({ + applications: { + some: { + id: { + in: applicationIds, + }, + }, + }, + }); + } + const afs = await this.prisma.applicationFlaggedSet.findFirst({ + where: { + AND: filter, + }, + include: { + listings: true, + applications: { + where: { + id: { + in: applicationIds, + }, + }, + }, + }, + }); + + if (afs.listings.status !== ListingsStatusEnum.closed) { + throw new BadRequestException( + `Listing ${afs.listings.id} must be closed before resolving any duplicates`, + ); + } + + const selectedApps = afs.applications + ? afs.applications.map((app) => app.id) + : []; + + if (dto.status === FlaggedSetStatusEnum.pending) { + if (selectedApps.length) { + // mark selected as pendingAndValid + await this.prisma.applications.updateMany({ + data: { + reviewStatus: ApplicationReviewStatusEnum.pendingAndValid, + markedAsDuplicate: false, + }, + where: { + id: { + in: selectedApps, + }, + }, + }); + } + + // mark unselected as pending + await this.prisma.applications.updateMany({ + data: { + reviewStatus: ApplicationReviewStatusEnum.pending, + markedAsDuplicate: false, + }, + where: { + applicationFlaggedSet: { + some: { + id: dto.afsId, + }, + }, + id: selectedApps.length + ? { + notIn: selectedApps, + } + : undefined, + }, + }); + + // mark the flagged set as pending + await this.prisma.applicationFlaggedSet.update({ + where: { + id: dto.afsId, + }, + data: { + resolvedTime: new Date(), + status: FlaggedSetStatusEnum.pending, + showConfirmationAlert: false, + userAccounts: user + ? { + connect: { + id: user.id, + }, + } + : undefined, + }, + }); + } else if (dto.status === FlaggedSetStatusEnum.resolved) { + if (selectedApps.length) { + // mark selected as valid + await this.prisma.applications.updateMany({ + data: { + reviewStatus: ApplicationReviewStatusEnum.valid, + markedAsDuplicate: false, + }, + where: { + id: { + in: selectedApps, + }, + }, + }); + } + // mark unselected as duplicate + await this.prisma.applications.updateMany({ + data: { + reviewStatus: ApplicationReviewStatusEnum.duplicate, + markedAsDuplicate: true, + }, + where: { + applicationFlaggedSet: { + some: { + id: dto.afsId, + }, + }, + id: selectedApps.length + ? { + notIn: selectedApps, + } + : undefined, + }, + }); + + // mark flagged set as resolved + await this.prisma.applicationFlaggedSet.update({ + data: { + resolvedTime: new Date(), + status: FlaggedSetStatusEnum.resolved, + showConfirmationAlert: true, + userAccounts: user + ? { + connect: { + id: user.id, + }, + } + : undefined, + }, + where: { + id: dto.afsId, + }, + }); + } + + return mapTo(ApplicationFlaggedSet, afs); + } + + /** + this will either find a record or throw a customized error + */ + async findOrThrow(afsId: string): Promise { + const rawAfs = await this.prisma.applicationFlaggedSet.findFirst({ + where: { + id: afsId, + }, + }); + + if (!rawAfs) { + throw new NotFoundException( + `applicationFlaggedSet ${afsId} was requested but not found`, + ); + } + + return true; + } + + /** + this goes through listings that have had an application added since the last cronjob run + it calls a series of helpers to add to or build a flagged set if duplicates are found + */ + async process(listingId?: string): Promise { + this.logger.warn('running the Application flagged sets cron job'); + await this.markCronJobAsStarted(); + const outOfDateListings = await this.prisma.listings.findMany({ + select: { + id: true, + afsLastRunAt: true, + }, + where: { + lastApplicationUpdateAt: { + not: null, + }, + id: listingId, + AND: [ + { + OR: [ + { + afsLastRunAt: { + equals: null, + }, + }, + { + afsLastRunAt: { + lte: this.prisma.listings.fields.lastApplicationUpdateAt, + }, + }, + ], + }, + ], + }, + }); + this.logger.warn( + `updating the flagged sets for ${outOfDateListings.length} listings`, + ); + + for (const listing of outOfDateListings) { + const newApplications = await this.prisma.applications.findMany({ + where: { + listingId: listing.id, + updatedAt: { + gte: listing.afsLastRunAt, + }, + }, + include: { + applicant: true, + householdMember: true, + }, + }); + + for (const application of newApplications) { + await this.testApplication( + mapTo(Application, application), + application.listingId, + ); + } + await this.prisma.listings.update({ + where: { + id: listing.id, + }, + data: { + afsLastRunAt: new Date(), + }, + }); + } + + return { + success: true, + }; + } + + /** + marks the db record for this cronjob as begun or creates a cronjob that + is marked as begun if one does not already exist + */ + async markCronJobAsStarted(): Promise { + const job = await this.prisma.cronJob.findFirst({ + where: { + name: CRON_JOB_NAME, + }, + }); + if (job) { + // if a job exists then we update db entry + await this.prisma.cronJob.update({ + data: { + lastRunDate: new Date(), + }, + where: { + id: job.id, + }, + }); + } else { + // if no job we create a new entry + await this.prisma.cronJob.create({ + data: { + lastRunDate: new Date(), + name: CRON_JOB_NAME, + }, + }); + } + } + + /** + tests application to see if its a duplicate + if it is then its either added to a flagged set or a new flagged set is created + */ + async testApplication( + application: Application, + listingId: string, + ): Promise { + // if we already found a match then we know that the application can't go into another flagged set + let alreadyFoundMatch = false; + + for (const rule of [RuleEnum.email, RuleEnum.nameAndDOB]) { + // get list of applications that the application matches on. Compared via the RuleEnum + const applicationsThatMatched = await this.checkForMatchesAgainstRule( + application, + rule, + listingId, + ); + + // get list of flagged sets this application is part of + const flagSetsThisAppBelongsTo = + await this.prisma.applicationFlaggedSet.findMany({ + include: { + applications: true, + }, + where: { + listingId, + applications: { + some: { + id: application.id, + }, + }, + rule, + }, + }); + + const builtRuleKey = this.buildRuleKey(application, rule, listingId); + if (!alreadyFoundMatch && applicationsThatMatched.length) { + // if there were duplicates (application could be a part of a flagged set) + if (flagSetsThisAppBelongsTo.length) { + // if application is part of a flagged set already + for (const flaggedSet of flagSetsThisAppBelongsTo) { + if (flaggedSet.ruleKey === builtRuleKey) { + // if application belongs in this flagged set + alreadyFoundMatch = true; + } else { + // application doesn't belong in this flagged set + await this.disconnectApplicationFromFlaggedSet( + flaggedSet.id, + flaggedSet.applications.length, + application.id, + ); + } + } + if (!alreadyFoundMatch) { + // if application didn't belong to any of its previous flagged sets + await this.createOrConnectToFlaggedSet( + rule, + builtRuleKey, + listingId, + [...applicationsThatMatched, application], + ); + } + } else { + // if application is not yet part of a flagged set + await this.createOrConnectToFlaggedSet( + rule, + builtRuleKey, + listingId, + [...applicationsThatMatched, application], + ); + } + alreadyFoundMatch = true; + } else if (flagSetsThisAppBelongsTo.length) { + // if application had no duplicates (application should not be part of a flagged set) + // and application is part of a flagged set + for (const flaggedSet of flagSetsThisAppBelongsTo) { + await this.disconnectApplicationFromFlaggedSet( + flaggedSet.id, + flaggedSet.applications.length, + application.id, + ); + } + } + } + } + + /** + builds the ruleKey field given an application, the rule, and the listingId + */ + buildRuleKey( + application: Application, + rule: RuleEnum, + listingId: string, + ): string { + if (rule == RuleEnum.email) { + return `${listingId}-email-${application.applicant.emailAddress}`; + } else { + return ( + `${listingId}-nameAndDOB-${application.applicant.firstName}-${application.applicant.lastName}-${application.applicant.birthMonth}-` + + `${application.applicant.birthDay}-${application.applicant.birthYear}` + ); + } + } + + /** + gathers a set of applications that match the passed in application based on the rule + */ + async checkForMatchesAgainstRule( + application: Application, + rule: RuleEnum, + listingId: string, + ): Promise { + if (rule === RuleEnum.email) { + return await this.checkAgainstEmail(application, listingId); + } else if (rule === RuleEnum.nameAndDOB) { + return await this.checkAgainstNameAndDOB(application, listingId); + } + } + + /** + gets a list of applications that matches based on email + */ + async checkAgainstEmail( + application: Application, + listingId: string, + ): Promise { + if (!application?.applicant?.emailAddress) { + return []; + } + + const apps = await this.prisma.applications.findMany({ + select: { + id: true, + }, + where: { + id: { + not: application.id, + }, + status: ApplicationStatusEnum.submitted, + listingId: listingId, + applicant: { + emailAddress: application.applicant.emailAddress, + }, + }, + }); + + return mapTo(Application, apps); + } + + /** + gets a list of applications that matches based on name and dob (spread across applicant + householdmember) + */ + async checkAgainstNameAndDOB( + application: Application, + listingId: string, + ): Promise { + const firstNames = this.criteriaBuilderForCheckAgainstNameAndDOB( + 'firstName', + application, + ).filter((value) => value); + const lastNames = this.criteriaBuilderForCheckAgainstNameAndDOB( + 'lastName', + application, + ).filter((value) => value); + const birthMonths = this.criteriaBuilderForCheckAgainstNameAndDOB( + 'birthMonth', + application, + ).filter((value) => value); + const birthDays = this.criteriaBuilderForCheckAgainstNameAndDOB( + 'birthDay', + application, + ).filter((value) => value); + const birthYears = this.criteriaBuilderForCheckAgainstNameAndDOB( + 'birthYear', + application, + ).filter((value) => value); + + const apps = + firstNames.length && lastNames.length + ? await this.prisma.applications.findMany({ + select: { + id: true, + }, + where: { + id: { + not: application.id, + }, + status: ApplicationStatusEnum.submitted, + listingId: listingId, + AND: [ + { + OR: [ + { + householdMember: { + some: { + firstName: { + in: firstNames, + }, + }, + }, + }, + { + applicant: { + firstName: { + in: firstNames, + }, + }, + }, + ], + }, + { + OR: [ + { + householdMember: { + some: { + lastName: { + in: lastNames, + }, + }, + }, + }, + { + applicant: { + lastName: { + in: lastNames, + }, + }, + }, + ], + }, + { + OR: [ + { + householdMember: { + some: { + birthMonth: { + in: birthMonths, + }, + }, + }, + }, + { + applicant: { + birthMonth: { + in: birthMonths, + }, + }, + }, + ], + }, + { + OR: [ + { + householdMember: { + some: { + birthDay: { + in: birthDays, + }, + }, + }, + }, + { + applicant: { + birthDay: { + in: birthDays, + }, + }, + }, + ], + }, + { + OR: [ + { + householdMember: { + some: { + birthYear: { + in: birthYears, + }, + }, + }, + }, + { + applicant: { + birthYear: { + in: birthYears, + }, + }, + }, + ], + }, + ], + }, + }) + : []; + + return mapTo(Application, apps); + } + + criteriaBuilderForCheckAgainstNameAndDOB( + key: string, + application: Application, + ): string[] { + return [ + application.applicant[key], + ...(application.householdMember + ? application.householdMember.map((member) => member[key]) + : []), + ]; + } + + /** + either disconnects an application from a flagged set, + or if the flagged set would only have 1 element, deletes the flagged set + */ + async disconnectApplicationFromFlaggedSet( + afsId: string, + numberOfAttachedApplications: number, + applicationId: string, + ): Promise { + if (numberOfAttachedApplications === 2) { + // if after we removed this application only 1 application would be left in the flagged set + await this.prisma.applicationFlaggedSet.delete({ + where: { + id: afsId, + }, + }); + } else { + // remove application from flagged set + await this.prisma.applicationFlaggedSet.update({ + where: { + id: afsId, + }, + data: { + applications: { + disconnect: { + id: applicationId, + }, + }, + }, + }); + } + + // since application no longer belongs to the flagged set it can't be marked as duplicate + await this.prisma.applications.update({ + data: { + markedAsDuplicate: false, + reviewStatus: ApplicationReviewStatusEnum.valid, + }, + where: { + id: applicationId, + }, + }); + } + + /** + creates a new flagged set + */ + async createOrConnectToFlaggedSet( + rule: RuleEnum, + ruleKey: string, + listingId: string, + applicationIds: IdDTO[], + ): Promise { + const correctFlaggedSet = await this.prisma.applicationFlaggedSet.findMany({ + where: { + listingId, + ruleKey, + }, + }); + + if (correctFlaggedSet.length) { + // if we found a flagged set the application should belong to + for (const flaggedSet of correctFlaggedSet) { + await this.prisma.applicationFlaggedSet.update({ + data: { + applications: { + connect: applicationIds.map((app) => ({ + id: app.id, + })), + }, + // regardless of former status the afs should be reviewed again + status: FlaggedSetStatusEnum.pending, + resolvedTime: null, + resolvingUserId: null, + }, + where: { + id: flaggedSet.id, + ruleKey: ruleKey, + }, + }); + } + } else { + // if no flagged set currently exists + await this.prisma.applicationFlaggedSet.create({ + data: { + rule, + ruleKey, + resolvedTime: null, + status: FlaggedSetStatusEnum.pending, + listings: { + connect: { + id: listingId, + }, + }, + applications: { + connect: applicationIds.map((app) => ({ + id: app.id, + })), + }, + }, + }); + } + } +} diff --git a/api/src/services/application.service.ts b/api/src/services/application.service.ts new file mode 100644 index 0000000000..15a3641061 --- /dev/null +++ b/api/src/services/application.service.ts @@ -0,0 +1,669 @@ +import { + BadRequestException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import crypto from 'crypto'; +import { Prisma, YesNoEnum } from '@prisma/client'; +import { PrismaService } from './prisma.service'; +import { Application } from '../dtos/applications/application.dto'; +import { mapTo } from '../utilities/mapTo'; +import { ApplicationQueryParams } from '../dtos/applications/application-query-params.dto'; +import { calculateSkip, calculateTake } from '../utilities/pagination-helpers'; +import { buildOrderByForApplications } from '../utilities/build-order-by'; +import { buildPaginationInfo } from '../utilities/build-pagination-meta'; +import { IdDTO } from '../dtos/shared/id.dto'; +import { SuccessDTO } from '../dtos/shared/success.dto'; +import { ApplicationViews } from '../enums/applications/view-enum'; +import { ApplicationUpdate } from '../dtos/applications/application-update.dto'; +import { ApplicationCreate } from '../dtos/applications/application-create.dto'; +import { PaginatedApplicationDto } from '../dtos/applications/paginated-application.dto'; +import { EmailService } from './email.service'; +import { PermissionService } from './permission.service'; +import Listing from '../dtos/listings/listing.dto'; +import { User } from '../dtos/users/user.dto'; +import { permissionActions } from '../enums/permissions/permission-actions-enum'; +import { GeocodingService } from './geocoding.service'; + +export const view: Partial< + Record +> = { + partnerList: { + applicant: { + include: { + applicantAddress: true, + applicantWorkAddress: true, + }, + }, + householdMember: true, + accessibility: true, + applicationsMailingAddress: true, + applicationsAlternateAddress: true, + alternateContact: { + include: { + address: true, + }, + }, + }, +}; + +view.base = { + ...view.partnerList, + demographics: true, + preferredUnitTypes: true, + listings: true, + householdMember: { + include: { + householdMemberAddress: true, + householdMemberWorkAddress: true, + }, + }, +}; + +view.details = { + ...view.base, + userAccounts: true, +}; + +/* + this is the service for applicationss + it handles all the backend's business logic for reading/writing/deleting application data +*/ +@Injectable() +export class ApplicationService { + constructor( + private prisma: PrismaService, + private emailService: EmailService, + private permissionService: PermissionService, + private geocodingService: GeocodingService, + ) {} + + /* + this will get a set of applications given the params passed in + this set can either be paginated or not depending on the params + it will return both the set of applications, and some meta information to help with pagination + */ + async list(params: ApplicationQueryParams): Promise { + const whereClause = this.buildWhereClause(params); + + const count = await this.prisma.applications.count({ + where: whereClause, + }); + + const rawApplications = await this.prisma.applications.findMany({ + skip: calculateSkip(params.limit, params.page), + take: calculateTake(params.limit), + orderBy: buildOrderByForApplications([params.orderBy], [params.order]), + include: view[params.listingId ? 'partnerList' : 'base'], + where: whereClause, + }); + + const applications = mapTo(Application, rawApplications); + + const promiseArray = applications.map((application) => + this.getDuplicateFlagsForApplication(application.id), + ); + + const flags = await Promise.all(promiseArray); + applications.forEach((application, index) => { + application.flagged = !!flags[index]?.id; + }); + + return { + items: applications, + meta: buildPaginationInfo( + params.limit, + params.page, + count, + applications.length, + ), + }; + } + + /* + this builds the where clause for list() + */ + buildWhereClause( + params: ApplicationQueryParams, + ): Prisma.ApplicationsWhereInput { + const toReturn: Prisma.ApplicationsWhereInput[] = []; + + if (params.userId) { + toReturn.push({ + userAccounts: { + id: params.userId, + }, + }); + } + if (params.listingId) { + toReturn.push({ + listingId: params.listingId, + }); + } + if (params.search) { + const searchFilter: Prisma.StringFilter = { + contains: params.search, + mode: 'insensitive', + }; + toReturn.push({ + OR: [ + { + confirmationCode: searchFilter, + }, + { + applicant: { + firstName: searchFilter, + }, + }, + { + applicant: { + lastName: searchFilter, + }, + }, + { + applicant: { + emailAddress: searchFilter, + }, + }, + { + applicant: { + phoneNumber: searchFilter, + }, + }, + { + alternateContact: { + firstName: searchFilter, + }, + }, + { + alternateContact: { + lastName: searchFilter, + }, + }, + { + alternateContact: { + emailAddress: searchFilter, + }, + }, + { + alternateContact: { + phoneNumber: searchFilter, + }, + }, + ], + }); + } + if (params.markedAsDuplicate !== undefined) { + toReturn.push({ + markedAsDuplicate: params.markedAsDuplicate, + }); + } + // We only should display non-deleted applications + toReturn.push({ + deletedAt: null, + }); + return { + AND: toReturn, + }; + } + + /* + this is to calculate the `flagged` property of an application + ideally in the future we save this data on the application so we don't have to keep + recalculating it + */ + async getDuplicateFlagsForApplication(applicationId: string): Promise { + return this.prisma.applications.findFirst({ + select: { + id: true, + }, + where: { + id: applicationId, + applicationFlaggedSet: { + some: {}, + }, + }, + }); + } + + /* + this will return 1 application or error + */ + async findOne(applicationId: string): Promise { + const rawApplication = await this.findOrThrow( + applicationId, + ApplicationViews.details, + ); + + return mapTo(Application, rawApplication); + } + + /* + this will create an application + */ + async create( + dto: ApplicationCreate, + forPublic: boolean, + requestingUser: User, + ): Promise { + if (!forPublic) { + await this.authorizeAction( + requestingUser, + dto as Application, + dto.listings.id, + permissionActions.create, + ); + } + + const listing = await this.prisma.listings.findUnique({ + where: { + id: dto.listings.id, + }, + include: { + jurisdictions: true, + // multiselect questions and address is needed for geocoding + listingMultiselectQuestions: { + include: { + multiselectQuestions: true, + }, + }, + listingsBuildingAddress: true, + }, + }); + // if its a public submission + if (forPublic) { + // SubmissionDate is time the application was created for public + dto.submissionDate = new Date(); + // if the submission is after the application due date + if ( + listing?.applicationDueDate && + dto.submissionDate > listing.applicationDueDate + ) { + throw new BadRequestException( + `Listing is not open for application submission`, + ); + } + } + + const rawApplication = await this.prisma.applications.create({ + data: { + ...dto, + confirmationCode: this.generateConfirmationCode(), + applicant: dto.applicant + ? { + create: { + ...dto.applicant, + applicantAddress: { + create: { + ...dto.applicant.applicantAddress, + }, + }, + applicantWorkAddress: { + create: { + ...dto.applicant.applicantWorkAddress, + }, + }, + }, + } + : undefined, + accessibility: dto.accessibility + ? { + create: { + ...dto.accessibility, + }, + } + : undefined, + alternateContact: dto.alternateContact + ? { + create: { + ...dto.alternateContact, + address: { + create: { + ...dto.alternateContact.address, + }, + }, + }, + } + : undefined, + applicationsAlternateAddress: dto.applicationsAlternateAddress + ? { + create: { + ...dto.applicationsAlternateAddress, + }, + } + : undefined, + applicationsMailingAddress: dto.applicationsMailingAddress + ? { + create: { + ...dto.applicationsMailingAddress, + }, + } + : undefined, + listings: dto.listings + ? { + connect: { + id: dto.listings.id, + }, + } + : undefined, + demographics: dto.demographics + ? { + create: { + ...dto.demographics, + }, + } + : undefined, + preferredUnitTypes: dto.preferredUnitTypes + ? { + connect: dto.preferredUnitTypes.map((unitType) => ({ + id: unitType.id, + })), + } + : undefined, + householdMember: dto.householdMember + ? { + create: dto.householdMember.map((member) => ({ + ...member, + sameAddress: member.sameAddress || YesNoEnum.no, + workInRegion: member.workInRegion || YesNoEnum.no, + householdMemberAddress: { + create: { + ...member.householdMemberAddress, + }, + }, + householdMemberWorkAddress: { + create: { + ...member.householdMemberWorkAddress, + }, + }, + })), + } + : undefined, + programs: dto.programs as unknown as Prisma.JsonArray, + preferences: dto.preferences as unknown as Prisma.JsonArray, + userAccounts: requestingUser + ? { + connect: { + id: requestingUser.id, + }, + } + : undefined, + }, + include: view.details, + }); + + const mappedApplication = mapTo(Application, rawApplication); + if (dto.applicant.emailAddress && forPublic) { + this.emailService.applicationConfirmation( + mapTo(Listing, listing), + dto, + listing.jurisdictions?.publicUrl, + ); + } + // Update the lastApplicationUpdateAt to now after every submission + await this.updateListingApplicationEditTimestamp(listing.id); + + // Calculate geocoding preferences after save and email sent + if (listing.jurisdictions?.enableGeocodingPreferences) { + try { + void this.geocodingService.validateGeocodingPreferences( + mappedApplication, + mapTo(Listing, listing), + ); + } catch (e) { + // If the geocoding fails it should not prevent the request from completing so + // catching all errors here + console.warn('error while validating geocoding preferences'); + } + } + + return mappedApplication; + } + + /* + this will update an application + if no application has the id of the incoming argument an error is thrown + */ + async update( + dto: ApplicationUpdate, + requestingUser: User, + ): Promise { + const rawApplication = await this.findOrThrow(dto.id); + + await this.authorizeAction( + requestingUser, + mapTo(Application, rawApplication), + rawApplication.listingId, + permissionActions.update, + ); + + // All connected household members should be deleted so they can be recreated in the update below. + // This solves for all cases of deleted members, updated members, and new members + await this.prisma.householdMember.deleteMany({ + where: { + applicationId: dto.id, + }, + }); + + const res = await this.prisma.applications.update({ + where: { + id: dto.id, + }, + include: view.details, + data: { + ...dto, + id: undefined, + applicant: dto.applicant + ? { + create: { + ...dto.applicant, + applicantAddress: { + create: { + ...dto.applicant.applicantAddress, + }, + }, + applicantWorkAddress: { + create: { + ...dto.applicant.applicantWorkAddress, + }, + }, + }, + } + : undefined, + accessibility: dto.accessibility + ? { + create: { + ...dto.accessibility, + }, + } + : undefined, + alternateContact: dto.alternateContact + ? { + create: { + ...dto.alternateContact, + address: { + create: { + ...dto.alternateContact.address, + }, + }, + }, + } + : undefined, + applicationsAlternateAddress: dto.applicationsAlternateAddress + ? { + create: { + ...dto.applicationsAlternateAddress, + }, + } + : undefined, + applicationsMailingAddress: dto.applicationsMailingAddress + ? { + create: { + ...dto.applicationsMailingAddress, + }, + } + : undefined, + listings: dto.listings + ? { + connect: { + id: dto.listings.id, + }, + } + : undefined, + demographics: dto.demographics + ? { + create: { + ...dto.demographics, + }, + } + : undefined, + preferredUnitTypes: dto.preferredUnitTypes + ? { + connect: dto.preferredUnitTypes.map((unitType) => ({ + id: unitType.id, + })), + } + : undefined, + householdMember: dto.householdMember + ? { + create: dto.householdMember.map((member) => ({ + ...member, + sameAddress: member.sameAddress || YesNoEnum.no, + workInRegion: member.workInRegion || YesNoEnum.no, + householdMemberAddress: { + create: { + ...member.householdMemberAddress, + }, + }, + householdMemberWorkAddress: { + create: { + ...member.householdMemberWorkAddress, + }, + }, + })), + } + : undefined, + programs: dto.programs as unknown as Prisma.JsonArray, + preferences: dto.preferences as unknown as Prisma.JsonArray, + }, + }); + + const listing = await this.prisma.listings.findFirst({ + where: { id: dto.id }, + include: { jurisdictions: true }, + }); + const application = mapTo(Application, res); + + // Calculate geocoding preferences after save and email sent + if (listing?.jurisdictions?.enableGeocodingPreferences) { + try { + void this.geocodingService.validateGeocodingPreferences( + application, + mapTo(Listing, listing), + ); + } catch (e) { + // If the geocoding fails it should not prevent the request from completing so + // catching all errors here + console.warn('error while validating geocoding preferences'); + } + } + + await this.updateListingApplicationEditTimestamp(res.listingId); + return application; + } + + /* + this will mark an application as deleted by setting the deletedAt column for the application + */ + async delete( + applicationId: string, + requestingUser: User, + ): Promise { + const application = await this.findOrThrow(applicationId); + + await this.authorizeAction( + requestingUser, + mapTo(Application, application), + application.listingId, + permissionActions.delete, + ); + + await this.updateListingApplicationEditTimestamp(application.listingId); + await this.prisma.applications.update({ + where: { + id: applicationId, + }, + data: { + deletedAt: new Date(), + }, + }); + + return { + success: true, + }; + } + + /* + finds the requested listing or throws an error + */ + async findOrThrow(applicationId: string, includeView?: ApplicationViews) { + const res = await this.prisma.applications.findUnique({ + where: { + id: applicationId, + }, + include: view[includeView] ?? undefined, + }); + + if (!res) { + throw new NotFoundException( + `applicationId ${applicationId} was requested but not found`, + ); + } + + return res; + } + + /* + updates a listing's lastApplicationUpdateAt date + */ + async updateListingApplicationEditTimestamp( + listingId: string, + ): Promise { + await this.prisma.listings.update({ + where: { + id: listingId, + }, + data: { + lastApplicationUpdateAt: new Date(), + }, + }); + } + + async authorizeAction( + user: User, + application: Application, + listingId: string, + action: permissionActions, + ): Promise { + const listingJurisdiction = await this.prisma.jurisdictions.findFirst({ + where: { + listings: { + some: { + id: listingId, + }, + }, + }, + }); + await this.permissionService.canOrThrow(user, 'application', action, { + listingId, + jurisdictionId: listingJurisdiction.id, + }); + } + + /* + generates a random confirmation code + */ + generateConfirmationCode(): string { + return crypto.randomBytes(4).toString('hex').toUpperCase(); + } +} diff --git a/api/src/services/asset.service.ts b/api/src/services/asset.service.ts new file mode 100644 index 0000000000..08125c52d1 --- /dev/null +++ b/api/src/services/asset.service.ts @@ -0,0 +1,37 @@ +import { Injectable } from '@nestjs/common'; +import { v2 as cloudinary } from 'cloudinary'; +import { CreatePresignedUploadMetadata } from '../dtos/assets/create-presigned-upload-meta.dto'; +import { CreatePresignedUploadMetadataResponse } from '../dtos/assets/create-presign-upload-meta-response.dto'; + +/* + this is the service for assets + it handles all the backend's business logic for signing meta data for asset upload +*/ + +@Injectable() +export class AssetService { + /* + this will create a signed signature for upload to cloudinary + */ + async createPresignedUploadMetadata( + createPresignedUploadMetadata: CreatePresignedUploadMetadata, + ): Promise { + // Based on https://cloudinary.com/documentation/upload_images#signed_upload_video_tutorial + + const parametersToSignWithTimestamp = { + ...createPresignedUploadMetadata.parametersToSign, + timestamp: parseInt( + createPresignedUploadMetadata.parametersToSign.timestamp, + ), + }; + + const signature = await cloudinary.utils.api_sign_request( + parametersToSignWithTimestamp, + process.env.CLOUDINARY_SECRET, + ); + + return { + signature, + }; + } +} diff --git a/api/src/services/auth.service.ts b/api/src/services/auth.service.ts new file mode 100644 index 0000000000..b00a7103df --- /dev/null +++ b/api/src/services/auth.service.ts @@ -0,0 +1,339 @@ +import { + BadRequestException, + Injectable, + NotFoundException, + UnauthorizedException, +} from '@nestjs/common'; +import { Response } from 'express'; +import { CookieOptions } from 'express'; +import { sign, verify } from 'jsonwebtoken'; +import { randomInt } from 'crypto'; +import { Prisma } from '@prisma/client'; +import { UpdatePassword } from '../dtos/auth/update-password.dto'; +import { MfaType } from '../enums/mfa/mfa-type-enum'; +import { isPasswordValid, passwordToHash } from '../utilities/password-helpers'; +import { RequestMfaCodeResponse } from '../dtos/mfa/request-mfa-code-response.dto'; +import { RequestMfaCode } from '../dtos/mfa/request-mfa-code.dto'; +import { SuccessDTO } from '../dtos/shared/success.dto'; +import { User } from '../dtos/users/user.dto'; +import { PrismaService } from './prisma.service'; +import { UserService } from './user.service'; +import { IdDTO } from '../dtos/shared/id.dto'; +import { mapTo } from '../utilities/mapTo'; +import { Confirm } from '../dtos/auth/confirm.dto'; +import { SmsService } from './sms.service'; +import { EmailService } from './email.service'; + +// since our local env doesn't have an https cert we can't be secure. Hosted envs should be secure +const secure = process.env.NODE_ENV !== 'development'; +const sameSite = process.env.NODE_ENV === 'development' ? 'strict' : 'none'; + +const TOKEN_COOKIE_MAXAGE = 86400000; // 24 hours +export const TOKEN_COOKIE_NAME = 'access-token'; +export const REFRESH_COOKIE_NAME = 'refresh-token'; +export const ACCESS_TOKEN_AVAILABLE_NAME = 'access-token-available'; +export const AUTH_COOKIE_OPTIONS: CookieOptions = { + httpOnly: true, + secure, + sameSite, + maxAge: TOKEN_COOKIE_MAXAGE / 24, // access token should last 1 hr +}; +export const REFRESH_COOKIE_OPTIONS: CookieOptions = { + ...AUTH_COOKIE_OPTIONS, + maxAge: TOKEN_COOKIE_MAXAGE, +}; +export const ACCESS_TOKEN_AVAILABLE_OPTIONS: CookieOptions = { + ...AUTH_COOKIE_OPTIONS, + httpOnly: false, +}; + +type IdAndEmail = { + id: string; + email: string; +}; + +@Injectable() +export class AuthService { + constructor( + private prisma: PrismaService, + private userService: UserService, + private smsService: SmsService, + private emailsService: EmailService, + ) {} + + /* + generates a signed token for a user + willBeRefreshToken changes the TTL of the token with true being longer and false being shorter + willBeRefreshToken is true when trying to sign a refresh token instead of the standard auth token + */ + generateAccessToken(user: User, willBeRefreshToken?: boolean): string { + const payload = { + sub: user.id, + expiresIn: willBeRefreshToken + ? REFRESH_COOKIE_OPTIONS.maxAge + : AUTH_COOKIE_OPTIONS.maxAge, + }; + return sign(payload, process.env.APP_SECRET); + } + + /* + this sets credentials as part of the response's cookies + handles the storage and creation of these credentials + */ + async setCredentials( + res: Response, + user: User, + incomingRefreshToken?: string, + ): Promise { + if (!user?.id) { + throw new UnauthorizedException('no user found'); + } + + if (incomingRefreshToken) { + // if token is provided, verify that its the correct refresh token + const userCount = await this.prisma.userAccounts.count({ + where: { + id: user.id, + activeRefreshToken: incomingRefreshToken, + }, + }); + + if (!userCount) { + // if the incoming refresh token is not the active refresh token for the user, clear the user's tokens + await this.prisma.userAccounts.update({ + data: { + activeAccessToken: null, + activeRefreshToken: null, + }, + where: { + id: user.id, + }, + }); + res.clearCookie(TOKEN_COOKIE_NAME, AUTH_COOKIE_OPTIONS); + res.clearCookie(REFRESH_COOKIE_NAME, REFRESH_COOKIE_OPTIONS); + res.clearCookie( + ACCESS_TOKEN_AVAILABLE_NAME, + ACCESS_TOKEN_AVAILABLE_OPTIONS, + ); + + throw new UnauthorizedException( + `User ${user.id} was attempting to use outdated token ${incomingRefreshToken} to generate new tokens`, + ); + } + } + + const accessToken = this.generateAccessToken(user); + const newRefreshToken = this.generateAccessToken(user, true); + + // store access and refresh token into db + await this.prisma.userAccounts.update({ + data: { + activeAccessToken: accessToken, + activeRefreshToken: newRefreshToken, + }, + where: { + id: user.id, + }, + }); + + res.cookie(TOKEN_COOKIE_NAME, accessToken, AUTH_COOKIE_OPTIONS); + res.cookie(REFRESH_COOKIE_NAME, newRefreshToken, REFRESH_COOKIE_OPTIONS); + res.cookie( + ACCESS_TOKEN_AVAILABLE_NAME, + 'True', + ACCESS_TOKEN_AVAILABLE_OPTIONS, + ); + + return { + success: true, + } as SuccessDTO; + } + + /* + this clears credentials from response and the db + */ + async clearCredentials(res: Response, user: User): Promise { + if (!user?.id) { + throw new UnauthorizedException('no user found'); + } + + // clear access and refresh tokens from db + await this.prisma.userAccounts.update({ + data: { + activeAccessToken: null, + activeRefreshToken: null, + }, + where: { + id: user.id, + }, + }); + + res.clearCookie(TOKEN_COOKIE_NAME, AUTH_COOKIE_OPTIONS); + res.clearCookie(REFRESH_COOKIE_NAME, REFRESH_COOKIE_OPTIONS); + res.clearCookie( + ACCESS_TOKEN_AVAILABLE_NAME, + ACCESS_TOKEN_AVAILABLE_OPTIONS, + ); + + return { + success: true, + } as SuccessDTO; + } + + /* + verifies that the requesting user can/should be provided an mfa code + generates then sends an mfa code to a users phone or email + */ + async requestMfaCode(dto: RequestMfaCode): Promise { + const user = await this.userService.findUserOrError( + { email: dto.email }, + true, + ); + + if (!user.mfaEnabled) { + throw new UnauthorizedException( + `user ${dto.email} requested an mfa code, but has mfa disabled`, + ); + } + + if (!(await isPasswordValid(user.passwordHash, dto.password))) { + throw new UnauthorizedException( + `user ${dto.email} requested an mfa code, but provided incorrect password`, + ); + } + + if (dto.mfaType === MfaType.sms) { + if (dto.phoneNumber) { + if (!user.phoneNumberVerified) { + user.phoneNumber = dto.phoneNumber; + } else { + throw new UnauthorizedException( + 'phone number can only be specified the first time using mfa', + ); + } + } else if (!dto.phoneNumber && !user.phoneNumber) { + throw new UnauthorizedException({ + name: 'phoneNumberMissing', + message: 'no valid phone number was found', + }); + } + } + + const mfaCode = this.generateMfaCode(); + await this.prisma.userAccounts.update({ + data: { + mfaCode, + mfaCodeUpdatedAt: new Date(), + phoneNumber: user.phoneNumber, + }, + where: { + id: user.id, + }, + }); + + if (dto.mfaType === MfaType.email) { + await this.emailsService.sendMfaCode(mapTo(User, user), mfaCode); + } else if (dto.mfaType === MfaType.sms) { + await this.smsService.sendMfaCode(user.phoneNumber, mfaCode); + } + + return dto.mfaType === MfaType.email + ? { email: user.email, phoneNumberVerified: user.phoneNumberVerified } + : { + phoneNumber: user.phoneNumber, + phoneNumberVerified: user.phoneNumberVerified, + }; + } + + /* + updates a user's password and logs them in + */ + async updatePassword( + dto: UpdatePassword, + res: Response, + ): Promise { + const user = await this.prisma.userAccounts.findFirst({ + where: { resetToken: dto.token }, + }); + + if (!user) { + throw new NotFoundException( + `user resetToken: ${dto.token} was requested but not found`, + ); + } + + const token: IdDTO = verify(dto.token, process.env.APP_SECRET) as IdDTO; + + if (token.id !== user.id) { + throw new UnauthorizedException( + `resetToken ${dto.token} does not match user ${user.id}'s reset token (${user.resetToken})`, + ); + } + + await this.prisma.userAccounts.update({ + data: { + passwordHash: await passwordToHash(dto.password), + passwordUpdatedAt: new Date(), + resetToken: null, + }, + where: { + id: user.id, + }, + }); + + return await this.setCredentials(res, mapTo(User, user)); + } + + /* + confirms a user and logs them in + */ + async confirmUser(dto: Confirm, res?: Response): Promise { + const token = verify(dto.token, process.env.APP_SECRET) as IdAndEmail; + + let user = await this.userService.findUserOrError( + { userId: token.id }, + false, + ); + + if (user.confirmationToken !== dto.token) { + throw new BadRequestException( + `Confirmation token mismatch for user stored: ${user.confirmationToken}, incoming token: ${dto.token}`, + ); + } + + const data: Prisma.UserAccountsUpdateInput = { + confirmedAt: new Date(), + confirmationToken: null, + }; + + if (dto.password) { + data.passwordHash = await passwordToHash(dto.password); + data.passwordUpdatedAt = new Date(); + } + + if (token.email) { + data.email = token.email; + } + + user = await this.prisma.userAccounts.update({ + data, + where: { + id: user.id, + }, + }); + + return await this.setCredentials(res, mapTo(User, user)); + } + + /* + generates a numeric mfa code + */ + generateMfaCode() { + let out = ''; + const characters = '0123456789'; + for (let i = 0; i < Number(process.env.MFA_CODE_LENGTH); i++) { + out += characters.charAt(randomInt(characters.length)); + } + return out; + } +} diff --git a/api/src/services/email.service.ts b/api/src/services/email.service.ts new file mode 100644 index 0000000000..e1c58c9721 --- /dev/null +++ b/api/src/services/email.service.ts @@ -0,0 +1,507 @@ +import { HttpException, Injectable } from '@nestjs/common'; +import { ResponseError } from '@sendgrid/helpers/classes'; +import { MailDataRequired } from '@sendgrid/helpers/classes/mail'; +import fs from 'fs'; +import Handlebars from 'handlebars'; +import Polyglot from 'node-polyglot'; +import path from 'path'; +import dayjs from 'dayjs'; +import utc from 'dayjs/plugin/utc'; +import tz from 'dayjs/plugin/timezone'; +import advanced from 'dayjs/plugin/advancedFormat'; +import { TranslationService } from './translation.service'; +import { JurisdictionService } from './jurisdiction.service'; +import { Jurisdiction } from '../dtos/jurisdictions/jurisdiction.dto'; +import { LanguagesEnum, ReviewOrderTypeEnum } from '@prisma/client'; +import { IdDTO } from '../dtos/shared/id.dto'; +import { Listing } from '../dtos/listings/listing.dto'; +import { SendGridService } from './sendgrid.service'; +import { ApplicationCreate } from '../dtos/applications/application-create.dto'; +import { User } from '../dtos/users/user.dto'; +dayjs.extend(utc); +dayjs.extend(tz); +dayjs.extend(advanced); + +type EmailAttachmentData = { + data: string; + name: string; + type: string; +}; + +@Injectable() +export class EmailService { + polyglot: Polyglot; + + constructor( + private readonly sendGrid: SendGridService, + private readonly translationService: TranslationService, + private readonly jurisdictionService: JurisdictionService, + ) { + 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); + } + + private template(view: string) { + return Handlebars.compile( + fs.readFileSync( + path.join(path.resolve(__dirname, '..', 'views'), `/${view}.hbs`), + 'utf8', + ), + ); + } + + private partial(view: string) { + return fs.readFileSync( + path.join(path.resolve(__dirname, '..', 'views'), `/${view}`), + 'utf8', + ); + } + + private partials() { + const partials = {}; + const dirName = path.resolve(__dirname, '..', 'views/partials'); + + fs.readdirSync(dirName).forEach((filename) => { + partials[filename.slice(0, -4)] = this.partial('partials/' + filename); + }); + + const layoutsDirName = path.resolve(__dirname, '..', 'views/layouts'); + + fs.readdirSync(layoutsDirName).forEach((filename) => { + partials[`layout_${filename.slice(0, -4)}`] = this.partial( + 'layouts/' + filename, + ); + }); + + return partials; + } + + private async send( + to: string | string[], + from: string, + subject: string, + body: string, + retry = 3, + attachment?: EmailAttachmentData, + ) { + const isMultipleRecipients = Array.isArray(to); + const emailParams: MailDataRequired = { + to, + from, + subject, + html: body, + }; + if (attachment) { + emailParams.attachments = [ + { + content: Buffer.from(attachment.data).toString('base64'), + filename: attachment.name, + type: attachment.type, + disposition: 'attachment', + }, + ]; + } + const handleError = (error) => { + if (error instanceof ResponseError) { + const { response } = error; + const { body: errBody } = response; + console.error( + `Error sending email to: ${ + isMultipleRecipients ? to.toString() : to + }! Error body: ${errBody}`, + ); + if (retry > 0) { + void this.send(to, from, subject, body, retry - 1); + } + } + }; + await this.sendGrid.send(emailParams, isMultipleRecipients, handleError); + } + + // TODO: update this to be memoized based on jurisdiction and language + // https://github.com/bloom-housing/bloom/issues/3648 + private async loadTranslations( + jurisdiction: Jurisdiction | null, + language?: LanguagesEnum, + ) { + const translations = await this.translationService.getMergedTranslations( + jurisdiction?.id, + language, + ); + this.polyglot.replace(translations); + } + + private async getJurisdiction( + jurisdictionIds: IdDTO[] | null, + jurisdictionName?: string, + ): Promise { + // Only return the jurisdiction if there is one jurisdiction passed in. + // For example if the user is tied to more than one jurisdiction the user should received the generic translations + if (jurisdictionIds?.length === 1) { + return await this.jurisdictionService.findOne({ + jurisdictionId: jurisdictionIds[0]?.id, + }); + } else if (jurisdictionName) { + return await this.jurisdictionService.findOne({ + jurisdictionName: jurisdictionName, + }); + } + return null; + } + + private async getEmailToSendFrom( + jurisdictionIds: IdDTO[], + jurisdiction: Jurisdiction, + ): Promise { + if (jurisdiction) { + return jurisdiction.emailFromAddress; + } + // An admin will be attached to more than one jurisdiction so we want generic translations + // but still need an email to send from + if (jurisdictionIds.length > 1) { + const firstJurisdiction = await this.jurisdictionService.findOne({ + jurisdictionId: jurisdictionIds[0].id, + }); + return firstJurisdiction?.emailFromAddress || ''; + } + return ''; + } + + /* Send welcome email to new public users */ + public async welcome( + jurisdictionName: string, + user: User, + appUrl: string, + confirmationUrl: string, + ) { + const jurisdiction = await this.getJurisdiction(null, jurisdictionName); + await this.loadTranslations(jurisdiction, user.language); + await this.send( + user.email, + jurisdiction.emailFromAddress, + this.polyglot.t('register.welcome'), + this.template('register-email')({ + user: user, + confirmationUrl: confirmationUrl, + appOptions: { appUrl: appUrl }, + }), + ); + } + + /* Send invite email to partner users */ + async invitePartnerUser( + jurisdictionIds: IdDTO[], + user: User, + appUrl: string, + confirmationUrl: string, + ) { + const jurisdiction = await this.getJurisdiction(jurisdictionIds); + void (await this.loadTranslations(jurisdiction, user.language)); + const emailFromAddress = await this.getEmailToSendFrom( + jurisdictionIds, + jurisdiction, + ); + await this.send( + user.email, + emailFromAddress, + this.polyglot.t('invite.hello'), + this.template('invite')({ + user: user, + confirmationUrl: confirmationUrl, + appOptions: { appUrl }, + }), + ); + } + + /* send account update email */ + async portalAccountUpdate( + jurisdictionIds: IdDTO[], + user: User, + appUrl: string, + ) { + const jurisdiction = await this.getJurisdiction(jurisdictionIds); + void (await this.loadTranslations(jurisdiction, user.language)); + const emailFromAddress = await this.getEmailToSendFrom( + jurisdictionIds, + jurisdiction, + ); + await this.send( + user.email, + emailFromAddress, + this.polyglot.t('invite.portalAccountUpdate'), + this.template('portal-account-update')({ + user, + appUrl, + }), + ); + } + + /* send change of email email */ + public async changeEmail( + jurisdictionName: string, + user: User, + appUrl: string, + confirmationUrl: string, + newEmail: string, + ) { + const jurisdiction = await this.getJurisdiction(null, jurisdictionName); + await this.loadTranslations(jurisdiction, user.language); + await this.send( + newEmail, + jurisdiction.emailFromAddress, + 'Bloom email change request', + this.template('change-email')({ + user: user, + confirmationUrl: confirmationUrl, + appOptions: { appUrl: appUrl }, + }), + ); + } + + /* Send forgot password email */ + public async forgotPassword( + jurisdictionIds: IdDTO[], + user: User, + appUrl: string, + resetToken: string, + ) { + const jurisdiction = await this.getJurisdiction(jurisdictionIds); + void (await this.loadTranslations(jurisdiction, user.language)); + const compiledTemplate = this.template('forgot-password'); + const resetUrl = `${appUrl}/reset-password?token=${resetToken}`; + const emailFromAddress = await this.getEmailToSendFrom( + jurisdictionIds, + jurisdiction, + ); + + await this.send( + user.email, + emailFromAddress, + this.polyglot.t('forgotPassword.subject'), + compiledTemplate({ + resetUrl: resetUrl, + resetOptions: { appUrl: appUrl }, + user: user, + }), + ); + } + + public async sendMfaCode(user: User, mfaCode: string) { + const jurisdiction = await this.getJurisdiction(user.jurisdictions); + void (await this.loadTranslations(jurisdiction, user.language)); + const emailFromAddress = await this.getEmailToSendFrom( + user.jurisdictions, + jurisdiction, + ); + await this.send( + user.email, + emailFromAddress, + 'Partners Portal account access token', + this.template('mfa-code')({ + user: user, + mfaCodeOptions: { mfaCode }, + }), + ); + } + + public async applicationConfirmation( + listing: Listing, + application: ApplicationCreate, + appUrl: string, + ) { + const jurisdiction = await this.getJurisdiction([listing.jurisdictions]); + void (await this.loadTranslations(jurisdiction, application.language)); + const listingUrl = `${appUrl}/listing/${listing.id}`; + const compiledTemplate = this.template('confirmation'); + + let eligibleText: string; + let preferenceText: string; + let contactText = null; + if (listing.reviewOrderType === ReviewOrderTypeEnum.firstComeFirstServe) { + eligibleText = this.polyglot.t('confirmation.eligible.fcfs'); + preferenceText = this.polyglot.t('confirmation.eligible.fcfsPreference'); + } + if (listing.reviewOrderType === ReviewOrderTypeEnum.lottery) { + eligibleText = this.polyglot.t('confirmation.eligible.lottery'); + preferenceText = this.polyglot.t( + 'confirmation.eligible.lotteryPreference', + ); + } + if (listing.reviewOrderType === ReviewOrderTypeEnum.waitlist) { + eligibleText = this.polyglot.t('confirmation.eligible.waitlist'); + contactText = this.polyglot.t('confirmation.eligible.waitlistContact'); + preferenceText = this.polyglot.t( + 'confirmation.eligible.waitlistPreference', + ); + } + + const user = { + firstName: application.applicant.firstName, + middleName: application.applicant.middleName, + lastName: application.applicant.lastName, + }; + + const nextStepsUrl = this.polyglot.t('confirmation.nextStepsUrl'); + + await this.send( + application.applicant.emailAddress, + jurisdiction.emailFromAddress, + this.polyglot.t('confirmation.subject'), + compiledTemplate({ + subject: this.polyglot.t('confirmation.subject'), + header: { + logoTitle: this.polyglot.t('header.logoTitle'), + logoUrl: this.polyglot.t('header.logoUrl'), + }, + listing, + listingUrl, + application, + preferenceText, + interviewText: this.polyglot.t('confirmation.interview'), + eligibleText, + contactText, + nextStepsUrl: + nextStepsUrl != 'confirmation.nextStepsUrl' ? nextStepsUrl : null, + user, + }), + ); + } + + public async requestApproval( + jurisdictionId: IdDTO, + listingInfo: IdDTO, + emails: string[], + appUrl: string, + ) { + try { + const jurisdiction = await this.getJurisdiction([jurisdictionId]); + void (await this.loadTranslations(jurisdiction)); + await this.send( + emails, + jurisdiction.emailFromAddress, + this.polyglot.t('requestApproval.header'), + this.template('request-approval')({ + appOptions: { listingName: listingInfo.name }, + appUrl: appUrl, + listingUrl: `${appUrl}/listings/${listingInfo.id}`, + }), + ); + } catch (err) { + console.log('Request approval email failed', err); + throw new HttpException('email failed', 500); + } + } + + public async changesRequested( + jurisdictionId: IdDTO, + listingInfo: IdDTO, + emails: string[], + appUrl: string, + ) { + try { + const jurisdiction = await this.getJurisdiction([jurisdictionId]); + void (await this.loadTranslations(jurisdiction)); + await this.send( + emails, + jurisdiction.emailFromAddress, + this.polyglot.t('changesRequested.header'), + this.template('changes-requested')({ + appOptions: { listingName: listingInfo.name }, + appUrl: appUrl, + listingUrl: `${appUrl}/listings/${listingInfo.id}`, + }), + ); + } catch (err) { + console.log('changes requested email failed', err); + throw new HttpException('email failed', 500); + } + } + + public async listingApproved( + jurisdictionId: IdDTO, + listingInfo: IdDTO, + emails: string[], + publicUrl: string, + ) { + try { + const jurisdiction = await this.getJurisdiction([jurisdictionId]); + void (await this.loadTranslations(jurisdiction)); + await this.send( + emails, + jurisdiction.emailFromAddress, + this.polyglot.t('listingApproved.header'), + this.template('listing-approved')({ + appOptions: { listingName: listingInfo.name }, + listingUrl: `${publicUrl}/listing/${listingInfo.id}`, + }), + ); + } catch (err) { + console.log('listing approval email failed', err); + throw new HttpException('email failed', 500); + } + } + + /** + * + * @param jurisdictionIds the set of jurisdicitons for the user (sent as IdDTO[] + * @param user the user that should received the csv export + * @param csvData the data that makes up the content of the csv to be sent as an attachment + * @param exportEmailTitle the title of the email ('User Export' is an example) + * @param exportEmailFileDescription describes what is being sent. Completes the line: + 'The attached file is %{fileDescription}. If you have any questions, please reach out to your administrator. + */ + async sendCSV( + jurisdictionIds: IdDTO[], + user: User, + csvData: string, + exportEmailTitle: string, + exportEmailFileDescription: string, + ): Promise { + const jurisdiction = await this.getJurisdiction(jurisdictionIds); + void (await this.loadTranslations(jurisdiction, user.language)); + const emailFromAddress = await this.getEmailToSendFrom( + user.jurisdictions, + jurisdiction, + ); + await this.send( + user.email, + emailFromAddress, + exportEmailTitle, + this.template('csv-export')({ + user: user, + appOptions: { + title: exportEmailTitle, + fileDescription: exportEmailFileDescription, + appUrl: process.env.PARTNERS_PORTAL_URL, + }, + }), + undefined, + { + data: csvData, + name: `users-${this.formatLocalDate( + new Date(), + 'YYYY-MM-DD_HH:mm:ss', + )}.csv`, + type: 'text/csv', + }, + ); + } + + formatLocalDate(rawDate: string | Date, format: string): string { + const utcDate = dayjs.utc(rawDate); + return utcDate.format(format); + } +} diff --git a/api/src/services/geocoding.service.ts b/api/src/services/geocoding.service.ts new file mode 100644 index 0000000000..540f363349 --- /dev/null +++ b/api/src/services/geocoding.service.ts @@ -0,0 +1,257 @@ +import { FeatureCollection, point, polygons } from '@turf/helpers'; +import buffer from '@turf/buffer'; +import booleanPointInPolygon from '@turf/boolean-point-in-polygon'; +import { MapLayers, Prisma } from '@prisma/client'; +import { Injectable } from '@nestjs/common'; +import { Application } from '../dtos/applications/application.dto'; +import Listing from '../dtos/listings/listing.dto'; +import { MultiselectOption } from '../dtos/multiselect-questions/multiselect-option.dto'; +import { ApplicationMultiselectQuestion } from '../dtos/applications/application-multiselect-question.dto'; +import { PrismaService } from './prisma.service'; +import { ValidationMethod } from '../enums/multiselect-questions/validation-method-enum'; +import { ApplicationMultiselectQuestionOption } from '../dtos/applications/application-multiselect-question-option.dto'; +import { Address } from '../dtos/addresses/address.dto'; +import { InputType } from '../enums/shared/input-type-enum'; +import pointsWithinPolygon from '@turf/points-within-polygon'; + +@Injectable() +export class GeocodingService { + constructor(private prisma: PrismaService) {} + + public async validateGeocodingPreferences( + application: Application, + listing: Listing, + ) { + let preferences = application.preferences; + preferences = this.validateRadiusPreferences(preferences, listing); + preferences = await this.validateGeoLayerPreferences(preferences, listing); + + await this.prisma.applications.update({ + where: { id: application.id }, + data: { preferences: preferences as unknown as Prisma.InputJsonObject }, + }); + } + + verifyRadius( + preferenceAddress: Address, + radius: number, + listingAddress: Address, + ): boolean | null { + try { + if (preferenceAddress.latitude && preferenceAddress.longitude) { + const preferencePoint = point([ + Number.parseFloat(preferenceAddress.longitude.toString()), + Number.parseFloat(preferenceAddress.latitude.toString()), + ]); + const listingPoint = point([ + Number.parseFloat(listingAddress.longitude.toString()), + Number.parseFloat(listingAddress.latitude.toString()), + ]); + const calculatedBuffer = buffer(listingPoint.geometry, radius, { + units: 'miles', + }); + return booleanPointInPolygon(preferencePoint, calculatedBuffer) + ? true + : false; + } + } catch (e) { + console.log('e', e); + console.log('error happened while calculating radius'); + } + return null; + } + + verifyLayers( + preferenceAddress: Address, + featureCollectionLayers: FeatureCollection, + ): boolean | null { + try { + if (preferenceAddress.latitude && preferenceAddress.longitude) { + const preferencePoint = point([ + Number.parseFloat(preferenceAddress.longitude.toString()), + Number.parseFloat(preferenceAddress.latitude.toString()), + ]); + + // Convert the features to the format that turfjs wants + const polygonsFromFeature = []; + featureCollectionLayers.features.forEach((feature) => { + if ( + feature.geometry.type === 'MultiPolygon' || + feature.geometry.type === 'Polygon' + ) { + feature.geometry.coordinates.forEach((coordinate) => { + polygonsFromFeature.push(coordinate); + }); + } + }); + const layer = polygons(polygonsFromFeature); + + const points = pointsWithinPolygon(preferencePoint, layer); + if (points && points.features?.length) { + return true; + } + + return false; + } + } catch (e) { + console.log('e', e); + } + // If the geocoding value was not able to be verified we need to set it as "unknown" + // in order to signify we are unable to automatically verify and manually checking will need to be done + return null; + } + + /** + * Checks if there are any preferences that have a validation method of radius, validates those preferences addresses, + * and then adds the appropriate validation check field to those preferences + * + * @param preferences + * @param listing + * @returns the preferences with the geocoding verified field added to preferences that have validation method of radius + */ + public validateRadiusPreferences( + preferences: ApplicationMultiselectQuestion[], + listing: Listing, + ): ApplicationMultiselectQuestion[] { + // Get all radius preferences from the listing + const radiusPreferenceOptions: MultiselectOption[] = + listing.listingMultiselectQuestions.reduce( + (options, multiselectQuestion) => { + const newOptions = + multiselectQuestion.multiselectQuestions.options?.filter( + (option) => option.validationMethod === ValidationMethod.radius, + ); + return [...options, ...newOptions]; + }, + [], + ); + // If there are any radius preferences do the calculation and save the new preferences + if (radiusPreferenceOptions.length) { + const newPreferences: ApplicationMultiselectQuestion[] = preferences.map( + (preference) => { + const newPreferenceOptions: ApplicationMultiselectQuestionOption[] = + preference.options.map((option) => { + const addressData = option.extraData?.find( + (data) => data.type === InputType.address, + ); + if (option.checked && addressData) { + const foundOption = radiusPreferenceOptions.find( + (preferenceOption) => preferenceOption.text === option.key, + ); + if (foundOption) { + const geocodingVerified = this.verifyRadius( + addressData.value as Address, + foundOption.radiusSize, + listing.listingsBuildingAddress, + ); + return { + ...option, + extraData: [ + ...option.extraData, + { + key: 'geocodingVerified', + type: InputType.text, + value: + // If the geocoding value was not able to be verified we need to set it as "unknown" + // in order to signify we are unable to automatically verify and manually checking will need to be done + geocodingVerified === null + ? 'unknown' + : geocodingVerified, + }, + ], + }; + } + } + return option; + }); + return { ...preference, options: newPreferenceOptions }; + }, + ); + return newPreferences; + } + return preferences; + } + + /** + * Checks if there are any preferences that have a validation method of 'map', validates those preferences addresses, + * and then adds the appropriate validation check field to those preferences + * + * @param preferences + * @param listing + * @returns all preferences on the application + */ + public async validateGeoLayerPreferences( + preferences: ApplicationMultiselectQuestion[], + listing: Listing, + ): Promise { + // Get all map layer preferences from the listing + const mapPreferenceOptions: MultiselectOption[] = + listing.listingMultiselectQuestions?.reduce( + (options, multiselectQuestion) => { + const newOptions = + multiselectQuestion.multiselectQuestions?.options?.filter( + (option) => option.validationMethod === ValidationMethod.map, + ) || []; + return [...options, ...newOptions]; + }, + [], + ); + + const preferencesOptions = ( + preference: ApplicationMultiselectQuestion, + mapLayers: MapLayers[], + ): ApplicationMultiselectQuestionOption[] => { + const preferenceOptions = []; + preference.options.forEach((option) => { + const addressData = option.extraData?.find( + (data) => data.type === InputType.address, + ); + if (option.checked && addressData) { + const foundOption = mapPreferenceOptions.find( + (preferenceOption) => preferenceOption.text === option.key, + ); + if (foundOption && foundOption.mapLayerId) { + const layer = mapLayers.find( + (layer) => layer.id === foundOption.mapLayerId, + ); + const geocodingVerified = this.verifyLayers( + addressData.value as Address, + layer?.featureCollection as unknown as FeatureCollection, + ); + preferenceOptions.push({ + ...option, + extraData: [ + ...option.extraData, + { + key: 'geocodingVerified', + type: InputType.text, + value: + // If the geocoding value was not able to be verified we need to set it as "unknown" + // in order to signify we are unable to automatically verify and manually checking will need to be done + geocodingVerified === null ? 'unknown' : geocodingVerified, + }, + ], + }); + return; + } + } + preferenceOptions.push(option); + }); + return preferenceOptions; + }; + if (mapPreferenceOptions?.length) { + const newPreferences = []; + const mapLayers = await this.prisma.mapLayers.findMany({ + where: { + id: { in: mapPreferenceOptions.map((option) => option.mapLayerId) }, + }, + }); + preferences.forEach((preference) => { + const newPreferenceOptions = preferencesOptions(preference, mapLayers); + newPreferences.push({ ...preference, options: newPreferenceOptions }); + }); + return newPreferences; + } + return preferences; + } +} diff --git a/api/src/services/google-translate.service.ts b/api/src/services/google-translate.service.ts new file mode 100644 index 0000000000..89edd82faa --- /dev/null +++ b/api/src/services/google-translate.service.ts @@ -0,0 +1,30 @@ +import { Injectable } from '@nestjs/common'; +import { Translate } from '@google-cloud/translate/build/src/v2'; +import { LanguagesEnum } from '@prisma/client'; + +@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: LanguagesEnum) { + return await GoogleTranslateService.makeTranslateService().translate( + values, + { + from: LanguagesEnum.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/api/src/services/jurisdiction.service.ts b/api/src/services/jurisdiction.service.ts new file mode 100644 index 0000000000..349c175cf7 --- /dev/null +++ b/api/src/services/jurisdiction.service.ts @@ -0,0 +1,154 @@ +import { + Injectable, + BadRequestException, + NotFoundException, +} from '@nestjs/common'; +import { PrismaService } from './prisma.service'; +import { Jurisdiction } from '../dtos/jurisdictions/jurisdiction.dto'; +import { JurisdictionCreate } from '../dtos/jurisdictions/jurisdiction-create.dto'; +import { mapTo } from '../utilities/mapTo'; +import { SuccessDTO } from '../dtos/shared/success.dto'; +import { Prisma } from '@prisma/client'; +import { JurisdictionUpdate } from '../dtos/jurisdictions/jurisdiction-update.dto'; + +const view: Prisma.JurisdictionsInclude = { + multiselectQuestions: true, +}; +/** + this is the service for jurisdictions + it handles all the backend's business logic for reading/writing/deleting jurisdiction data +*/ +@Injectable() +export class JurisdictionService { + constructor(private prisma: PrismaService) {} + + /** + this will get a set of jurisdictions given the params passed in + */ + async list(): Promise { + const rawJurisdictions = await this.prisma.jurisdictions.findMany({ + include: view, + }); + return mapTo(Jurisdiction, rawJurisdictions); + } + + /* + this will build the where clause for findOne() + */ + buildWhere({ + jurisdictionId, + jurisdictionName, + }: { + jurisdictionId?: string; + jurisdictionName?: string; + }): Prisma.JurisdictionsWhereInput { + const toReturn: Prisma.JurisdictionsWhereInput = {}; + if (jurisdictionId) { + toReturn.id = { + equals: jurisdictionId, + }; + } else if (jurisdictionName) { + toReturn.name = { + equals: jurisdictionName, + }; + } + return toReturn; + } + + /* + this will return 1 jurisdiction or error + */ + async findOne(condition: { + jurisdictionId?: string; + jurisdictionName?: string; + }): Promise { + if (!condition.jurisdictionId && !condition.jurisdictionName) { + throw new BadRequestException( + 'a jurisdiction id or jurisdiction name must be provided', + ); + } + + const rawJurisdiction = await this.prisma.jurisdictions.findFirst({ + where: this.buildWhere(condition), + include: view, + }); + + if (!rawJurisdiction) { + throw new NotFoundException( + `jurisdiction ${ + condition.jurisdictionId || condition.jurisdictionName + } was requested but not found`, + ); + } + + return mapTo(Jurisdiction, rawJurisdiction); + } + + /* + this will create a jurisdiction + */ + async create(incomingData: JurisdictionCreate): Promise { + const rawResult = await this.prisma.jurisdictions.create({ + data: { + ...incomingData, + }, + include: view, + }); + + return mapTo(Jurisdiction, rawResult); + } + + /* + this will update a jurisdiction's name or items field + if no jurisdiction has the id of the incoming argument an error is thrown + */ + async update(incomingData: JurisdictionUpdate): Promise { + await this.findOrThrow(incomingData.id); + + const rawResults = await this.prisma.jurisdictions.update({ + data: { + ...incomingData, + id: undefined, + }, + where: { + id: incomingData.id, + }, + include: view, + }); + return mapTo(Jurisdiction, rawResults); + } + + /* + this will delete a jurisdiction + */ + async delete(jurisdictionId: string): Promise { + await this.findOrThrow(jurisdictionId); + await this.prisma.jurisdictions.delete({ + where: { + id: jurisdictionId, + }, + }); + return { + success: true, + } as SuccessDTO; + } + + /* + this will either find a record or throw a customized error + */ + async findOrThrow(jurisdictionId: string): Promise { + const jurisdiction = await this.prisma.jurisdictions.findFirst({ + where: { + id: jurisdictionId, + }, + }); + + if (!jurisdiction) { + throw new NotFoundException( + `jurisdictionId ${jurisdictionId} was requested but not found`, + ); + } + + return true; + } +} diff --git a/api/src/services/listing-csv-export.service.ts b/api/src/services/listing-csv-export.service.ts new file mode 100644 index 0000000000..bcc75bb1d9 --- /dev/null +++ b/api/src/services/listing-csv-export.service.ts @@ -0,0 +1,863 @@ +import archiver from 'archiver'; +import fs, { createReadStream } from 'fs'; +import { join } from 'path'; +import { + ForbiddenException, + Inject, + Injectable, + Logger, + StreamableFile, +} from '@nestjs/common'; +import { SchedulerRegistry } from '@nestjs/schedule'; +import { + Request as ExpressRequest, + Response as ExpressResponse, +} from 'express'; +import { + ApplicationMethodsTypeEnum, + ListingEventsTypeEnum, +} from '@prisma/client'; +import { views } from './listing.service'; +import { PrismaService } from './prisma.service'; +import { + CsvExporterServiceInterface, + CsvHeader, +} from '../types/CsvExportInterface'; +import { ListingCsvQueryParams } from '../dtos/listings/listing-csv-query-params.dto'; +import { User } from '../dtos/users/user.dto'; +import { formatLocalDate } from '../utilities/format-local-date'; +import { ListingReviewOrder } from '../enums/listings/review-order-enum'; +import { isEmpty } from '../utilities/is-empty'; +import { ListingEvent } from '../dtos/listings/listing-event.dto'; +import { ApplicationMethod } from '../dtos/application-methods/application-method.dto'; +import Unit from '../dtos/units/unit.dto'; +import Listing from '../dtos/listings/listing.dto'; +import { mapTo } from '../utilities/mapTo'; +import { ListingMultiselectQuestion } from '../dtos/listings/listing-multiselect-question.dto'; + +views.csv = { + ...views.details, + userAccounts: true, +}; + +export const formatStatus = { + active: 'Public', + closed: 'Closed', + pending: 'Draft', + pendingReview: 'Pending Review', + changesRequested: 'Changes Requested', +}; + +export const formatCommunityType = { + senior55: 'Seniors 55+', + senior62: 'Seniors 62+', + specialNeeds: 'Special Needs', +}; + +@Injectable() +export class ListingCsvExporterService implements CsvExporterServiceInterface { + readonly dateFormat: string = 'MM-DD-YYYY hh:mm:ssA z'; + timeZone = 'America/Los_Angeles'; + constructor( + private prisma: PrismaService, + @Inject(Logger) + private logger = new Logger(ListingCsvExporterService.name), + private schedulerRegistry: SchedulerRegistry, + ) {} + + /** + * + * @param queryParams + * @param req + * @returns a promise containing a streamable file + */ + async exportFile( + req: ExpressRequest, + res: ExpressResponse, + queryParams: QueryParams, + ): Promise { + this.logger.warn('Generating Listing-Unit Zip'); + const user = mapTo(User, req['user']); + await this.authorizeCSVExport(mapTo(User, req['user'])); + + const zipFileName = `listings-units-${user.id}-${new Date().getTime()}.zip`; + const zipFilePath = join(process.cwd(), `src/temp/${zipFileName}`); + res.set({ + 'Content-Type': 'application/zip', + 'Content-Disposition': `attachment; filename: ${zipFileName}`, + }); + + const listingFilePath = join( + process.cwd(), + `src/temp/listings-${user.id}-${new Date().getTime()}.csv`, + ); + const unitFilePath = join( + process.cwd(), + `src/temp/units-${user.id}-${new Date().getTime()}.csv`, + ); + + if (queryParams.timeZone) { + this.timeZone = queryParams.timeZone; + } + + const whereClause = { + jurisdictions: { + OR: [], + }, + }; + + user.jurisdictions?.forEach((jurisdiction) => { + whereClause.jurisdictions.OR.push({ + id: jurisdiction.id, + }); + }); + + const listings = await this.prisma.listings.findMany({ + include: views.csv, + where: whereClause, + }); + + await this.createCsv( + listingFilePath, + queryParams, + listings as unknown as Listing[], + ); + const listingCsv = createReadStream(listingFilePath); + + await this.createUnitCsv(unitFilePath, listings as unknown as Listing[]); + const unitCsv = createReadStream(unitFilePath); + return new Promise((resolve) => { + // Create a writable stream to the zip file + const output = fs.createWriteStream(zipFilePath); + const archive = archiver('zip', { + zlib: { level: 9 }, + }); + output.on('close', () => { + const zipFile = createReadStream(zipFilePath); + resolve(new StreamableFile(zipFile)); + }); + + archive.pipe(output); + archive.append(listingCsv, { name: 'listings.csv' }); + archive.append(unitCsv, { name: 'units.csv' }); + archive.finalize(); + }); + } + + /** + * + * @param filename + * @param queryParams + * @returns a promise with SuccessDTO + */ + async createCsv( + filename: string, + queryParams: QueryParams, + listings: Listing[], + ): Promise { + const csvHeaders = await this.getCsvHeaders(); + + return new Promise((resolve, reject) => { + // create stream + const writableStream = fs.createWriteStream(`${filename}`); + writableStream + .on('error', (err) => { + console.log('csv writestream error'); + console.log(err); + reject(err); + }) + .on('close', () => { + resolve(); + }) + .on('open', () => { + writableStream.write( + csvHeaders.map((header) => header.label).join(',') + '\n', + ); + + // now loop over listings and write them to file + listings.forEach((listing) => { + let row = ''; + csvHeaders.forEach((header, index) => { + let value = header.path.split('.').reduce((acc, curr) => { + // handles working with arrays, e.g. householdMember.0.firstName + if (!isNaN(Number(curr))) { + const index = Number(curr); + return acc[index]; + } + + if (acc === null || acc === undefined) { + return ''; + } + return acc[curr]; + }, listing); + value = value === undefined ? '' : value === null ? '' : value; + if (header.format) { + value = header.format(value, listing); + } + + row += value ? `"${value.toString().replace(/"/g, `""`)}"` : ''; + if (index < csvHeaders.length - 1) { + row += ','; + } + }); + + try { + writableStream.write(row + '\n'); + } catch (e) { + console.log('writeStream write error = ', e); + writableStream.once('drain', () => { + writableStream.write(row + '\n'); + }); + } + }); + + writableStream.end(); + }); + }); + } + + async createUnitCsv(filename: string, listings: Listing[]): Promise { + const csvHeaders = this.getUnitCsvHeaders(); + // flatten those listings + const units = listings.flatMap((listing) => + listing.units.map((unit) => ({ + listing: { + id: listing.id, + name: listing.name, + }, + unit, + })), + ); + // TODO: the below is essentially the same as above in this.createCsv + return new Promise((resolve, reject) => { + const writableStream = fs.createWriteStream(`${filename}`); + writableStream + .on('error', (err) => { + console.log('csv writestream error'); + console.log(err); + reject(err); + }) + .on('close', () => { + resolve(); + }) + .on('open', () => { + writableStream.write( + csvHeaders.map((header) => header.label).join(',') + '\n', + ); + units.forEach((unit) => { + let row = ''; + csvHeaders.forEach((header, index) => { + let value = header.path.split('.').reduce((acc, curr) => { + // handles working with arrays, e.g. householdMember.0.firstName + if (!isNaN(Number(curr))) { + const index = Number(curr); + return acc[index]; + } + + if (acc === null || acc === undefined) { + return ''; + } + return acc[curr]; + }, unit); + value = value === undefined ? '' : value === null ? '' : value; + if (header.format) { + value = header.format(value); + } + + row += value; + if (index < csvHeaders.length - 1) { + row += ','; + } + }); + + try { + writableStream.write(row + '\n'); + } catch (e) { + console.log('writeStream write error = ', e); + writableStream.once('drain', () => { + writableStream.write(row + '\n'); + }); + } + }); + + writableStream.end(); + }); + }); + } + + formatCurrency(value: string): string { + return value ? `$${value}` : ''; + } + + cloudinaryPdfFromId(publicId: string, listing?: Listing): string { + if (publicId) { + const cloudName = + process.env.cloudinaryCloudName || process.env.CLOUDINARY_CLOUD_NAME; + return `https://res.cloudinary.com/${cloudName}/image/upload/${publicId}.pdf`; + } else if (!publicId && listing?.buildingSelectionCriteria) { + return listing.buildingSelectionCriteria; + } + + return ''; + } + + formatYesNo = (value: boolean | null): string => { + if (value === null || typeof value == 'undefined') return ''; + else if (value) return 'Yes'; + else return 'No'; + }; + + hideZero = (fieldValue: number | string) => { + if (isEmpty(fieldValue) || fieldValue === 0 || fieldValue === '0') + return ''; + return fieldValue; + }; + + async getCsvHeaders(): Promise { + const headers: CsvHeader[] = [ + { + path: 'id', + label: 'Listing Id', + }, + { + path: 'createdAt', + label: 'Crated At Date', + format: (val: string): string => + formatLocalDate(val, this.dateFormat, this.timeZone), + }, + { + path: 'jurisdictions.name', + label: 'Jurisdiction', + }, + { + path: 'name', + label: 'Listing Name', + }, + { + path: 'status', + label: 'Listing Status', + format: (val: string): string => formatStatus[val], + }, + { + path: 'publishedAt', + label: 'Publish Date', + format: (val: string): string => + formatLocalDate(val, this.dateFormat, this.timeZone), + }, + { + path: 'updatedAt', + label: 'Last Updated', + format: (val: string): string => + formatLocalDate(val, this.dateFormat, this.timeZone), + }, + { + path: 'developer', + label: 'Developer', + }, + { + path: 'listingsBuildingAddress.street', + label: 'Building Street Address', + }, + { + path: 'listingsBuildingAddress.city', + label: 'Building City', + }, + { + path: 'listingsBuildingAddress.state', + label: 'Building State', + }, + { + path: 'listingsBuildingAddress.zipCode', + label: 'Building Zip', + }, + { + path: 'neighborhood', + label: 'Building Neighborhood', + }, + { + path: 'yearBuilt', + label: 'Building Year Built', + }, + { + path: 'reservedCommunityTypes.name', + label: 'Reserved Community Types', + format: (val: string): string => formatCommunityType[val] || '', + }, + { + path: 'listingsBuildingAddress.latitude', + label: 'Latitude', + }, + { + path: 'listingsBuildingAddress.longitude', + label: 'Longitude', + }, + { + path: 'units.length', + label: 'Number of Units', + }, + { + path: 'reviewOrderType', + label: 'Listing Availability', + format: (val: string): string => + val === ListingReviewOrder.waitlist + ? 'Open Waitlist' + : 'Available Units', + }, + { + path: 'reviewOrderType', + label: 'Review Order', + format: (val: string): string => { + if (!val) return ''; + const spacedValue = val.replace(/([A-Z])/g, (match) => ` ${match}`); + const result = + spacedValue.charAt(0).toUpperCase() + spacedValue.slice(1); + return result; + }, + }, + { + path: 'listingEvents', + label: 'Lottery Date', + format: (val: ListingEvent[]): string => { + if (!val) return ''; + const lottery = val.filter( + (event) => event.type === ListingEventsTypeEnum.publicLottery, + ); + return lottery.length + ? formatLocalDate(lottery[0].startTime, 'MM-DD-YYYY', this.timeZone) + : ''; + }, + }, + { + path: 'listingEvents', + label: 'Lottery Start', + format: (val: ListingEvent[]): string => { + if (!val) return ''; + const lottery = val.filter( + (event) => event.type === ListingEventsTypeEnum.publicLottery, + ); + return lottery.length + ? formatLocalDate(lottery[0].startTime, 'hh:mmA z', this.timeZone) + : ''; + }, + }, + { + path: 'listingEvents', + label: 'Lottery End', + format: (val: ListingEvent[]): string => { + if (!val) return ''; + const lottery = val.filter( + (event) => event.type === ListingEventsTypeEnum.publicLottery, + ); + return lottery.length + ? formatLocalDate(lottery[0].endTime, 'hh:mmA z', this.timeZone) + : ''; + }, + }, + { + path: 'listingEvents', + label: 'Lottery Notes', + format: (val: ListingEvent[]): string => { + if (!val) return ''; + const lottery = val.filter( + (event) => event.type === ListingEventsTypeEnum.publicLottery, + ); + return lottery.length ? lottery[0].note : ''; + }, + }, + { + path: 'listingMultiselectQuestions', + label: 'Housing Preferences', + format: (val: ListingMultiselectQuestion[]): string => { + return val + .filter( + (question) => + question.multiselectQuestions.applicationSection === + 'preferences', + ) + .map((question) => question.multiselectQuestions.text) + .join(','); + }, + }, + { + path: 'listingMultiselectQuestions', + label: 'Housing Programs', + format: (val: ListingMultiselectQuestion[]): string => { + return val + .filter( + (question) => + question.multiselectQuestions.applicationSection === 'programs', + ) + .map((question) => question.multiselectQuestions.text) + .join(','); + }, + }, + { + path: 'applicationFee', + label: 'Application Fee', + format: this.formatCurrency, + }, + { + path: 'depositHelperText', + label: 'Deposit Helper Text', + }, + { + path: 'depositMin', + label: 'Deposit Min', + format: this.formatCurrency, + }, + { + path: 'depositMax', + label: 'Deposit Max', + format: this.formatCurrency, + }, + { + path: 'costsNotIncluded', + label: 'Costs Not Included', + }, + { + path: 'amenities', + label: 'Property Amenities', + }, + { + path: 'accessibility', + label: 'Additional Accessibility', + }, + { + path: 'unitAmenities', + label: 'Unit Amenities', + }, + { + path: 'smokingPolicy', + label: 'Smoking Policy', + }, + { + path: 'petPolicy', + label: 'Pets Policy', + }, + { + path: 'servicesOffered', + label: 'Services Offered', + }, + { + path: 'creditHistory', + label: 'Eligibility Rules - Credit History', + }, + { + path: 'rentalHistory', + label: 'Eligibility Rules - Rental History', + }, + { + path: 'criminalBackground', + label: 'Eligibility Rules - Criminal Background', + }, + { + path: 'rentalAssistance', + label: 'Eligibility Rules - Rental Assistance', + }, + { + path: 'buildingSelectionCriteriaFileId', + label: 'Building Selection Criteria', + format: this.cloudinaryPdfFromId, + }, + { + path: 'programRules', + label: 'Important Program Rules', + }, + { + path: 'requiredDocuments', + label: 'Required Documents', + }, + { + path: 'specialNotes', + label: 'Special Notes', + }, + { + path: 'isWaitlistOpen', + label: 'Waitlist', + format: this.formatYesNo, + }, + { + path: 'leasingAgentName', + label: 'Leasing Agent Name', + }, + { + path: 'leasingAgentEmail', + label: 'Leasing Agent Email', + }, + { + path: 'leasingAgentPhone', + label: 'Leasing Agent Phone', + }, + { + path: 'leasingAgentTitle', + label: 'Leasing Agent Title', + }, + { + path: 'leasingAgentOfficeHours', + label: 'Leasing Agent Office Hours', + }, + { + path: 'listingsLeasingAgentAddress.street', + label: 'Leasing Agent Street Address', + }, + { + path: 'listingsLeasingAgentAddress.street2', + label: 'Leasing Agent Apt/Unit #', + }, + { + path: 'listingsLeasingAgentAddress.city', + label: 'Leasing Agent City', + }, + { + path: 'listingsLeasingAgentAddress.state', + label: 'Leasing Agent State', + }, + { + path: 'listingsLeasingAgentAddress.zipCode', + label: 'Leasing Agent Zip', + }, + { + path: 'listingsLeasingAgentAddress.street', + label: 'Leasing Agency Mailing Address', + }, + { + path: 'listingsLeasingAgentAddress.street2', + label: 'Leasing Agency Mailing Address Street 2', + }, + { + path: 'listingsLeasingAgentAddress.city', + label: 'Leasing Agency Mailing Address City', + }, + { + path: 'listingsLeasingAgentAddress.state', + label: 'Leasing Agency Mailing Address State', + }, + { + path: 'listingsLeasingAgentAddress.zipCode', + label: 'Leasing Agency Mailing Address Zip', + }, + { + path: 'listingsApplicationPickUpAddress.street', + label: 'Leasing Agency Pickup Address', + }, + { + path: 'listingsApplicationPickUpAddress.street2', + label: 'Leasing Agency Pickup Address Street 2', + }, + { + path: 'listingsApplicationPickUpAddress.city', + label: 'Leasing Agency Pickup Address City', + }, + { + path: 'listingsApplicationPickUpAddress.state', + label: 'Leasing Agency Pickup Address State', + }, + { + path: 'listingsApplicationPickUpAddress.zipCode', + label: 'Leasing Agency Pickup Address Zip', + }, + { + path: 'applicationPickUpAddressOfficeHours', + label: 'Leasing Pick Up Office Hours', + }, + { + path: 'digitalApplication', + label: 'Digital Application', + format: this.formatYesNo, + }, + { + path: 'applicationMethods', + label: 'Digital Application URL', + format: (val: ApplicationMethod[]): string => { + const method = val.filter( + (appMethod) => + appMethod.type === ApplicationMethodsTypeEnum.ExternalLink, + ); + return method.length ? method[0].externalReference : ''; + }, + }, + { + path: 'paperApplication', + label: 'Paper Application', + format: this.formatYesNo, + }, + { + path: 'applicationMethods', + label: 'Paper Application URL', + format: (val: ApplicationMethod[]): string => { + const method = val.filter( + (appMethod) => appMethod.paperApplications.length > 0, + ); + const paperApps = method.length ? method[0].paperApplications : []; + return paperApps.length + ? paperApps + .map((app) => this.cloudinaryPdfFromId(app.assets.fileId)) + .join(', ') + : ''; + }, + }, + { + path: 'referralOpportunity', + label: 'Referral Opportunity', + format: this.formatYesNo, + }, + { + path: 'applicationMailingAddressId', + label: 'Can applications be mailed in?', + format: this.formatYesNo, + }, + { + path: 'applicationPickUpAddressId', + label: 'Can applications be picked up?', + format: this.formatYesNo, + }, + { + path: 'applicationPickUpAddressId', + label: 'Can applications be dropped off?', + format: this.formatYesNo, + }, + { + path: 'postmarkedApplicationsReceivedByDate', + label: 'Postmark', + format: (val: string): string => + formatLocalDate(val, this.dateFormat, this.timeZone), + }, + { + path: 'additionalApplicationSubmissionNotes', + label: 'Additional Application Submission Notes', + }, + { + path: 'applicationDueDate', + label: 'Application Due Date', + format: (val: string): string => + formatLocalDate(val, 'MM-DD-YYYY', this.timeZone), + }, + { + path: 'applicationDueDate', + label: 'Application Due Time', + format: (val: string): string => + formatLocalDate(val, 'hh:mmA z', this.timeZone), + }, + { + path: 'listingEvents', + label: 'Open House', + format: (val: ListingEvent[]): string => { + if (!val) return ''; + return val + .filter((event) => event.type === ListingEventsTypeEnum.openHouse) + .map((event) => { + let openHouseStr = ''; + if (event.label) openHouseStr += `${event.label}`; + if (event.startTime) { + const date = formatLocalDate( + event.startTime, + 'MM-DD-YYYY', + this.timeZone, + ); + openHouseStr += `: ${date}`; + if (event.endTime) { + const startTime = formatLocalDate( + event.startTime, + 'hh:mmA', + this.timeZone, + ); + const endTime = formatLocalDate( + event.endTime, + 'hh:mmA z', + this.timeZone, + ); + openHouseStr += ` (${startTime} - ${endTime})`; + } + } + return openHouseStr; + }) + .filter((str) => str.length) + .join(', '); + }, + }, + { + path: 'userAccounts', + label: 'Partners Who Have Access', + format: (val: User[]): string => + val.map((user) => `${user.firstName} ${user.lastName}`).join(', '), + }, + ]; + + return headers; + } + + getUnitCsvHeaders(): CsvHeader[] { + return [ + { + path: 'listing.id', + label: 'Listing Id', + }, + { + path: 'listing.name', + label: 'Listing Name', + }, + { + path: 'unit.number', + label: 'Unit Number', + }, + { + path: 'unit.unitTypes.name', + label: 'Unit Type', + }, + { + path: 'unit.numBathrooms', + label: 'Number of Bathrooms', + }, + { + path: 'unit.floor', + label: 'Unit Floor', + format: this.hideZero, + }, + { + path: 'unit.sqFeet', + label: 'Square Footage', + }, + { + path: 'unit.minOccupancy', + label: 'Minimum Occupancy', + format: this.hideZero, + }, + { + path: 'unit.maxOccupancy', + label: 'Max Occupancy', + format: this.hideZero, + }, + { + path: 'unit.amiChart.name', + label: 'AMI Chart', + }, + { + path: 'unit.amiChart.items.0.percentOfAmi', + label: 'AMI Level', + }, + { + path: 'unit', + label: 'Rent Type', + format: (val: Unit) => + !isEmpty(val.monthlyRentAsPercentOfIncome) + ? '% of income' + : !isEmpty(val.monthlyRent) + ? 'Fixed amount' + : '', + }, + ]; + } + + async authorizeCSVExport(user?: User): Promise { + if ( + user && + (user.userRoles?.isAdmin || + user.userRoles?.isJurisdictionalAdmin || + user.userRoles?.isPartner) + ) { + return; + } else { + throw new ForbiddenException(); + } + } +} diff --git a/api/src/services/listing.service.ts b/api/src/services/listing.service.ts new file mode 100644 index 0000000000..4e950a34e0 --- /dev/null +++ b/api/src/services/listing.service.ts @@ -0,0 +1,1441 @@ +import { + Inject, + Injectable, + Logger, + NotFoundException, + OnModuleInit, + HttpException, +} from '@nestjs/common'; +import { HttpService } from '@nestjs/axios'; +import { ConfigService } from '@nestjs/config'; +import { SchedulerRegistry } from '@nestjs/schedule'; +import { + LanguagesEnum, + ListingsStatusEnum, + Prisma, + ReviewOrderTypeEnum, + UserRoleEnum, +} from '@prisma/client'; +import { firstValueFrom } from 'rxjs'; +import { PrismaService } from './prisma.service'; +import { ListingsQueryParams } from '../dtos/listings/listings-query-params.dto'; +import { + buildPaginationMetaInfo, + calculateSkip, + calculateTake, +} from '../utilities/pagination-helpers'; +import { buildOrderByForListings } from '../utilities/build-order-by'; +import { ListingFilterParams } from '../dtos/listings/listings-filter-params.dto'; +import { ListingFilterKeys } from '../enums/listings/filter-key-enum'; +import { buildFilter } from '../utilities/build-filter'; +import { Listing } from '../dtos/listings/listing.dto'; +import { mapTo } from '../utilities/mapTo'; +import { + summarizeUnitsByTypeAndRent, + summarizeUnits, +} from '../utilities/unit-utilities'; +import { AmiChart } from '../dtos/ami-charts/ami-chart.dto'; +import { ListingViews } from '../enums/listings/view-enum'; +import { TranslationService } from './translation.service'; +import { ListingCreate } from '../dtos/listings/listing-create.dto'; +import { SuccessDTO } from '../dtos/shared/success.dto'; +import { ListingUpdate } from '../dtos/listings/listing-update.dto'; +import { ApplicationFlaggedSetService } from './application-flagged-set.service'; +import { User } from '../dtos/users/user.dto'; +import { EmailService } from './email.service'; +import { IdDTO } from '../dtos/shared/id.dto'; +import { startCronJob } from '../utilities/cron-job-starter'; +import { PermissionService } from './permission.service'; +import { permissionActions } from '../enums/permissions/permission-actions-enum'; + +export type getListingsArgs = { + skip: number; + take: number; + orderBy: any; + where: Prisma.ListingsWhereInput; +}; + +export const views: Partial> = { + fundamentals: { + jurisdictions: true, + listingsBuildingAddress: true, + reservedCommunityTypes: true, + listingImages: { + include: { + assets: true, + }, + }, + listingMultiselectQuestions: { + include: { + multiselectQuestions: true, + }, + }, + listingFeatures: true, + listingUtilities: true, + }, +}; + +views.base = { + ...views.fundamentals, + units: { + include: { + unitTypes: true, + unitAmiChartOverrides: true, + amiChart: true, + }, + }, +}; + +views.full = { + ...views.fundamentals, + applicationMethods: { + include: { + paperApplications: { + include: { + assets: true, + }, + }, + }, + }, + listingsBuildingSelectionCriteriaFile: true, + listingEvents: { + include: { + assets: true, + }, + }, + listingsResult: true, + listingsLeasingAgentAddress: true, + listingsApplicationPickUpAddress: true, + listingsApplicationDropOffAddress: true, + listingsApplicationMailingAddress: true, + units: { + include: { + unitAmiChartOverrides: true, + unitTypes: true, + unitRentTypes: true, + unitAccessibilityPriorityTypes: true, + amiChart: { + include: { + jurisdictions: true, + unitGroupAmiLevels: true, + }, + }, + }, + }, +}; + +views.details = { + ...views.base, + ...views.full, +}; + +views.csv = { + ...views.base, + ...views.full, + userAccounts: true, +}; + +const CRON_JOB_NAME = 'LISTING_CRON_JOB'; + +/* + this is the service for listings + it handles all the backend's business logic for reading in listing(s) +*/ +@Injectable() +export class ListingService implements OnModuleInit { + constructor( + private prisma: PrismaService, + private translationService: TranslationService, + private httpService: HttpService, + private afsService: ApplicationFlaggedSetService, + private emailService: EmailService, + private configService: ConfigService, + @Inject(Logger) + private logger = new Logger(ListingService.name), + private schedulerRegistry: SchedulerRegistry, + private permissionService: PermissionService, + ) {} + + onModuleInit() { + startCronJob( + this.prisma, + CRON_JOB_NAME, + process.env.LISTING_PROCESSING_CRON_STRING, + this.process.bind(this), + this.logger, + this.schedulerRegistry, + ); + } + + /* + this will get a set of listings given the params passed in + this set can either be paginated or not depending on the params + it will return both the set of listings, and some meta information to help with pagination + */ + async list(params: ListingsQueryParams): Promise<{ + items: Listing[]; + meta: { + currentPage: number; + itemCount: number; + itemsPerPage: number; + totalItems: number; + totalPages: number; + }; + }> { + const whereClause = this.buildWhereClause(params.filter, params.search); + const count = await this.prisma.listings.count({ + where: whereClause, + }); + + // if passed in page and limit would result in no results because there aren't that many listings + // revert back to the first page + let page = params.page; + if (count && params.limit && params.limit !== 'all' && params.page > 1) { + if (Math.ceil(count / params.limit) < params.page) { + page = 1; + } + } + + const listingsRaw = await this.prisma.listings.findMany({ + skip: calculateSkip(params.limit, page), + take: calculateTake(params.limit), + orderBy: buildOrderByForListings(params.orderBy, params.orderDir), + include: views[params.view ?? 'full'], + where: whereClause, + }); + + const listings = mapTo(Listing, listingsRaw); + + listings.forEach((listing) => { + if (Array.isArray(listing.units) && listing.units.length > 0) { + listing.unitsSummarized = { + byUnitTypeAndRent: summarizeUnitsByTypeAndRent( + listing.units, + listing, + ), + }; + } + }); + + const paginationInfo = buildPaginationMetaInfo( + params, + count, + listings.length, + ); + + return { + items: listings, + meta: paginationInfo, + }; + } + + public async getUserEmailInfo( + userRoles: UserRoleEnum | UserRoleEnum[], + listingId?: string, + jurisId?: string, + getPublicUrl = false, + ): Promise<{ emails: string[]; publicUrl?: string | null }> { + // determine where clause(s) + const userRolesWhere: Prisma.UserAccountsWhereInput[] = []; + if (userRoles.includes(UserRoleEnum.admin)) + userRolesWhere.push({ userRoles: { isAdmin: true } }); + if (userRoles.includes(UserRoleEnum.partner)) + userRolesWhere.push({ + userRoles: { isPartner: true }, + listings: { some: { id: listingId } }, + }); + if (userRoles.includes(UserRoleEnum.jurisdictionAdmin)) { + userRolesWhere.push({ + userRoles: { isJurisdictionalAdmin: true }, + jurisdictions: { some: { id: jurisId } }, + }); + } + + const userResults = await this.prisma.userAccounts.findMany({ + include: { + jurisdictions: { + select: { + id: true, + publicUrl: getPublicUrl, + }, + }, + }, + where: { + OR: userRolesWhere, + }, + }); + + // account for users having access to multiple jurisdictions + const publicUrl = getPublicUrl + ? userResults[0]?.jurisdictions?.find((juris) => juris.id === jurisId) + ?.publicUrl + : null; + const userEmails: string[] = []; + userResults?.forEach((user) => user?.email && userEmails.push(user.email)); + return { emails: userEmails, publicUrl }; + } + + public async listingApprovalNotify(params: { + user: User; + listingInfo: IdDTO; + status: ListingsStatusEnum; + approvingRoles: UserRoleEnum[]; + previousStatus?: ListingsStatusEnum; + jurisId: string; + }) { + const nonApprovingRoles: UserRoleEnum[] = [UserRoleEnum.partner]; + if (!params.approvingRoles.includes(UserRoleEnum.jurisdictionAdmin)) + nonApprovingRoles.push(UserRoleEnum.jurisdictionAdmin); + if ( + params.status === ListingsStatusEnum.pendingReview && + params.previousStatus !== ListingsStatusEnum.pendingReview + ) { + const userInfo = await this.getUserEmailInfo( + params.approvingRoles, + params.listingInfo.id, + params.jurisId, + ); + await this.emailService.requestApproval( + { id: params.jurisId }, + { id: params.listingInfo.id, name: params.listingInfo.name }, + userInfo.emails, + this.configService.get('PARTNERS_PORTAL_URL'), + ); + } + // admin updates status to changes requested when approval requires partner changes + else if ( + params.status === ListingsStatusEnum.changesRequested && + params.previousStatus !== ListingsStatusEnum.changesRequested + ) { + const userInfo = await this.getUserEmailInfo( + nonApprovingRoles, + params.listingInfo.id, + params.jurisId, + ); + await this.emailService.changesRequested( + params.user, + { id: params.listingInfo.id, name: params.listingInfo.name }, + userInfo.emails, + this.configService.get('PARTNERS_PORTAL_URL'), + ); + } + // check if status of active requires notification + else if (params.status === ListingsStatusEnum.active) { + // if newly published listing, notify non-approving users with access + if ( + params.previousStatus === ListingsStatusEnum.pendingReview || + params.previousStatus === ListingsStatusEnum.changesRequested || + params.previousStatus === ListingsStatusEnum.pending + ) { + const userInfo = await this.getUserEmailInfo( + nonApprovingRoles, + params.listingInfo.id, + params.jurisId, + true, + ); + await this.emailService.listingApproved( + { id: params.jurisId }, + { id: params.listingInfo.id, name: params.listingInfo.name }, + userInfo.emails, + userInfo.publicUrl, + ); + } + } + } + + /* + this helps build the where clause for the list() + */ + buildWhereClause( + params?: ListingFilterParams[], + search?: string, + ): Prisma.ListingsWhereInput { + const filters: Prisma.ListingsWhereInput[] = []; + + if (params?.length) { + params.forEach((filter) => { + if (filter[ListingFilterKeys.name]) { + const builtFilter = buildFilter({ + $comparison: filter.$comparison, + $include_nulls: false, + value: filter[ListingFilterKeys.name], + key: ListingFilterKeys.name, + caseSensitive: false, + }); + filters.push({ + OR: builtFilter.map((filt) => ({ [ListingFilterKeys.name]: filt })), + }); + } else if (filter[ListingFilterKeys.status]) { + const builtFilter = buildFilter({ + $comparison: filter.$comparison, + $include_nulls: false, + value: filter[ListingFilterKeys.status], + key: ListingFilterKeys.status, + caseSensitive: true, + }); + filters.push({ + OR: builtFilter.map((filt) => ({ + [ListingFilterKeys.status]: filt, + })), + }); + } else if (filter[ListingFilterKeys.neighborhood]) { + const builtFilter = buildFilter({ + $comparison: filter.$comparison, + $include_nulls: false, + value: filter[ListingFilterKeys.neighborhood], + key: ListingFilterKeys.neighborhood, + }); + filters.push({ + OR: builtFilter.map((filt) => ({ + [ListingFilterKeys.neighborhood]: filt, + })), + }); + } else if (filter[ListingFilterKeys.bedrooms]) { + const builtFilter = buildFilter({ + $comparison: filter.$comparison, + $include_nulls: false, + value: filter[ListingFilterKeys.bedrooms], + key: ListingFilterKeys.bedrooms, + }); + filters.push({ + OR: builtFilter.map((filt) => ({ + units: { + some: { + numBedrooms: filt, + }, + }, + })), + }); + } else if (filter[ListingFilterKeys.zipcode]) { + const builtFilter = buildFilter({ + $comparison: filter.$comparison, + $include_nulls: false, + value: filter[ListingFilterKeys.zipcode], + key: ListingFilterKeys.zipcode, + }); + filters.push({ + OR: builtFilter.map((filt) => ({ + listingsBuildingAddress: { + zipCode: filt, + }, + })), + }); + } else if (filter[ListingFilterKeys.leasingAgents]) { + const builtFilter = buildFilter({ + $comparison: filter.$comparison, + $include_nulls: false, + value: filter[ListingFilterKeys.leasingAgents], + key: ListingFilterKeys.leasingAgents, + caseSensitive: true, + }); + filters.push({ + OR: builtFilter.map((filt) => ({ + userAccounts: { + some: { + id: filt, + }, + }, + })), + }); + } else if (filter[ListingFilterKeys.jurisdiction]) { + const builtFilter = buildFilter({ + $comparison: filter.$comparison, + $include_nulls: false, + value: filter[ListingFilterKeys.jurisdiction], + key: ListingFilterKeys.jurisdiction, + caseSensitive: true, + }); + filters.push({ + OR: builtFilter.map((filt) => ({ + jurisdictionId: filt, + })), + }); + } + }); + } + + if (search) { + filters.push({ + name: { + contains: search, + mode: Prisma.QueryMode.insensitive, + }, + }); + } + + return { + AND: filters, + }; + } + + /* + this will return 1 listing or error + the scope of data it returns is dependent on the view arg passed in + */ + async findOne( + listingId: string, + lang: LanguagesEnum = LanguagesEnum.en, + view: ListingViews = ListingViews.full, + ): Promise { + const listingRaw = await this.findOrThrow(listingId, view); + + let result = mapTo(Listing, listingRaw); + + if (lang !== LanguagesEnum.en) { + result = await this.translationService.translateListing(result, lang); + } + + await this.addUnitsSummarized(result); + return result; + } + + /* + creates a listing + */ + async create(dto: ListingCreate, requestingUser: User): Promise { + await this.permissionService.canOrThrow( + requestingUser, + 'listing', + permissionActions.create, + { + jurisdictionId: dto.jurisdictions.id, + }, + ); + + dto.unitsAvailable = + dto.reviewOrderType !== ReviewOrderTypeEnum.waitlist && dto.units + ? dto.units.length + : 0; + + const rawListing = await this.prisma.listings.create({ + include: views.details, + data: { + ...dto, + assets: dto.assets + ? { + create: dto.assets.map((asset) => ({ + fileId: asset.fileId, + label: asset.label, + })), + } + : undefined, + applicationMethods: dto.applicationMethods + ? { + create: dto.applicationMethods.map((applicationMethod) => ({ + ...applicationMethod, + paperApplications: applicationMethod.paperApplications + ? { + create: applicationMethod.paperApplications.map( + (paperApplication) => ({ + ...paperApplication, + assets: { + create: { + ...paperApplication.assets, + }, + }, + }), + ), + } + : undefined, + })), + } + : undefined, + listingEvents: dto.listingEvents + ? { + create: dto.listingEvents.map((event) => ({ + type: event.type, + startDate: event.startDate, + startTime: event.startTime, + endTime: event.endTime, + url: event.url, + note: event.note, + label: event.label, + assets: event.assets + ? { + create: { + ...event.assets, + }, + } + : undefined, + })), + } + : undefined, + listingImages: dto.listingImages + ? { + create: dto.listingImages.map((image) => ({ + assets: { + create: { + ...image.assets, + }, + }, + ordinal: image.ordinal, + })), + } + : undefined, + listingMultiselectQuestions: dto.listingMultiselectQuestions + ? { + create: dto.listingMultiselectQuestions.map( + (multiselectQuestion) => ({ + ordinal: multiselectQuestion.ordinal, + multiselectQuestions: { + connect: { + id: multiselectQuestion.id, + }, + }, + }), + ), + } + : undefined, + listingsApplicationDropOffAddress: dto.listingsApplicationDropOffAddress + ? { + create: { + ...dto.listingsApplicationDropOffAddress, + }, + } + : undefined, + reservedCommunityTypes: dto.reservedCommunityTypes + ? { + connect: { + id: dto.reservedCommunityTypes.id, + }, + } + : undefined, + listingsBuildingSelectionCriteriaFile: + dto.listingsBuildingSelectionCriteriaFile + ? { + create: { + ...dto.listingsBuildingSelectionCriteriaFile, + }, + } + : undefined, + listingUtilities: dto.listingUtilities + ? { + create: { + ...dto.listingUtilities, + }, + } + : undefined, + listingsApplicationMailingAddress: dto.listingsApplicationMailingAddress + ? { + create: { + ...dto.listingsApplicationMailingAddress, + }, + } + : undefined, + listingsLeasingAgentAddress: dto.listingsLeasingAgentAddress + ? { + create: { + ...dto.listingsLeasingAgentAddress, + }, + } + : undefined, + listingFeatures: dto.listingFeatures + ? { + create: { + ...dto.listingFeatures, + }, + } + : undefined, + jurisdictions: dto.jurisdictions + ? { + connect: { + id: dto.jurisdictions.id, + }, + } + : undefined, + listingsApplicationPickUpAddress: dto.listingsApplicationPickUpAddress + ? { + create: { + ...dto.listingsApplicationPickUpAddress, + }, + } + : undefined, + listingsBuildingAddress: dto.listingsBuildingAddress + ? { + create: { + ...dto.listingsBuildingAddress, + }, + } + : undefined, + units: dto.units + ? { + create: dto.units.map((unit) => ({ + amiPercentage: unit.amiPercentage, + annualIncomeMin: unit.annualIncomeMin, + monthlyIncomeMin: unit.monthlyIncomeMin, + floor: unit.floor, + annualIncomeMax: unit.annualIncomeMax, + maxOccupancy: unit.maxOccupancy, + minOccupancy: unit.minOccupancy, + monthlyRent: unit.monthlyRent, + numBathrooms: unit.numBathrooms, + numBedrooms: unit.numBedrooms, + number: unit.number, + sqFeet: unit.sqFeet, + monthlyRentAsPercentOfIncome: unit.monthlyRentAsPercentOfIncome, + bmrProgramChart: unit.bmrProgramChart, + unitTypes: unit.unitTypes + ? { + connect: { + id: unit.unitTypes.id, + }, + } + : undefined, + amiChart: unit.amiChart + ? { + connect: { + id: unit.amiChart.id, + }, + } + : undefined, + unitAmiChartOverrides: unit.unitAmiChartOverrides + ? { + create: { + items: unit.unitAmiChartOverrides.items, + }, + } + : undefined, + unitAccessibilityPriorityTypes: + unit.unitAccessibilityPriorityTypes + ? { + connect: { + id: unit.unitAccessibilityPriorityTypes.id, + }, + } + : undefined, + unitRentTypes: unit.unitRentTypes + ? { + connect: { + id: unit.unitRentTypes.id, + }, + } + : undefined, + })), + } + : undefined, + unitsSummary: dto.unitsSummary + ? { + create: dto.unitsSummary.map((unitSummary) => ({ + ...unitSummary, + unitTypes: unitSummary.unitTypes + ? { + connect: { + id: unitSummary.unitTypes.id, + }, + } + : undefined, + unitAccessibilityPriorityTypes: + unitSummary.unitAccessibilityPriorityTypes + ? { + connect: { + id: unitSummary.unitAccessibilityPriorityTypes.id, + }, + } + : undefined, + })), + } + : undefined, + listingsResult: dto.listingsResult + ? { + create: { + ...dto.listingsResult, + }, + } + : undefined, + }, + }); + + if (rawListing.status === ListingsStatusEnum.pendingReview) { + const jurisdiction = await this.prisma.jurisdictions.findFirst({ + where: { + id: rawListing.jurisdictions?.id, + }, + }); + await this.listingApprovalNotify({ + user: requestingUser, + listingInfo: { id: rawListing.id, name: rawListing.name }, + status: rawListing.status, + approvingRoles: jurisdiction?.listingApprovalPermissions, + jurisId: rawListing.jurisdictions.id, + }); + } + await this.cachePurge(undefined, dto.status, rawListing.id); + return mapTo(Listing, rawListing); + } + + /* + deletes a listing + */ + async delete(id: string, requestingUser: User): Promise { + const storedListing = await this.findOrThrow(id); + + await this.permissionService.canOrThrow( + requestingUser, + 'listing', + permissionActions.delete, + { + id: storedListing.id, + jurisdictionId: storedListing.jurisdictionId, + }, + ); + + await this.prisma.listings.delete({ + where: { + id, + }, + }); + + return { + success: true, + } as SuccessDTO; + } + + /* + This will either find a listing or throw an error + a listing view can be provided which will add the joins to produce that view correctly + */ + async findOrThrow(id: string, view?: ListingViews) { + const listing = await this.prisma.listings.findUnique({ + include: view ? views[view] : undefined, + where: { + id, + }, + }); + + if (!listing) { + throw new NotFoundException( + `listingId ${id} was requested but not found`, + ); + } + return listing; + } + + /* + This should be run for all address fields on an update of a listing. + The address either needs to be updated if fields are changed, deleted if no longer attached, + or created if it is a new address + */ + async addressUpdate( + existingListing, + newListing: ListingUpdate, + field: string, + ) { + if (existingListing[field]) { + if (newListing[field]) { + return await this.prisma.address.update({ + data: { + ...newListing[field], + }, + where: { + id: existingListing[field].id, + }, + }); + } else { + await this.prisma.address.delete({ + where: { + id: existingListing[field].id, + }, + }); + } + } else if (newListing[field]) { + return await this.prisma.address.create({ + data: { + ...newListing[field], + }, + }); + } + return undefined; + } + + /* + update a listing + */ + async update(dto: ListingUpdate, requestingUser: User): Promise { + const storedListing = await this.findOrThrow(dto.id, ListingViews.details); + + await this.permissionService.canOrThrow( + requestingUser, + 'listing', + permissionActions.update, + { + id: storedListing.id, + jurisdictionId: storedListing.jurisdictionId, + }, + ); + + dto.unitsAvailable = + dto.reviewOrderType !== ReviewOrderTypeEnum.waitlist && dto.units + ? dto.units.length + : 0; + + // We need to save the assets before saving it to the listing_images table + let allAssets = []; + const unsavedImages = dto.listingImages?.reduce((values, value) => { + if (!value.assets.id) { + values.push(value); + } else { + allAssets.push(value); + } + return values; + }, []); + + if (unsavedImages?.length) { + const assetCreates = unsavedImages.map((unsavedImage) => { + return this.prisma.assets.create({ + data: unsavedImage.assets, + }); + }); + const uploadedImages = await Promise.all(assetCreates); + allAssets = [ + ...allAssets, + ...uploadedImages.map((image, index) => { + return { assets: image, ordinal: unsavedImages[index].ordinal }; + }), + ]; + } + + const pickUpAddress = await this.addressUpdate( + storedListing, + dto, + 'listingsApplicationPickUpAddress', + ); + const mailAddress = await this.addressUpdate( + storedListing, + dto, + 'listingsApplicationMailingAddress', + ); + const dropOffAddress = await this.addressUpdate( + storedListing, + dto, + 'listingsApplicationDropOffAddress', + ); + const leasingAgentAddress = await this.addressUpdate( + storedListing, + dto, + 'listingsLeasingAgentAddress', + ); + const buildingAddress = await this.addressUpdate( + storedListing, + dto, + 'listingsBuildingAddress', + ); + + // Wrap the deletion and update in one transaction so that units aren't lost if update fails + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const transactions = await this.prisma.$transaction([ + // delete all connected units before recreating in update + this.prisma.units.deleteMany({ + where: { + listingId: storedListing.id, + }, + }), + // Delete all listing images before creating new ones + this.prisma.listingImages.deleteMany({ + where: { + listingId: dto.id, + }, + }), + // Delete all listing events before creating new ones + this.prisma.listingEvents.deleteMany({ + where: { + listingId: dto.id, + }, + }), + // Delete all paper applications and methods before creating new ones + this.prisma.paperApplications.deleteMany({ + where: { + applicationMethods: { + is: { + listingId: dto.id, + }, + }, + }, + }), + this.prisma.applicationMethods.deleteMany({ + where: { + listingId: dto.id, + }, + }), + this.prisma.listingMultiselectQuestions.deleteMany({ + where: { + listingId: dto.id, + }, + }), + this.prisma.listings.update({ + data: { + ...dto, + id: undefined, + createdAt: undefined, + updatedAt: undefined, + assets: dto.assets as unknown as Prisma.InputJsonArray, + applicationMethods: dto.applicationMethods + ? { + create: dto.applicationMethods.map((applicationMethod) => ({ + ...applicationMethod, + paperApplications: applicationMethod.paperApplications + ? { + create: applicationMethod.paperApplications.map( + (paperApplication) => ({ + ...paperApplication, + assets: { + create: { + ...paperApplication.assets, + id: undefined, + }, + }, + }), + ), + } + : undefined, + })), + } + : undefined, + listingEvents: dto.listingEvents + ? { + create: dto.listingEvents.map((event) => ({ + type: event.type, + startDate: event.startDate, + startTime: event.startTime, + endTime: event.endTime, + url: event.url, + note: event.note, + label: event.label, + assets: event.assets + ? { + create: { + ...event.assets, + }, + } + : undefined, + })), + } + : undefined, + listingImages: allAssets.length + ? { + create: allAssets.map((asset) => { + return { + ordinal: asset.ordinal, + assets: { + connect: { + id: asset.assets.id, + }, + }, + }; + }), + } + : undefined, + listingMultiselectQuestions: dto.listingMultiselectQuestions + ? { + create: dto.listingMultiselectQuestions.map( + (multiselectQuestion) => ({ + ordinal: multiselectQuestion.ordinal, + multiselectQuestionId: multiselectQuestion.id, + }), + ), + } + : undefined, + listingsApplicationDropOffAddress: dropOffAddress + ? { + connect: { + id: dropOffAddress.id, + }, + } + : undefined, + reservedCommunityTypes: dto.reservedCommunityTypes + ? { + connect: { + id: dto.reservedCommunityTypes.id, + }, + } + : undefined, + // Three options for the building selection criteria file + // create new one, connect existing one, or deleted (disconnect) + listingsBuildingSelectionCriteriaFile: + dto.listingsBuildingSelectionCriteriaFile + ? dto.listingsBuildingSelectionCriteriaFile.id + ? { + connectOrCreate: { + where: { + id: dto.listingsBuildingSelectionCriteriaFile.id, + }, + create: { + ...dto.listingsBuildingSelectionCriteriaFile, + }, + }, + } + : { + create: { + ...dto.listingsBuildingSelectionCriteriaFile, + }, + } + : { + disconnect: true, + }, + listingUtilities: dto.listingUtilities + ? { + create: { + ...dto.listingUtilities, + }, + } + : undefined, + listingsApplicationMailingAddress: mailAddress + ? { + connect: { + id: mailAddress.id, + }, + } + : undefined, + listingsLeasingAgentAddress: leasingAgentAddress + ? { + connect: { + id: leasingAgentAddress.id, + }, + } + : undefined, + listingFeatures: dto.listingFeatures + ? { + create: { + ...dto.listingFeatures, + }, + } + : undefined, + jurisdictions: dto.jurisdictions + ? { + connect: { + id: dto.jurisdictions.id, + }, + } + : undefined, + listingsApplicationPickUpAddress: pickUpAddress + ? { + connect: { + id: pickUpAddress.id, + }, + } + : undefined, + listingsBuildingAddress: buildingAddress + ? { + connect: { + id: buildingAddress.id, + }, + } + : undefined, + units: dto.units + ? { + create: dto.units.map((unit) => ({ + amiPercentage: unit.amiPercentage, + annualIncomeMin: unit.annualIncomeMin, + monthlyIncomeMin: unit.monthlyIncomeMin, + floor: unit.floor, + annualIncomeMax: unit.annualIncomeMax, + maxOccupancy: unit.maxOccupancy, + minOccupancy: unit.minOccupancy, + monthlyRent: unit.monthlyRent, + numBathrooms: unit.numBathrooms, + numBedrooms: unit.numBedrooms, + number: unit.number, + sqFeet: unit.sqFeet, + monthlyRentAsPercentOfIncome: + unit.monthlyRentAsPercentOfIncome, + bmrProgramChart: unit.bmrProgramChart, + unitTypes: unit.unitTypes + ? { + connect: { + id: unit.unitTypes.id, + }, + } + : undefined, + amiChart: unit.amiChart + ? { + connect: { + id: unit.amiChart.id, + }, + } + : undefined, + unitAmiChartOverrides: unit.unitAmiChartOverrides + ? { + create: { + items: unit.unitAmiChartOverrides.items, + }, + } + : undefined, + unitAccessibilityPriorityTypes: + unit.unitAccessibilityPriorityTypes + ? { + connect: { + id: unit.unitAccessibilityPriorityTypes.id, + }, + } + : undefined, + unitRentTypes: unit.unitRentTypes + ? { + connect: { + id: unit.unitRentTypes.id, + }, + } + : undefined, + })), + } + : undefined, + unitsSummary: dto.unitsSummary + ? { + create: dto.unitsSummary.map((unitSummary) => ({ + ...unitSummary, + unitTypes: unitSummary.unitTypes + ? { + connect: { + id: unitSummary.unitTypes.id, + }, + } + : undefined, + unitAccessibilityPriorityTypes: + unitSummary.unitAccessibilityPriorityTypes + ? { + connect: { + id: unitSummary.unitAccessibilityPriorityTypes.id, + }, + } + : undefined, + })), + } + : undefined, + publishedAt: + storedListing.status !== ListingsStatusEnum.active && + dto.status === ListingsStatusEnum.active + ? new Date() + : storedListing.publishedAt, + closedAt: + storedListing.status !== ListingsStatusEnum.closed && + dto.status === ListingsStatusEnum.closed + ? new Date() + : storedListing.closedAt, + requestedChangesUserId: + dto.status === ListingsStatusEnum.changesRequested && + storedListing.status !== ListingsStatusEnum.changesRequested + ? requestingUser.id + : storedListing.requestedChangesUserId, + listingsResult: dto.listingsResult + ? { + create: { + ...dto.listingsResult, + }, + } + : undefined, + }, + include: views.details, + where: { + id: dto.id, + }, + }), + ]); + + const rawListing = transactions[ + transactions.length - 1 + ] as unknown as Listing; + + if (!rawListing) { + throw new HttpException('listing failed to save', 500); + } + const listingApprovalPermissions = ( + await this.prisma.jurisdictions.findFirst({ + where: { id: dto.jurisdictions.id }, + }) + )?.listingApprovalPermissions; + + if (listingApprovalPermissions?.length > 0) + await this.listingApprovalNotify({ + user: requestingUser, + listingInfo: { id: dto.id, name: dto.name }, + approvingRoles: listingApprovalPermissions, + status: dto.status, + previousStatus: storedListing.status, + jurisId: dto.jurisdictions.id, + }); + + // if listing is closed for the first time the application flag set job needs to run + if ( + storedListing.status === ListingsStatusEnum.active && + dto.status === ListingsStatusEnum.closed + ) { + await this.afsService.process(dto.id); + } + + await this.cachePurge(storedListing.status, dto.status, rawListing.id); + + return mapTo(Listing, rawListing); + } + + /** + clears the listing cache of either 1 listing or all listings + @param storedListingStatus the status that was stored for the listing + @param incomingListingStatus the incoming "new" status for a listing + @param savedResponseId the id of the listing + */ + async cachePurge( + storedListingStatus: ListingsStatusEnum | undefined, + incomingListingStatus: ListingsStatusEnum, + savedResponseId: string, + ): Promise { + if (!process.env.PROXY_URL) { + return; + } + const shouldPurgeAllListings = + incomingListingStatus !== ListingsStatusEnum.pending || + storedListingStatus === ListingsStatusEnum.active; + await firstValueFrom( + this.httpService.request({ + baseURL: process.env.PROXY_URL, + method: 'PURGE', + url: shouldPurgeAllListings + ? '/listings?*' + : `/listings/${savedResponseId}*`, + }), + undefined, + ).catch((e) => + console.error( + shouldPurgeAllListings + ? 'purge all listings error = ' + : `purge listing ${savedResponseId} error = `, + e, + ), + ); + } + + /* + this builds the units summarized for the list() + */ + addUnitsSummarized = async (listing: Listing) => { + if (Array.isArray(listing.units) && listing.units.length > 0) { + const amiChartsRaw = await this.prisma.amiChart.findMany({ + where: { + id: { + in: listing.units.map((unit) => unit.amiChart?.id), + }, + }, + }); + const amiCharts = mapTo(AmiChart, amiChartsRaw); + listing.unitsSummarized = summarizeUnits(listing, amiCharts); + } + return listing; + }; + + /* + returns id, name of listing given a multiselect question id + */ + findListingsWithMultiSelectQuestion = async ( + multiselectQuestionId: string, + ) => { + const listingsRaw = await this.prisma.listings.findMany({ + select: { + id: true, + name: true, + }, + where: { + listingMultiselectQuestions: { + some: { + multiselectQuestionId: multiselectQuestionId, + }, + }, + }, + }); + return mapTo(Listing, listingsRaw); + }; + + /** + runs the job to auto close listings that are passed their due date + will call the the cache purge to purge all listings as long as updates had to be made + */ + async process(): Promise { + this.logger.warn('changeOverdueListingsStatusCron job running'); + await this.markCronJobAsStarted(); + const res = await this.prisma.listings.updateMany({ + data: { + status: ListingsStatusEnum.closed, + closedAt: new Date(), + }, + where: { + status: ListingsStatusEnum.active, + AND: [ + { + applicationDueDate: { + not: null, + }, + }, + { + applicationDueDate: { + lte: new Date(), + }, + }, + ], + }, + }); + this.logger.warn(`Changed the status of ${res?.count} listings`); + if (res?.count) { + await this.cachePurge( + ListingsStatusEnum.closed, + ListingsStatusEnum.active, + '', + ); + } + + return { + success: true, + }; + } + + /** + marks the db record for this cronjob as begun or creates a cronjob that + is marked as begun if one does not already exist + */ + async markCronJobAsStarted(): Promise { + const job = await this.prisma.cronJob.findFirst({ + where: { + name: CRON_JOB_NAME, + }, + }); + if (job) { + // if a job exists then we update db entry + await this.prisma.cronJob.update({ + data: { + lastRunDate: new Date(), + }, + where: { + id: job.id, + }, + }); + } else { + // if no job we create a new entry + await this.prisma.cronJob.create({ + data: { + lastRunDate: new Date(), + name: CRON_JOB_NAME, + }, + }); + } + } + /** + * + * @param listingId + * @returns the jurisdiction ID assigned to a listing + */ + public async getJurisdictionIdByListingId( + listingId: string, + ): Promise { + const listing = await this.prisma.listings.findUnique({ + select: { + jurisdictionId: true, + }, + where: { + id: listingId, + }, + }); + + if (!listing) { + throw new NotFoundException( + `No jurisdiction for listing ${listingId} found`, + ); + } + + return listing.jurisdictionId; + } +} diff --git a/api/src/services/map-layers.service.ts b/api/src/services/map-layers.service.ts new file mode 100644 index 0000000000..5618219c2a --- /dev/null +++ b/api/src/services/map-layers.service.ts @@ -0,0 +1,20 @@ +import { Injectable } from '@nestjs/common'; +import { PrismaService } from './prisma.service'; +import { MapLayersQueryParams } from '../dtos/map-layers/map-layers-query-params.dto'; +import { MapLayerDto } from '../dtos/map-layers/map-layer.dto'; + +@Injectable() +export class MapLayersService { + constructor(private prisma: PrismaService) {} + + async list(queryParams: MapLayersQueryParams): Promise { + if (queryParams.jurisdictionId) { + return await this.prisma.mapLayers.findMany({ + where: { + jurisdictionId: queryParams.jurisdictionId, + }, + }); + } + return await this.prisma.mapLayers.findMany(); + } +} diff --git a/api/src/services/multiselect-question.service.ts b/api/src/services/multiselect-question.service.ts new file mode 100644 index 0000000000..9f2f4416ab --- /dev/null +++ b/api/src/services/multiselect-question.service.ts @@ -0,0 +1,243 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { PrismaService } from './prisma.service'; +import { MultiselectQuestion } from '../dtos/multiselect-questions/multiselect-question.dto'; +import { MultiselectQuestionUpdate } from '../dtos/multiselect-questions/multiselect-question-update.dto'; +import { MultiselectQuestionCreate } from '../dtos/multiselect-questions/multiselect-question-create.dto'; +import { mapTo } from '../utilities/mapTo'; +import { SuccessDTO } from '../dtos/shared/success.dto'; +import { Prisma } from '@prisma/client'; +import { buildFilter } from '../utilities/build-filter'; +import { MultiselectQuestionFilterKeys } from '../enums/multiselect-questions/filter-key-enum'; +import { MultiselectQuestionQueryParams } from '../dtos/multiselect-questions/multiselect-question-query-params.dto'; + +const view: Prisma.MultiselectQuestionsInclude = { + jurisdictions: true, +}; + +/* + this is the service for multiselect questions + it handles all the backend's business logic for reading/writing/deleting multiselect questione data +*/ +@Injectable() +export class MultiselectQuestionService { + constructor(private prisma: PrismaService) {} + + /* + this will get a set of multiselect questions given the params passed in + */ + async list( + params: MultiselectQuestionQueryParams, + ): Promise { + const rawMultiselectQuestions = + await this.prisma.multiselectQuestions.findMany({ + include: view, + where: this.buildWhere(params), + }); + return mapTo(MultiselectQuestion, rawMultiselectQuestions); + } + + /* + this will build the where clause for list() + */ + buildWhere( + params: MultiselectQuestionQueryParams, + ): Prisma.MultiselectQuestionsWhereInput { + const filters: Prisma.MultiselectQuestionsWhereInput[] = []; + if (!params?.filter?.length) { + return { + AND: filters, + }; + } + params.filter.forEach((filter) => { + if (filter[MultiselectQuestionFilterKeys.jurisdiction]) { + const builtFilter = buildFilter({ + $comparison: filter.$comparison, + $include_nulls: false, + value: filter[MultiselectQuestionFilterKeys.jurisdiction], + key: MultiselectQuestionFilterKeys.jurisdiction, + caseSensitive: true, + }); + filters.push({ + OR: builtFilter.map((filt) => ({ + jurisdictions: { + some: { + id: filt, + }, + }, + })), + }); + } else if (filter[MultiselectQuestionFilterKeys.applicationSection]) { + const builtFilter = buildFilter({ + $comparison: filter.$comparison, + $include_nulls: false, + value: filter[MultiselectQuestionFilterKeys.applicationSection], + key: MultiselectQuestionFilterKeys.applicationSection, + caseSensitive: true, + }); + filters.push({ + OR: builtFilter.map((filt) => ({ + applicationSection: filt, + })), + }); + } + }); + return { + AND: filters, + }; + } + + /* + this will return 1 multiselect question or error + */ + async findOne(multiSelectQuestionId: string): Promise { + const rawMultiselectQuestion = + await this.prisma.multiselectQuestions.findFirst({ + where: { + id: { + equals: multiSelectQuestionId, + }, + }, + include: view, + }); + + if (!rawMultiselectQuestion) { + throw new NotFoundException( + `multiselectQuestionId ${multiSelectQuestionId} was requested but not found`, + ); + } + + return mapTo(MultiselectQuestion, rawMultiselectQuestion); + } + + /* + this will create a multiselect question + */ + async create( + incomingData: MultiselectQuestionCreate, + ): Promise { + const rawResult = await this.prisma.multiselectQuestions.create({ + data: { + ...incomingData, + jurisdictions: { + connect: incomingData.jurisdictions.map((juris) => ({ + id: juris.id, + })), + }, + links: incomingData.links + ? (incomingData.links as unknown as Prisma.InputJsonArray) + : undefined, + options: incomingData.options + ? (incomingData.options as unknown as Prisma.InputJsonArray) + : undefined, + }, + include: view, + }); + + return mapTo(MultiselectQuestion, rawResult); + } + + /* + this will update a multiselect question's name or items field + if no multiselect question has the id of the incoming argument an error is thrown + */ + async update( + incomingData: MultiselectQuestionUpdate, + ): Promise { + const existingMultiSelectQ = await this.findOrThrow(incomingData.id); + + if (existingMultiSelectQ.jurisdictions?.length) { + await this.prisma.multiselectQuestions.update({ + data: { + jurisdictions: { + disconnect: existingMultiSelectQ.jurisdictions.map((juris) => ({ + id: juris.id, + })), + }, + }, + where: { + id: existingMultiSelectQ.id, + }, + }); + } + + const rawResults = await this.prisma.multiselectQuestions.update({ + data: { + ...incomingData, + jurisdictions: { + connect: incomingData.jurisdictions.map((juris) => ({ + id: juris.id, + })), + }, + links: incomingData.links + ? JSON.parse(JSON.stringify(incomingData.links)) + : undefined, + options: incomingData.options + ? JSON.parse(JSON.stringify(incomingData.options)) + : undefined, + id: undefined, + }, + where: { + id: incomingData.id, + }, + include: view, + }); + return mapTo(MultiselectQuestion, rawResults); + } + + /* + this will delete a multiselect question + */ + async delete(multiSelectQuestionId: string): Promise { + await this.findOrThrow(multiSelectQuestionId); + await this.prisma.multiselectQuestions.delete({ + where: { + id: multiSelectQuestionId, + }, + }); + return { + success: true, + } as SuccessDTO; + } + + /* + this will either find a record or throw a customized error + */ + async findOrThrow( + multiselectQuestionId: string, + ): Promise { + const multiselectQuestion = + await this.prisma.multiselectQuestions.findFirst({ + include: { + jurisdictions: true, + }, + where: { + id: multiselectQuestionId, + }, + }); + + if (!multiselectQuestion) { + throw new NotFoundException( + `multiselectQuestionId ${multiselectQuestionId} was requested but not found`, + ); + } + + return mapTo(MultiselectQuestion, multiselectQuestion); + } + + async findByListingId(listingId: string): Promise { + const questions = await this.prisma.multiselectQuestions.findMany({ + include: { + listings: true, + }, + where: { + listings: { + some: { + listingId, + }, + }, + }, + }); + + return mapTo(MultiselectQuestion, questions); + } +} diff --git a/api/src/services/permission.service.ts b/api/src/services/permission.service.ts new file mode 100644 index 0000000000..17cfdf09f0 --- /dev/null +++ b/api/src/services/permission.service.ts @@ -0,0 +1,144 @@ +import { Injectable, ForbiddenException } from '@nestjs/common'; +import { Enforcer, newEnforcer } from 'casbin'; +import path from 'path'; +import { UserRoleEnum } from '../enums/permissions/user-role-enum'; +import { User } from '../dtos/users/user.dto'; +import { PrismaService } from './prisma.service'; +import { permissionActions } from '../enums/permissions/permission-actions-enum'; +import { Jurisdiction } from '../dtos/jurisdictions/jurisdiction.dto'; +import Listing from '../dtos/listings/listing.dto'; + +export type permissionCheckingObj = { + jurisdictionId?: string; + id?: string; + listingId?: string; + userId?: string; +}; + +@Injectable() +export class PermissionService { + constructor(private prisma: PrismaService) {} + + /** + Check whether this is an authorized action based on the permission 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). + */ + async can( + user: User | undefined, + type: string, + action: string, + obj?: permissionCheckingObj, + ): Promise { + let e = await newEnforcer( + path.join(__dirname, '../permission-configs', 'permission_model.conf'), + path.join(__dirname, '../permission-configs', 'permission_policy.csv'), + ); + + if (user) { + e = await this.addUserPermissions(e, user); + + if (type === 'user' && obj?.id) { + const accessedUser = await this.prisma.userAccounts.findUnique({ + select: { + id: true, + jurisdictions: { + where: { + id: { + in: user.jurisdictions.map((juris) => juris.id), + }, + }, + }, + listings: true, + userRoles: true, + }, + where: { + id: obj.id, + }, + }); + obj.jurisdictionId = + accessedUser?.jurisdictions.map( + (jurisdiction) => jurisdiction.id, + )[0] || ''; + } + } + return await e.enforce(user ? user.id : 'anonymous', type, action, obj); + } + + /** + adds permissions for users + Casbin doesn't support our permissioning requirements for jurisdictionalAdmin or partners so we custom build the permission set here + */ + async addUserPermissions(enforcer: Enforcer, user: User): Promise { + await enforcer.addRoleForUser(user.id, UserRoleEnum.user); + + if (user.userRoles?.isAdmin) { + await enforcer.addRoleForUser(user.id, UserRoleEnum.admin); + } else if (user.userRoles?.isJurisdictionalAdmin) { + await enforcer.addRoleForUser(user.id, UserRoleEnum.jurisdictionAdmin); + + await Promise.all( + user.jurisdictions.map(async (adminInJurisdiction: Jurisdiction) => { + await enforcer.addPermissionForUser( + user.id, + 'application', + `r.obj.jurisdictionId == '${adminInJurisdiction.id}'`, + `(${permissionActions.read}|${permissionActions.create}|${permissionActions.update}|${permissionActions.delete})`, + ); + await enforcer.addPermissionForUser( + user.id, + 'listing', + `r.obj.jurisdictionId == '${adminInJurisdiction.id}'`, + `(${permissionActions.read}|${permissionActions.create}|${permissionActions.update}|${permissionActions.delete})`, + ); + await enforcer.addPermissionForUser( + user.id, + 'user', + `r.obj.jurisdictionId == '${adminInJurisdiction.id}'`, + `(${permissionActions.read}|${permissionActions.invitePartner}|${permissionActions.inviteJurisdictionalAdmin}|${permissionActions.update}|${permissionActions.delete})`, + ); + }), + ); + } else if (user.userRoles?.isPartner) { + await enforcer.addRoleForUser(user.id, UserRoleEnum.partner); + + await Promise.all( + user?.listings.map(async (listing: Listing) => { + await enforcer.addPermissionForUser( + user.id, + 'application', + `r.obj.listingId == '${listing.id}'`, + `(${permissionActions.read}|${permissionActions.create}|${permissionActions.update}|${permissionActions.delete})`, + ); + await enforcer.addPermissionForUser( + user.id, + 'listing', + `r.obj.id == '${listing.id}'`, + `(${permissionActions.read}|${permissionActions.update})`, + ); + }), + ); + } + return enforcer; + } + + /** + if the user does not have permissions to perform the request + then an error is thrown + */ + async canOrThrow( + user: User | undefined, + type: string, + action: string, + obj?: permissionCheckingObj, + ): Promise { + if (!(await this.can(user, type, action, obj))) { + throw new ForbiddenException(); + } + } +} diff --git a/api/src/services/prisma.service.ts b/api/src/services/prisma.service.ts new file mode 100644 index 0000000000..332639addc --- /dev/null +++ b/api/src/services/prisma.service.ts @@ -0,0 +1,12 @@ +import { Injectable, OnModuleInit } from '@nestjs/common'; +import { PrismaClient } from '@prisma/client'; + +/* + This service sets up our database connections +*/ +@Injectable() +export class PrismaService extends PrismaClient implements OnModuleInit { + async onModuleInit() { + await this.$connect(); + } +} diff --git a/api/src/services/reserved-community-type.service.ts b/api/src/services/reserved-community-type.service.ts new file mode 100644 index 0000000000..fa9b69179f --- /dev/null +++ b/api/src/services/reserved-community-type.service.ts @@ -0,0 +1,162 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { PrismaService } from './prisma.service'; +import { Prisma } from '@prisma/client'; +import { ReservedCommunityType } from '../dtos/reserved-community-types/reserved-community-type.dto'; +import { ReservedCommunityTypeCreate } from '../dtos/reserved-community-types/reserved-community-type-create.dto'; +import { ReservedCommunityTypeUpdate } from '../dtos/reserved-community-types/reserved-community-type-update.dto'; +import { mapTo } from '../utilities/mapTo'; +import { SuccessDTO } from '../dtos/shared/success.dto'; +import { ReservedCommunityTypeQueryParams } from '../dtos/reserved-community-types/reserved-community-type-query-params.dto'; + +/* + this is the service for reserved community types + it handles all the backend's business logic for reading/writing/deleting reserved community type data +*/ + +const view: Prisma.ReservedCommunityTypesInclude = { + jurisdictions: true, +}; + +@Injectable() +export class ReservedCommunityTypeService { + constructor(private prisma: PrismaService) {} + + /* + this will get a set of reserved community types given the params passed in + */ + async list( + params: ReservedCommunityTypeQueryParams, + ): Promise { + const rawReservedCommunityTypes = + await this.prisma.reservedCommunityTypes.findMany({ + include: view, + where: this.buildWhereClause(params), + }); + return mapTo(ReservedCommunityType, rawReservedCommunityTypes); + } + + /* + this helps build the where clause for the list() + */ + buildWhereClause( + params: ReservedCommunityTypeQueryParams, + ): Prisma.ReservedCommunityTypesWhereInput { + const filters: Prisma.ReservedCommunityTypesWhereInput[] = []; + + if (params && 'jurisdictionId' in params && params.jurisdictionId) { + filters.push({ + jurisdictions: { + id: params.jurisdictionId, + }, + }); + } + + return { + AND: filters, + }; + } + + /* + this will return 1 reserved community type or error + */ + async findOne( + reservedCommunityTypeId: string, + ): Promise { + const rawReservedCommunityTypes = + await this.prisma.reservedCommunityTypes.findFirst({ + include: view, + where: { + id: { + equals: reservedCommunityTypeId, + }, + }, + }); + + if (!rawReservedCommunityTypes) { + throw new NotFoundException( + `reservedCommunityTypeId ${reservedCommunityTypeId} was requested but not found`, + ); + } + ReservedCommunityTypeCreate; + return mapTo(ReservedCommunityType, rawReservedCommunityTypes); + } + + /* + this will create a reserved community type + */ + async create( + incomingData: ReservedCommunityTypeCreate, + ): Promise { + const rawResult = await this.prisma.reservedCommunityTypes.create({ + data: { + ...incomingData, + jurisdictions: { + connect: { + id: incomingData.jurisdictions.id, + }, + }, + }, + include: view, + }); + + return mapTo(ReservedCommunityType, rawResult); + } + + /* + this will update a reserved community type's name or items field + if no eserved community type has the id of the incoming argument an error is thrown + */ + async update( + incomingData: ReservedCommunityTypeUpdate, + ): Promise { + await this.findOrThrow(incomingData.id); + + const rawResults = await this.prisma.reservedCommunityTypes.update({ + include: view, + data: { + ...incomingData, + jurisdictions: undefined, + id: undefined, + }, + where: { + id: incomingData.id, + }, + }); + return mapTo(ReservedCommunityType, rawResults); + } + + /* + this will delete a reserved community type + */ + async delete(reservedCommunityTypeId: string): Promise { + await this.findOrThrow(reservedCommunityTypeId); + await this.prisma.reservedCommunityTypes.delete({ + where: { + id: reservedCommunityTypeId, + }, + }); + return { + success: true, + } as SuccessDTO; + } + + /* + this will either find a record or throw a customized error + */ + async findOrThrow(reservedCommunityTypeId: string): Promise { + const reservedCommunityType = + await this.prisma.reservedCommunityTypes.findFirst({ + where: { + id: reservedCommunityTypeId, + }, + }); + + if (!reservedCommunityType) { + throw new NotFoundException( + `reservedCommunityTypeId ${reservedCommunityTypeId} was requested but not found`, + ); + } + + return true; + } +} diff --git a/api/src/services/sendgrid.service.ts b/api/src/services/sendgrid.service.ts new file mode 100644 index 0000000000..b6875ac57e --- /dev/null +++ b/api/src/services/sendgrid.service.ts @@ -0,0 +1,29 @@ +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { + ClientResponse, + MailDataRequired, + MailService, + ResponseError, +} from '@sendgrid/mail'; + +@Injectable() +export class SendGridService { + constructor( + private readonly mailService: MailService, + private readonly configService: ConfigService, + ) { + this.mailService.setApiKey(configService.get('EMAIL_API_KEY')); + } + + public async send( + data: MailDataRequired, + isMultiple?: boolean, + cb?: ( + err: Error | ResponseError, + result: [ClientResponse, unknown], + ) => void, + ): Promise<[ClientResponse, unknown]> { + return this.mailService.send(data, isMultiple, cb); + } +} diff --git a/api/src/services/sms.service.ts b/api/src/services/sms.service.ts new file mode 100644 index 0000000000..2325a73d73 --- /dev/null +++ b/api/src/services/sms.service.ts @@ -0,0 +1,29 @@ +import { Injectable } from '@nestjs/common'; +import twilio from 'twilio'; +import TwilioClient from 'twilio/lib/rest/Twilio'; + +@Injectable() +export class SmsService { + client: TwilioClient; + public constructor() { + if (process.env.TWILIO_ACCOUNT_SID && process.env.TWILIO_AUTH_TOKEN) { + this.client = twilio( + process.env.TWILIO_ACCOUNT_SID, + process.env.TWILIO_AUTH_TOKEN, + ); + } + } + public async sendMfaCode( + phoneNumber: string, + mfaCode: string, + ): Promise { + if (!this.client) { + return; + } + await this.client.messages.create({ + body: `Your Partners Portal account access token: ${mfaCode}`, + from: process.env.TWILIO_PHONE_NUMBER, + to: phoneNumber, + }); + } +} diff --git a/api/src/services/translation.service.ts b/api/src/services/translation.service.ts new file mode 100644 index 0000000000..5ac2c83fda --- /dev/null +++ b/api/src/services/translation.service.ts @@ -0,0 +1,215 @@ +import { Injectable } from '@nestjs/common'; +import { LanguagesEnum, Translations } from '@prisma/client'; +import { PrismaService } from './prisma.service'; +import { Listing } from '../dtos/listings/listing.dto'; +import { GoogleTranslateService } from './google-translate.service'; +import * as lodash from 'lodash'; +import { Jurisdiction } from '../dtos/jurisdictions/jurisdiction.dto'; + +@Injectable() +export class TranslationService { + constructor( + private prisma: PrismaService, + private readonly googleTranslateService: GoogleTranslateService, + ) {} + + public async getMergedTranslations( + jurisdictionId: string | null, + language?: LanguagesEnum, + ) { + let jurisdictionalTranslations: Promise, + genericTranslations: Promise, + jurisdictionalDefaultTranslations: Promise; + + if (language && language !== LanguagesEnum.en) { + if (jurisdictionId) { + jurisdictionalTranslations = + this.getTranslationByLanguageAndJurisdictionOrDefaultEn( + language, + jurisdictionId, + ); + } + genericTranslations = + this.getTranslationByLanguageAndJurisdictionOrDefaultEn(language, null); + } + + if (jurisdictionId) { + jurisdictionalDefaultTranslations = + this.getTranslationByLanguageAndJurisdictionOrDefaultEn( + LanguagesEnum.en, + jurisdictionId, + ); + } + + const genericDefaultTranslations = + this.getTranslationByLanguageAndJurisdictionOrDefaultEn( + LanguagesEnum.en, + null, + ); + + const [genericDefault, generic, jurisdictionalDefault, jurisdictional] = + await Promise.all([ + genericDefaultTranslations, + genericTranslations, + jurisdictionalDefaultTranslations, + jurisdictionalTranslations, + ]); + // Deep merge + const translations = lodash.merge( + genericDefault?.translations, + generic?.translations, + jurisdictionalDefault?.translations, + jurisdictional?.translations, + ); + + return translations; + } + + public async getTranslationByLanguageAndJurisdictionOrDefaultEn( + language: LanguagesEnum, + jurisdictionId: string | null, + ) { + let translations = await this.prisma.translations.findFirst({ + where: { AND: [{ language: language }, { jurisdictionId }] }, + }); + + if (translations === null && language !== LanguagesEnum.en) { + console.warn( + `Fetching translations for ${language} failed on jurisdiction ${jurisdictionId}, defaulting to english.`, + ); + translations = await this.prisma.translations.findFirst({ + where: { AND: [{ language: LanguagesEnum.en }, { jurisdictionId }] }, + }); + } + return translations; + } + + public async translateListing(listing: Listing, language: LanguagesEnum) { + if (!this.googleTranslateService.isConfigured()) { + console.warn( + 'listing translation requested, but google translate service is not configured', + ); + return; + } + + const pathsToFilter = { + applicationPickUpAddressOfficeHours: + listing.applicationPickUpAddressOfficeHours, + costsNotIncluded: listing.costsNotIncluded, + creditHistory: listing.creditHistory, + criminalBackground: listing.criminalBackground, + programRules: listing.programRules, + rentalAssistance: listing.rentalAssistance, + rentalHistory: listing.rentalHistory, + requiredDocuments: listing.requiredDocuments, + specialNotes: listing.specialNotes, + whatToExpect: listing.whatToExpect, + accessibility: listing.accessibility, + amenities: listing.amenities, + neighborhood: listing.neighborhood, + petPolicy: listing.petPolicy, + servicesOffered: listing.servicesOffered, + smokingPolicy: listing.smokingPolicy, + unitAmenities: listing.unitAmenities, + }; + + listing.listingEvents?.forEach((_, index) => { + pathsToFilter[`listingEvents[${index}].note`] = + listing.listingEvents[index].note; + pathsToFilter[`listingEvents[${index}].label`] = + listing.listingEvents[index].label; + }); + + if (listing.listingMultiselectQuestions) { + listing.listingMultiselectQuestions.map((multiselectQuestion, index) => { + multiselectQuestion.multiselectQuestions.untranslatedText = + multiselectQuestion.multiselectQuestions?.text; + multiselectQuestion.multiselectQuestions.untranslatedOptOutText = + multiselectQuestion.multiselectQuestions?.optOutText; + pathsToFilter[ + `listingMultiselectQuestions[${index}].multiselectQuestions.text` + ] = multiselectQuestion.multiselectQuestions?.text; + pathsToFilter[ + `listingMultiselectQuestions[${index}].multiselectQuestions.description` + ] = multiselectQuestion.multiselectQuestions?.description; + pathsToFilter[ + `listingMultiselectQuestions[${index}].multiselectQuestions.subText` + ] = multiselectQuestion.multiselectQuestions?.subText; + pathsToFilter[ + `listingMultiselectQuestions[${index}].multiselectQuestions.optOutText` + ] = multiselectQuestion.multiselectQuestions?.optOutText; + multiselectQuestion.multiselectQuestions?.options?.map( + (multiselectOption, optionIndex) => { + multiselectOption.untranslatedText = multiselectOption.text; + pathsToFilter[ + `listingMultiselectQuestions[${index}].multiselectQuestions.options[${optionIndex}].text` + ] = multiselectOption.text; + pathsToFilter[ + `listingMultiselectQuestions[${index}].multiselectQuestions.options[${optionIndex}].description` + ] = multiselectOption.description; + pathsToFilter[ + `listingMultiselectQuestions[${index}].multiselectQuestions.options[${optionIndex}].description` + ] = multiselectOption.description; + }, + ); + }); + } + + const persistedTranslationsFromDB = await this.getPersistedTranslatedValues( + listing, + language, + ); + let translatedValue; + + // Remove all null or undefined values + const cleanedPaths = {}; + Object.entries(pathsToFilter).forEach(([key, value]) => { + if (value) { + cleanedPaths[key] = value; + } + }); + + if (persistedTranslationsFromDB) { + translatedValue = persistedTranslationsFromDB.translations; + } else { + translatedValue = await this.googleTranslateService.fetch( + Object.values(cleanedPaths), + language, + ); + await this.persistNewTranslatedValues(listing, language, translatedValue); + } + + if (translatedValue) { + [...Object.keys(cleanedPaths).values()].forEach((path, index) => { + lodash.set(listing, path, translatedValue[0][index]); + }); + } + + return listing; + } + + private async getPersistedTranslatedValues( + listing: Listing, + language: LanguagesEnum, + ) { + return this.prisma.generatedListingTranslations.findFirst({ + where: { listingId: listing.id, language: language }, + }); + } + + private async persistNewTranslatedValues( + listing: Listing, + language: LanguagesEnum, + translatedValues: any, + ) { + return this.prisma.generatedListingTranslations.create({ + data: { + jurisdictionId: listing.jurisdictions.id, + listingId: listing.id, + language: language, + translations: translatedValues, + timestamp: new Date(), + }, + }); + } +} diff --git a/api/src/services/unit-accessibility-priority-type.service.ts b/api/src/services/unit-accessibility-priority-type.service.ts new file mode 100644 index 0000000000..a174727e7b --- /dev/null +++ b/api/src/services/unit-accessibility-priority-type.service.ts @@ -0,0 +1,119 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { PrismaService } from './prisma.service'; +import { UnitAccessibilityPriorityType } from '../dtos/unit-accessibility-priority-types/unit-accessibility-priority-type.dto'; +import { UnitAccessibilityPriorityTypeCreate } from '../dtos/unit-accessibility-priority-types/unit-accessibility-priority-type-create.dto'; +import { mapTo } from '../utilities/mapTo'; +import { SuccessDTO } from '../dtos/shared/success.dto'; +import { UnitAccessibilityPriorityTypeUpdate } from '../dtos/unit-accessibility-priority-types/unit-accessibility-priority-type-update.dto'; + +/* + this is the service for unit accessibility priority types + it handles all the backend's business logic for reading/writing/deleting unit type data +*/ + +@Injectable() +export class UnitAccessibilityPriorityTypeService { + constructor(private prisma: PrismaService) {} + + /* + this will get a set of unit accessibility priority types given the params passed in + */ + async list(): Promise { + const rawunitPriortyTypes = + await this.prisma.unitAccessibilityPriorityTypes.findMany(); + return mapTo(UnitAccessibilityPriorityType, rawunitPriortyTypes); + } + + /* + this will return 1 unit accessibility priority type or error + */ + async findOne( + unitAccessibilityPriorityTypeId: string, + ): Promise { + const rawunitPriortyTypes = + await this.prisma.unitAccessibilityPriorityTypes.findUnique({ + where: { + id: unitAccessibilityPriorityTypeId, + }, + }); + + if (!rawunitPriortyTypes) { + throw new NotFoundException( + `unitAccessibilityPriorityTypeId ${unitAccessibilityPriorityTypeId} was requested but not found`, + ); + } + + return mapTo(UnitAccessibilityPriorityType, rawunitPriortyTypes); + } + + /* + this will create a unit accessibility priority type + */ + async create( + incomingData: UnitAccessibilityPriorityTypeCreate, + ): Promise { + const rawResult = await this.prisma.unitAccessibilityPriorityTypes.create({ + data: { + ...incomingData, + }, + }); + + return mapTo(UnitAccessibilityPriorityType, rawResult); + } + + /* + this will update a unit accessibility priority type's name or items field + if no unit accessibility priority type has the id of the incoming argument an error is thrown + */ + async update( + incomingData: UnitAccessibilityPriorityTypeUpdate, + ): Promise { + await this.findOrThrow(incomingData.id); + + const rawResults = await this.prisma.unitAccessibilityPriorityTypes.update({ + data: { + ...incomingData, + id: undefined, + }, + where: { + id: incomingData.id, + }, + }); + return mapTo(UnitAccessibilityPriorityType, rawResults); + } + + /* + this will delete a unit accessibility priority type + */ + async delete(unitAccessibilityPriorityTypeId: string): Promise { + await this.findOrThrow(unitAccessibilityPriorityTypeId); + await this.prisma.unitAccessibilityPriorityTypes.delete({ + where: { + id: unitAccessibilityPriorityTypeId, + }, + }); + return { + success: true, + } as SuccessDTO; + } + + /* + this will either find a record or throw a customized error + */ + async findOrThrow(unitAccessibilityPriorityTypeId: string): Promise { + const unitType = + await this.prisma.unitAccessibilityPriorityTypes.findUnique({ + where: { + id: unitAccessibilityPriorityTypeId, + }, + }); + + if (!unitType) { + throw new NotFoundException( + `unitAccessibilityPriorityTypeId ${unitAccessibilityPriorityTypeId} was requested but not found`, + ); + } + + return true; + } +} diff --git a/api/src/services/unit-rent-type.service.ts b/api/src/services/unit-rent-type.service.ts new file mode 100644 index 0000000000..b7636f3ff4 --- /dev/null +++ b/api/src/services/unit-rent-type.service.ts @@ -0,0 +1,107 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { PrismaService } from './prisma.service'; +import { UnitRentType } from '../dtos/unit-rent-types/unit-rent-type.dto'; +import { UnitRentTypeCreate } from '../dtos/unit-rent-types/unit-rent-type-create.dto'; +import { UnitRentTypeUpdate } from '../dtos/unit-rent-types/unit-rent-type-update.dto'; +import { mapTo } from '../utilities/mapTo'; +import { SuccessDTO } from '../dtos/shared/success.dto'; +import { UnitRentTypes } from '@prisma/client'; + +/* + this is the service for unit rent types + it handles all the backend's business logic for reading/writing/deleting unit rent type data +*/ + +@Injectable() +export class UnitRentTypeService { + constructor(private prisma: PrismaService) {} + + /* + this will get a set of unit rent types given the params passed in + */ + async list(): Promise { + const rawUnitRentTypes = await this.prisma.unitRentTypes.findMany(); + return mapTo(UnitRentType, rawUnitRentTypes); + } + + /* + this will return 1 unit rent type or error + */ + async findOne(unitRentTypeId: string): Promise { + const rawUnitRentType = await this.findOrThrow(unitRentTypeId); + + if (!rawUnitRentType) { + throw new NotFoundException( + `unitRentTypeId ${unitRentTypeId} was requested but not found`, + ); + } + + return mapTo(UnitRentType, rawUnitRentType); + } + + /* + this will create a unit rent type + */ + async create(incomingData: UnitRentTypeCreate): Promise { + const rawResult = await this.prisma.unitRentTypes.create({ + data: { + ...incomingData, + id: undefined, + }, + }); + + return mapTo(UnitRentType, rawResult); + } + + /* + this will update a unit rent type's name or items field + if no unit rent type has the id of the incoming argument an error is thrown + */ + async update(incomingData: UnitRentTypeUpdate): Promise { + await this.findOrThrow(incomingData.id); + + const rawResults = await this.prisma.unitRentTypes.update({ + data: { + name: incomingData.name, + }, + where: { + id: incomingData.id, + }, + }); + return mapTo(UnitRentType, rawResults); + } + + /* + this will delete a unit rent type + */ + async delete(unitRentTypeId: string): Promise { + await this.findOrThrow(unitRentTypeId); + await this.prisma.unitRentTypes.delete({ + where: { + id: unitRentTypeId, + }, + }); + return { + success: true, + } as SuccessDTO; + } + + /* + this will either find a record or throw a customized error + */ + async findOrThrow(unitRentTypeId: string): Promise { + const unitRentType = await this.prisma.unitRentTypes.findUnique({ + where: { + id: unitRentTypeId, + }, + }); + + if (!unitRentType) { + throw new NotFoundException( + `unitRentTypeId ${unitRentTypeId} was requested but not found`, + ); + } + + return unitRentType; + } +} diff --git a/api/src/services/unit-type.service.ts b/api/src/services/unit-type.service.ts new file mode 100644 index 0000000000..bc75e2f97d --- /dev/null +++ b/api/src/services/unit-type.service.ts @@ -0,0 +1,112 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { PrismaService } from './prisma.service'; +import { UnitType } from '../dtos/unit-types/unit-type.dto'; +import { UnitTypeCreate } from '../dtos/unit-types/unit-type-create.dto'; +import { mapTo } from '../utilities/mapTo'; +import { SuccessDTO } from '../dtos/shared/success.dto'; +import { UnitTypeUpdate } from '../dtos/unit-types/unit-type-update.dto'; + +/* + this is the service for unit types + it handles all the backend's business logic for reading/writing/deleting unit type data +*/ + +@Injectable() +export class UnitTypeService { + constructor(private prisma: PrismaService) {} + + /* + this will get a set of unit types given the params passed in + */ + async list(): Promise { + const rawUnitTypes = await this.prisma.unitTypes.findMany(); + return mapTo(UnitType, rawUnitTypes); + } + + /* + this will return 1 unit type or error + */ + async findOne(unitTypeId: string): Promise { + const rawUnitType = await this.prisma.unitTypes.findFirst({ + where: { + id: { + equals: unitTypeId, + }, + }, + }); + + if (!rawUnitType) { + throw new NotFoundException( + `unitTypeId ${unitTypeId} was requested but not found`, + ); + } + + return mapTo(UnitType, rawUnitType); + } + + /* + this will create a unit type + */ + async create(incomingData: UnitTypeCreate): Promise { + const rawResult = await this.prisma.unitTypes.create({ + data: { + ...incomingData, + }, + }); + + return mapTo(UnitType, rawResult); + } + + /* + this will update a unit type's name or items field + if no unit type has the id of the incoming argument an error is thrown + */ + async update(incomingData: UnitTypeUpdate): Promise { + await this.findOrThrow(incomingData.id); + + const rawResults = await this.prisma.unitTypes.update({ + data: { + ...incomingData, + id: undefined, + }, + where: { + id: incomingData.id, + }, + }); + return mapTo(UnitType, rawResults); + } + + /* + this will delete a unit type + */ + async delete(unitTypeId: string): Promise { + await this.findOrThrow(unitTypeId); + await this.prisma.unitTypes.delete({ + where: { + id: unitTypeId, + }, + }); + return { + success: true, + } as SuccessDTO; + } + + /* + this will either find a record or throw a customized error + */ + async findOrThrow(unitTypeId: string): Promise { + const unitType = await this.prisma.unitTypes.findFirst({ + where: { + id: unitTypeId, + }, + }); + + if (!unitType) { + throw new NotFoundException( + `unitTypeId ${unitTypeId} was requested but not found`, + ); + } + + return true; + } +} diff --git a/api/src/services/user.service.ts b/api/src/services/user.service.ts new file mode 100644 index 0000000000..25ae88001f --- /dev/null +++ b/api/src/services/user.service.ts @@ -0,0 +1,1070 @@ +import { + BadRequestException, + ForbiddenException, + Injectable, + NotFoundException, + UnauthorizedException, +} from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { Prisma } from '@prisma/client'; +import dayjs from 'dayjs'; +import advancedFormat from 'dayjs/plugin/advancedFormat'; +import crypto from 'crypto'; +import { verify, sign } from 'jsonwebtoken'; +import { PrismaService } from './prisma.service'; +import { User } from '../dtos/users/user.dto'; +import { mapTo } from '../utilities/mapTo'; +import { + buildPaginationMetaInfo, + calculateSkip, + calculateTake, +} from '../utilities/pagination-helpers'; +import { buildOrderBy } from '../utilities/build-order-by'; +import { UserQueryParams } from '../dtos/users/user-query-param.dto'; +import { PaginatedUserDto } from '../dtos/users/paginated-user.dto'; +import { OrderByEnum } from '../enums/shared/order-by-enum'; +import { UserUpdate } from '../dtos/users/user-update.dto'; +import { isPasswordValid, passwordToHash } from '../utilities/password-helpers'; +import { SuccessDTO } from '../dtos/shared/success.dto'; +import { EmailAndAppUrl } from '../dtos/users/email-and-app-url.dto'; +import { ConfirmationRequest } from '../dtos/users/confirmation-request.dto'; +import { IdDTO } from '../dtos/shared/id.dto'; +import { UserInvite } from '../dtos/users/user-invite.dto'; +import { UserCreate } from '../dtos/users/user-create.dto'; +import { EmailService } from './email.service'; +import { buildFromIdIndex } from '../utilities/csv-builder'; +import { PermissionService } from './permission.service'; +import { permissionActions } from '../enums/permissions/permission-actions-enum'; + +/* + this is the service for users + it handles all the backend's business logic for reading/writing/deleting user data +*/ + +const view: Prisma.UserAccountsInclude = { + listings: true, + jurisdictions: true, + userRoles: true, +}; + +type findByOptions = { + userId?: string; + email?: string; + resetToken?: string; +}; + +@Injectable() +export class UserService { + constructor( + private prisma: PrismaService, + private emailService: EmailService, + private readonly configService: ConfigService, + private permissionService: PermissionService, + ) { + dayjs.extend(advancedFormat); + } + + /* + this will get a set of users given the params passed in + Only users with a user role of admin or jurisdictional admin can get the list of available users. + This means we don't need to account for a user with only the partner role when it comes to accessing this function + */ + async list(params: UserQueryParams, user: User): Promise { + const whereClause = this.buildWhereClause(params, user); + const count = await this.prisma.userAccounts.count({ + where: whereClause, + }); + + // if passed in page and limit would result in no results because there aren't that many listings + // revert back to the first page + let page = params.page; + if (count && params.limit && params.limit !== 'all' && params.page > 1) { + if (Math.ceil(count / params.limit) < params.page) { + page = 1; + } + } + + const rawUsers = await this.prisma.userAccounts.findMany({ + skip: calculateSkip(params.limit, page), + take: calculateTake(params.limit), + orderBy: buildOrderBy( + ['firstName', 'lastName'], + [OrderByEnum.ASC, OrderByEnum.ASC], + ), + include: view, + where: whereClause, + }); + + const users = mapTo(User, rawUsers); + + const paginationInfo = buildPaginationMetaInfo(params, count, users.length); + + return { + items: users, + meta: paginationInfo, + }; + } + + /* + this helps build the where clause for the list() + */ + buildWhereClause( + params: UserQueryParams, + user: User, + ): Prisma.UserAccountsWhereInput { + const filters: Prisma.UserAccountsWhereInput[] = []; + + if (params.search) { + filters.push({ + OR: [ + { + firstName: { + contains: params.search, + mode: Prisma.QueryMode.insensitive, + }, + }, + { + lastName: { + contains: params.search, + mode: Prisma.QueryMode.insensitive, + }, + }, + { + email: { + contains: params.search, + mode: Prisma.QueryMode.insensitive, + }, + }, + { + listings: { + some: { + name: { + contains: params.search, + mode: Prisma.QueryMode.insensitive, + }, + }, + }, + }, + ], + }); + } + + if (!params.filter?.length) { + return { + AND: filters, + }; + } + + params.filter.forEach((filter) => { + if (filter['isPortalUser']) { + if (user?.userRoles?.isAdmin) { + filters.push({ + OR: [ + { + userRoles: { + isPartner: true, + }, + }, + { + userRoles: { + isAdmin: true, + }, + }, + { + userRoles: { + isJurisdictionalAdmin: true, + }, + }, + ], + }); + } else if (user?.userRoles?.isJurisdictionalAdmin) { + filters.push({ + OR: [ + { + userRoles: { + isPartner: true, + }, + }, + { + userRoles: { + isJurisdictionalAdmin: true, + }, + }, + ], + }); + filters.push({ + jurisdictions: { + some: { + id: { + in: user?.jurisdictions?.map((juris) => juris.id), + }, + }, + }, + }); + } + } else if ('isPortalUser' in filter) { + filters.push({ + AND: [ + { + OR: [ + { + userRoles: { + isPartner: null, + }, + }, + { + userRoles: { + isPartner: false, + }, + }, + ], + }, + { + OR: [ + { + userRoles: { + isJurisdictionalAdmin: null, + }, + }, + { + userRoles: { + isJurisdictionalAdmin: false, + }, + }, + ], + }, + { + OR: [ + { + userRoles: { + isAdmin: null, + }, + }, + { + userRoles: { + isAdmin: false, + }, + }, + ], + }, + ], + }); + } + }); + return { + AND: filters, + }; + } + + /* + this will return 1 user or error + */ + async findOne(userId: string): Promise { + const rawUser = await this.findUserOrError({ userId: userId }, true); + return mapTo(User, rawUser); + } + + /* + this will update a user or error if no user is found with the Id + */ + async update( + dto: UserUpdate, + requestingUser: User, + jurisdictionName: string, + ): Promise { + const storedUser = await this.findUserOrError({ userId: dto.id }, true); + + if (dto.jurisdictions?.length) { + // if the incoming dto has jurisdictions make sure the user has access to update users in that jurisdiction + await Promise.all( + dto.jurisdictions.map(async (jurisdiction) => { + await this.permissionService.canOrThrow( + requestingUser, + 'user', + permissionActions.update, + { + id: dto.id, + jurisdictionId: jurisdiction.id, + }, + ); + }), + ); + } else { + // if the incoming dto has no jurisdictions make sure the user has access to update the user + await this.permissionService.canOrThrow( + requestingUser, + 'userProfile', + permissionActions.update, + { + id: dto.id, + }, + ); + } + + let passwordHash: string; + let passwordUpdatedAt: Date; + if (dto.password) { + if (!dto.currentPassword) { + throw new BadRequestException( + `userID ${dto.id}: request missing currentPassword`, + ); + } + if ( + !(await isPasswordValid(storedUser.passwordHash, dto.currentPassword)) + ) { + throw new UnauthorizedException( + `userID ${dto.id}: incoming password doesn't match stored password`, + ); + } + + passwordHash = await passwordToHash(dto.password); + passwordUpdatedAt = new Date(); + delete dto.password; + } + + let confirmationToken: string; + if (dto.newEmail && dto.appUrl) { + confirmationToken = this.createConfirmationToken( + storedUser.id, + dto.newEmail, + ); + + const confirmationUrl = this.getPublicConfirmationUrl( + dto.appUrl, + confirmationToken, + ); + + this.emailService.changeEmail( + dto.jurisdictions && dto.jurisdictions[0] + ? dto.jurisdictions[0].name + : jurisdictionName, + mapTo(User, storedUser), + dto.appUrl, + confirmationUrl, + dto.newEmail, + ); + } + + // only update userRoles if something has changed + if (dto.userRoles && storedUser.userRoles) { + if ( + !( + dto.userRoles.isAdmin === storedUser.userRoles.isAdmin && + dto.userRoles.isJurisdictionalAdmin === + storedUser.userRoles.isJurisdictionalAdmin && + dto.userRoles.isPartner === storedUser.userRoles.isPartner + ) + ) { + await this.prisma.userRoles.update({ + data: { + ...dto.userRoles, + }, + where: { + userId: storedUser.id, + }, + }); + } + } + + // disconnect existing connected listings/jurisdictions + if (storedUser.listings?.length) { + await this.prisma.userAccounts.update({ + data: { + listings: { + disconnect: storedUser.listings.map((listing) => ({ + id: listing.id, + })), + }, + }, + where: { + id: dto.id, + }, + }); + } + if (storedUser.jurisdictions?.length) { + await this.prisma.userAccounts.update({ + data: { + jurisdictions: { + disconnect: storedUser.jurisdictions.map((jurisdiction) => ({ + id: jurisdiction.id, + })), + }, + }, + where: { + id: dto.id, + }, + }); + } + + const res = await this.prisma.userAccounts.update({ + include: view, + data: { + email: dto.email, + agreedToTermsOfService: dto.agreedToTermsOfService, + passwordHash: passwordHash ?? undefined, + passwordUpdatedAt: passwordUpdatedAt ?? undefined, + confirmationToken: confirmationToken ?? undefined, + firstName: dto.firstName, + middleName: dto.middleName, + lastName: dto.lastName, + dob: dto.dob, + phoneNumber: dto.phoneNumber, + language: dto.language, + listings: dto.listings + ? { + connect: dto.listings.map((listing) => ({ id: listing.id })), + } + : undefined, + jurisdictions: dto.jurisdictions + ? { + connect: dto.jurisdictions.map((jurisdiction) => ({ + id: jurisdiction.id, + })), + } + : undefined, + }, + where: { + id: dto.id, + }, + }); + + return mapTo(User, res); + } + + /* + this will delete a user or error if no user is found with the Id + */ + async delete(userId: string, requestingUser: User): Promise { + const targetUser = await this.findUserOrError({ userId: userId }, false); + + this.authorizeAction( + requestingUser, + mapTo(User, targetUser), + permissionActions.delete, + ); + + await this.prisma.userRoles.delete({ + where: { + userId: userId, + }, + }); + + await this.prisma.userAccounts.delete({ + where: { + id: userId, + }, + }); + + return { + success: true, + } as SuccessDTO; + } + + /* + resends a confirmation email or errors if no user matches the incoming email + if forPublic is true then we resend a confirmation for a public site user + if forPublic is false then we resend a confirmation for a partner site user + */ + async resendConfirmation( + dto: EmailAndAppUrl, + forPublic: boolean, + ): Promise { + const storedUser = await this.findUserOrError({ email: dto.email }, true); + + if (!storedUser.confirmedAt) { + const confirmationToken = this.createConfirmationToken( + storedUser.id, + storedUser.email, + ); + await this.prisma.userAccounts.update({ + data: { + confirmationToken: confirmationToken, + }, + where: { + id: storedUser.id, + }, + }); + + if (forPublic) { + const confirmationUrl = this.getPublicConfirmationUrl( + dto.appUrl, + confirmationToken, + ); + this.emailService.welcome( + storedUser.jurisdictions && storedUser.jurisdictions.length + ? storedUser.jurisdictions[0].name + : null, + storedUser as unknown as User, + dto.appUrl, + confirmationUrl, + ); + } else { + const confirmationUrl = this.getPartnersConfirmationUrl( + dto.appUrl, + confirmationToken, + ); + this.emailService.invitePartnerUser( + storedUser.jurisdictions, + storedUser as unknown as User, + dto.appUrl, + confirmationUrl, + ); + } + } + + return { + success: true, + } as SuccessDTO; + } + + /* + sets a reset token so a user can recover their account if they forgot the password + */ + async forgotPassword(dto: EmailAndAppUrl): Promise { + const storedUser = await this.findUserOrError({ email: dto.email }, true); + + const payload = { + id: storedUser.id, + exp: Number.parseInt(dayjs().add(1, 'hour').format('X')), + }; + const resetToken = sign(payload, process.env.APP_SECRET); + await this.prisma.userAccounts.update({ + data: { + resetToken: resetToken, + }, + where: { + id: storedUser.id, + }, + }); + this.emailService.forgotPassword( + storedUser.jurisdictions, + mapTo(User, storedUser), + dto.appUrl, + resetToken, + ); + return { + success: true, + } as SuccessDTO; + } + + /* + checks to see if the confirmation token is valid + sets the hitConfirmationUrl field on the user if the user exists + */ + async isUserConfirmationTokenValid( + dto: ConfirmationRequest, + ): Promise { + try { + const token = verify(dto.token, process.env.APP_SECRET) as IdDTO; + + const storedUser = await this.prisma.userAccounts.findUnique({ + where: { + id: token.id, + }, + }); + + await this.setHitConfirmationUrl( + storedUser?.id, + storedUser?.confirmationToken, + dto.token, + ); + + return { + success: true, + } as SuccessDTO; + } catch (_) { + try { + const storedUser = await this.prisma.userAccounts.findFirst({ + where: { + confirmationToken: dto.token, + }, + }); + await this.setHitConfirmationUrl( + storedUser?.id, + storedUser?.confirmationToken, + dto.token, + ); + } catch (e) { + console.error('isUserConfirmationTokenValid error = ', e); + } + } + } + + /* + Updates the hitConfirmationUrl for the user + this is so we can tell if a user attempted to confirm their account + */ + async setHitConfirmationUrl( + userId: string, + confirmationToken: string, + token: string, + ): Promise { + if (!userId) { + throw new NotFoundException( + `user confirmation token ${token} was requested but not found`, + ); + } + if (confirmationToken !== token) { + throw new BadRequestException('tokenMissing'); + } + await this.prisma.userAccounts.update({ + data: { + hitConfirmationUrl: new Date(), + }, + where: { + id: userId, + }, + }); + } + + /* + creates a new user + takes in either the dto for creating a public user or the dto for creating a partner user + if forPartners is true then we are creating a partner, otherwise we are creating a public user + if sendWelcomeEmail is true then we are sending a public user a welcome email + */ + async create( + dto: UserCreate | UserInvite, + forPartners: boolean, + sendWelcomeEmail = false, + requestingUser: User, + jurisdictionName?: string, + ): Promise { + if (forPartners) { + await this.authorizeAction( + requestingUser, + mapTo(User, dto), + permissionActions.confirm, + ); + } + const existingUser = await this.prisma.userAccounts.findUnique({ + include: view, + where: { + email: dto.email, + }, + }); + + if (existingUser) { + // if attempting to recreate an existing user + if (!existingUser.userRoles && 'userRoles' in dto) { + // existing user && public user && user will get roles -> trying to grant partner access to a public user + const res = await this.prisma.userAccounts.update({ + include: view, + data: { + userRoles: { + create: { + ...dto.userRoles, + }, + }, + listings: { + connect: dto.listings.map((listing) => ({ id: listing.id })), + }, + confirmationToken: + existingUser.confirmationToken || + this.createConfirmationToken(existingUser.id, existingUser.email), + confirmedAt: null, + }, + where: { + id: existingUser.id, + }, + }); + return mapTo(User, res); + } else if ( + existingUser?.userRoles?.isPartner && + 'userRoles' in dto && + dto?.userRoles?.isPartner && + this.jurisdictionMismatch(dto.jurisdictions, existingUser.jurisdictions) + ) { + // recreating a partner with jurisdiction mismatch -> giving partner a new jurisdiction + const jursidictions = existingUser.jurisdictions + .map((juris) => ({ id: juris.id })) + .concat(dto.jurisdictions); + + const listings = existingUser.listings + .map((juris) => ({ id: juris.id })) + .concat(dto.listings); + + const res = await this.prisma.userAccounts.update({ + include: view, + data: { + jurisdictions: { + connect: jursidictions.map((juris) => ({ id: juris.id })), + }, + listings: { + connect: listings.map((listing) => ({ id: listing.id })), + }, + userRoles: { + create: { + ...dto.userRoles, + }, + }, + }, + where: { + id: existingUser.id, + }, + }); + + return mapTo(User, res); + } else { + // existing user && ((partner user -> trying to recreate user) || (public user -> trying to recreate a public user)) + throw new BadRequestException('emailInUse'); + } + } + + let passwordHash = ''; + if (forPartners) { + passwordHash = await passwordToHash( + crypto.randomBytes(8).toString('hex'), + ); + } else { + passwordHash = await passwordToHash((dto as UserCreate).password); + } + + let jurisdictions: + | { + jurisdictions: Prisma.JurisdictionsCreateNestedManyWithoutUser_accountsInput; + } + | Record = dto.jurisdictions + ? { + jurisdictions: { + connect: dto.jurisdictions.map((juris) => ({ + id: juris.id, + })), + }, + } + : {}; + + if (!forPartners && jurisdictionName) { + jurisdictions = { + jurisdictions: { + connect: { + name: jurisdictionName, + }, + }, + }; + } + + let newUser = await this.prisma.userAccounts.create({ + data: { + passwordHash: passwordHash, + email: dto.email, + firstName: dto.firstName, + middleName: dto.middleName, + lastName: dto.lastName, + dob: dto.dob, + phoneNumber: dto.phoneNumber, + language: dto.language, + mfaEnabled: forPartners, + ...jurisdictions, + userRoles: + 'userRoles' in dto + ? { + create: { + ...dto.userRoles, + }, + } + : undefined, + listings: dto.listings + ? { + connect: dto.listings.map((listing) => ({ + id: listing.id, + })), + } + : undefined, + }, + }); + + const confirmationToken = this.createConfirmationToken( + newUser.id, + newUser.email, + ); + newUser = await this.prisma.userAccounts.update({ + include: view, + data: { + confirmationToken: confirmationToken, + }, + where: { + id: newUser.id, + }, + }); + + // Public user that needs email + if (!forPartners && sendWelcomeEmail) { + const confirmationUrl = this.getPublicConfirmationUrl( + dto.appUrl, + confirmationToken, + ); + this.emailService.welcome( + jurisdictionName, + mapTo(User, newUser), + dto.appUrl, + confirmationUrl, + ); + // Partner user that is given access to an additional jurisdiction + } else if ( + forPartners && + existingUser && + 'userRoles' in dto && + existingUser?.userRoles?.isPartner && + dto?.userRoles?.isPartner && + this.jurisdictionMismatch(dto.jurisdictions, existingUser.jurisdictions) + ) { + const newJurisdictions = this.getMismatchedJurisdictions( + dto.jurisdictions, + existingUser.jurisdictions, + ); + this.emailService.portalAccountUpdate( + newJurisdictions, + mapTo(User, newUser), + dto.appUrl, + ); + } else if (forPartners) { + const confirmationUrl = this.getPartnersConfirmationUrl( + this.configService.get('PARTNERS_PORTAL_URL'), + confirmationToken, + ); + this.emailService.invitePartnerUser( + dto.jurisdictions, + mapTo(User, newUser), + this.configService.get('PARTNERS_PORTAL_URL'), + confirmationUrl, + ); + } + + if (!forPartners) { + await this.connectUserWithExistingApplications(newUser.email, newUser.id); + } + + return mapTo(User, newUser); + } + + /* + connects a newly created public user with any applications they may have already submitted + */ + async connectUserWithExistingApplications( + newUserEmail: string, + newUserId: string, + ): Promise { + const applications = await this.prisma.applications.findMany({ + where: { + applicant: { + emailAddress: newUserEmail, + }, + userAccounts: null, + }, + }); + + for (const app of applications) { + await this.prisma.applications.update({ + data: { + userAccounts: { + connect: { + id: newUserId, + }, + }, + }, + where: { + id: app.id, + }, + }); + } + } + + /* + this will return 1 user or error + takes in a userId or email to find by, and a boolean to indicate if joins should be included + */ + async findUserOrError(findBy: findByOptions, includeJoins: boolean) { + const where: Prisma.UserAccountsWhereUniqueInput = { + id: undefined, + email: undefined, + }; + if (findBy.userId) { + where.id = findBy.userId; + } else if (findBy.email) { + where.email = findBy.email; + } + const rawUser = await this.prisma.userAccounts.findUnique({ + include: includeJoins ? view : undefined, + where, + }); + + if (!rawUser) { + let str = ''; + if (findBy.userId) { + str = `id: ${findBy.userId}`; + } else if (findBy.email) { + str = `email: ${findBy.email}`; + } + throw new NotFoundException(`user ${str} was requested but not found`); + } + + return rawUser; + } + + /* + gets and formats user data to be handed to the csv builder helper + this data will be emailed to the requesting user + */ + async export(requestingUser: User): Promise { + const users = await this.list( + { + page: 1, + limit: 'all', + filter: [ + { + isPortalUser: true, + }, + ], + }, + requestingUser, + ); + + const parsedUsers = users.items.reduce((accum, user) => { + const roles: string[] = []; + if (user.userRoles?.isAdmin) { + roles.push('Administrator'); + } + if (user.userRoles?.isPartner) { + roles.push('Partner'); + } + if (user.userRoles?.isJurisdictionalAdmin) { + roles.push('Jurisdictional Admin'); + } + + const listingNames: string[] = []; + const listingIds: string[] = []; + + user.listings?.forEach((listing) => { + listingNames.push(listing.name); + listingIds.push(listing.id); + }); + + accum[user.id] = { + 'First Name': user.firstName, + 'Last Name': user.lastName, + Email: user.email, + Role: roles.join(', '), + 'Date Created': dayjs(user.createdAt).format('MM-DD-YYYY HH:mmZ[Z]'), + Status: user.confirmedAt ? 'Confirmed' : 'Unconfirmed', + 'Listing Names': listingNames.join(', '), + 'Listing Ids': listingIds.join(', '), + 'Last Logged In': dayjs(user.lastLoginAt).format( + 'MM-DD-YYYY HH:mmZ[Z]', + ), + }; + return accum; + }, {}); + + const csvData = buildFromIdIndex(parsedUsers); + await this.emailService.sendCSV( + requestingUser.jurisdictions, + requestingUser, + csvData, + 'User Export', + 'an export of all users', + ); + return { + success: true, + }; + } + + async authorizeAction( + requestingUser: User, + targetUser: User, + action: permissionActions, + ): Promise { + if (!requestingUser) { + throw new UnauthorizedException( + `User attempted ${action} wihtout being signed in`, + ); + } + + if (!requestingUser.userRoles?.isJurisdictionalAdmin) { + // if its an admin, partner, or a user without roles + await this.permissionService.canOrThrow(requestingUser, 'user', action, { + id: targetUser.id, + }); + } else if (targetUser.userRoles?.isAdmin) { + // if its a jurisdictional admin trying to perform an action on an admin user + throw new ForbiddenException( + `a jurisdictional admin is attempting to ${action} an admin user`, + ); + } else { + // jurisdictional admins should only be allowed to perform an action on a user if they share a jurisdiction + const requesterJurisdictions = requestingUser.jurisdictions?.map( + (juris) => juris.id, + ); + const targetJurisdictions = targetUser.jurisdictions?.map( + (juris) => juris.id, + ); + + if ( + !requesterJurisdictions.some((juris) => + targetJurisdictions.includes(juris), + ) + ) { + throw new ForbiddenException( + `a jurisdictional admin is attempting to ${action} a user they do not share a jurisdiction with`, + ); + } + } + } + + /* + encodes a confirmation token given a userId and email + */ + createConfirmationToken(userId: string, email: string) { + const payload = { + id: userId, + email, + exp: Number.parseInt(dayjs().add(24, 'hours').format('X')), + }; + return sign(payload, process.env.APP_SECRET); + } + + /* + constructs the url to confirm a public site user + */ + getPublicConfirmationUrl(appUrl: string, confirmationToken: string) { + return `${appUrl}?token=${confirmationToken}`; + } + + /* + constructs the url to confirm the partner site user + */ + getPartnersConfirmationUrl(appUrl: string, confirmationToken: string) { + return `${appUrl}/users/confirm?token=${confirmationToken}`; + } + + /* + verify that there is a jurisdictional difference between the incoming user and the existing user + */ + jurisdictionMismatch( + incomingJurisdictions: IdDTO[], + existingJurisdictions: IdDTO[], + ): boolean { + return ( + this.getMismatchedJurisdictions( + incomingJurisdictions, + existingJurisdictions, + ).length > 0 + ); + } + + getMismatchedJurisdictions( + incomingJurisdictions: IdDTO[], + existingJurisdictions: IdDTO[], + ) { + return incomingJurisdictions.reduce((misMatched, jurisdiction) => { + if ( + !existingJurisdictions?.some( + (existingJuris) => existingJuris.id === jurisdiction.id, + ) + ) { + misMatched.push(jurisdiction.id); + } + return misMatched; + }, []); + } +} diff --git a/api/src/temp/.gitignore b/api/src/temp/.gitignore new file mode 100644 index 0000000000..5e7d2734cf --- /dev/null +++ b/api/src/temp/.gitignore @@ -0,0 +1,4 @@ +# Ignore everything in this directory +* +# Except this file +!.gitignore diff --git a/api/src/types/CsvExportInterface.ts b/api/src/types/CsvExportInterface.ts new file mode 100644 index 0000000000..7bff4267ab --- /dev/null +++ b/api/src/types/CsvExportInterface.ts @@ -0,0 +1,32 @@ +import { StreamableFile } from '@nestjs/common'; +import { Request, Response } from 'express'; +import { ApplicationCsvQueryParams } from '../dtos/applications/application-csv-query-params.dto'; +import { ListingCsvQueryParams } from '../dtos/listings/listing-csv-query-params.dto'; +import Listing from '../dtos/listings/listing.dto'; + +export type CsvHeader = { + path: string; + label: string; + format?: (val: unknown, fullObject?: unknown) => unknown; +}; + +type OneOrMoreArgs = { 0: T } & Array; + +export interface CsvExporterServiceInterface { + exportFile: < + QueryParams extends ApplicationCsvQueryParams & ListingCsvQueryParams, + >( + req: Request, + res: Response, + queryParams?: QueryParams, + ) => Promise; + createCsv< + QueryParams extends ApplicationCsvQueryParams & ListingCsvQueryParams, + >( + filename: string, + queryParams?: QueryParams, + listings?: Listing[], + ): Promise; + getCsvHeaders(...args: OneOrMoreArgs): Promise; + authorizeCSVExport(user: unknown, id?: string): Promise; +} diff --git a/api/src/utilities/applications-utilities.ts b/api/src/utilities/applications-utilities.ts new file mode 100644 index 0000000000..a3d84a81e9 --- /dev/null +++ b/api/src/utilities/applications-utilities.ts @@ -0,0 +1,5 @@ +import { randomBytes } from 'crypto'; + +export const generateConfirmationCode = (): string => { + return randomBytes(4).toString('hex').toUpperCase(); +}; diff --git a/api/src/utilities/build-filter.ts b/api/src/utilities/build-filter.ts new file mode 100644 index 0000000000..43c8b90556 --- /dev/null +++ b/api/src/utilities/build-filter.ts @@ -0,0 +1,79 @@ +import { HttpException, HttpStatus } from '@nestjs/common'; +import { Prisma } from '@prisma/client'; +import { Compare } from '../dtos/shared/base-filter.dto'; + +type filter = { + $comparison: Compare; + $include_nulls: boolean; + value: any; + key: string; + caseSensitive?: boolean; +}; + +/* + This constructs the "where" part of a prisma query + Because the where clause is specific to each model we are working with this has to be very generic. + It only constructs the actual body of the where statement, how that clause is used must be managed by the service calling this helper function +*/ +export function buildFilter(filter: filter): any { + const toReturn = []; + const comparison = filter['$comparison']; + const includeNulls = filter['$include_nulls']; + const filterValue = filter.value; + // Mode should only be set if we want insensitive. + // "default" is the default value and not all filters can have mode set such as "status" + let mode = {}; + if (!filter.caseSensitive) { + mode = { mode: Prisma.QueryMode.insensitive }; + } + + if (comparison === Compare.IN) { + toReturn.push({ + in: String(filterValue) + .split(',') + .map((s) => s.trim().toLowerCase()) + .filter((s) => s.length !== 0), + ...mode, + }); + } else if (comparison === Compare['<>']) { + toReturn.push({ + not: { + equals: filterValue, + }, + ...mode, + }); + } else if (comparison === Compare['=']) { + toReturn.push({ + equals: filterValue, + ...mode, + }); + } else if (comparison === Compare['>=']) { + toReturn.push({ + gte: filterValue, + ...mode, + }); + } else if (comparison === Compare['<=']) { + toReturn.push({ + lte: filterValue, + ...mode, + }); + } else if (Compare.NA) { + throw new HttpException( + `Filter "${filter.key}" expected to be handled by a custom filter handler, but one was not implemented.`, + HttpStatus.NOT_IMPLEMENTED, + ); + } else { + throw new HttpException( + 'Comparison Not Implemented', + HttpStatus.NOT_IMPLEMENTED, + ); + } + + if (includeNulls) { + toReturn.push({ + equals: null, + }); + } + + return toReturn; +} diff --git a/api/src/utilities/build-order-by.ts b/api/src/utilities/build-order-by.ts new file mode 100644 index 0000000000..839cb2d07f --- /dev/null +++ b/api/src/utilities/build-order-by.ts @@ -0,0 +1,87 @@ +import { ApplicationOrderByKeys } from '../enums/applications/order-by-enum'; +import { ListingOrderByKeys } from '../enums/listings/order-by-enum'; +import { OrderByEnum } from '../enums/shared/order-by-enum'; +import { Prisma } from '@prisma/client'; + +/* + Constructs the "orderBy" part of the prisma query and maps the values to + the appropriate listing field +*/ +export const buildOrderByForListings = ( + orderBy?: string[], + orderDir?: OrderByEnum[], +): Prisma.ListingsOrderByWithRelationInput[] => { + if (!orderBy?.length || orderBy.length !== orderDir?.length) { + return undefined; + } + + return orderBy.map((param, index) => { + switch (param) { + case ListingOrderByKeys.mostRecentlyUpdated: + return { updatedAt: orderDir[index] }; + case ListingOrderByKeys.status: + return { status: orderDir[index] }; + case ListingOrderByKeys.name: + return { name: orderDir[index] }; + case ListingOrderByKeys.waitlistOpen: + return { isWaitlistOpen: orderDir[index] }; + case ListingOrderByKeys.unitsAvailable: + return { unitsAvailable: orderDir[index] }; + case ListingOrderByKeys.mostRecentlyClosed: + return { + closedAt: orderDir[index], + }; + case ListingOrderByKeys.mostRecentlyPublished: + return { + publishedAt: orderDir[index], + }; + case ListingOrderByKeys.marketingType: + return { marketingType: orderDir[index] }; + case ListingOrderByKeys.applicationDates: + case undefined: + // Default to ordering by applicationDates (i.e. applicationDueDate + // and applicationOpenDate) if no orderBy param is specified. + return { applicationDueDate: orderDir[index] }; + } + }) as Prisma.ListingsOrderByWithRelationInput[]; +}; + +/* + Constructs the "orderBy" part of the prisma query and maps the values to + the appropriate application field +*/ +export const buildOrderByForApplications = ( + orderBy?: string[], + orderDir?: OrderByEnum[], +): Prisma.ApplicationsOrderByWithRelationInput[] => { + if (!orderBy?.length || orderBy.length !== orderDir?.length) { + return undefined; + } + + return orderBy.map((param, index) => { + switch (param) { + case ApplicationOrderByKeys.firstName: + return { applicant: { firstName: orderDir[index] } }; + case ApplicationOrderByKeys.lastName: + return { applicant: { lastName: orderDir[index] } }; + case ApplicationOrderByKeys.createdAt: + return { createdAt: orderDir[index] }; + case ApplicationOrderByKeys.submissionDate: + case undefined: + return { submissionDate: orderDir[index] }; + } + }) as Prisma.ApplicationsOrderByWithRelationInput[]; +}; + +/* + This constructs the "orderBy" part of a prisma query + We are guaranteed to have the same length for both the orderBy and orderDir arrays +*/ +export const buildOrderBy = (orderBy?: string[], orderDir?: OrderByEnum[]) => { + if (!orderBy?.length) { + return undefined; + } + return orderBy.map((param, index) => ({ + [param]: orderDir[index], + })); +}; diff --git a/api/src/utilities/build-pagination-meta.ts b/api/src/utilities/build-pagination-meta.ts new file mode 100644 index 0000000000..899956ea12 --- /dev/null +++ b/api/src/utilities/build-pagination-meta.ts @@ -0,0 +1,24 @@ +import { shouldPaginate } from './pagination-helpers'; + +export const buildPaginationInfo = ( + limit: 'all' | number, + page: number, + count: number, + returnedRecordCount: number, +) => { + const isPaginated = shouldPaginate(limit, page); + + const itemsPerPage = + isPaginated && limit !== 'all' ? limit : returnedRecordCount; + const totalItems = isPaginated ? count : returnedRecordCount; + + return { + currentPage: isPaginated ? page : 1, + itemCount: returnedRecordCount, + itemsPerPage: itemsPerPage, + totalItems: totalItems, + totalPages: Math.ceil( + totalItems / (itemsPerPage ? itemsPerPage : totalItems), + ), + }; +}; diff --git a/api/src/utilities/cron-job-starter.ts b/api/src/utilities/cron-job-starter.ts new file mode 100644 index 0000000000..290c968e29 --- /dev/null +++ b/api/src/utilities/cron-job-starter.ts @@ -0,0 +1,63 @@ +import { Logger } from '@nestjs/common'; +import { SchedulerRegistry } from '@nestjs/schedule'; +import { CronJob } from 'cron'; +import dayjs from 'dayjs'; +import { PrismaService } from '../services/prisma.service'; +import { SuccessDTO } from '../dtos/shared/success.dto'; + +/** + * + * @param prisma the instantiated prisma service from the service attempting to create a new cronjob + * @param cronName this is the name of the cronjob we will be using + * @param cronString this is the cron string, to tell the cron job how frequently to run + * @param functionToCall this is the function signature for the function to execute on cron run + * @param logger the Logger to log information, should be Direct Injected into the service calling this function + * @param schedulerRegistry the nestjs schedule, should be Direct Injected into the service calling this function + * @description this function will start a nestJs cron job, calling the on frequency. We check to see if there already is a job running in our cronjob table + */ +export const startCronJob = ( + prisma: PrismaService, + cronName: string, + cronString: string, + functionToCall: () => Promise, + logger: Logger, + schedulerRegistry: SchedulerRegistry, +): void => { + if (!cronString) { + // If missing cron string an error should throw but not prevent the app from starting up + logger.error( + `${cronName} cron string does not exist and ${cronName} job will not run`, + ); + return; + } + // Take the cron job frequency from .env and add a random seconds to it. + // That way when there are multiple instances running they won't run at the exact same time. + const repeatCron = cronString; + const randomSecond = Math.floor(Math.random() * 30); + const newCron = `${randomSecond * 2} ${repeatCron}`; + const job = new CronJob(newCron, () => { + void (async () => { + const currentCronJob = await prisma.cronJob.findFirst({ + where: { + name: cronName, + }, + }); + // To prevent multiple overlapped jobs only run if one hasn't started in the last 5 minutes + if ( + !currentCronJob || + currentCronJob.lastRunDate < + dayjs(new Date()).subtract(5, 'minutes').toDate() + ) { + try { + await functionToCall(); + } catch (e) { + logger.error(`${cronName} failed to run. ${e}`); + } + } + })(); + }); + schedulerRegistry.addCronJob(cronName, job); + if (process.env.NODE_ENV !== 'test') { + job.start(); + } +}; diff --git a/api/src/utilities/csv-builder.ts b/api/src/utilities/csv-builder.ts new file mode 100644 index 0000000000..2d2eb73772 --- /dev/null +++ b/api/src/utilities/csv-builder.ts @@ -0,0 +1,123 @@ +/** + * @param data string data + * @returns a string where double quotes (") are escaped properly for csv export + */ +export const escapeDoubleQuotes = (data: string): string => { + return data.replace(/\\"/g, `""`); +}; + +/** + * @param value the value that we want to format for csv export + * @returns escaped json stringified version of the incoming value + */ +export const formatValue = (value: any): string => { + return value !== undefined && value !== null + ? escapeDoubleQuotes(JSON.stringify(value)) + : ''; +}; + +export interface KeyNumber { + [key: string]: number; +} + +/** + * Builds the csv from the incoming data + * @param obj the key should be the Id of the record coming in, and the value is the field(s) + * @returns csv formatted string + */ +export const 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 } = {}; + const rootKeys = Object.keys(obj); + + if (rootKeys.length === 0) return ''; + + const initialKeys = obj[rootKeys[0]]; + let index = 0; + + Object.keys(initialKeys).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, initialKeys); + 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); + + // init arrays to insert data + const rows = Array.from({ length: rootKeys.length }, () => + Array(headers.length), + ); + + // set rows data + rootKeys.forEach((id, rowNumber) => { + const record = obj[id]; + Object.keys(record).forEach((key) => { + const val = record[key]; + const groupKeys = extraGroupKeys && extraGroupKeys(key, initialKeys); + if (extraHeaders && extraHeaders[key] && groupKeys) { + 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}`]; + rows[rowNumber][column] = formatValue(val[sub_id][groupKey]); + }); + }); + } else if (groupKeys.nested === false) { + Object.keys(val).forEach((sub_key) => { + const column = headerIndex[`${key} ${sub_key}`]; + rows[rowNumber][column] = formatValue(val[sub_key]); + }); + } + } 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[rowNumber][column] = formatValue(value); + } + }); + }); + + let csvString = headers.reduce((accumulator, curr) => { + return `${accumulator}${accumulator.length ? ',' : ''}"${escapeDoubleQuotes( + curr, + )}"`; + }, ''); + csvString += '\n'; + + // turn rows into csv format + rows.forEach((row) => { + if (row.length) { + csvString += row.join(','); + csvString += '\n'; + } + }); + + return csvString; +}; diff --git a/api/src/utilities/custom-exception-filter.ts b/api/src/utilities/custom-exception-filter.ts new file mode 100644 index 0000000000..7a44e03515 --- /dev/null +++ b/api/src/utilities/custom-exception-filter.ts @@ -0,0 +1,24 @@ +import { ArgumentsHost, Catch, Logger } from '@nestjs/common'; +import { AbstractHttpAdapter, BaseExceptionFilter } from '@nestjs/core'; + +/* + This creates a simple custom catch all exception filter for us + As for right now its just a pass through, but as we find more errors that we don't want the + front end to be exposed to this will grow +*/ +@Catch() +export class CustomExceptionFilter extends BaseExceptionFilter { + logger: Logger; + constructor(httpAdapter: AbstractHttpAdapter) { + super(httpAdapter); + this.logger = new Logger('Exception Filter'); + } + catch(exception: any, host: ArgumentsHost) { + this.logger.error({ + message: exception?.response?.message, + stack: exception.stack, + exception, + }); + super.catch(exception, host); + } +} diff --git a/api/src/utilities/deep-find.ts b/api/src/utilities/deep-find.ts new file mode 100644 index 0000000000..bb100412bb --- /dev/null +++ b/api/src/utilities/deep-find.ts @@ -0,0 +1,11 @@ +export function deepFind(obj: Record, path: string) { + const paths = path.split('.'); + let current = obj; + for (const currPath of paths) { + if (current[currPath] === undefined) { + return undefined; + } + current = current[currPath]; + } + return current; +} diff --git a/api/src/utilities/default-validation-pipe-options.ts b/api/src/utilities/default-validation-pipe-options.ts new file mode 100644 index 0000000000..acb0f6ecb1 --- /dev/null +++ b/api/src/utilities/default-validation-pipe-options.ts @@ -0,0 +1,16 @@ +import { ValidationPipeOptions } from '@nestjs/common'; +import { ValidationsGroupsEnum } from '../enums/shared/validation-groups-enum'; + +/* + This controls the validation pipe that is inherent to NestJs +*/ +export const defaultValidationPipeOptions: ValidationPipeOptions = { + transform: true, + transformOptions: { + excludeExtraneousValues: true, + enableImplicitConversion: false, + }, + groups: [ValidationsGroupsEnum.default], + forbidUnknownValues: true, + skipMissingProperties: true, +}; diff --git a/api/src/utilities/format-local-date.ts b/api/src/utilities/format-local-date.ts new file mode 100644 index 0000000000..a7c5987e8a --- /dev/null +++ b/api/src/utilities/format-local-date.ts @@ -0,0 +1,22 @@ +import dayjs from 'dayjs'; +import utc from 'dayjs/plugin/utc'; +import tz from 'dayjs/plugin/timezone'; +import advanced from 'dayjs/plugin/advancedFormat'; +import { isEmpty } from './is-empty'; +dayjs.extend(utc); +dayjs.extend(tz); +dayjs.extend(advanced); + +export const formatLocalDate = ( + rawDate: string | Date, + format: string, + timeZone?: string, +): string => { + if (!isEmpty(rawDate)) { + const utcDate = dayjs.utc(rawDate); + if (!isEmpty(timeZone)) + return utcDate.tz(timeZone.replace('-', '/')).format(format); + return utcDate.format(format); + } + return ''; +}; diff --git a/api/src/utilities/is-empty.ts b/api/src/utilities/is-empty.ts new file mode 100644 index 0000000000..93d02c8dec --- /dev/null +++ b/api/src/utilities/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/api/src/utilities/listing-url-slug.ts b/api/src/utilities/listing-url-slug.ts new file mode 100644 index 0000000000..77a9de5203 --- /dev/null +++ b/api/src/utilities/listing-url-slug.ts @@ -0,0 +1,35 @@ +import Listing from '../dtos/listings/listing.dto'; + +/* + This maps a listing to its url slug + This is used by the public site front end + */ +export function listingUrlSlug(listing: Listing): string { + const { name, listingsBuildingAddress } = listing; + if (!listingsBuildingAddress) { + return listingUrlSlugHelper(name); + } + return listingUrlSlugHelper( + [ + name, + listingsBuildingAddress.street, + listingsBuildingAddress.city, + listingsBuildingAddress.state, + ].join(' '), + ); +} + +/* + This creates a string "_" separated at every upper case letter then lower cased + This also removes special characters + e.g. "ExampLe namE @ 17 11th Street Phoenix Az" -> "examp_le_nam_e_17_11_th_street_phoenix_az" +*/ +export function listingUrlSlugHelper(input: string): string { + return ( + (input || '').match( + /[A-Z]{2,}(?=[A-Z][a-z]+[0-9]*|\b)|[A-Z]?[a-z]+[0-9]*|[A-Z]+|[0-9]+/g, + ) || [] + ) + .join('_') + .toLowerCase(); +} diff --git a/api/src/utilities/mapTo.ts b/api/src/utilities/mapTo.ts new file mode 100644 index 0000000000..3c84cea04b --- /dev/null +++ b/api/src/utilities/mapTo.ts @@ -0,0 +1,32 @@ +import { + ClassTransformOptions, + plainToClass, + ClassConstructor, +} from 'class-transformer'; + +export function mapTo( + cls: ClassConstructor, + plain: V[], + options?: ClassTransformOptions, +): T[]; +export function mapTo( + cls: ClassConstructor, + plain: V, + options?: ClassTransformOptions, +): T; + +/* + This maps a plain object to the class provided + This is mostly used by controllers to map the result of a service to the type returned by the endpoint +*/ +export function mapTo( + cls: ClassConstructor, + plain, + options?: ClassTransformOptions, +) { + return plainToClass(cls, plain, { + ...options, + excludeExtraneousValues: true, + enableImplicitConversion: true, + }); +} diff --git a/api/src/utilities/order-by-validator.ts b/api/src/utilities/order-by-validator.ts new file mode 100644 index 0000000000..014a7d94bf --- /dev/null +++ b/api/src/utilities/order-by-validator.ts @@ -0,0 +1,30 @@ +import { + ValidatorConstraint, + ValidatorConstraintInterface, + ValidationArguments, +} from 'class-validator'; + +/* + This is a custom validator to make sure the orderBy and orderDir arrays have the same length +*/ +@ValidatorConstraint({ name: 'orderDir', async: false }) +export class OrderQueryParamValidator implements ValidatorConstraintInterface { + validate(order: Array | undefined, args: ValidationArguments) { + if (args.property === 'orderDir') { + return Array.isArray(order) + ? (args.object as { orderBy: Array }).orderBy?.length === + order.length + : false; + } else if (args.property === 'orderBy') { + return Array.isArray(order) + ? (args.object as { orderDir: Array }).orderDir?.length === + order.length + : false; + } + return false; + } + + defaultMessage() { + return 'order array length must be equal to orderBy array length'; + } +} diff --git a/api/src/utilities/pagination-helpers.ts b/api/src/utilities/pagination-helpers.ts new file mode 100644 index 0000000000..cdff57c898 --- /dev/null +++ b/api/src/utilities/pagination-helpers.ts @@ -0,0 +1,62 @@ +import { PaginationMeta } from '../dtos/shared/pagination.dto'; + +/* + takes in the params for limit and page + responds true if we should account for pagination + responds false if we don't need to take pagination into account +*/ +export const shouldPaginate = (limit: number | 'all', page: number) => { + return limit !== 'all' && limit > 0 && page > 0; +}; + +/* + takes in the params for limit and page + responds with how many records we should skip over (if we are on page 2 we need to skip over page 1's records) +*/ +export const calculateSkip = (limit?: number | 'all', page?: number) => { + if (shouldPaginate(limit, page)) { + return (page - 1) * (limit as number); + } + return 0; +}; + +/* + takes in the params for limit and page + responds with the # of records per page + e.g. if limit is 10 that means each page should only contain 10 records +*/ +export const calculateTake = (limit?: number | 'all') => { + return limit !== 'all' ? limit : undefined; +}; + +interface paginationMetaParams { + limit?: number | 'all'; + page?: number; +} + +/* + takes in params for limit and page, the results from the "count" query (the total number of records that meet whatever criteria) and the current "page" of record's length + responds with the meta info needed for the pagination meta info section +*/ +export const buildPaginationMetaInfo = ( + params: paginationMetaParams, + count: number, + recordArrayLength: number, +): PaginationMeta => { + const isPaginated = shouldPaginate(params.limit, params.page); + const itemsPerPage = + isPaginated && params.limit !== 'all' ? params.limit : recordArrayLength; + const totalItems = isPaginated ? count : recordArrayLength; + + const paginationInfo = { + currentPage: isPaginated ? params.page : 1, + itemCount: recordArrayLength, + itemsPerPage: itemsPerPage, + totalItems: totalItems, + totalPages: Math.ceil( + totalItems / (itemsPerPage ? itemsPerPage : totalItems), + ), + }; + + return paginationInfo; +}; diff --git a/api/src/utilities/password-helpers.ts b/api/src/utilities/password-helpers.ts new file mode 100644 index 0000000000..35d1ab2de7 --- /dev/null +++ b/api/src/utilities/password-helpers.ts @@ -0,0 +1,64 @@ +import { randomBytes, scrypt } from 'crypto'; +const SCRYPT_KEYLEN = 64; +const SALT_SIZE = SCRYPT_KEYLEN; + +/* + verifies that the hash of the incoming password matches the stored password hash +*/ +export const isPasswordValid = async ( + storedPasswordHash: string, + incomingPassword: string, +): Promise => { + const [salt, savedPasswordHash] = storedPasswordHash.split('#'); + const verifyPasswordHash = await hashPassword( + incomingPassword, + Buffer.from(salt, 'hex'), + ); + + return savedPasswordHash === verifyPasswordHash; +}; + +/* + hashes the incoming password with the incoming salt +*/ +export const hashPassword = async ( + password: string, + salt: Buffer, +): Promise => { + return new Promise((resolve, reject) => + scrypt(password, salt, SCRYPT_KEYLEN, (err, key) => + err ? reject(err) : resolve(key.toString('hex')), + ), + ); +}; + +/* + hashes and salts the incoming password +*/ +export const passwordToHash = async (password: string): Promise => { + const salt = generateSalt(); + const hash = await hashPassword(password, salt); + // TODO: redo how we append the salt to the hash + return `${salt.toString('hex')}#${hash}`; +}; + +/* + generates a random salt +*/ +export const generateSalt = (size = SALT_SIZE) => { + return randomBytes(size); +}; + +/* + verifies the password's TTL is still valid +*/ +export const isPasswordOutdated = ( + passwordValidForDays: number, + passwordUpdatedAt: Date, +): boolean => { + return ( + new Date( + passwordUpdatedAt.getTime() + passwordValidForDays * 24 * 60 * 60 * 1000, + ) < new Date() + ); +}; diff --git a/api/src/utilities/password-regex.ts b/api/src/utilities/password-regex.ts new file mode 100644 index 0000000000..f7579d1961 --- /dev/null +++ b/api/src/utilities/password-regex.ts @@ -0,0 +1 @@ +export const passwordRegex = /^(?=.*[0-9])(?=.*[a-zA-Z])([a-zA-Z0-9]+).{7,}$/; diff --git a/api/src/utilities/unit-utilities.ts b/api/src/utilities/unit-utilities.ts new file mode 100644 index 0000000000..c596b42739 --- /dev/null +++ b/api/src/utilities/unit-utilities.ts @@ -0,0 +1,533 @@ +import { ReviewOrderTypeEnum, UnitTypeEnum } from '@prisma/client'; +import { UnitSummary } from '../dtos/units/unit-summary.dto'; +import Unit from '../dtos/units/unit.dto'; +import { AmiChart } from '../dtos/ami-charts/ami-chart.dto'; +import { Listing } from '../dtos/listings/listing.dto'; +import { MinMaxCurrency } from '../dtos/shared/min-max-currency.dto'; +import { MinMax } from '../dtos/shared/min-max.dto'; +import { UnitsSummarized } from '../dtos/units/unit-summarized.dto'; +import { UnitType } from '../dtos/unit-types/unit-type.dto'; +import { UnitAccessibilityPriorityType } from '../dtos/unit-accessibility-priority-types/unit-accessibility-priority-type.dto'; +import { AmiChartItem } from '../dtos/units/ami-chart-item.dto'; +import { UnitAmiChartOverride } from '../dtos/units/ami-chart-override.dto'; + +type AnyDict = { [key: string]: unknown }; +type UnitMap = { + [key: string]: Unit[]; +}; + +export const UnitTypeSort = [ + UnitTypeEnum.SRO, + UnitTypeEnum.studio, + UnitTypeEnum.oneBdrm, + UnitTypeEnum.twoBdrm, + UnitTypeEnum.threeBdrm, + UnitTypeEnum.fourBdrm, + UnitTypeEnum.fiveBdrm, +]; + +const usd = new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + minimumFractionDigits: 0, + maximumFractionDigits: 0, +}); + +export const minMax = (baseValue: MinMax, newValue: number): MinMax => { + return { + min: Math.min(baseValue.min, newValue), + max: Math.max(baseValue.max, newValue), + }; +}; + +export const minMaxCurrency = ( + baseValue: MinMaxCurrency, + newValue: number, +): MinMaxCurrency => { + return { + min: usd.format( + Math.min(parseFloat(baseValue.min.replace(/[^0-9.-]+/g, '')), newValue), + ), + max: usd.format( + Math.max(parseFloat(baseValue.max.replace(/[^0-9.-]+/g, '')), newValue), + ), + }; +}; + +export const yearlyCurrencyStringToMonthly = (currency: string) => { + return usd.format(parseFloat(currency.replace(/[^0-9.-]+/g, '')) / 12); +}; + +export const getAmiChartItemUniqueKey = (amiChartItem: AmiChartItem) => { + return ( + amiChartItem.householdSize.toString() + + '-' + + amiChartItem.percentOfAmi.toString() + ); +}; + +export const mergeAmiChartWithOverrides = ( + amiChart: AmiChart, + override: UnitAmiChartOverride, +) => { + const householdAmiPercentageOverrideMap: Map = + override.items?.reduce((acc, amiChartItem) => { + acc.set(getAmiChartItemUniqueKey(amiChartItem), amiChartItem); + return acc; + }, new Map()); + + for (const amiChartItem of amiChart.items) { + const amiChartItemOverride = householdAmiPercentageOverrideMap.get( + getAmiChartItemUniqueKey(amiChartItem), + ); + if (amiChartItemOverride) { + amiChartItem.income = amiChartItemOverride.income; + } + } + return amiChart; +}; + +// Creates data used to display a table of household size/unit size by maximum income per the AMI charts on the units +// Unit sets can have multiple AMI charts used, in which case the table displays ranges +export const generateHmiData = ( + units: Unit[], + minMaxHouseholdSize: MinMax[], + amiCharts: AmiChart[], +) => { + if (!units || units.length === 0) { + return null; + } + // Currently, BMR chart is just toggling whether or not the first column shows Household Size or Unit Type + const showUnitType = units[0].bmrProgramChart; + + type ChartAndPercentage = { + percentage: number; + chart: AmiChart; + }; + + const maxAMIChartHouseholdSize = amiCharts.reduce((maxSize, amiChart) => { + const amiChartMax = amiChart.items.reduce((max, item) => { + return Math.max(max, item.householdSize); + }, 0); + return Math.max(maxSize, amiChartMax); + }, 0); + + // All unique AMI percentages across all units + const allPercentages: number[] = [ + ...new Set( + units + .filter((item) => item != null) + .map((unit) => parseInt(unit.amiPercentage, 10)), + ), + ].sort(function (a, b) { + return a - b; + }); + + const amiChartMap: Record = amiCharts.reduce( + (acc, amiChart) => { + acc[amiChart.id] = amiChart; + return acc; + }, + {}, + ); + + // All unique combinations of an AMI percentage and an AMI chart across all units + const uniquePercentageChartSet: ChartAndPercentage[] = amiCharts.length + ? [ + ...new Set( + units + .filter((unit) => amiChartMap[unit.amiChart.id]) + .map((unit) => { + let amiChart = amiChartMap[unit.amiChart.id]; + if (unit.unitAmiChartOverrides) { + amiChart = mergeAmiChartWithOverrides( + amiChart, + unit.unitAmiChartOverrides, + ); + } + return JSON.stringify({ + percentage: parseInt(unit.amiPercentage, 10), + chart: amiChart, + }); + }), + ), + ].map((uniqueSetString) => JSON.parse(uniqueSetString)) + : []; + + const hmiHeaders = { + sizeColumn: showUnitType ? 't.unitType' : 'listings.householdSize', + } as AnyDict; + + let bmrHeaders = [ + 'listings.unitTypes.SRO', + 'listings.unitTypes.studio', + 'listings.unitTypes.oneBdrm', + 'listings.unitTypes.twoBdrm', + 'listings.unitTypes.threeBdrm', + 'listings.unitTypes.fourBdrm', + ]; + // this is to map currentHouseholdSize to a units max occupancy + const unitOccupancy = []; + + let validHouseholdSizes = minMaxHouseholdSize.reduce((validSizes, minMax) => { + // Get all numbers between min and max + // If min is more than the largest chart value, make sure we show the largest value + const unitHouseholdSizes = [ + ...Array(Math.min(minMax.max, maxAMIChartHouseholdSize) + 1).keys(), + ].filter( + (value) => value >= Math.min(minMax.min, maxAMIChartHouseholdSize), + ); + return [...new Set([...validSizes, ...unitHouseholdSizes])].sort((a, b) => + a < b ? -1 : 1, + ); + }, []); + + if (showUnitType) { + // the unit types used by the listing + const selectedUnitTypes = units.reduce((obj, unit) => { + if (unit.unitTypes) { + obj[unit.unitTypes.name] = { + rooms: unit.unitTypes.numBedrooms, + maxOccupancy: unit.maxOccupancy, + }; + } + return obj; + }, {}); + const sortedUnitTypeNames = Object.keys(selectedUnitTypes).sort((a, b) => + selectedUnitTypes[a].rooms < selectedUnitTypes[b].rooms + ? -1 + : selectedUnitTypes[a].rooms > selectedUnitTypes[b].rooms + ? 1 + : 0, + ); + // setbmrHeaders based on the actual units + bmrHeaders = sortedUnitTypeNames.map( + (type) => `listings.unitTypes.${type}`, + ); + + // set unitOccupancy based off of a units max occupancy + sortedUnitTypeNames.forEach((name) => { + unitOccupancy.push(selectedUnitTypes[name].maxOccupancy); + }); + + // if showUnitType, we want to set the bedroom sizes to the valid household sizes + validHouseholdSizes = [ + ...new Set(units.map((unit) => unit.unitTypes?.numBedrooms || 0)), + ]; + } + + // 1. If there are multiple AMI levels, show each AMI level (max income per + // year only) for each size (number of cols = the size col + # ami levels) + // 2. If there is only one AMI level, show max income per month and per + // year for each size (number of cols = the size col + 2 for each income style) + if (allPercentages.length > 1) { + allPercentages.forEach((percent) => { + // Pass translation with its respective argument with format `key*argumentName:argumentValue` + hmiHeaders[ + `ami${percent}` + ] = `listings.percentAMIUnit*percent:${percent}`; + }); + } else { + hmiHeaders['maxIncomeMonth'] = 'listings.maxIncomeMonth'; + hmiHeaders['maxIncomeYear'] = 'listings.maxIncomeYear'; + } + + const findAmiValueInChart = ( + amiChart: AmiChartItem[], + householdSize: number, + percentOfAmi: number, + ) => { + return amiChart.find((item) => { + return ( + item.householdSize === householdSize && + item.percentOfAmi === percentOfAmi + ); + })?.income; + }; + + // Build row data by household size + const hmiRows = validHouseholdSizes.reduce( + (hmiRowsData, householdSize: number) => { + const currentHouseholdSize = showUnitType + ? unitOccupancy[householdSize - 1] + : householdSize; + + const rowData = { + sizeColumn: showUnitType + ? bmrHeaders[householdSize - 1] + : currentHouseholdSize, + }; + + let rowHasData = false; // Row is valid if at least one column is filled, otherwise don't push the row + allPercentages.forEach((currentAmiPercent) => { + // Get all the charts that we're using with this percentage and size + const uniquePercentCharts = uniquePercentageChartSet.filter( + (uniqueChartAndPercentage) => { + return uniqueChartAndPercentage.percentage === currentAmiPercent; + }, + ); + // If we don't have data for this AMI percentage and household size, this cell is empty + if (uniquePercentCharts.length === 0) { + if (allPercentages.length === 1) { + rowData['maxIncomeMonth'] = ''; + rowData['maxIncomeYear'] = ''; + } else { + rowData[`ami${currentAmiPercent}`] = ''; + } + } else { + if (!uniquePercentCharts[0].chart) { + return hmiRowsData; + } + // If we have chart data, create a max income range string + const firstChartValue = findAmiValueInChart( + uniquePercentCharts[0].chart.items, + currentHouseholdSize, + currentAmiPercent, + ); + if (!firstChartValue) { + return hmiRowsData; + } + const maxIncomeRange = uniquePercentCharts.reduce( + (incomeRange, uniqueSet) => { + return minMaxCurrency( + incomeRange, + findAmiValueInChart( + uniqueSet.chart.items, + currentHouseholdSize, + currentAmiPercent, + ), + ); + }, + { + min: usd.format(firstChartValue), + max: usd.format(firstChartValue), + } as MinMaxCurrency, + ); + if (allPercentages.length === 1) { + rowData[ + 'maxIncomeMonth' + ] = `listings.monthlyIncome*income:${yearlyCurrencyStringToMonthly( + maxIncomeRange.max, + )}`; + rowData[ + 'maxIncomeYear' + ] = `listings.annualIncome*income:${maxIncomeRange.max}`; + } else { + rowData[ + `ami${currentAmiPercent}` + ] = `listings.annualIncome*income:${maxIncomeRange.max}`; + } + rowHasData = true; + } + }); + if (rowHasData) { + hmiRowsData.push(rowData); + } + return hmiRowsData; + }, + [], + ); + + return { columns: hmiHeaders, rows: hmiRows }; +}; + +export const getCurrencyString = (initialValue: string) => { + const roundedValue = getRoundedNumber(initialValue); + if (Number.isNaN(roundedValue)) return 't.n/a'; + return usd.format(roundedValue); +}; + +export const getRoundedNumber = (initialValue: string) => { + return parseFloat(parseFloat(initialValue).toFixed(2)); +}; + +export const getDefaultSummaryRanges = (unit: Unit) => { + return { + areaRange: { min: parseFloat(unit.sqFeet), max: parseFloat(unit.sqFeet) }, + minIncomeRange: { + min: getCurrencyString(unit.monthlyIncomeMin), + max: getCurrencyString(unit.monthlyIncomeMin), + }, + occupancyRange: { min: unit.minOccupancy, max: unit.maxOccupancy }, + rentRange: { + min: getCurrencyString(unit.monthlyRent), + max: getCurrencyString(unit.monthlyRent), + }, + rentAsPercentIncomeRange: { + min: parseFloat(unit.monthlyRentAsPercentOfIncome), + max: parseFloat(unit.monthlyRentAsPercentOfIncome), + }, + floorRange: { + min: unit.floor, + max: unit.floor, + }, + unitTypes: unit.unitTypes, + totalAvailable: 0, + }; +}; + +export const getUnitsSummary = (unit: Unit, existingSummary?: UnitSummary) => { + if (!existingSummary) { + return getDefaultSummaryRanges(unit); + } + const summary = existingSummary; + + // Income Range + summary.minIncomeRange = minMaxCurrency( + summary.minIncomeRange, + getRoundedNumber(unit.monthlyIncomeMin), + ); + + // Occupancy Range + summary.occupancyRange = minMax(summary.occupancyRange, unit.minOccupancy); + summary.occupancyRange = minMax(summary.occupancyRange, unit.maxOccupancy); + + // Rent Ranges + summary.rentAsPercentIncomeRange = minMax( + summary.rentAsPercentIncomeRange, + parseFloat(unit.monthlyRentAsPercentOfIncome), + ); + summary.rentRange = minMaxCurrency( + summary.rentRange, + getRoundedNumber(unit.monthlyRent), + ); + + // Floor Range + if (unit.floor) { + summary.floorRange = minMax(summary.floorRange, unit.floor); + } + + // Area Range + summary.areaRange = minMax(summary.areaRange, parseFloat(unit.sqFeet)); + + return summary; +}; + +// Allows for multiples rows under one unit type if the rent methods differ +export const summarizeUnitsByTypeAndRent = ( + units: Unit[], + listing: Listing, +): UnitSummary[] => { + const summaries: UnitSummary[] = []; + const unitMap: UnitMap = {}; + + units.forEach((unit) => { + const currentUnitType = unit.unitTypes; + const currentUnitRent = unit.monthlyRentAsPercentOfIncome; + const thisKey = currentUnitType?.name.concat(currentUnitRent); + if (!(thisKey in unitMap)) unitMap[thisKey] = []; + unitMap[thisKey].push(unit); + }); + + for (const key in unitMap) { + const finalSummary = unitMap[key].reduce((summary, unit, index) => { + return getUnitsSummary(unit, index === 0 ? null : summary); + }, {} as UnitSummary); + if (listing.reviewOrderType !== ReviewOrderTypeEnum.waitlist) { + finalSummary.totalAvailable = unitMap[key].length; + } + summaries.push(finalSummary); + } + + return summaries.sort((a, b) => { + return ( + UnitTypeSort.findIndex((sortedType) => a.unitTypes.name === sortedType) - + UnitTypeSort.findIndex( + (sortedType) => b.unitTypes.name === sortedType, + ) || Number(a.minIncomeRange.min) - Number(b.minIncomeRange.min) + ); + }); +}; + +// One row per unit type +export const summarizeUnitsByType = ( + units: Unit[], + unitTypes: UnitType[], +): UnitSummary[] => { + const summaries = unitTypes.map((unitType: UnitType): UnitSummary => { + const summary = {} as UnitSummary; + const unitsByType = units.filter( + (unit: Unit) => unit.unitTypes.name == unitType.name, + ); + const finalSummary = Array.from(unitsByType).reduce( + (summary, unit, index) => { + return getUnitsSummary(unit, index === 0 ? null : summary); + }, + summary, + ); + return finalSummary; + }); + return summaries.sort((a, b) => { + return ( + UnitTypeSort.findIndex((sortedType) => a.unitTypes.name === sortedType) - + UnitTypeSort.findIndex( + (sortedType) => b.unitTypes.name === sortedType, + ) || Number(a.minIncomeRange.min) - Number(b.minIncomeRange.min) + ); + }); +}; + +export const summarizeByAmi = (listing: Listing, amiPercentages: string[]) => { + return amiPercentages.map((percent: string) => { + const unitsByAmiPercentage = listing.units.filter( + (unit: Unit) => unit.amiPercentage == percent, + ); + return { + percent: percent, + byUnitType: summarizeUnitsByTypeAndRent(unitsByAmiPercentage, listing), + }; + }); +}; + +export const getUnitTypes = (units: Unit[]): UnitType[] => { + const unitTypes = new Map(); + for (const unitType of units + .map((unit) => unit.unitTypes) + .filter((item) => item != null)) { + unitTypes.set(unitType.id, unitType); + } + + return Array.from(unitTypes.values()); +}; + +export const summarizeUnits = ( + listing: Listing, + amiCharts: AmiChart[], +): UnitsSummarized => { + const data = {} as UnitsSummarized; + const units = listing.units; + if (!units || (units && units.length === 0)) { + return data; + } + + const unitTypes = new Map(); + for (const unitType of units + .map((unit) => unit.unitTypes) + .filter((item) => item != null)) { + unitTypes.set(unitType.id, unitType); + } + data.unitTypes = getUnitTypes(units); + + const priorityTypes = new Map(); + for (const priorityType of units + .map((unit) => unit.unitAccessibilityPriorityTypes) + .filter((item) => item != null)) { + priorityTypes.set(priorityType.id, priorityType); + } + data.priorityTypes = Array.from(priorityTypes.values()); + + data.amiPercentages = Array.from( + new Set( + units.map((unit) => unit.amiPercentage).filter((item) => item != null), + ), + ); + data.byUnitTypeAndRent = summarizeUnitsByTypeAndRent(listing.units, listing); + data.byUnitType = summarizeUnitsByType(units, data.unitTypes); + data.byAMI = summarizeByAmi(listing, data.amiPercentages); + data.hmi = generateHmiData( + units, + data.byUnitType.map((byUnitType) => byUnitType.occupancyRange), + amiCharts, + ); + return data; +}; diff --git a/api/src/validation-pipes/listing-create-update-pipe.ts b/api/src/validation-pipes/listing-create-update-pipe.ts new file mode 100644 index 0000000000..94c27037f2 --- /dev/null +++ b/api/src/validation-pipes/listing-create-update-pipe.ts @@ -0,0 +1,43 @@ +import { ArgumentMetadata, ValidationPipe } from '@nestjs/common'; +import { ListingsStatusEnum } from '@prisma/client'; +import { ListingUpdate } from '../dtos/listings/listing-update.dto'; +import { ListingPublishedUpdate } from '../dtos/listings/listing-published-update.dto'; +import { ListingCreate } from '../dtos/listings/listing-create.dto'; +import { ListingPublishedCreate } from '../dtos/listings/listing-published-create.dto'; + +export class ListingCreateUpdateValidationPipe extends ValidationPipe { + statusToListingValidationModelMapForUpdate: Record< + ListingsStatusEnum, + typeof ListingUpdate + > = { + [ListingsStatusEnum.closed]: ListingUpdate, + [ListingsStatusEnum.pending]: ListingUpdate, + [ListingsStatusEnum.active]: ListingPublishedUpdate, + [ListingsStatusEnum.pendingReview]: ListingUpdate, + [ListingsStatusEnum.changesRequested]: ListingUpdate, + }; + + statusToListingValidationModelMapForCreate: Record< + ListingsStatusEnum, + typeof ListingCreate + > = { + [ListingsStatusEnum.closed]: ListingCreate, + [ListingsStatusEnum.pending]: ListingCreate, + [ListingsStatusEnum.active]: ListingPublishedCreate, + [ListingsStatusEnum.pendingReview]: ListingCreate, + [ListingsStatusEnum.changesRequested]: ListingCreate, + }; + + // 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: value.id + ? this.statusToListingValidationModelMapForUpdate[value.status] + : this.statusToListingValidationModelMapForCreate[value.status], + }); + } + return await super.transform(value, metadata); + } +} diff --git a/api/src/views/change-email.hbs b/api/src/views/change-email.hbs new file mode 100644 index 0000000000..13493e26c2 --- /dev/null +++ b/api/src/views/change-email.hbs @@ -0,0 +1,11 @@ +

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

+

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

+

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

+

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

+{{> simple-footer }} diff --git a/api/src/views/changes-requested.hbs b/api/src/views/changes-requested.hbs new file mode 100644 index 0000000000..6dec5019d3 --- /dev/null +++ b/api/src/views/changes-requested.hbs @@ -0,0 +1,53 @@ +{{#> layout_default }} +

+ {{ t "changesRequested.header"}} +

+ + + + + + +
+

+ {{t "t.hello"}}, +

+ {{t "changesRequested.adminRequestStart" appOptions}} {{t "t.partnersPortal"}} {{t "changesRequested.adminRequestEnd"}} +

+ {{t "requestApproval.accessListing"}} +

+

+
+ + + + + + +
+ + {{t "t.editListing"}} + +
+ + + + + + +
+

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

+ {{t "header.logoTitle"}} +

+
+ + +{{/layout_default }} diff --git a/api/src/views/confirmation.hbs b/api/src/views/confirmation.hbs new file mode 100644 index 0000000000..fb779769de --- /dev/null +++ b/api/src/views/confirmation.hbs @@ -0,0 +1,86 @@ +{{#> layout_default }} +

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

+ + + + + + + + + + + + + + + +
+ {{t "t.seeListing"}} + +
+{{/layout_default }} diff --git a/api/src/views/csv-export.hbs b/api/src/views/csv-export.hbs new file mode 100644 index 0000000000..e335c1d857 --- /dev/null +++ b/api/src/views/csv-export.hbs @@ -0,0 +1,30 @@ +{{#> layout_default }} +

+ {{ t "csvExport.title" appOptions }} +

+ + + + + + + + + + +
+

+ {{t "csvExport.hello" appOptions}} +

+

+ {{t "csvExport.body" appOptions}} +

+
+

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

+

+ {{t "header.logoTitle" }} +

+
+{{/layout_default }} \ No newline at end of file diff --git a/api/src/views/forgot-password.hbs b/api/src/views/forgot-password.hbs new file mode 100644 index 0000000000..0209837abe --- /dev/null +++ b/api/src/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"}} +

+{{> simple-footer }} diff --git a/api/src/views/invite.hbs b/api/src/views/invite.hbs new file mode 100644 index 0000000000..23a02715e5 --- /dev/null +++ b/api/src/views/invite.hbs @@ -0,0 +1,44 @@ +{{#> layout_default }} +

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

+ + + + + + + +
+

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

+ {{t "invite.inviteManageListings"}} +

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

+
+ + + + + + + +
+ + {{t "invite.confirmMyAccount"}} + +
+{{/layout_default }} diff --git a/api/src/views/layouts/default.hbs b/api/src/views/layouts/default.hbs new file mode 100644 index 0000000000..2960258da4 --- /dev/null +++ b/api/src/views/layouts/default.hbs @@ -0,0 +1,9 @@ + + + {{> head }} + + {{> header }} + {{> @partial-block }} + {{> footer }} + + diff --git a/api/src/views/listing-approved.hbs b/api/src/views/listing-approved.hbs new file mode 100644 index 0000000000..c1ad7ff2d2 --- /dev/null +++ b/api/src/views/listing-approved.hbs @@ -0,0 +1,53 @@ +{{#> layout_default }} +

+ {{ t "listingApproved.header"}} +

+ + + + + + +
+

+ {{t "t.hello"}}, +

+ {{t "listingApproved.adminApproved" appOptions}} +

+ {{t "listingApproved.viewPublished"}} +

+

+
+ + + + + + +
+ + {{t "t.viewListing"}} + +
+ + + + + + +
+

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

+ {{t "header.logoTitle"}} +

+
+ + +{{/layout_default }} diff --git a/api/src/views/mfa-code.hbs b/api/src/views/mfa-code.hbs new file mode 100644 index 0000000000..ac650a65c0 --- /dev/null +++ b/api/src/views/mfa-code.hbs @@ -0,0 +1,8 @@ +

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

+

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

+

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

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

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

diff --git a/api/src/views/partials/footer.hbs b/api/src/views/partials/footer.hbs new file mode 100644 index 0000000000..ac448bffe5 --- /dev/null +++ b/api/src/views/partials/footer.hbs @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + +   + + diff --git a/api/src/views/partials/head.hbs b/api/src/views/partials/head.hbs new file mode 100644 index 0000000000..e813416c57 --- /dev/null +++ b/api/src/views/partials/head.hbs @@ -0,0 +1,417 @@ + + + + {{ subject }} + + + + + diff --git a/api/src/views/partials/header.hbs b/api/src/views/partials/header.hbs new file mode 100644 index 0000000000..419b66e25c --- /dev/null +++ b/api/src/views/partials/header.hbs @@ -0,0 +1,23 @@ + + +