diff --git a/.eslintrc.js b/.eslintrc.js index 49f97e4c24..70e36da8f7 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -56,6 +56,7 @@ module.exports = { "**/.eslintrc.js", "sentry-example-page.js", "sentry-example-api.js", + "next-env.d.ts", "*.csv", ], } diff --git a/.github/workflows/cypress_partners.yml b/.github/workflows/cypress_partners.yml index 886c280641..7c0a1b57c2 100644 --- a/.github/workflows/cypress_partners.yml +++ b/.github/workflows/cypress_partners.yml @@ -4,6 +4,8 @@ on: pull_request: branches: - main + paths-ignore: + - 'infra/**' env: PORT: 3100 EMAIL_API_KEY: 'SOME-LONG-SECRET-KEY' @@ -55,7 +57,7 @@ jobs: - name: Set up Node.js uses: actions/setup-node@v1 with: - node-version: 18 + node-version: 22 - name: yarn in root dir run: yarn @@ -91,4 +93,4 @@ jobs: record: false # set this value to true to record videos in cypress cloud for debugging purposes - skip passing tests env: CYPRESS_RECORD_KEY: ${{ secrets.PARTNERS_CYPRESS_RECORD_KEY }} - \ No newline at end of file + diff --git a/.github/workflows/cypress_partners_listingapproval.yml b/.github/workflows/cypress_partners_listingapproval.yml index bec2516e21..1a25b625e9 100644 --- a/.github/workflows/cypress_partners_listingapproval.yml +++ b/.github/workflows/cypress_partners_listingapproval.yml @@ -4,6 +4,8 @@ on: pull_request: branches: - main + paths-ignore: + - 'infra/**' env: PORT: 3100 EMAIL_API_KEY: 'SOME-LONG-SECRET-KEY' @@ -48,7 +50,7 @@ jobs: - name: Set up Node.js uses: actions/setup-node@v1 with: - node-version: 18 + node-version: 22 - name: yarn in root dir run: yarn @@ -84,4 +86,4 @@ jobs: record: false # set this value to true to record videos in cypress cloud for debugging purposes - skip passing tests env: CYPRESS_RECORD_KEY: ${{ secrets.PARTNERS_CYPRESS_RECORD_KEY }} - \ No newline at end of file + diff --git a/.github/workflows/cypress_public.yml b/.github/workflows/cypress_public.yml index bf86f733fa..bf19cdfab9 100644 --- a/.github/workflows/cypress_public.yml +++ b/.github/workflows/cypress_public.yml @@ -4,6 +4,8 @@ on: pull_request: branches: - main + paths-ignore: + - 'infra/**' env: PORT: 3100 EMAIL_API_KEY: 'SOME-LONG-SECRET-KEY' @@ -56,7 +58,7 @@ jobs: - name: Set up Node.js uses: actions/setup-node@v1 with: - node-version: 18 + node-version: 22 - name: yarn in root dir run: yarn @@ -92,4 +94,4 @@ jobs: env: CYPRESS_RECORD_KEY: ${{ secrets.PUBLIC_CYPRESS_RECORD_KEY }} - \ No newline at end of file + diff --git a/.github/workflows/docker_image_build.yml b/.github/workflows/docker_image_build.yml index 664981e691..850b5e94df 100644 --- a/.github/workflows/docker_image_build.yml +++ b/.github/workflows/docker_image_build.yml @@ -26,12 +26,27 @@ jobs: - container: api docker_context: "{{defaultContext}}:api" dockerfile: Dockerfile + platforms: linux/amd64 + - container: dbseed + docker_context: "{{defaultContext}}:api" + dockerfile: Dockerfile.dbseed + platforms: linux/amd64 - container: partners docker_context: "{{defaultContext}}" dockerfile: Dockerfile.sites.partners + platforms: linux/amd64 - container: public docker_context: "{{defaultContext}}" dockerfile: Dockerfile.sites.public + platforms: linux/amd64 + - container: infra + docker_context: "{{defaultContext}}:infra" + dockerfile: Dockerfile + platforms: linux/amd64,linux/arm64 + - container: infra-dev + docker_context: "{{defaultContext}}:infra" + dockerfile: Dockerfile.dev + platforms: linux/amd64,linux/arm64 runs-on: ubuntu-latest steps: - name: Log in to GitHub Container Registry @@ -41,6 +56,10 @@ jobs: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} + # Required to build multi-arch images. + - name: Setup Docker QEMU + uses: docker/setup-qemu-action@v3.7.0 + # Required to use image layer cache. - name: Setup Docker Buildx uses: docker/setup-buildx-action@v3.11.1 @@ -53,8 +72,9 @@ jobs: cache-from: type=registry,ref=${{ env.image_base }}/${{ matrix.container }}/container-layer-cache:latest context: ${{ matrix.docker_context }} file: ${{ matrix.dockerfile }} + platforms: ${{ matrix.platforms }} tags: | - ${{ env.image_base }}/${{ matrix.container }}:latest + ${{ env.image_base }}/${{ matrix.container }}:gitbranch-${{ github.ref_name }}-latest ${{ env.image_base }}/${{ matrix.container }}:gitsha-${{ github.sha }} # Connects the image to the repository: https://docs.github.com/en/packages/learn-github-packages/connecting-a-repository-to-a-package. labels: org.opencontainers.image.source=https://github.com/${{ github.repository }} diff --git a/.github/workflows/infra_ci.yml b/.github/workflows/infra_ci.yml new file mode 100644 index 0000000000..509fba8191 --- /dev/null +++ b/.github/workflows/infra_ci.yml @@ -0,0 +1,87 @@ +name: Infra CI + +on: + push: + branches: + - main + pull_request: + branches: + - main + paths: + - '.github/workflows/infra_ci.yml' + - 'infra/**' + +permissions: + contents: read + +jobs: + check-tofu-fmt: + if: ${{ false }} # This condition ensures the workflow is skipped + runs-on: ubuntu-latest + steps: + - name: Check out Git repository + uses: actions/checkout@v6 + + - name: Check tofu fmt + shell: bash + run: | + if ! docker container run --rm \ + -v ./infra:/infra \ + --workdir /infra \ + --entrypoint tofu \ + ghcr.io/${{ github.repository }}/infra-dev:gitbranch-main-latest \ + fmt -check -diff -recursive + then + echo 'ERROR: not all tofu files are correctly formatted' + exit 1 + fi + + check-tofu-provider-lock: + if: ${{ false }} # This condition ensures the workflow is skipped + strategy: + matrix: + root_module: + - bloom_dev + - bloom_dev_deployer_permission_set_policy + # TODO: add once root mod is added: bloom_prod + - bloom_prod_deployer_permission_set_policy + runs-on: ubuntu-latest + steps: + - name: Check out Git repository + uses: actions/checkout@v6 + + - name: Check tofu providers lock + shell: bash + run: | + if ! docker container run --rm \ + -v ./infra:/infra \ + --workdir /infra/tofu_root_modules/${{ matrix.root_module }} \ + --entrypoint bash \ + ghcr.io/${{ github.repository }}/infra-dev:gitbranch-main-latest \ + -c 'sha256sum .terraform.lock.hcl > hash' + then + echo 'ERROR: recording sha256 of .terraform.lock.hcl failed' + exit 1 + fi + + if ! docker container run --rm \ + -v ./infra:/infra \ + --workdir /infra/tofu_root_modules/${{ matrix.root_module }} \ + --entrypoint bash \ + ghcr.io/${{ github.repository }}/infra-dev:gitbranch-main-latest \ + -c 'tofu init -backend=false && tofu providers lock -platform=linux_amd64 -platform=linux_arm64 -platform=darwin_amd64 -platform=darwin_arm64' + then + echo 'ERROR: tofu providers lock did not run successfully' + exit 1 + fi + + if ! docker container run --rm \ + -v ./infra:/infra \ + --workdir /infra/tofu_root_modules/${{ matrix.root_module }} \ + --entrypoint bash \ + ghcr.io/${{ github.repository }}/infra-dev:gitbranch-main-latest \ + -c 'sha256sum -c hash' + then + echo 'ERROR: .terraform.lock.hcl changed after running tofu providers lock' + exit 1 + fi diff --git a/.github/workflows/integration_tests_api.yml b/.github/workflows/integration_tests_api.yml index 8b9b6b267c..afd65440ce 100644 --- a/.github/workflows/integration_tests_api.yml +++ b/.github/workflows/integration_tests_api.yml @@ -4,6 +4,8 @@ on: pull_request: branches: - main + paths-ignore: + - 'infra/**' env: PORT: 3100 EMAIL_API_KEY: 'SG.SOME-LONG-SECRET-KEY' @@ -52,7 +54,7 @@ jobs: - name: Set up Node.js uses: actions/setup-node@v1 with: - node-version: 18 + node-version: 22 - name: yarn in api dir working-directory: ./api diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index d4dfcf18cc..911055e397 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -6,6 +6,8 @@ on: pull_request: branches: - main + paths-ignore: + - 'infra/**' jobs: run-linters: @@ -14,12 +16,12 @@ jobs: steps: - name: Check out Git repository - uses: actions/checkout@v2 + uses: actions/checkout@v4 - name: Set up Node.js uses: actions/setup-node@v1 with: - node-version: 18 + node-version: 22 # ESLint and Prettier must be in `package.json` - name: Install Node.js dependencies diff --git a/.github/workflows/missing-translations.yml b/.github/workflows/missing-translations.yml new file mode 100644 index 0000000000..cc3f007739 --- /dev/null +++ b/.github/workflows/missing-translations.yml @@ -0,0 +1,41 @@ +name: Missing Translations + +on: + pull_request: + branches: + - main + paths-ignore: + - 'infra/**' + +jobs: + shared-helpers-unit: + runs-on: ubuntu-latest + + steps: + - name: Check out Git repository + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v1 + with: + node-version: 22 + + - name: yarn in root dir + run: yarn + + # Rerun yarn in each app. + # It may seem unnecessary but there are some complex yarn package + # versioning dependencies here, so best to run to be safe. + # See https://github.com/bloom-housing/bloom/issues/3217#issuecomment-1430301029 + # for more context. + - name: yarn in sites/public dir + working-directory: ./sites/public + run: yarn + + - name: yarn in sites/partners dir + working-directory: ./sites/partners + run: yarn + + # Tests + - name: yarn translations test shared/helpers + run: yarn test:shared-helpers:missing-translations diff --git a/.github/workflows/unit_tests_api.yml b/.github/workflows/unit_tests_api.yml index 13ba65f34a..dcd1a7de90 100644 --- a/.github/workflows/unit_tests_api.yml +++ b/.github/workflows/unit_tests_api.yml @@ -4,6 +4,8 @@ on: pull_request: branches: - main + paths-ignore: + - 'infra/**' env: APP_SECRET: 'SOME-LONG-SECRET-KEY' DATABASE_URL: postgres://bloom-dev:bloom@127.0.0.1:5432/bloom @@ -25,6 +27,7 @@ env: RECAPTCHA_KEY: "secret-key" GOOGLE_CLOUD_PROJECT_ID: "secret-key" LOTTERY_DAYS_TILL_EXPIRY: 45 + CLOUDINARY_CLOUD_NAME: "exygy" jobs: backend-unit: @@ -37,7 +40,7 @@ jobs: - name: Set up Node.js uses: actions/setup-node@v1 with: - node-version: 18 + node-version: 22 - name: yarn in api working-directory: ./api diff --git a/.github/workflows/unit_tests_shared_helpers.yml b/.github/workflows/unit_tests_shared_helpers.yml index 591729521a..bc318b82a7 100644 --- a/.github/workflows/unit_tests_shared_helpers.yml +++ b/.github/workflows/unit_tests_shared_helpers.yml @@ -4,6 +4,8 @@ on: pull_request: branches: - main + paths-ignore: + - 'infra/**' jobs: shared-helpers-unit: @@ -16,7 +18,7 @@ jobs: - name: Set up Node.js uses: actions/setup-node@v1 with: - node-version: 18 + node-version: 22 - name: yarn in root dir run: yarn diff --git a/.github/workflows/unit_tests_sites_partners.yml b/.github/workflows/unit_tests_sites_partners.yml index 0720580296..c30d59ac97 100644 --- a/.github/workflows/unit_tests_sites_partners.yml +++ b/.github/workflows/unit_tests_sites_partners.yml @@ -4,6 +4,8 @@ on: pull_request: branches: - main + paths-ignore: + - 'infra/**' jobs: partners-unit: @@ -16,7 +18,7 @@ jobs: - name: Set up Node.js uses: actions/setup-node@v1 with: - node-version: 18 + node-version: 22 - name: yarn in root dir run: yarn diff --git a/.github/workflows/unit_tests_sites_public.yml b/.github/workflows/unit_tests_sites_public.yml index 08bfd2d5e4..bd3b50c843 100644 --- a/.github/workflows/unit_tests_sites_public.yml +++ b/.github/workflows/unit_tests_sites_public.yml @@ -4,6 +4,8 @@ on: pull_request: branches: - main + paths-ignore: + - 'infra/**' jobs: public-unit: @@ -16,7 +18,7 @@ jobs: - name: Set up Node.js uses: actions/setup-node@v1 with: - node-version: 18 + node-version: 22 - name: yarn in root dir run: yarn diff --git a/.gitignore b/.gitignore index 4b5d57bfe4..0eb9490f98 100644 --- a/.gitignore +++ b/.gitignore @@ -102,3 +102,4 @@ memory-bank/ # Terraform providers .terraform +*.tfstate diff --git a/api/.env.template b/api/.env.template index 992d3cc44e..ae4144653d 100644 --- a/api/.env.template +++ b/api/.env.template @@ -10,6 +10,8 @@ GOOGLE_API_ID= GOOGLE_API_KEY= # cloudinary secret CLOUDINARY_SECRET= +# cloudinary cloud name +CLOUDINARY_CLOUD_NAME=exygy # app secret APP_SECRET="some-long-secret-key" # url for the proxy @@ -44,8 +46,18 @@ LOTTERY_PUBLISH_PROCESSING_CRON_STRING=58 23 * * * LOTTERY_PROCESSING_CRON_STRING=0 * * * * # how many days till lottery data expires LOTTERY_DAYS_TILL_EXPIRY=45 +# controls the repetition of the msq retire cron job (should occur after LISTING_PROCESSING_CRON_STRING) +MSQ_RETIRE_CRON_STRING=5 * * * * # how many days until application PII data exists APPLICATION_DAYS_TILL_EXPIRY= +# controls the repetition of the PII deletion cron job +PII_DELETION_CRON_STRING=0 * * * * +# how many days until you delete users after inactivity +USERS_DAYS_TILL_EXPIRY= +# controls the repetition of the user deletion cron job +USER_DELETION_CRON_STRING=0 * * * * +# controls the repetition of the user deletion warn cron job +USER_DELETION_WARN_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) diff --git a/api/Dockerfile b/api/Dockerfile index e115897d69..79d934c1de 100644 --- a/api/Dockerfile +++ b/api/Dockerfile @@ -46,7 +46,7 @@ COPY --from=build /build/prisma/schema.prisma ./prisma/schema.prisma COPY --from=build /build/prisma/migrations ./prisma/migrations COPY --from=build /build/node_modules/.prisma ./node_modules/.prisma -# Create a non-root user to run (priciple of least priviledge). +# Create a non-root user to run (principle of least privilege). WORKDIR /run RUN groupadd --gid 2002 run && useradd --gid 2002 --uid 2002 --home /run run RUN chown --recursive 2002:2002 /run diff --git a/api/Dockerfile.dbseed b/api/Dockerfile.dbseed index 7147231e0f..93a8b1c0c3 100644 --- a/api/Dockerfile.dbseed +++ b/api/Dockerfile.dbseed @@ -1,7 +1,7 @@ # Keep up to date with Active LTS: https://nodejs.org/en/about/previous-releases FROM node:22 AS build -# Create a non-root user to build (priciple of least priviledge). +# Create a non-root user to build (principle of least privilege). WORKDIR /dbseed RUN groupadd --gid 2002 dbseed && useradd --gid 2002 --uid 2002 --home /dbseed dbseed RUN chown 2002:2002 /dbseed diff --git a/api/README.md b/api/README.md index 416b0cdb18..d4d8258fde 100644 --- a/api/README.md +++ b/api/README.md @@ -20,7 +20,7 @@ If you don't have yarn installed, you can install homebrew with [these instructi #### Installing Node -We are currently using Node version 18. You can install Node using homebrew with the following command: `brew install node@18`. +We are currently using Node version 22. You can install Node using homebrew with the following command: `brew install node@22`. If you have multiple versions of Node installed, you can use [nvm](https://github.com/nvm-sh/nvm) (node version manager), or other similar tools, to switch between them. Ensure you're on the right version by checking with `node -v`. diff --git a/api/prisma/migrations/32_add_non_regulated_listings_fields/migration.sql b/api/prisma/migrations/32_add_non_regulated_listings_fields/migration.sql index d7e306e10e..bbeaec3e7f 100644 --- a/api/prisma/migrations/32_add_non_regulated_listings_fields/migration.sql +++ b/api/prisma/migrations/32_add_non_regulated_listings_fields/migration.sql @@ -10,8 +10,6 @@ CREATE TYPE "rent_type_enum" AS ENUM ('fixedRent', 'rentRange'); -- AlterTable ALTER TABLE "listings" ADD COLUMN "coc_info" TEXT, -ADD COLUMN "deposit_range_max" INTEGER, -ADD COLUMN "deposit_range_min" INTEGER, ADD COLUMN "deposit_type" "deposit_type_enum", ADD COLUMN "deposit_value" DECIMAL(65,30), ADD COLUMN "has_hud_ebll_clearance" BOOLEAN, diff --git a/api/prisma/migrations/37_add_listing_file_number/migration.sql b/api/prisma/migrations/37_add_listing_file_number/migration.sql new file mode 100644 index 0000000000..41307ec55d --- /dev/null +++ b/api/prisma/migrations/37_add_listing_file_number/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "listings" ADD COLUMN "listing_file_number" TEXT; diff --git a/api/prisma/migrations/37_add_new_neighborhood_amenities/migration.sql b/api/prisma/migrations/37_add_new_neighborhood_amenities/migration.sql new file mode 100644 index 0000000000..bc5e2d9292 --- /dev/null +++ b/api/prisma/migrations/37_add_new_neighborhood_amenities/migration.sql @@ -0,0 +1,15 @@ +-- AlterEnum +ALTER TYPE "neighborhood_amenities_enum" ADD VALUE 'shoppingVenues'; +ALTER TYPE "neighborhood_amenities_enum" ADD VALUE 'hospitals'; +ALTER TYPE "neighborhood_amenities_enum" ADD VALUE 'seniorCenters'; +ALTER TYPE "neighborhood_amenities_enum" ADD VALUE 'recreationalFacilities'; +ALTER TYPE "neighborhood_amenities_enum" ADD VALUE 'playgrounds'; +ALTER TYPE "neighborhood_amenities_enum" ADD VALUE 'busStops'; + +-- AlterTable +ALTER TABLE "listing_neighborhood_amenities" ADD COLUMN "bus_stops" TEXT, +ADD COLUMN "hospitals" TEXT, +ADD COLUMN "playgrounds" TEXT, +ADD COLUMN "recreational_facilities" TEXT, +ADD COLUMN "senior_centers" TEXT, +ADD COLUMN "shopping_venues" TEXT; diff --git a/api/prisma/migrations/37_add_required_documents_list_model/migration.sql b/api/prisma/migrations/37_add_required_documents_list_model/migration.sql new file mode 100644 index 0000000000..4d8792b5ce --- /dev/null +++ b/api/prisma/migrations/37_add_required_documents_list_model/migration.sql @@ -0,0 +1,26 @@ +-- AlterTable +ALTER TABLE "listings" ADD COLUMN "documents_id" UUID; + +-- CreateTable +CREATE TABLE "listing_documents" ( + "id" UUID NOT NULL DEFAULT uuid_generate_v4(), + "created_at" TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(6) NOT NULL, + "birth_certificate" BOOLEAN, + "current_landlord_reference" BOOLEAN, + "government_issued_id" BOOLEAN, + "previous_landlord_reference" BOOLEAN, + "proof_of_assets" BOOLEAN, + "proof_of_custody" BOOLEAN, + "proof_of_income" BOOLEAN, + "residency_documents" BOOLEAN, + "social_security_card" BOOLEAN, + + CONSTRAINT "listing_documents_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "listings_documents_id_key" ON "listings"("documents_id"); + +-- AddForeignKey +ALTER TABLE "listings" ADD CONSTRAINT "listings_documents_id_fkey" FOREIGN KEY ("documents_id") REFERENCES "listing_documents"("id") ON DELETE NO ACTION ON UPDATE NO ACTION; diff --git a/api/prisma/migrations/38_add_monthly_rent_to_unit_group/migration.sql b/api/prisma/migrations/38_add_monthly_rent_to_unit_group/migration.sql new file mode 100644 index 0000000000..01fcf4cce1 --- /dev/null +++ b/api/prisma/migrations/38_add_monthly_rent_to_unit_group/migration.sql @@ -0,0 +1,4 @@ +ALTER TABLE "unit_group" +ADD COLUMN "monthly_rent" DECIMAL, +ALTER COLUMN "flat_rent_value_from" SET DATA TYPE DECIMAL, +ALTER COLUMN "flat_rent_value_to" SET DATA TYPE DECIMAL; diff --git a/api/prisma/migrations/38_add_waitlist_lottery_translation/migration.sql b/api/prisma/migrations/38_add_waitlist_lottery_translation/migration.sql new file mode 100644 index 0000000000..a719c1d494 --- /dev/null +++ b/api/prisma/migrations/38_add_waitlist_lottery_translation/migration.sql @@ -0,0 +1,21 @@ +-- Adds new waitlist lottery confirmation translations for emails. + +UPDATE translations +SET translations = jsonb_set(translations, '{confirmation,eligible,waitlistLottery}', '"Eligible applicants will be placed on the waitlist based on lottery rank order."') +WHERE language = 'en'; + +UPDATE translations +SET translations = jsonb_set(translations, '{confirmation,eligible,waitlistLottery}', '"Los solicitantes elegibles serán colocados en la lista de espera según el orden de clasificación de la lotería."') +WHERE language = 'es'; + +UPDATE translations +SET translations = jsonb_set(translations, '{confirmation,eligible,waitlistLottery}', '"Ang mga karapat-dapat na aplikante ay ilalagay sa waitlist batay sa order ng ranggo ng lottery."') +WHERE language = 'tl'; + +UPDATE translations +SET translations = jsonb_set(translations, '{confirmation,eligible,waitlistLottery}', '"Những người nộp đơn đủ điều kiện sẽ được đưa vào danh sách chờ dựa trên thứ hạng xổ số."') +WHERE language = 'vi'; + +UPDATE translations +SET translations = jsonb_set(translations, '{confirmation,eligible,waitlistLottery}', '"符合資格的申請人將根據抽籤順序列入候補名單。"') +WHERE language = 'zh'; diff --git a/api/prisma/migrations/39_add_credit_screening_fee/migration.sql b/api/prisma/migrations/39_add_credit_screening_fee/migration.sql new file mode 100644 index 0000000000..7e44bb9256 --- /dev/null +++ b/api/prisma/migrations/39_add_credit_screening_fee/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "listings" ADD COLUMN "credit_screening_fee" TEXT; diff --git a/api/prisma/migrations/39_add_marketing_flyer_file/migration.sql b/api/prisma/migrations/39_add_marketing_flyer_file/migration.sql new file mode 100644 index 0000000000..9c489fb8e8 --- /dev/null +++ b/api/prisma/migrations/39_add_marketing_flyer_file/migration.sql @@ -0,0 +1,11 @@ +-- AlterTable +ALTER TABLE "listings" ADD COLUMN "accessible_marketing_flyer" TEXT, +ADD COLUMN "accessible_marketing_flyer_file_id" UUID, +ADD COLUMN "marketing_flyer" TEXT, +ADD COLUMN "marketing_flyer_file_id" UUID; + +-- AddForeignKey +ALTER TABLE "listings" ADD CONSTRAINT "listings_marketing_flyer_file_id_fkey" FOREIGN KEY ("marketing_flyer_file_id") REFERENCES "assets"("id") ON DELETE NO ACTION ON UPDATE NO ACTION; + +-- AddForeignKey +ALTER TABLE "listings" ADD CONSTRAINT "listings_accessible_marketing_flyer_file_id_fkey" FOREIGN KEY ("accessible_marketing_flyer_file_id") REFERENCES "assets"("id") ON DELETE NO ACTION ON UPDATE NO ACTION; diff --git a/api/prisma/migrations/40_add_description_to_lisring_images/migration.sql b/api/prisma/migrations/40_add_description_to_lisring_images/migration.sql new file mode 100644 index 0000000000..89f6a2cd21 --- /dev/null +++ b/api/prisma/migrations/40_add_description_to_lisring_images/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "listing_images" ADD COLUMN "description" TEXT; diff --git a/api/prisma/migrations/40_add_warn_email_translation/migration.sql b/api/prisma/migrations/40_add_warn_email_translation/migration.sql new file mode 100644 index 0000000000..bd42e040d4 --- /dev/null +++ b/api/prisma/migrations/40_add_warn_email_translation/migration.sql @@ -0,0 +1,75 @@ +-- Adds new waitlist lottery confirmation translations for emails. +UPDATE + translations +SET + translations = jsonb_set( + translations, + '{accountRemoval}', + '{ + "subject": "Bloom Housing Scheduled Account Removal Due to Inactivity", + "courtesyText": "This is a courtesy email to let you know that because your Bloom Housing Portal account has been inactive for 3 years, your account will be deleted in 30 days per our Terms of Use and Privacy Policy. If you’d like to keep your account, please log in sometime in the next month and we’ll consider your account active again.", + "signIn": "Sign in to Bloom Housing" + }' + ) +WHERE + language = 'en'; + +UPDATE + translations +SET + translations = jsonb_set( + translations, + '{accountRemoval}', + '{ + "subject": "Eliminación programada de cuenta de Bloom Housing debido a inactividad", + "courtesyText": "Este es un correo electrónico de cortesía para informarle que, debido a que su cuenta del Portal de Bloom Housing ha estado inactiva durante 3 años, se eliminará en 30 días según nuestros Términos de Uso y Política de Privacidad. Si desea conservar su cuenta, inicie sesión durante el próximo mes y la consideraremos activa de nuevo.", + "signIn": "Iniciar sesión en Bloom Housing" + }' + ) +WHERE + language = 'es'; + +UPDATE + translations +SET + translations = jsonb_set( + translations, + '{accountRemoval}', + '{ + "subject": "Bloom Housing Scheduled Account Removal Dahil sa Kawalan ng Aktibidad", + "courtesyText": "Ito ay isang courtesy email upang ipaalam sa iyo na dahil ang iyong Bloom Housing Portal account ay hindi aktibo sa loob ng 3 taon, ang iyong account ay tatanggalin sa loob ng 30 araw alinsunod sa aming Mga Tuntunin ng Paggamit at Patakaran sa Privacy. Kung gusto mong panatilihin ang iyong account, mangyaring mag-log in minsan sa susunod na buwan at ituturing naming aktibo muli ang iyong account.", + "signIn": "Mag-sign in sa Bloom Housing" + }' + ) +WHERE + language = 'tl'; + +UPDATE + translations +SET + translations = jsonb_set( + translations, + '{accountRemoval}', + '{ + "subject": "Bloom Housing đã lên lịch xóa tài khoản do không hoạt động", + "courtesyText": "Đây là email lịch sự để thông báo cho bạn rằng vì tài khoản Bloom Housing Portal của bạn đã không hoạt động trong 3 năm, tài khoản của bạn sẽ bị xóa sau 30 ngày theo Điều khoản Sử dụng và Chính sách Quyền riêng tư của chúng tôi. Nếu bạn muốn giữ lại tài khoản, vui lòng đăng nhập vào thời điểm nào đó trong tháng tới và chúng tôi sẽ coi tài khoản của bạn là hoạt động trở lại.", + "signIn": "Đăng nhập vào Bloom Housing" + }' + ) +WHERE + language = 'vi'; + +UPDATE + translations +SET + translations = jsonb_set( + translations, + '{accountRemoval}', + '{ + "subject": "Bloom Housing 因帳戶長期不活躍,計劃刪除您的帳戶", + "courtesyText": "這是一封通知郵件,告知您由於您的 Bloom Housing Portal 帳戶已連續 3 年未使用,根據我們的使用條款和隱私政策,您的帳戶將在 30 天后被刪除。如果您希望保留您的帳戶,請在下個月登錄,我們將視您的帳戶為已激活狀態。", + "signIn": "登入 Bloom Housing" + }' + ) +WHERE + language = 'zh'; \ No newline at end of file diff --git a/api/prisma/migrations/41_add_minimum_images_required_field_to_jurisdition/migration.sql b/api/prisma/migrations/41_add_minimum_images_required_field_to_jurisdition/migration.sql new file mode 100644 index 0000000000..3528291f18 --- /dev/null +++ b/api/prisma/migrations/41_add_minimum_images_required_field_to_jurisdition/migration.sql @@ -0,0 +1 @@ +ALTER TABLE "jurisdictions" ADD COLUMN "minimum_listing_publish_images_required" INTEGER DEFAULT 1; \ No newline at end of file diff --git a/api/prisma/migrations/42_application_selection_on_delete/migration.sql b/api/prisma/migrations/42_application_selection_on_delete/migration.sql new file mode 100644 index 0000000000..a096454a47 --- /dev/null +++ b/api/prisma/migrations/42_application_selection_on_delete/migration.sql @@ -0,0 +1,11 @@ +-- DropForeignKey +ALTER TABLE "application_selection_options" DROP CONSTRAINT "application_selection_options_application_selection_id_fkey"; + +-- DropForeignKey +ALTER TABLE "application_selections" DROP CONSTRAINT "application_selections_application_id_fkey"; + +-- AddForeignKey +ALTER TABLE "application_selection_options" ADD CONSTRAINT "application_selection_options_application_selection_id_fkey" FOREIGN KEY ("application_selection_id") REFERENCES "application_selections"("id") ON DELETE CASCADE ON UPDATE NO ACTION; + +-- AddForeignKey +ALTER TABLE "application_selections" ADD CONSTRAINT "application_selections_application_id_fkey" FOREIGN KEY ("application_id") REFERENCES "applications"("id") ON DELETE CASCADE ON UPDATE NO ACTION; diff --git a/api/prisma/migrations/42_application_status_waitlist_fields/migration.sql b/api/prisma/migrations/42_application_status_waitlist_fields/migration.sql new file mode 100644 index 0000000000..e026105fd4 --- /dev/null +++ b/api/prisma/migrations/42_application_status_waitlist_fields/migration.sql @@ -0,0 +1,19 @@ +/* + Warnings: + + - The values [draft,removed] on the enum `application_status_enum` will be removed. If these variants are still used in the database, this will fail. +*/ +-- AlterEnum +BEGIN; +CREATE TYPE "application_status_enum_new" AS ENUM ('submitted', 'declined', 'receivedUnit', 'waitlist', 'waitlistDeclined'); +ALTER TABLE "applications" ALTER COLUMN "status" TYPE "application_status_enum_new" USING ("status"::text::"application_status_enum_new"); +ALTER TYPE "application_status_enum" RENAME TO "application_status_enum_old"; +ALTER TYPE "application_status_enum_new" RENAME TO "application_status_enum"; +DROP TYPE "application_status_enum_old"; +COMMIT; + +-- AlterTable +ALTER TABLE "applications" ADD COLUMN "accessible_unit_waitlist_number" INTEGER, +ADD COLUMN "conventional_unit_waitlist_number" INTEGER, +ADD COLUMN "manual_lottery_position_number" INTEGER, +ALTER COLUMN "status" SET DEFAULT 'submitted'; \ No newline at end of file diff --git a/api/prisma/migrations/43_add_parking_fee_property/migration.sql b/api/prisma/migrations/43_add_parking_fee_property/migration.sql new file mode 100644 index 0000000000..42503fc972 --- /dev/null +++ b/api/prisma/migrations/43_add_parking_fee_property/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "listings" ADD COLUMN "parking_fee" TEXT; diff --git a/api/prisma/schema.prisma b/api/prisma/schema.prisma index 9cf4d0f9c0..1f0ea703ee 100644 --- a/api/prisma/schema.prisma +++ b/api/prisma/schema.prisma @@ -182,7 +182,7 @@ model ApplicationSelectionOptions { isGeocodingVerified Boolean? @map("is_geocoding_verified") multiselectOptionId String @map("multiselect_option_id") @db.Uuid addressHolderAddress Address? @relation("application_selection_address", fields: [addressHolderAddressId], references: [id], onDelete: NoAction, onUpdate: NoAction) - applicationSelections ApplicationSelections @relation(fields: [applicationSelectionId], references: [id], onDelete: NoAction, onUpdate: NoAction) + applicationSelection ApplicationSelections @relation(fields: [applicationSelectionId], references: [id], onDelete: Cascade, onUpdate: NoAction) multiselectOption MultiselectOptions @relation(fields: [multiselectOptionId], references: [id], onDelete: NoAction, onUpdate: NoAction) @@map("application_selection_options") @@ -195,7 +195,7 @@ model ApplicationSelections { applicationId String @map("application_id") @db.Uuid hasOptedOut Boolean? @map("has_opted_out") multiselectQuestionId String @map("multiselect_question_id") @db.Uuid - application Applications @relation(fields: [applicationId], references: [id], onDelete: NoAction, onUpdate: NoAction) + application Applications @relation(fields: [applicationId], references: [id], onDelete: Cascade, onUpdate: NoAction) multiselectQuestion MultiselectQuestions @relation(fields: [multiselectQuestionId], references: [id], onDelete: NoAction, onUpdate: NoAction) selections ApplicationSelectionOptions[] @@ -203,63 +203,66 @@ model ApplicationSelections { } 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) + 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 @default(submitted) + accessibleUnitWaitlistNumber Int? @map("accessible_unit_waitlist_number") + conventionalUnitWaitlistNumber Int? @map("conventional_unit_waitlist_number") + manualLotteryPositionNumber Int? @map("manual_lottery_position_number") + 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") + markedAsDuplicate Boolean @default(false) @map("marked_as_duplicate") + confirmationCode String @map("confirmation_code") + reviewStatus ApplicationReviewStatusEnum @default(pending) @map("review_status") // Used to denote when an application was transferred from another jurisdiction - wasCreatedExternally Boolean @default(false) @map("was_created_externally") - userId String? @map("user_id") @db.Uuid - listingId String? @map("listing_id") @db.Uuid + wasCreatedExternally Boolean @default(false) @map("was_created_externally") + userId String? @map("user_id") @db.Uuid + listingId String? @map("listing_id") @db.Uuid // This application is the most recent for the user - isNewest Boolean? @default(false) @map("is_newest") - wasPIICleared Boolean? @default(false) @map("was_pii_cleared") + isNewest Boolean? @default(false) @map("is_newest") + wasPIICleared Boolean? @default(false) @map("was_pii_cleared") // Signifies when PII data should be cleared - expireAfter DateTime? @map("expire_after") @db.Timestamp(6) - 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[] - applicationSelections ApplicationSelections[] - 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: SetNull, onUpdate: NoAction) - demographics Demographics? @relation(fields: [demographicsId], references: [id], onDelete: NoAction, onUpdate: NoAction) - preferredUnitTypes UnitTypes[] - householdMember HouseholdMember[] - applicationLotteryPositions ApplicationLotteryPositions[] + expireAfter DateTime? @map("expire_after") @db.Timestamp(6) + 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[] + applicationSelections ApplicationSelections[] + 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: SetNull, onUpdate: NoAction) + demographics Demographics? @relation(fields: [demographicsId], references: [id], onDelete: NoAction, onUpdate: NoAction) + preferredUnitTypes UnitTypes[] + householdMember HouseholdMember[] + applicationLotteryPositions ApplicationLotteryPositions[] @@unique([listingId, confirmationCode]) @@index([listingId]) @@ -278,6 +281,8 @@ model Assets { listingEvents ListingEvents[] listingImages ListingImages[] buildingSelectionCriteriaFile Listings[] @relation("building_selection_criteria_file") + marketingFlyerFile Listings[] @relation("marketing_flyer_file") + accessibleMarketingFlyerFile Listings[] @relation("accessible_marketing_flyer_file") listingsResult Listings[] @relation("listings_result") paperApplications PaperApplications[] @@ -368,35 +373,36 @@ model HouseholdMember { // 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") - whatToExpect String @default("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.") @map("what_to_expect") - whatToExpectAdditionalText String @default("") @map("what_to_expect_additional_text") - whatToExpectUnderConstruction String @default("") @map("what_to_expect_under_construction") - enablePartnerSettings Boolean @default(false) @map("enable_partner_settings") - enablePartnerDemographics Boolean @default(false) @map("enable_partner_demographics") - enableGeocodingPreferences Boolean @default(false) @map("enable_geocoding_preferences") - enableGeocodingRadiusMethod Boolean @default(false) @map("enable_geocoding_radius_method") - allowSingleUseCodeLogin Boolean @default(false) @map("allow_single_use_code_login") - amiChart AmiChart[] - featureFlags FeatureFlags[] - multiselectQuestions MultiselectQuestions[] - listings Listings[] - reservedCommunityTypes ReservedCommunityTypes[] - translations Translations[] - user_accounts UserAccounts[] - listingApprovalPermissions UserRoleEnum[] @map("listing_approval_permission") - duplicateListingPermissions UserRoleEnum[] @map("duplicate_listing_permissions") - requiredListingFields String[] @default([]) @map("required_listing_fields") - visibleNeighborhoodAmenities NeighborhoodAmenitiesEnum[] @default([groceryStores, publicTransportation, schools, parksAndCommunityCenters, pharmacies, healthCareResources]) @map("visible_neighborhood_amenities") + 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") + whatToExpect String @default("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.") @map("what_to_expect") + whatToExpectAdditionalText String @default("") @map("what_to_expect_additional_text") + whatToExpectUnderConstruction String @default("") @map("what_to_expect_under_construction") + enablePartnerSettings Boolean @default(false) @map("enable_partner_settings") + enablePartnerDemographics Boolean @default(false) @map("enable_partner_demographics") + enableGeocodingPreferences Boolean @default(false) @map("enable_geocoding_preferences") + enableGeocodingRadiusMethod Boolean @default(false) @map("enable_geocoding_radius_method") + allowSingleUseCodeLogin Boolean @default(false) @map("allow_single_use_code_login") + amiChart AmiChart[] + featureFlags FeatureFlags[] + minimumListingPublishImagesRequired Int? @default(1) @map("minimum_listing_publish_images_required") + multiselectQuestions MultiselectQuestions[] + listings Listings[] + reservedCommunityTypes ReservedCommunityTypes[] + translations Translations[] + user_accounts UserAccounts[] + listingApprovalPermissions UserRoleEnum[] @map("listing_approval_permission") + duplicateListingPermissions UserRoleEnum[] @map("duplicate_listing_permissions") + requiredListingFields String[] @default([]) @map("required_listing_fields") + visibleNeighborhoodAmenities NeighborhoodAmenitiesEnum[] @default([groceryStores, publicTransportation, schools, parksAndCommunityCenters, pharmacies, healthCareResources]) @map("visible_neighborhood_amenities") @@map("jurisdictions") } @@ -461,11 +467,12 @@ model ListingFeatures { } 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: Cascade, onUpdate: NoAction) + ordinal Int? + description String? + 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: Cascade, onUpdate: NoAction) @@id([listingId, imageId]) @@index([listingId]) @@ -527,6 +534,24 @@ model ListingUtilities { @@map("listing_utilities") } +model ListingDocuments { + 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) + socialSecurityCard Boolean? @map("social_security_card") + currentLandlordReference Boolean? @map("current_landlord_reference") + birthCertificate Boolean? @map("birth_certificate") + previousLandlordReference Boolean? @map("previous_landlord_reference") + governmentIssuedId Boolean? @map("government_issued_id") + proofOfAssets Boolean? @map("proof_of_assets") + proofOfIncome Boolean? @map("proof_of_income") + residencyDocuments Boolean? @map("residency_documents") + proofOfCustody Boolean? @map("proof_of_custody") + listings Listings? + + @@map("listing_documents") +} + enum ListingTypeEnum { regulated nonRegulated @@ -566,6 +591,7 @@ model Listings { amenities String? buildingTotalUnits Int? @map("building_total_units") developer String? + listingFileNumber String? @map("listing_file_number") householdSizeMax Int? @map("household_size_max") householdSizeMin Int? @map("household_size_min") neighborhood String? @@ -585,6 +611,8 @@ model Listings { applicationDropOffAddressType ApplicationAddressTypeEnum? @map("application_drop_off_address_type") applicationMailingAddressType ApplicationAddressTypeEnum? @map("application_mailing_address_type") buildingSelectionCriteria String? @map("building_selection_criteria") + marketingFlyer String? @map("marketing_flyer") + accessibleMarketingFlyer String? @map("accessible_marketing_flyer") cocInfo String? @map("coc_info") costsNotIncluded String? @map("costs_not_included") creditHistory String? @map("credit_history") @@ -593,8 +621,6 @@ model Listings { depositMax String? @map("deposit_max") depositType DepositTypeEnum? @map("deposit_type") depositValue Decimal? @map("deposit_value") @db.Decimal(8, 2) - depositRangeMin Int? @map("deposit_range_min") - depositRangeMax Int? @map("deposit_range_max") depositHelperText String? @map("deposit_helper_text") disableUnitsAccordion Boolean? @map("disable_units_accordion") hasHudEbllClearance Boolean? @map("has_hud_ebll_clearance") @@ -610,6 +636,7 @@ model Listings { rentalAssistance String? @map("rental_assistance") rentalHistory String? @map("rental_history") requiredDocuments String? @map("required_documents") + requiredDocumentsList ListingDocuments? @relation(fields: [documentsId], references: [id], onDelete: NoAction, onUpdate: NoAction) specialNotes String? @map("special_notes") waitlistCurrentSize Int? @map("waitlist_current_size") waitlistMaxSize Int? @map("waitlist_max_size") @@ -636,6 +663,8 @@ model Listings { 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 + marketingFlyerFileId String? @map("marketing_flyer_file_id") @db.Uuid + accessibleMarketingFlyerFileId String? @map("accessible_marketing_flyer_file_id") @db.Uuid copyOfId String? @map("copy_of_id") @db.Uuid jurisdictionId String? @map("jurisdiction_id") @db.Uuid leasingAgentAddressId String? @map("leasing_agent_address_id") @db.Uuid @@ -643,6 +672,7 @@ model Listings { resultId String? @map("result_id") @db.Uuid featuresId String? @unique() @map("features_id") @db.Uuid utilitiesId String? @unique() @map("utilities_id") @db.Uuid + documentsId String? @unique() @map("documents_id") @db.Uuid includeCommunityDisclaimer Boolean? @map("include_community_disclaimer") communityDisclaimerTitle String? @map("community_disclaimer_title") communityDisclaimerDescription String? @map("community_disclaimer_description") @@ -664,6 +694,7 @@ model Listings { marketingMonth MonthEnum? @map("marketing_month") whatToExpectAdditionalText String? @map("what_to_expect_additional_text") section8Acceptance Boolean? @map("section8_acceptance") + creditScreeningFee String? @map("credit_screening_fee") neighborhoodAmenitiesId String? @unique() @map("neighborhood_amenities_id") @db.Uuid verifiedAt DateTime? @map("verified_at") @db.Timestamptz(6) homeType HomeTypeEnum? @map("home_type") @@ -679,6 +710,8 @@ model Listings { 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) + listingsMarketingFlyerFile Assets? @relation("marketing_flyer_file", fields: [marketingFlyerFileId], references: [id], onDelete: NoAction, onUpdate: NoAction) + listingsAccessibleMarketingFlyerFile Assets? @relation("accessible_marketing_flyer_file", fields: [accessibleMarketingFlyerFileId], 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) @@ -703,6 +736,7 @@ model Listings { Listings Listings[] @relation("copy_of") lastUpdatedByUserId String? @map("last_updated_by_user_id") @db.Uuid lastUpdatedByUser UserAccounts? @relation("last_updated_by_user", fields: [lastUpdatedByUserId], references: [id], onDelete: NoAction, onUpdate: NoAction) + parkingFee String? @map("parking_fee") @@index([jurisdictionId]) @@map("listings") @@ -999,6 +1033,12 @@ model ListingNeighborhoodAmenities { parksAndCommunityCenters String? @map("parks_and_community_centers") schools String? publicTransportation String? @map("public_transportation") + shoppingVenues String? @map("shopping_venues") + hospitals String? + seniorCenters String? @map("senior_centers") + recreationalFacilities String? @map("recreational_facilities") + playgrounds String? + busStops String? @map("bus_stops") listings Listings? @@map("listing_neighborhood_amenities") @@ -1009,8 +1049,9 @@ model UnitGroup { updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamp(6) maxOccupancy Int? @map("max_occupancy") minOccupancy Int? @map("min_occupancy") - flatRentValueFrom Decimal? @map("flat_rent_value_from") - flatRentValueTo Decimal? @map("flat_rent_value_to") + flatRentValueFrom Decimal? @map("flat_rent_value_from") @db.Decimal + flatRentValueTo Decimal? @map("flat_rent_value_to") @db.Decimal + monthlyRent Decimal? @map("monthly_rent") @db.Decimal floorMin Int? @map("floor_min") floorMax Int? @map("floor_max") totalCount Int? @map("total_count") @@ -1198,9 +1239,11 @@ enum IncomePeriodEnum { } enum ApplicationStatusEnum { - draft submitted - removed + declined + receivedUnit + waitlist + waitlistDeclined @@map("application_status_enum") } @@ -1322,6 +1365,12 @@ enum NeighborhoodAmenitiesEnum { parksAndCommunityCenters pharmacies healthCareResources + shoppingVenues + hospitals + seniorCenters + recreationalFacilities + playgrounds + busStops @@map("neighborhood_amenities_enum") } diff --git a/api/prisma/seed-dev.ts b/api/prisma/seed-dev.ts index 2f1b306888..61d2426eba 100644 --- a/api/prisma/seed-dev.ts +++ b/api/prisma/seed-dev.ts @@ -99,17 +99,19 @@ export const devSeeding = async ( }); // add jurisdiction specific translations and default ones await prismaClient.translations.create({ - data: translationFactory(jurisdiction.id, jurisdiction.name), + data: translationFactory({ + jurisdiction: { id: jurisdiction.id, name: jurisdiction.name }, + }), }); await prismaClient.translations.create({ - data: translationFactory(undefined, undefined, LanguagesEnum.es), + data: translationFactory({ language: LanguagesEnum.es }), }); await prismaClient.translations.create({ data: translationFactory(), }); const unitTypes = await unitTypeFactoryAll(prismaClient); const amiChart = await prismaClient.amiChart.create({ - data: amiChartFactory(10, jurisdiction.id), + data: amiChartFactory(10, jurisdiction.id, null, jurisdiction.name), }); const multiselectQuestions = await Promise.all( await createMultiselect(jurisdiction.id, prismaClient), @@ -160,7 +162,6 @@ export const devSeeding = async ( householdSize - 1, ); const app = await applicationFactory({ - householdSize, unitTypeId: unitTypes[randomInt(0, 5)].id, householdMember: householdMembers, multiselectQuestions, diff --git a/api/prisma/seed-helpers/address-factory.ts b/api/prisma/seed-helpers/address-factory.ts index 4d41a5f58d..a21424b8a9 100644 --- a/api/prisma/seed-helpers/address-factory.ts +++ b/api/prisma/seed-helpers/address-factory.ts @@ -21,8 +21,8 @@ export const yellowstoneAddress = { state: 'WY', street: '3200 Old Faithful Inn Rd', zipCode: '82190', - latitude: 44.459928576661824, - longitude: -110.83109211487681, + latitude: 44.45992857666182, + longitude: -110.8310921148768, }; export const yosemiteAddress = { diff --git a/api/prisma/seed-helpers/ami-chart-factory.ts b/api/prisma/seed-helpers/ami-chart-factory.ts index 9c76e69d5a..7a7ddcc916 100644 --- a/api/prisma/seed-helpers/ami-chart-factory.ts +++ b/api/prisma/seed-helpers/ami-chart-factory.ts @@ -5,8 +5,9 @@ export const amiChartFactory = ( numberToCreate: number, jurisdictionId: string, offset?: number, + jurisdictionName?: string, ): Prisma.AmiChartCreateInput => ({ - name: randomName(), + name: `${randomName()}${jurisdictionName ? ` - ${jurisdictionName}` : ''}`, items: amiChartItemsFactory(numberToCreate, offset), jurisdictions: { connect: { diff --git a/api/prisma/seed-helpers/application-factory.ts b/api/prisma/seed-helpers/application-factory.ts index 481b6f8d0c..553b27a4e3 100644 --- a/api/prisma/seed-helpers/application-factory.ts +++ b/api/prisma/seed-helpers/application-factory.ts @@ -22,16 +22,18 @@ import { randomBoolean } from './boolean-generator'; export const applicationFactory = async (optionalParams?: { createdAt?: Date; - householdSize?: number; unitTypeId?: string; applicant?: Prisma.ApplicantCreateWithoutApplicationsInput; - overrides?: Prisma.ApplicationsCreateInput; listingId?: string; householdMember?: Prisma.HouseholdMemberCreateWithoutApplicationsInput[]; demographics?: Prisma.DemographicsCreateWithoutApplicationsInput; multiselectQuestions?: Partial[]; userId?: string; submissionType?: ApplicationSubmissionTypeEnum; + isNewest?: boolean; + expireAfter?: Date; + wasPIICleared?: boolean; + additionalPhone?: string; }): Promise => { let preferredUnitTypes: Prisma.UnitTypesCreateNestedManyWithoutApplicationsInput; if (optionalParams?.unitTypeId) { @@ -44,7 +46,12 @@ export const applicationFactory = async (optionalParams?: { }; } const demographics = await demographicsFactory(); - const additionalPhone = randomBoolean(); + const includeAdditionalPhone = + !!optionalParams?.additionalPhone || randomBoolean(); + let householdSize = 1; + if (optionalParams?.householdMember) { + householdSize = optionalParams.householdMember.length + 1; + } return { createdAt: optionalParams?.createdAt || new Date(), confirmationCode: generateConfirmationCode(), @@ -55,7 +62,7 @@ export const applicationFactory = async (optionalParams?: { optionalParams?.submissionType ?? ApplicationSubmissionTypeEnum.electronical, submissionDate: new Date(), - householdSize: optionalParams?.householdSize ?? 1, + householdSize: householdSize, income: '40000', incomePeriod: randomBoolean() ? IncomePeriodEnum.perYear @@ -90,7 +97,6 @@ export const applicationFactory = async (optionalParams?: { }, } : undefined, - ...optionalParams?.overrides, householdMember: optionalParams?.householdMember ? { create: optionalParams.householdMember, @@ -108,9 +114,14 @@ export const applicationFactory = async (optionalParams?: { } : undefined, incomeVouchers: randomBoolean(), - additionalPhoneNumber: additionalPhone ? '(456) 456-4564' : undefined, - additionalPhone, - additionalPhoneNumberType: additionalPhone ? 'cell' : undefined, + additionalPhoneNumber: includeAdditionalPhone + ? optionalParams?.additionalPhone || '(456) 456-4564' + : undefined, + additionalPhone: includeAdditionalPhone, + additionalPhoneNumberType: includeAdditionalPhone ? 'cell' : undefined, + isNewest: optionalParams?.isNewest || false, + expireAfter: optionalParams?.expireAfter, + wasPIICleared: optionalParams?.wasPIICleared || false, }; }; diff --git a/api/prisma/seed-helpers/household-member-factory.ts b/api/prisma/seed-helpers/household-member-factory.ts index 115a23df0f..a8a54d2750 100644 --- a/api/prisma/seed-helpers/household-member-factory.ts +++ b/api/prisma/seed-helpers/household-member-factory.ts @@ -8,6 +8,7 @@ import { randomBirthMonth, randomBirthYear, } from './number-generator'; +import { randomBoolean } from './boolean-generator'; export const householdMemberFactorySingle = ( index: number, @@ -17,22 +18,27 @@ export const householdMemberFactorySingle = ( const lastName = randomNoun(); const relationshipKeys = Object.values(HouseholdMemberRelationship); + const sameAddress = randomBoolean(); + const workInRegion = randomBoolean(); return { firstName: firstName, middleName: randomNoun(), lastName: lastName, - // Question: why are these strings? birthMonth: randomBirthMonth(), birthDay: randomBirthDay(), birthYear: randomBirthYear(), - sameAddress: YesNoEnum.yes, + sameAddress: sameAddress ? YesNoEnum.yes : YesNoEnum.no, relationship: relationshipKeys[randomInt(relationshipKeys.length)], - workInRegion: YesNoEnum.yes, - householdMemberAddress: { create: addressFactory() }, - householdMemberWorkAddress: { - create: addressFactory(), - }, + workInRegion: workInRegion ? YesNoEnum.yes : YesNoEnum.no, + householdMemberAddress: sameAddress + ? undefined + : { create: addressFactory() }, + householdMemberWorkAddress: workInRegion + ? { + create: addressFactory(), + } + : undefined, orderId: index, ...overrides, }; diff --git a/api/prisma/seed-helpers/jurisdiction-factory.ts b/api/prisma/seed-helpers/jurisdiction-factory.ts index 259af7dce8..10a8c65c39 100644 --- a/api/prisma/seed-helpers/jurisdiction-factory.ts +++ b/api/prisma/seed-helpers/jurisdiction-factory.ts @@ -15,6 +15,7 @@ export const jurisdictionFactory = ( requiredListingFields?: string[]; languages?: LanguagesEnum[]; visibleNeighborhoodAmenities?: NeighborhoodAmenitiesEnum[]; + minimumListingPublishImagesRequired?: number; }, ): Prisma.JurisdictionsCreateInput => ({ name: jurisdictionName, @@ -50,4 +51,6 @@ export const jurisdictionFactory = ( : undefined, requiredListingFields: optionalFields?.requiredListingFields || [], visibleNeighborhoodAmenities: optionalFields?.visibleNeighborhoodAmenities, + minimumListingPublishImagesRequired: + optionalFields?.minimumListingPublishImagesRequired, }); diff --git a/api/prisma/seed-helpers/listing-factory.ts b/api/prisma/seed-helpers/listing-factory.ts index 1d192213de..9f501eec01 100644 --- a/api/prisma/seed-helpers/listing-factory.ts +++ b/api/prisma/seed-helpers/listing-factory.ts @@ -8,6 +8,7 @@ import { PrismaClient, ReservedCommunityTypes, ReviewOrderTypeEnum, + ListingTypeEnum, } from '@prisma/client'; import { randomInt } from 'crypto'; import dayjs from 'dayjs'; @@ -51,6 +52,18 @@ type optionalFeatures = { loweredCabinets?: boolean; }; +type requiredDocuments = { + socialSecurityCard?: boolean; + currentLandlordReference?: boolean; + birthCertificate?: boolean; + previousLandlordReference?: boolean; + governmentIssuedId?: boolean; + proofOfAssets?: boolean; + proofOfIncome?: boolean; + residencyDocuments?: boolean; + proofOfCustody?: boolean; +}; + type optionalUtilities = { water?: boolean; gas?: boolean; @@ -89,6 +102,7 @@ export const listingFactory = async ( unitGroups?: Prisma.UnitGroupCreateWithoutListingsInput[]; units?: Prisma.UnitsCreateWithoutListingsInput[]; userAccounts?: Prisma.UserAccountsWhereUniqueInput[]; + requiredDocumentsList?: requiredDocuments; }, ): Promise => { const previousListing = optionalParams?.listing || {}; @@ -260,6 +274,9 @@ export const listingFactory = async ( optionalParams?.optionalFeatures, optionalParams?.optionalUtilities, ), + ...(optionalParams?.listing?.listingType === ListingTypeEnum.nonRegulated + ? listingsRequiredDocuments(optionalParams?.requiredDocumentsList) + : {}), ...previousListing, }; }; @@ -289,6 +306,27 @@ const buildingFeatures = (includeBuildingFeatures: boolean) => { }; }; +export const listingsRequiredDocuments = ( + requiredDocumentsList?: requiredDocuments, +): { + requiredDocumentsList; +} => ({ + requiredDocumentsList: { + create: { + socialSecurityCard: randomBoolean(), + currentLandlordReference: randomBoolean(), + birthCertificate: randomBoolean(), + previousLandlordReference: randomBoolean(), + governmentIssuedId: randomBoolean(), + proofOfAssets: randomBoolean(), + proofOfIncome: randomBoolean(), + residencyDocuments: randomBoolean(), + proofOfCustody: randomBoolean(), + ...requiredDocumentsList, + }, + }, +}); + export const featuresAndUtilites = ( optionalFeatures?: optionalFeatures, optionalUtilities?: optionalUtilities, diff --git a/api/prisma/seed-helpers/multiselect-question-factory.ts b/api/prisma/seed-helpers/multiselect-question-factory.ts index 4c884f72b8..25cd5930d2 100644 --- a/api/prisma/seed-helpers/multiselect-question-factory.ts +++ b/api/prisma/seed-helpers/multiselect-question-factory.ts @@ -16,35 +16,62 @@ export const multiselectQuestionFactory = ( optOut?: boolean; multiselectQuestion?: Partial; }, + version2 = false, ): Prisma.MultiselectQuestionsCreateInput => { const previousMultiselectQuestion = optionalParams?.multiselectQuestion || {}; + const name = optionalParams?.multiselectQuestion?.name || randomName(); 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, + const baseFields = { applicationSection: optionalParams?.multiselectQuestion?.applicationSection || multiselectAppSectionAsArray[ randomInt(multiselectAppSectionAsArray.length) ], - - // TODO: Temporary until after MSQ refactor - isExclusive: optionalParams?.multiselectQuestion?.isExclusive ?? false, - multiselectOptions: undefined, - name: text, - status: MultiselectQuestionsStatusEnum.draft, - - ...previousMultiselectQuestion, + hideFromListing: false, jurisdiction: { connect: { id: jurisdictionId, }, }, + links: [], + }; + + const v1Fields = { + description: `description of ${text}`, + isExclusive: false, + name: text, + options: multiselectOptionFactory(randomInt(1, 3)), + optOutText: optionalParams?.optOut ? "I don't want this preference" : null, + status: MultiselectQuestionsStatusEnum.draft, + subText: `sub text for ${text}`, + text: text, + }; + const v2Fields = { + description: `description of ${name}`, + isExclusive: optionalParams?.multiselectQuestion?.isExclusive ?? false, + multiselectOptions: { + createMany: { + data: multiselectOptionFactoryV2(randomInt(1, 3)), + }, + }, + name: name, + subText: `sub text for ${name}`, + status: MultiselectQuestionsStatusEnum.draft, + // TODO: Can be removed after MSQ refactor + text: name, + }; + + if (version2) { + return { + ...v2Fields, + ...previousMultiselectQuestion, + ...baseFields, + }; + } + return { + ...v1Fields, + ...previousMultiselectQuestion, + ...baseFields, }; }; @@ -58,3 +85,11 @@ const multiselectOptionFactory = ( collectAddress: index % 2 === 0, })); }; + +const multiselectOptionFactoryV2 = (numberToMake: number) => { + if (!numberToMake) return []; + return [...new Array(numberToMake)].map((_, index) => ({ + name: randomNoun(), + ordinal: index, + })); +}; diff --git a/api/prisma/seed-helpers/translation-factory.ts b/api/prisma/seed-helpers/translation-factory.ts index 803f2c71c9..05eea243cb 100644 --- a/api/prisma/seed-helpers/translation-factory.ts +++ b/api/prisma/seed-helpers/translation-factory.ts @@ -1,7 +1,23 @@ import { LanguagesEnum, Prisma } from '@prisma/client'; -const translations = (jurisdictionName?: string, language?: LanguagesEnum) => { +const translations = ( + jurisdiction?: { + id: string; + name: string; + }, + language?: LanguagesEnum, +) => { if (!language || language === LanguagesEnum.en) { + if (jurisdiction) { + return { + footer: { + line1: jurisdiction.name, + line2: '', + thankYou: 'Thank you', + footer: jurisdiction.name, + }, + }; + } return { t: { hello: 'Hello', @@ -12,10 +28,10 @@ const translations = (jurisdictionName?: string, language?: LanguagesEnum) => { reviewListing: 'Review Listing', }, footer: { - line1: `${jurisdictionName || 'Detroit'}`, + line1: 'Detroit', line2: '', thankYou: 'Thank you', - footer: `${jurisdictionName || 'Detroit Home Connect'}`, + footer: 'Detroit Home Connect', }, header: { logoUrl: 'https://detroitmi.gov/themes/custom/detroitminew/logo.svg', @@ -52,6 +68,10 @@ const translations = (jurisdictionName?: string, language?: LanguagesEnum) => { 'Once the application period closes, applicants will be placed in order based on lottery rank order.', waitlist: 'Applicants will be placed on the waitlist on a first come first serve basis until waitlist spots are filled.', + waitlistLottery: + 'Eligible applicants will be placed on the waitlist based on lottery rank order.', + 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.', }, @@ -184,12 +204,21 @@ const translations = (jurisdictionName?: string, language?: LanguagesEnum) => { 'If you want to learn about how lotteries work, please see the lottery section of the', otherOpportunities4: 'Housing Portal Help Center', }, + accountRemoval: { + subject: 'Bloom Housing Scheduled Account Removal Due to Inactivity', + courtesyText: + 'This is a courtesy email to let you know that because your Bloom Housing Portal account has been inactive for 3 years, your account will be deleted in 30 days per our Terms of Use and Privacy Policy. If you’d like to keep your account, please log in sometime in the next month and we’ll consider your account active again.', + signIn: 'Sign in to Bloom Housing', + }, }; } else if (language === LanguagesEnum.es) { return { - t: { seeListing: 'VER EL LISTADO' }, + t: { + hello: 'Hola', + seeListing: 'VER EL LISTADO', + }, footer: { - line1: `${jurisdictionName || 'Bloom'}`, + line1: 'Bloom', line2: '', }, confirmation: { @@ -230,22 +259,42 @@ const translations = (jurisdictionName?: string, language?: LanguagesEnum) => { 'Si desea obtener información sobre cómo funcionan las loterías, consulte la sección de lotería del', otherOpportunities4: 'Housing Portal Centro de ayuda', }, + accountRemoval: { + subject: + 'Eliminación programada de cuenta de Bloom Housing debido a inactividad', + courtesyText: + 'Este es un correo electrónico de cortesía para informarle que, debido a que su cuenta del Portal de Bloom Housing ha estado inactiva durante 3 años, se eliminará en 30 días según nuestros Términos de Uso y Política de Privacidad. Si desea conservar su cuenta, inicie sesión durante el próximo mes y la consideraremos activa de nuevo.', + signIn: 'Iniciar sesión en Bloom Housing', + }, + register: { + welcome: 'Bienvenido', + welcomeMessage: + 'Gracias por crear su cuenta en %{appUrl}. Ahora le resultará más fácil iniciar, guardar y enviar solicitudes en línea para los anuncios que aparecen en el sitio.', + confirmMyAccount: 'Confirmar mi cuenta', + toConfirmAccountMessage: + 'Para completar la creación de su cuenta, haga clic en el siguiente enlace:', + }, }; } }; -export const translationFactory = ( - jurisdictionId?: string, - jurisdictionName?: string, - language?: LanguagesEnum, -): Prisma.TranslationsCreateInput => { +export const translationFactory = (optionalParams?: { + jurisdiction?: { + id: string; + name: string; + }; + language?: LanguagesEnum; +}): Prisma.TranslationsCreateInput => { return { - language: language || LanguagesEnum.en, - translations: translations(jurisdictionName, language), - jurisdictions: jurisdictionId + language: optionalParams?.language || LanguagesEnum.en, + translations: translations( + optionalParams?.jurisdiction, + optionalParams?.language, + ), + jurisdictions: optionalParams?.jurisdiction ? { connect: { - id: jurisdictionId, + id: optionalParams.jurisdiction.id, }, } : undefined, diff --git a/api/prisma/seed-helpers/user-factory.ts b/api/prisma/seed-helpers/user-factory.ts index 2b064bf5a3..f95e5221d6 100644 --- a/api/prisma/seed-helpers/user-factory.ts +++ b/api/prisma/seed-helpers/user-factory.ts @@ -1,4 +1,4 @@ -import { Prisma } from '@prisma/client'; +import { LanguagesEnum, Prisma } from '@prisma/client'; import { randomAdjective, randomNoun } from './word-generator'; import { passwordToHash } from '../../src/utilities/password-helpers'; @@ -18,9 +18,14 @@ export const userFactory = async (optionalParams?: { phoneNumberVerified?: boolean; roles?: Prisma.UserRolesUncheckedCreateWithoutUserAccountsInput; singleUseCode?: string; + lastLoginAt?: Date; + wasWarnedOfDeletion?: boolean; + language?: LanguagesEnum; }): Promise => ({ agreedToTermsOfService: optionalParams?.acceptedTerms || false, confirmedAt: optionalParams?.confirmedAt || null, + lastLoginAt: optionalParams?.lastLoginAt || new Date(), + wasWarnedOfDeletion: optionalParams?.wasWarnedOfDeletion || false, email: optionalParams?.email?.toLocaleLowerCase() || `${randomNoun().toLowerCase()}${randomNoun().toLowerCase()}@${randomAdjective().toLowerCase()}.com`, @@ -35,7 +40,7 @@ export const userFactory = async (optionalParams?: { phoneNumberVerified: optionalParams?.phoneNumberVerified || null, singleUseCode: optionalParams?.singleUseCode || null, singleUseCodeUpdatedAt: optionalParams?.mfaEnabled ? new Date() : undefined, - + language: optionalParams?.language || undefined, favoriteListings: optionalParams?.favoriteListings ? { connect: optionalParams.favoriteListings.map((listing) => { diff --git a/api/prisma/seed-staging.ts b/api/prisma/seed-staging.ts index 49b8576d50..b970bbcdbf 100644 --- a/api/prisma/seed-staging.ts +++ b/api/prisma/seed-staging.ts @@ -36,6 +36,7 @@ import { littleVillageApartments } from './seed-helpers/listing-data/little-vill import { elmVillage } from './seed-helpers/listing-data/elm-village'; import { lakeviewVilla } from './seed-helpers/listing-data/lakeview-villa'; import { sunshineFlats } from './seed-helpers/listing-data/sunshine-flats'; +import dayjs from 'dayjs'; export const stagingSeed = async ( prismaClient: PrismaClient, @@ -54,6 +55,7 @@ export const stagingSeed = async ( FeatureFlagEnum.enableGeocodingRadiusMethod, FeatureFlagEnum.enableHomeType, FeatureFlagEnum.enableIsVerified, + FeatureFlagEnum.enableLeasingAgentAltText, FeatureFlagEnum.enableListingFavoriting, FeatureFlagEnum.enableListingFiltering, FeatureFlagEnum.enableListingOpportunity, @@ -102,6 +104,7 @@ export const stagingSeed = async ( FeatureFlagEnum.enableHomeType, FeatureFlagEnum.enableIsVerified, FeatureFlagEnum.enableLimitedHowDidYouHear, + FeatureFlagEnum.enableLeasingAgentAltText, FeatureFlagEnum.enableListingFavoriting, FeatureFlagEnum.enableListingFiltering, FeatureFlagEnum.enableListingOpportunity, @@ -139,6 +142,7 @@ export const stagingSeed = async ( featureFlags: [ FeatureFlagEnum.enableGeocodingPreferences, FeatureFlagEnum.enableGeocodingRadiusMethod, + FeatureFlagEnum.enableLeasingAgentAltText, FeatureFlagEnum.enableListingFiltering, FeatureFlagEnum.enableListingOpportunity, FeatureFlagEnum.enableListingPagination, @@ -158,26 +162,46 @@ export const stagingSeed = async ( const angelopolisJurisdiction = await prismaClient.jurisdictions.create({ data: jurisdictionFactory('Angelopolis', { featureFlags: [ + FeatureFlagEnum.disableBuildingSelectionCriteria, + FeatureFlagEnum.disableListingPreferences, FeatureFlagEnum.enableAccessibilityFeatures, + FeatureFlagEnum.enableApplicationStatus, + FeatureFlagEnum.enableCreditScreeningFee, FeatureFlagEnum.enableHousingDeveloperOwner, + FeatureFlagEnum.enableLeasingAgentAltText, + FeatureFlagEnum.enableListingFileNumber, FeatureFlagEnum.enableListingFiltering, + FeatureFlagEnum.enableListingImageAltText, + FeatureFlagEnum.enableMarketingFlyer, FeatureFlagEnum.enableMarketingStatus, FeatureFlagEnum.enableMarketingStatusMonths, FeatureFlagEnum.enableNeighborhoodAmenities, FeatureFlagEnum.enableNeighborhoodAmenitiesDropdown, + FeatureFlagEnum.enableParkingFee, + FeatureFlagEnum.enableProperties, + FeatureFlagEnum.enableReferralQuestionUnits, + FeatureFlagEnum.enableSmokingPolicyRadio, ], visibleNeighborhoodAmenities: [ NeighborhoodAmenitiesEnum.groceryStores, NeighborhoodAmenitiesEnum.pharmacies, + NeighborhoodAmenitiesEnum.shoppingVenues, + NeighborhoodAmenitiesEnum.hospitals, + NeighborhoodAmenitiesEnum.seniorCenters, + NeighborhoodAmenitiesEnum.recreationalFacilities, + NeighborhoodAmenitiesEnum.playgrounds, + NeighborhoodAmenitiesEnum.busStops, ], - + minimumListingPublishImagesRequired: 3, requiredListingFields: [ 'digitalApplication', 'jurisdictions', 'leasingAgentEmail', 'leasingAgentName', 'leasingAgentPhone', + 'listingFileNumber', 'listingImages', + 'listingImages.description', 'listingsBuildingAddress', 'name', 'paperApplication', @@ -316,30 +340,46 @@ export const stagingSeed = async ( }); // add jurisdiction specific translations and default ones await prismaClient.translations.create({ - data: translationFactory(mainJurisdiction.id, mainJurisdiction.name), + data: translationFactory({ + jurisdiction: { id: mainJurisdiction.id, name: mainJurisdiction.name }, + }), }); await prismaClient.translations.create({ - data: translationFactory( - mainJurisdiction.id, - mainJurisdiction.name, - LanguagesEnum.es, - ), + data: translationFactory({ language: LanguagesEnum.es }), }); await prismaClient.translations.create({ data: translationFactory(), }); // build ami charts const amiChart = await prismaClient.amiChart.create({ - data: amiChartFactory(10, mainJurisdiction.id), + data: amiChartFactory(10, mainJurisdiction.id, null, mainJurisdiction.name), }); await prismaClient.amiChart.create({ - data: amiChartFactory(10, mainJurisdiction.id, 2), + data: amiChartFactory(10, mainJurisdiction.id, 2, mainJurisdiction.name), }); const lakeviewAmiChart = await prismaClient.amiChart.create({ - data: amiChartFactory(8, lakeviewJurisdiction.id), + data: amiChartFactory( + 8, + lakeviewJurisdiction.id, + null, + lakeviewJurisdiction.name, + ), }); await prismaClient.amiChart.create({ - data: amiChartFactory(8, lakeviewJurisdiction.id, 2), + data: amiChartFactory( + 8, + lakeviewJurisdiction.id, + 2, + lakeviewJurisdiction.name, + ), + }); + await prismaClient.amiChart.create({ + data: amiChartFactory( + 10, + angelopolisJurisdiction.id, + 2, + angelopolisJurisdiction.name, + ), }); // Create map layers await prismaClient.mapLayers.create({ @@ -759,6 +799,38 @@ export const stagingSeed = async ( { jurisdictionId: mainJurisdiction.id, listing: valleyHeightsSeniorCommunity, + applications: [ + await applicationFactory({ + isNewest: true, + expireAfter: process.env.APPLICATION_DAYS_TILL_EXPIRY + ? dayjs(new Date()).subtract(10, 'days').toDate() + : undefined, + }), + // applications below should have their PII removed via the cron job + await applicationFactory({ + isNewest: false, + expireAfter: process.env.APPLICATION_DAYS_TILL_EXPIRY + ? dayjs(new Date()).subtract(10, 'days').toDate() + : undefined, + }), + await applicationFactory({ + isNewest: false, + expireAfter: process.env.APPLICATION_DAYS_TILL_EXPIRY + ? dayjs(new Date()).subtract(10, 'days').toDate() + : undefined, + }), + await applicationFactory({ + isNewest: false, + expireAfter: process.env.APPLICATION_DAYS_TILL_EXPIRY + ? dayjs(new Date()).subtract(10, 'days').toDate() + : undefined, + householdMember: [ + householdMemberFactorySingle(1, {}), + householdMemberFactorySingle(2, {}), + householdMemberFactorySingle(4, {}), + ], + }), + ], userAccounts: [{ id: partnerUser.id }], }, { diff --git a/api/src/controllers/ami-chart.controller.ts b/api/src/controllers/ami-chart.controller.ts index 5a0e3c93a6..28b4fa521a 100644 --- a/api/src/controllers/ami-chart.controller.ts +++ b/api/src/controllers/ami-chart.controller.ts @@ -17,6 +17,8 @@ import { ApiOperation, ApiTags, } from '@nestjs/swagger'; +// The linter struggles with this import. It flags it as not being used despite its usage +// eslint-disable-next-line @typescript-eslint/no-unused-vars 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'; diff --git a/api/src/controllers/application.controller.ts b/api/src/controllers/application.controller.ts index 09fe6c3a5b..9703546b75 100644 --- a/api/src/controllers/application.controller.ts +++ b/api/src/controllers/application.controller.ts @@ -266,6 +266,16 @@ export class ApplicationController { }; } + @Put('removePIICronJob') + @ApiOperation({ + summary: 'trigger the remove PII cron job', + operationId: 'removePIICronJob', + }) + @ApiOkResponse({ type: SuccessDTO }) + async removePIICronJob(): Promise { + return await this.applicationService.removePIICronJob(); + } + @Put(`:id`) @ApiOperation({ summary: 'Update application by id', operationId: 'update' }) @ApiOkResponse({ type: Application }) diff --git a/api/src/controllers/multiselect-question.controller.ts b/api/src/controllers/multiselect-question.controller.ts index cf5835ef75..af20b8ce62 100644 --- a/api/src/controllers/multiselect-question.controller.ts +++ b/api/src/controllers/multiselect-question.controller.ts @@ -1,3 +1,4 @@ +import { Request as ExpressRequest } from 'express'; import { Body, Controller, @@ -6,6 +7,7 @@ import { Param, Post, Put, + Request, Query, UseGuards, UseInterceptors, @@ -18,22 +20,25 @@ import { ApiOperation, ApiTags, } from '@nestjs/swagger'; -import { MultiselectQuestionService } from '../services/multiselect-question.service'; +import { PermissionAction } from '../decorators/permission-action.decorator'; +import { PermissionTypeDecorator } from '../decorators/permission-type.decorator'; import { MultiselectQuestion } from '../dtos/multiselect-questions/multiselect-question.dto'; import { MultiselectQuestionCreate } from '../dtos/multiselect-questions/multiselect-question-create.dto'; +import { MultiselectQuestionFilterParams } from '../dtos/multiselect-questions/multiselect-question-filter-params.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 { SuccessDTO } from '../dtos/shared/success.dto'; +import { User } from '../dtos/users/user.dto'; +import { permissionActions } from '../enums/permissions/permission-actions-enum'; +import { ApiKeyGuard } from '../guards/api-key.guard'; import { AdminOrJurisdictionalAdminGuard } from '../guards/admin-or-jurisdiction-admin.guard'; +import { OptionalAuthGuard } from '../guards/optional.guard'; import { ActivityLogInterceptor } from '../interceptors/activity-log.interceptor'; -import { ApiKeyGuard } from '../guards/api-key.guard'; +import { MultiselectQuestionService } from '../services/multiselect-question.service'; +import { defaultValidationPipeOptions } from '../utilities/default-validation-pipe-options'; +import { mapTo } from '../utilities/mapTo'; @Controller('multiselectQuestions') @ApiTags('multiselectQuestions') @@ -47,7 +52,7 @@ import { ApiKeyGuard } from '../guards/api-key.guard'; IdDTO, ) @PermissionTypeDecorator('multiselectQuestion') -@UseGuards(ApiKeyGuard, OptionalAuthGuard, PermissionGuard) +@UseGuards(ApiKeyGuard, OptionalAuthGuard) export class MultiselectQuestionController { constructor( private readonly multiselectQuestionService: MultiselectQuestionService, @@ -62,42 +67,38 @@ export class MultiselectQuestionController { 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) + @UseGuards(AdminOrJurisdictionalAdminGuard) async create( @Body() multiselectQuestion: MultiselectQuestionCreate, + @Request() req: ExpressRequest, ): Promise { - return await this.multiselectQuestionService.create(multiselectQuestion); + return await this.multiselectQuestionService.create( + multiselectQuestion, + mapTo(User, req['user']), + ); } - @Put(`:multiselectQuestionId`) + @Put() @ApiOperation({ summary: 'Update multiselect question', operationId: 'update', }) @ApiOkResponse({ type: MultiselectQuestion }) - @UseGuards(OptionalAuthGuard, AdminOrJurisdictionalAdminGuard) + @UseGuards(AdminOrJurisdictionalAdminGuard) async update( @Body() multiselectQuestion: MultiselectQuestionUpdate, + @Request() req: ExpressRequest, ): Promise { - return await this.multiselectQuestionService.update(multiselectQuestion); + return await this.multiselectQuestionService.update( + multiselectQuestion, + mapTo(User, req['user']), + ); } @Delete() @@ -106,9 +107,74 @@ export class MultiselectQuestionController { operationId: 'delete', }) @ApiOkResponse({ type: SuccessDTO }) - @UseGuards(OptionalAuthGuard, AdminOrJurisdictionalAdminGuard) + @UseGuards(AdminOrJurisdictionalAdminGuard) + @UseInterceptors(ActivityLogInterceptor) + async delete( + @Body() dto: IdDTO, + @Request() req: ExpressRequest, + ): Promise { + return await this.multiselectQuestionService.delete( + dto.id, + mapTo(User, req['user']), + ); + } + + @Put('reActivate') + @ApiOperation({ + summary: 'Re-activate a multiselect question', + operationId: 'reActivate', + }) + @ApiOkResponse({ type: SuccessDTO }) + @UseGuards(AdminOrJurisdictionalAdminGuard) + async reActivate( + @Body() dto: IdDTO, + @Request() req: ExpressRequest, + ): Promise { + return await this.multiselectQuestionService.reActivate( + dto.id, + mapTo(User, req['user']), + ); + } + + @Put('retire') + @ApiOperation({ + summary: 'Retire a multiselect question', + operationId: 'retire', + }) + @ApiOkResponse({ type: SuccessDTO }) + @UseGuards(AdminOrJurisdictionalAdminGuard) + async retire( + @Body() dto: IdDTO, + @Request() req: ExpressRequest, + ): Promise { + return await this.multiselectQuestionService.retire( + dto.id, + mapTo(User, req['user']), + ); + } + + @Put('retireMultiselectQuestions') + @ApiOperation({ + summary: 'Trigger the retirement of multiselect questions cron job', + operationId: 'retireMultiselectQuestions', + }) + @ApiOkResponse({ type: SuccessDTO }) + @PermissionAction(permissionActions.submit) @UseInterceptors(ActivityLogInterceptor) - async delete(@Body() dto: IdDTO): Promise { - return await this.multiselectQuestionService.delete(dto.id); + @UseGuards(ApiKeyGuard, AdminOrJurisdictionalAdminGuard) + async retireMultiselectQuestions(): Promise { + return await this.multiselectQuestionService.retireMultiselectQuestions(); + } + + @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); } } diff --git a/api/src/controllers/reserved-community-type.controller.ts b/api/src/controllers/reserved-community-type.controller.ts index b4e938eed9..c59bb1d82f 100644 --- a/api/src/controllers/reserved-community-type.controller.ts +++ b/api/src/controllers/reserved-community-type.controller.ts @@ -20,6 +20,8 @@ import { 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'; +// The linter struggles with this import. It flags it as not being used despite its usage +// eslint-disable-next-line @typescript-eslint/no-unused-vars import { ReservedCommunityTypeService } from '../services/reserved-community-type.service'; import { defaultValidationPipeOptions } from '../utilities/default-validation-pipe-options'; import { ReservedCommunityTypeQueryParams } from '../dtos/reserved-community-types/reserved-community-type-query-params.dto'; diff --git a/api/src/controllers/user.controller.ts b/api/src/controllers/user.controller.ts index e02bcbe404..77d81b0b2e 100644 --- a/api/src/controllers/user.controller.ts +++ b/api/src/controllers/user.controller.ts @@ -52,6 +52,7 @@ import { UserCsvExporterService } from '../services/user-csv-export.service'; import { ExportLogInterceptor } from '../interceptors/export-log.interceptor'; import { RequestSingleUseCode } from '../dtos/single-use-code/request-single-use-code.dto'; import { ApiKeyGuard } from '../guards/api-key.guard'; +import { UserDeleteDTO } from '../dtos/users/user-delete.dto'; @Controller('user') @ApiTags('user') @@ -111,23 +112,17 @@ export class UserController { return await this.userCSVExportService.exportFile(req, res); } - @Put('forgot-password') - @ApiOperation({ summary: 'Forgot Password', operationId: 'forgotPassword' }) - @ApiOkResponse({ type: SuccessDTO }) - async forgotPassword(@Body() dto: EmailAndAppUrl): Promise { - return await this.userService.forgotPassword(dto); - } - - @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'])); + @Get('favoriteListings/:id') + @ApiOperation({ + summary: 'Get the ids of the user favorites', + operationId: 'favoriteListings', + }) + @ApiOkResponse({ type: IdDTO, isArray: true }) + @UseGuards(JwtAuthGuard, UserProfilePermissionGuard) + async favoriteListings( + @Param('id', new ParseUUIDPipe({ version: '4' })) userId: string, + ): Promise { + return await this.userService.favoriteListings(userId); } @Post() @@ -150,6 +145,22 @@ export class UserController { ); } + @Delete() + @ApiOperation({ summary: 'Delete user by id', operationId: 'delete' }) + @ApiOkResponse({ type: SuccessDTO }) + @UseGuards(OptionalAuthGuard, PermissionGuard) + @UseInterceptors(ActivityLogInterceptor) + async delete( + @Body() dto: UserDeleteDTO, + @Request() req: ExpressRequest, + ): Promise { + return await this.userService.delete( + dto.id, + mapTo(User, req['user']), + dto.shouldRemoveApplication, + ); + } + @Post('/invite') @ApiOperation({ summary: 'Invite partner user', operationId: 'invite' }) @ApiOkResponse({ type: User }) @@ -212,17 +223,11 @@ export class UserController { return await this.userService.isUserConfirmationTokenValid(dto); } - @Get('favoriteListings/:id') - @ApiOperation({ - summary: 'Get the ids of the user favorites', - operationId: 'favoriteListings', - }) - @ApiOkResponse({ type: IdDTO, isArray: true }) - @UseGuards(JwtAuthGuard, UserProfilePermissionGuard) - async favoriteListings( - @Param('id', new ParseUUIDPipe({ version: '4' })) userId: string, - ): Promise { - return await this.userService.favoriteListings(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(`modifyFavoriteListings`) @@ -242,6 +247,27 @@ export class UserController { ); } + @Put('userWarnCronJob') + @ApiOperation({ + summary: 'trigger the user warn of deletion cron job', + operationId: 'userWarnCronJob', + }) + @ApiOkResponse({ type: SuccessDTO }) + @UseGuards(OptionalAuthGuard, AdminOrJurisdictionalAdminGuard) + async userWarnCronJob(): Promise { + return await this.userService.warnUserOfDeletionCronJob(); + } + + @Put('deleteInactiveUsersCronJob') + @ApiOperation({ + summary: 'trigger the delete inactive users cron job', + operationId: 'deleteInactiveUsersCronJob', + }) + @ApiOkResponse({ type: SuccessDTO }) + async process(): Promise { + return await this.userService.deleteAfterInactivity(); + } + @Put(':id') @ApiOperation({ summary: 'Update user', operationId: 'update' }) @ApiOkResponse({ type: User }) diff --git a/api/src/decorators/fix-large-object-array.ts b/api/src/decorators/fix-large-object-array.ts index b127de89f2..10250f1e87 100644 --- a/api/src/decorators/fix-large-object-array.ts +++ b/api/src/decorators/fix-large-object-array.ts @@ -10,6 +10,7 @@ import { Transform } from 'class-transformer'; * objects with key-pairs of indices and values. This can break data input at the controller level. * The following decorator restores that object to a standard array. * See: https://stackoverflow.com/a/75380449 + * * @returns {PropertyDecorator} */ export function FixLargeObjectArray(): PropertyDecorator { diff --git a/api/src/decorators/validate-listing-deposit.decorator.ts b/api/src/decorators/validate-listing-deposit.decorator.ts index 71c5d058b7..c009bf9225 100644 --- a/api/src/decorators/validate-listing-deposit.decorator.ts +++ b/api/src/decorators/validate-listing-deposit.decorator.ts @@ -7,7 +7,7 @@ import { ValidatorOptions, } from 'class-validator'; import Listing from '../dtos/listings/listing.dto'; -import { DepositTypeEnum } from '@prisma/client'; +import { DepositTypeEnum, ListingTypeEnum } from '@prisma/client'; export function ValidateListingDeposit(validationOptions?: ValidatorOptions) { return function (object: object, propertyName: string) { @@ -28,28 +28,32 @@ export class DepositValueConstraint implements ValidatorConstraintInterface { } validate(value: DepositTypeEnum, args: ValidationArguments) { - const { depositValue, depositRangeMin, depositRangeMax } = + const { depositValue, depositMin, depositMax, listingType } = args.object as Listing; + if (!listingType || listingType === ListingTypeEnum.regulated) { + return true; + } + if (value === DepositTypeEnum.fixedDeposit) { - return ( - !this.isFieldEmpty(depositValue) && - this.isFieldEmpty(depositRangeMin) && - this.isFieldEmpty(depositRangeMax) - ); + return this.isFieldEmpty(depositMin) && this.isFieldEmpty(depositMax); + } + if (value === DepositTypeEnum.depositRange) { + return this.isFieldEmpty(depositValue); } + // If no Deposit type is selected then validate that it's either just depositValue or just the range values return ( - this.isFieldEmpty(depositValue) && - !this.isFieldEmpty(depositRangeMin) && - !this.isFieldEmpty(depositRangeMax) + this.isFieldEmpty(depositValue) || + (this.isFieldEmpty(depositMin) && this.isFieldEmpty(depositMax)) ); } defaultMessage(args?: ValidationArguments): string { const value = args.value as DepositTypeEnum; + if (value === DepositTypeEnum.fixedDeposit) { - return 'When deposit is of type "fixedDeposit" the "depositValue" must be filled and the "depositRangeMin"|"depositRangeMax" fields must be null'; + return 'When deposit is of type "fixedDeposit" the "depositValue" must be filled and the "depositMin"|"depositMax" fields must be null'; } - return 'When deposit is of type "depositRange" the "depositRangeMin" and "depositRangeMax" fields must be filled and "depositValue" must be null'; + return 'When deposit is of type "depositRange" the "depositMin" and "depositMax" fields must be filled and "depositValue" must be null'; } } diff --git a/api/src/decorators/validate-listing-images.decorator.ts b/api/src/decorators/validate-listing-images.decorator.ts new file mode 100644 index 0000000000..78dbd23ab9 --- /dev/null +++ b/api/src/decorators/validate-listing-images.decorator.ts @@ -0,0 +1,42 @@ +import { ListingImages } from '@prisma/client'; +import { + registerDecorator, + ValidationArguments, + ValidatorConstraint, + ValidatorConstraintInterface, + ValidatorOptions, +} from 'class-validator'; + +export function ValidateListingImages(validationOptions?: ValidatorOptions) { + return function (object: object, propertyName: string) { + registerDecorator({ + name: 'ValidateListingImages', + target: object.constructor, + propertyName: propertyName, + options: validationOptions, + validator: ListingImagesConstraint, + }); + }; +} + +@ValidatorConstraint() +export class ListingImagesConstraint implements ValidatorConstraintInterface { + validate( + value: ListingImages[], + validationArguments?: ValidationArguments, + ): Promise | boolean { + const isImagesFieldRequired = + validationArguments?.object['requiredFields']?.includes('listingImages'); + + const minimumImagesRequired = + validationArguments?.object['minimumImagesRequired']; + + if (!minimumImagesRequired || !isImagesFieldRequired) return true; + else { + return value?.length >= minimumImagesRequired; + } + } + defaultMessage(validationArguments?: ValidationArguments): string { + return `listingImages must contain at least ${validationArguments?.object['minimumImagesRequired']} photos`; + } +} diff --git a/api/src/decorators/validate-unit-groups-rent.decorator.ts b/api/src/decorators/validate-unit-groups-rent.decorator.ts index af080b5f52..71ef713e6c 100644 --- a/api/src/decorators/validate-unit-groups-rent.decorator.ts +++ b/api/src/decorators/validate-unit-groups-rent.decorator.ts @@ -7,7 +7,7 @@ import { ValidatorConstraintInterface, ValidatorOptions, } from 'class-validator'; -import UnitGroup from 'src/dtos/unit-groups/unit-group.dto'; +import UnitGroup from '../dtos/unit-groups/unit-group.dto'; export function ValidateUnitGroupRent(validationOptions?: ValidatorOptions) { return function (object: object, propertyName: string) { diff --git a/api/src/decorators/validate-units-rent.decorator.ts b/api/src/decorators/validate-units-rent.decorator.ts new file mode 100644 index 0000000000..a93cc22ca0 --- /dev/null +++ b/api/src/decorators/validate-units-rent.decorator.ts @@ -0,0 +1,58 @@ +import { RentTypeEnum } from '@prisma/client'; +import { + registerDecorator, + ValidationArguments, + ValidationTypes, + ValidatorConstraint, + ValidatorConstraintInterface, + ValidatorOptions, +} from 'class-validator'; +import UnitGroup from '../dtos/unit-groups/unit-group.dto'; + +export function ValidateUnitGroupRent(validationOptions?: ValidatorOptions) { + return function (object: object, propertyName: string) { + registerDecorator({ + name: ValidationTypes.CUSTOM_VALIDATION, + target: object.constructor, + propertyName: propertyName, + options: validationOptions, + validator: RentValueConstraint, + }); + }; +} + +@ValidatorConstraint() +export class RentValueConstraint implements ValidatorConstraintInterface { + isFieldEmpty(value): boolean { + return value === null || value === undefined; + } + + validate( + value: RentTypeEnum, + validationArguments?: ValidationArguments, + ): Promise | boolean { + const { flatRentValueFrom, flatRentValueTo } = + validationArguments.object as UnitGroup; + + if (value === RentTypeEnum.rentRange) { + return ( + !this.isFieldEmpty(flatRentValueFrom) && + !this.isFieldEmpty(flatRentValueTo) + ); + } else { + return ( + this.isFieldEmpty(flatRentValueFrom) && + this.isFieldEmpty(flatRentValueTo) + ); + } + + return true; + } + + defaultMessage(validationArguments?: ValidationArguments): string { + const rentType = validationArguments.value as RentTypeEnum; + if (rentType === RentTypeEnum.rentRange) { + return 'When rent is of type "rentRange" the "flatRentValueFrom" and "flatRentValueTo" fields must be filled'; + } + } +} diff --git a/api/src/dtos/addresses/address-create.dto.ts b/api/src/dtos/addresses/address-create.dto.ts index e040876165..54e51f6608 100644 --- a/api/src/dtos/addresses/address-create.dto.ts +++ b/api/src/dtos/addresses/address-create.dto.ts @@ -1,8 +1,4 @@ +import { AddressUpdate } from './address-update.dto'; import { OmitType } from '@nestjs/swagger'; -import { Address } from './address.dto'; -export class AddressCreate extends OmitType(Address, [ - 'id', - 'createdAt', - 'updatedAt', -]) {} +export class AddressCreate extends OmitType(AddressUpdate, ['id']) {} diff --git a/api/src/dtos/addresses/address-update.dto.ts b/api/src/dtos/addresses/address-update.dto.ts new file mode 100644 index 0000000000..00ab5adbdf --- /dev/null +++ b/api/src/dtos/addresses/address-update.dto.ts @@ -0,0 +1,17 @@ +import { Expose } from 'class-transformer'; +import { IsString, IsUUID } from 'class-validator'; +import { ApiPropertyOptional, OmitType } from '@nestjs/swagger'; +import { Address } from './address.dto'; +import { ValidationsGroupsEnum } from '../../enums/shared/validation-groups-enum'; + +export class AddressUpdate extends OmitType(Address, [ + 'id', + 'createdAt', + 'updatedAt', +]) { + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @IsUUID(4, { groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + id?: string; +} diff --git a/api/src/dtos/application-methods/application-method-create.dto.ts b/api/src/dtos/application-methods/application-method-create.dto.ts index 05f557059e..507692c0f0 100644 --- a/api/src/dtos/application-methods/application-method-create.dto.ts +++ b/api/src/dtos/application-methods/application-method-create.dto.ts @@ -1,14 +1,12 @@ -import { OmitType, ApiPropertyOptional } from '@nestjs/swagger'; +import { ApplicationMethodUpdate } from './application-method-update.dto'; import { Expose, Type } from 'class-transformer'; +import { OmitType, ApiPropertyOptional } from '@nestjs/swagger'; +import { PaperApplicationCreate } from '../paper-applications/paper-application-create.dto'; 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, [ +export class ApplicationMethodCreate extends OmitType(ApplicationMethodUpdate, [ 'id', - 'createdAt', - 'updatedAt', 'paperApplications', ]) { @Expose() diff --git a/api/src/dtos/application-methods/application-method-update.dto.ts b/api/src/dtos/application-methods/application-method-update.dto.ts new file mode 100644 index 0000000000..425543fb05 --- /dev/null +++ b/api/src/dtos/application-methods/application-method-update.dto.ts @@ -0,0 +1,25 @@ +import { ApplicationMethod } from './application-method.dto'; +import { Expose, Type } from 'class-transformer'; +import { IsString, IsUUID, ValidateNested } from 'class-validator'; +import { OmitType, ApiPropertyOptional } from '@nestjs/swagger'; +import { PaperApplicationUpdate } from '../paper-applications/paper-application-update.dto'; +import { ValidationsGroupsEnum } from '../../enums/shared/validation-groups-enum'; + +export class ApplicationMethodUpdate extends OmitType(ApplicationMethod, [ + 'createdAt', + 'id', + 'paperApplications', + 'updatedAt', +]) { + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @IsUUID(4, { groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + id?: string; + + @Expose() + @ValidateNested({ groups: [ValidationsGroupsEnum.default], each: true }) + @Type(() => PaperApplicationUpdate) + @ApiPropertyOptional({ type: PaperApplicationUpdate, isArray: true }) + paperApplications?: PaperApplicationUpdate[]; +} diff --git a/api/src/dtos/applications/application-create.dto.ts b/api/src/dtos/applications/application-create.dto.ts index 32b5dc1b76..d272c35471 100644 --- a/api/src/dtos/applications/application-create.dto.ts +++ b/api/src/dtos/applications/application-create.dto.ts @@ -1,4 +1,23 @@ -import { OmitType } from '@nestjs/swagger'; +import { Expose, Type } from 'class-transformer'; +import { ArrayMaxSize, ValidateNested } from 'class-validator'; +import { ApiPropertyOptional, OmitType } from '@nestjs/swagger'; import { ApplicationUpdate } from './application-update.dto'; +import { ApplicationSelectionCreate } from './application-selection-create.dto'; +import { ValidationsGroupsEnum } from '../../enums/shared/validation-groups-enum'; -export class ApplicationCreate extends OmitType(ApplicationUpdate, ['id']) {} +export class ApplicationCreate extends OmitType(ApplicationUpdate, [ + 'id', + 'applicationSelections', +]) { + // TODO: Temporarily optional until after MSQ refactor + @Expose() + // @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ArrayMaxSize(64, { groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default], each: true }) + @Type(() => ApplicationSelectionCreate) + @ApiPropertyOptional({ + type: ApplicationSelectionCreate, + isArray: true, + }) + applicationSelections?: ApplicationSelectionCreate[]; +} diff --git a/api/src/dtos/applications/application-selection-create.dto.ts b/api/src/dtos/applications/application-selection-create.dto.ts new file mode 100644 index 0000000000..a98534acba --- /dev/null +++ b/api/src/dtos/applications/application-selection-create.dto.ts @@ -0,0 +1,21 @@ +import { ApiProperty, OmitType } from '@nestjs/swagger'; +import { Expose, Type } from 'class-transformer'; +import { IsDefined, ValidateNested } from 'class-validator'; +import { ApplicationSelectionUpdate } from './application-selection-update.dto'; +import { ApplicationSelectionOptionCreate } from './application-selection-option-create.dto'; +import { ValidationsGroupsEnum } from '../../enums/shared/validation-groups-enum'; + +export class ApplicationSelectionCreate extends OmitType( + ApplicationSelectionUpdate, + ['id', 'application', 'selections'], +) { + @Expose() + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default], each: true }) + @Type(() => ApplicationSelectionOptionCreate) + @ApiProperty({ + type: ApplicationSelectionOptionCreate, + isArray: true, + }) + selections: ApplicationSelectionOptionCreate[]; +} diff --git a/api/src/dtos/applications/application-selection-option-create.dto.ts b/api/src/dtos/applications/application-selection-option-create.dto.ts new file mode 100644 index 0000000000..227f1e383e --- /dev/null +++ b/api/src/dtos/applications/application-selection-option-create.dto.ts @@ -0,0 +1,7 @@ +import { OmitType } from '@nestjs/swagger'; +import { ApplicationSelectionOptionUpdate } from './application-selection-option-update.dto'; + +export class ApplicationSelectionOptionCreate extends OmitType( + ApplicationSelectionOptionUpdate, + ['id'], +) {} diff --git a/api/src/dtos/applications/application-selection-option-update.dto.ts b/api/src/dtos/applications/application-selection-option-update.dto.ts new file mode 100644 index 0000000000..a37355bcf1 --- /dev/null +++ b/api/src/dtos/applications/application-selection-option-update.dto.ts @@ -0,0 +1,36 @@ +import { ApiPropertyOptional, OmitType } from '@nestjs/swagger'; +import { Expose, Type } from 'class-transformer'; +import { IsString, IsUUID, ValidateNested } from 'class-validator'; +import { ApplicationSelectionOption } from './application-selection-option.dto'; +import { AddressUpdate } from '../addresses/address-update.dto'; +import { IdDTO } from '../shared/id.dto'; +import { ValidationsGroupsEnum } from '../../enums/shared/validation-groups-enum'; + +export class ApplicationSelectionOptionUpdate extends OmitType( + ApplicationSelectionOption, + [ + 'id', + 'createdAt', + 'updatedAt', + 'addressHolderAddress', + 'applicationSelection', + ], +) { + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @IsUUID(4, { groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + id?: string; + + @Expose() + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => AddressUpdate) + @ApiPropertyOptional({ type: AddressUpdate }) + addressHolderAddress?: AddressUpdate; + + @Expose() + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => IdDTO) + @ApiPropertyOptional({ type: IdDTO }) + applicationSelection?: IdDTO; +} diff --git a/api/src/dtos/applications/application-selection-options.dto.ts b/api/src/dtos/applications/application-selection-option.dto.ts similarity index 81% rename from api/src/dtos/applications/application-selection-options.dto.ts rename to api/src/dtos/applications/application-selection-option.dto.ts index 05a8fffe1c..cf90199538 100644 --- a/api/src/dtos/applications/application-selection-options.dto.ts +++ b/api/src/dtos/applications/application-selection-option.dto.ts @@ -9,14 +9,15 @@ import { import { AbstractDTO } from '../shared/abstract.dto'; import { IdDTO } from '../shared/id.dto'; import { ValidationsGroupsEnum } from '../../enums/shared/validation-groups-enum'; +import { Address } from '../addresses/address.dto'; -class ApplicationSelectionOptions extends AbstractDTO { +export class ApplicationSelectionOption extends AbstractDTO { @Expose() @IsDefined({ groups: [ValidationsGroupsEnum.default] }) @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) - @Type(() => IdDTO) - @ApiProperty({ type: IdDTO }) - addressHolderAddress?: IdDTO; + @Type(() => Address) + @ApiProperty({ type: Address }) + addressHolderAddress?: Address; @Expose() @IsString({ groups: [ValidationsGroupsEnum.default] }) @@ -42,10 +43,7 @@ class ApplicationSelectionOptions extends AbstractDTO { @Expose() @IsDefined({ groups: [ValidationsGroupsEnum.default] }) - @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) @Type(() => IdDTO) @ApiProperty({ type: IdDTO }) multiselectOption: IdDTO; } - -export { ApplicationSelectionOptions as default, ApplicationSelectionOptions }; diff --git a/api/src/dtos/applications/application-selection-update.dto.ts b/api/src/dtos/applications/application-selection-update.dto.ts new file mode 100644 index 0000000000..5b2f7cea9f --- /dev/null +++ b/api/src/dtos/applications/application-selection-update.dto.ts @@ -0,0 +1,29 @@ +import { ApiProperty, ApiPropertyOptional, OmitType } from '@nestjs/swagger'; +import { Expose, Type } from 'class-transformer'; +import { IsDefined, IsString, IsUUID, ValidateNested } from 'class-validator'; +import { ApplicationSelection } from './application-selection.dto'; +import { ApplicationSelectionOptionUpdate } from './application-selection-option-update.dto'; +import { ValidationsGroupsEnum } from '../../enums/shared/validation-groups-enum'; + +export class ApplicationSelectionUpdate extends OmitType(ApplicationSelection, [ + 'id', + 'createdAt', + 'updatedAt', + 'selections', +]) { + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @IsUUID(4, { groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + id?: string; + + @Expose() + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default], each: true }) + @Type(() => ApplicationSelectionOptionUpdate) + @ApiProperty({ + type: ApplicationSelectionOptionUpdate, + isArray: true, + }) + selections: ApplicationSelectionOptionUpdate[]; +} diff --git a/api/src/dtos/applications/application-selections.dto.ts b/api/src/dtos/applications/application-selection.dto.ts similarity index 71% rename from api/src/dtos/applications/application-selections.dto.ts rename to api/src/dtos/applications/application-selection.dto.ts index b7cf4797dc..96ac8cd26d 100644 --- a/api/src/dtos/applications/application-selections.dto.ts +++ b/api/src/dtos/applications/application-selection.dto.ts @@ -1,12 +1,12 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { Expose, Type } from 'class-transformer'; import { ValidateNested, IsBoolean, IsDefined } from 'class-validator'; -import ApplicationSelectionOptions from './application-selection-options.dto'; +import { ApplicationSelectionOption } from './application-selection-option.dto'; import { AbstractDTO } from '../shared/abstract.dto'; import { IdDTO } from '../shared/id.dto'; import { ValidationsGroupsEnum } from '../../enums/shared/validation-groups-enum'; -class ApplicationSelections extends AbstractDTO { +export class ApplicationSelection extends AbstractDTO { @Expose() @IsDefined({ groups: [ValidationsGroupsEnum.default] }) @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) @@ -21,7 +21,6 @@ class ApplicationSelections extends AbstractDTO { @Expose() @IsDefined({ groups: [ValidationsGroupsEnum.default] }) - @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) @Type(() => IdDTO) @ApiProperty({ type: IdDTO }) multiselectQuestion: IdDTO; @@ -29,9 +28,7 @@ class ApplicationSelections extends AbstractDTO { @Expose() @IsDefined({ groups: [ValidationsGroupsEnum.default] }) @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) - @Type(() => ApplicationSelectionOptions) - @ApiProperty({ type: ApplicationSelectionOptions }) - selections: ApplicationSelectionOptions[]; + @Type(() => ApplicationSelectionOption) + @ApiProperty({ type: ApplicationSelectionOption }) + selections: ApplicationSelectionOption[]; } - -export { ApplicationSelections as default, ApplicationSelections }; diff --git a/api/src/dtos/applications/application-update.dto.ts b/api/src/dtos/applications/application-update.dto.ts index 71a2222411..b143b492a7 100644 --- a/api/src/dtos/applications/application-update.dto.ts +++ b/api/src/dtos/applications/application-update.dto.ts @@ -1,39 +1,65 @@ -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 { ApiProperty, ApiPropertyOptional, OmitType } from '@nestjs/swagger'; 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 { ApplicationSelectionUpdate } from './application-selection-update.dto'; import { DemographicUpdate } from './demographic-update.dto'; import { HouseholdMemberUpdate } from './household-member-update.dto'; +import { AddressCreate } from '../addresses/address-create.dto'; import { IdDTO } from '../shared/id.dto'; +import { ValidationsGroupsEnum } from '../../enums/shared/validation-groups-enum'; export class ApplicationUpdate extends OmitType(Application, [ 'createdAt', 'updatedAt', 'deletedAt', + 'accessibility', + 'alternateContact', 'applicant', - 'applicationsMailingAddress', + 'applicationLotteryPositions', + 'applicationSelections', 'applicationsAlternateAddress', - 'alternateContact', - 'accessibility', + 'applicationsMailingAddress', + 'confirmationCode', 'demographics', + 'flagged', 'householdMember', 'markedAsDuplicate', - 'flagged', - 'confirmationCode', 'preferredUnitTypes', - 'applicationLotteryPositions', ]) { + @Expose() + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => AccessibilityUpdate) + @ApiProperty({ type: AccessibilityUpdate }) + accessibility: AccessibilityUpdate; + + @Expose() + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => AlternateContactUpdate) + @ApiProperty({ type: AlternateContactUpdate }) + alternateContact: AlternateContactUpdate; + @Expose() @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) @Type(() => ApplicantUpdate) @ApiProperty({ type: ApplicantUpdate }) applicant: ApplicantUpdate; + // TODO: Temporarily optional until after MSQ refactor + @Expose() + // @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ArrayMaxSize(64, { groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default], each: true }) + @Type(() => ApplicationSelectionUpdate) + @ApiPropertyOptional({ + type: ApplicationSelectionUpdate, + isArray: true, + }) + applicationSelections?: ApplicationSelectionUpdate[]; + @Expose() @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) @Type(() => AddressCreate) @@ -46,18 +72,6 @@ export class ApplicationUpdate extends OmitType(Application, [ @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) diff --git a/api/src/dtos/applications/application.dto.ts b/api/src/dtos/applications/application.dto.ts index c7329b984d..3f86931386 100644 --- a/api/src/dtos/applications/application.dto.ts +++ b/api/src/dtos/applications/application.dto.ts @@ -15,23 +15,24 @@ import { IsDefined, IsEnum, IsNumber, + IsOptional, 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 { ApplicationLotteryPosition } from './application-lottery-position.dto'; import { ApplicationMultiselectQuestion } from './application-multiselect-question.dto'; +import { ApplicationSelection } from './application-selection.dto'; import { Demographic } from './demographic.dto'; import { HouseholdMember } from './household-member.dto'; +import { Address } from '../addresses/address.dto'; +import { AbstractDTO } from '../shared/abstract.dto'; +import { IdDTO } from '../shared/id.dto'; import { UnitType } from '../unit-types/unit-type.dto'; -import { ApplicationLotteryPosition } from './application-lottery-position.dto'; -import ApplicationSelections from './application-selections.dto'; +import { ValidationsGroupsEnum } from '../../enums/shared/validation-groups-enum'; export class Application extends AbstractDTO { @Expose() @@ -130,6 +131,24 @@ export class Application extends AbstractDTO { }) status: ApplicationStatusEnum; + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsNumber({}, { groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + accessibleUnitWaitlistNumber?: number; + + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsNumber({}, { groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + conventionalUnitWaitlistNumber?: number; + + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsNumber({}, { groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + manualLotteryPositionNumber?: number; + @Expose() @IsEnum(LanguagesEnum, { groups: [ValidationsGroupsEnum.default] }) @IsDefined({ groups: [ValidationsGroupsEnum.applicants] }) @@ -243,16 +262,16 @@ export class Application extends AbstractDTO { householdMember: HouseholdMember[]; // TODO: Temporarily optional until after MSQ refactor - // @Expose() + @Expose() // @IsDefined({ groups: [ValidationsGroupsEnum.default] }) @ArrayMaxSize(64, { groups: [ValidationsGroupsEnum.default] }) @ValidateNested({ groups: [ValidationsGroupsEnum.default], each: true }) - @Type(() => ApplicationSelections) + @Type(() => ApplicationSelection) @ApiPropertyOptional({ - type: ApplicationSelections, + type: ApplicationSelection, isArray: true, }) - applicationSelections?: ApplicationSelections[]; + applicationSelections?: ApplicationSelection[]; @Expose() @IsDefined({ groups: [ValidationsGroupsEnum.default] }) diff --git a/api/src/dtos/jurisdictions/jurisdiction.dto.ts b/api/src/dtos/jurisdictions/jurisdiction.dto.ts index 47a7306ff9..7b3c025983 100644 --- a/api/src/dtos/jurisdictions/jurisdiction.dto.ts +++ b/api/src/dtos/jurisdictions/jurisdiction.dto.ts @@ -8,6 +8,7 @@ import { IsArray, ValidateNested, IsBoolean, + IsNumber, } from 'class-validator'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { @@ -55,6 +56,11 @@ export class Jurisdiction extends AbstractDTO { @ApiProperty({ type: IdDTO, isArray: true }) multiselectQuestions: IdDTO[]; + @Expose() + @IsNumber() + @ApiPropertyOptional() + minimumListingPublishImagesRequired?: number; + @Expose() @IsString({ groups: [ValidationsGroupsEnum.default] }) @ApiPropertyOptional() diff --git a/api/src/dtos/listings/listing-create.dto.ts b/api/src/dtos/listings/listing-create.dto.ts index 9adbd0ebb2..d85634d519 100644 --- a/api/src/dtos/listings/listing-create.dto.ts +++ b/api/src/dtos/listings/listing-create.dto.ts @@ -1,4 +1,155 @@ -import { OmitType } from '@nestjs/swagger'; +import { AddressCreate } from '../addresses/address-create.dto'; +import { ApiProperty, ApiPropertyOptional, OmitType } from '@nestjs/swagger'; +import { ApplicationMethodCreate } from '../application-methods/application-method-create.dto'; +import { ArrayMaxSize, Validate, ValidateNested } from 'class-validator'; +import { Expose, Type } from 'class-transformer'; +import { ListingEventCreate } from './listing-event-create.dto'; +import { ListingFeaturesCreate } from './listing-feature-create.dto'; +import { ListingNeighborhoodAmenitiesCreate } from './listing-neighborhood-amenities-create.dto'; import { ListingUpdate } from './listing-update.dto'; +import { ListingUtilitiesCreate } from './listing-utiliity-create.dto'; +import { LotteryDateParamValidator } from '../../utilities/lottery-date-validator'; +import { UnitCreate } from '../units/unit-create.dto'; +import { UnitGroupCreate } from '../unit-groups/unit-group-create.dto'; +import { + ValidateAtLeastOneUnit, + ValidateOnlyUnitsOrUnitGroups, +} from '../../decorators/validate-units-required.decorator'; +import { ValidateListingPublish } from '../../decorators/validate-listing-publish.decorator'; +import { ValidationsGroupsEnum } from '../../enums/shared/validation-groups-enum'; -export class ListingCreate extends OmitType(ListingUpdate, ['id']) {} +export class ListingCreate extends OmitType(ListingUpdate, [ + 'applicationMethods', + 'id', + 'listingEvents', + 'listingNeighborhoodAmenities', + 'listingsApplicationDropOffAddress', + 'listingsApplicationMailingAddress', + 'listingsApplicationPickUpAddress', + 'listingsBuildingAddress', + 'listingsLeasingAgentAddress', + 'listingUtilities', + 'unitGroups', + 'units', +]) { + @Expose() + @ValidateAtLeastOneUnit({ + groups: [ValidationsGroupsEnum.default], + }) + @ValidateOnlyUnitsOrUnitGroups({ + groups: [ValidationsGroupsEnum.default], + }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default], each: true }) + @Type(() => UnitCreate) + @ArrayMaxSize(256, { groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional({ type: UnitCreate, isArray: true }) + units?: UnitCreate[]; + + @Expose() + @ValidateAtLeastOneUnit({ + groups: [ValidationsGroupsEnum.default], + }) + @ValidateOnlyUnitsOrUnitGroups({ + groups: [ValidationsGroupsEnum.default], + }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default], each: true }) + @Type(() => UnitGroupCreate) + @ApiPropertyOptional({ type: UnitGroupCreate, isArray: true }) + unitGroups?: UnitGroupCreate[]; + + @Expose() + @ValidateListingPublish('applicationMethods', { + groups: [ValidationsGroupsEnum.default], + }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default], each: true }) + @Type(() => ApplicationMethodCreate) + @ApiPropertyOptional({ + type: ApplicationMethodCreate, + isArray: true, + }) + applicationMethods?: ApplicationMethodCreate[]; + + @Expose() + @ValidateListingPublish('listingsApplicationPickUpAddress', { + groups: [ValidationsGroupsEnum.default], + }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => AddressCreate) + @ApiPropertyOptional({ type: AddressCreate }) + listingsApplicationPickUpAddress?: AddressCreate; + + @Expose() + @ValidateListingPublish('listingsApplicationMailingAddress', { + groups: [ValidationsGroupsEnum.default], + }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => AddressCreate) + @ApiPropertyOptional({ type: AddressCreate }) + listingsApplicationMailingAddress?: AddressCreate; + + @Expose() + @ValidateListingPublish('listingsApplicationDropOffAddress', { + groups: [ValidationsGroupsEnum.default], + }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => AddressCreate) + @ApiPropertyOptional({ type: AddressCreate }) + listingsApplicationDropOffAddress?: AddressCreate; + + @Expose() + @ValidateListingPublish('listingsLeasingAgentAddress', { + groups: [ValidationsGroupsEnum.default], + }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => AddressCreate) + @ApiPropertyOptional({ type: AddressCreate }) + listingsLeasingAgentAddress?: AddressCreate; + + @Expose() + @ValidateListingPublish('listingsBuildingAddress', { + groups: [ValidationsGroupsEnum.default], + }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => AddressCreate) + @ApiPropertyOptional({ type: AddressCreate }) + listingsBuildingAddress?: AddressCreate; + + @Expose() + @ValidateListingPublish('listingEvents', { + groups: [ValidationsGroupsEnum.default], + }) + @Validate(LotteryDateParamValidator, { + groups: [ValidationsGroupsEnum.default], + }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default], each: true }) + @Type(() => ListingEventCreate) + @ApiProperty({ type: ListingEventCreate, isArray: true }) + listingEvents: ListingEventCreate[]; + + @Expose() + @ValidateListingPublish('listingFeatures', { + groups: [ValidationsGroupsEnum.default], + }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default], each: true }) + @Type(() => ListingFeaturesCreate) + @ApiPropertyOptional({ type: ListingFeaturesCreate }) + listingFeatures?: ListingFeaturesCreate; + + @Expose() + @ValidateListingPublish('listingUtilities', { + groups: [ValidationsGroupsEnum.default], + }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default], each: true }) + @Type(() => ListingUtilitiesCreate) + @ApiPropertyOptional({ type: ListingUtilitiesCreate }) + listingUtilities?: ListingUtilitiesCreate; + + @Expose() + @ValidateListingPublish('listingNeighborhoodAmenities', { + groups: [ValidationsGroupsEnum.default], + }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default], each: true }) + @Type(() => ListingNeighborhoodAmenitiesCreate) + @ApiPropertyOptional({ type: ListingNeighborhoodAmenitiesCreate }) + listingNeighborhoodAmenities?: ListingNeighborhoodAmenitiesCreate; +} diff --git a/api/src/dtos/listings/listing-documents.dto.ts b/api/src/dtos/listings/listing-documents.dto.ts new file mode 100644 index 0000000000..733a3eb120 --- /dev/null +++ b/api/src/dtos/listings/listing-documents.dto.ts @@ -0,0 +1,51 @@ +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 ListingDocuments { + @Expose() + @IsBoolean({ groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + socialSecurityCard?: boolean; + + @Expose() + @IsBoolean({ groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + currentLandlordReference?: boolean; + + @Expose() + @IsBoolean({ groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + birthCertificate?: boolean; + + @Expose() + @IsBoolean({ groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + previousLandlordReference?: boolean; + + @Expose() + @IsBoolean({ groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + governmentIssuedId?: boolean; + + @Expose() + @IsBoolean({ groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + proofOfAssets?: boolean; + + @Expose() + @IsBoolean({ groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + proofOfIncome?: boolean; + + @Expose() + @IsBoolean({ groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + residencyDocuments?: boolean; + + @Expose() + @IsBoolean({ groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + proofOfCustody?: boolean; +} diff --git a/api/src/dtos/listings/listing-event-create.dto.ts b/api/src/dtos/listings/listing-event-create.dto.ts index 76b61e8777..24acad5f77 100644 --- a/api/src/dtos/listings/listing-event-create.dto.ts +++ b/api/src/dtos/listings/listing-event-create.dto.ts @@ -1,19 +1,4 @@ -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'; +import { ListingEventUpdate } from './listing-event-update.dto'; +import { OmitType } from '@nestjs/swagger'; -export class ListingEventCreate extends OmitType(ListingEvent, [ - 'id', - 'createdAt', - 'updatedAt', - 'assets', -]) { - @Expose() - @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) - @Type(() => AssetCreate) - @ApiPropertyOptional({ type: AssetCreate }) - assets?: AssetCreate; -} +export class ListingEventCreate extends OmitType(ListingEventUpdate, ['id']) {} diff --git a/api/src/dtos/listings/listing-event-update.dto.ts b/api/src/dtos/listings/listing-event-update.dto.ts new file mode 100644 index 0000000000..7bf355f9bc --- /dev/null +++ b/api/src/dtos/listings/listing-event-update.dto.ts @@ -0,0 +1,25 @@ +import { ApiPropertyOptional, OmitType } from '@nestjs/swagger'; +import { AssetCreate } from '../assets/asset-create.dto'; +import { Expose, Type } from 'class-transformer'; +import { IsString, IsUUID, ValidateNested } from 'class-validator'; +import { ListingEvent } from './listing-event.dto'; +import { ValidationsGroupsEnum } from '../../enums/shared/validation-groups-enum'; + +export class ListingEventUpdate extends OmitType(ListingEvent, [ + 'assets', + 'createdAt', + 'id', + 'updatedAt', +]) { + @Expose() + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => AssetCreate) + @ApiPropertyOptional({ type: AssetCreate }) + assets?: AssetCreate; + + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @IsUUID(4, { groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + id?: string; +} diff --git a/api/src/dtos/listings/listing-feature-create.dto.ts b/api/src/dtos/listings/listing-feature-create.dto.ts new file mode 100644 index 0000000000..33e5069ca9 --- /dev/null +++ b/api/src/dtos/listings/listing-feature-create.dto.ts @@ -0,0 +1,6 @@ +import { ListingFeaturesUpdate } from './listing-feature-update.dto'; +import { OmitType } from '@nestjs/swagger'; + +export class ListingFeaturesCreate extends OmitType(ListingFeaturesUpdate, [ + 'id', +]) {} diff --git a/api/src/dtos/listings/listing-feature-update.dto.ts b/api/src/dtos/listings/listing-feature-update.dto.ts new file mode 100644 index 0000000000..ce5973bb0e --- /dev/null +++ b/api/src/dtos/listings/listing-feature-update.dto.ts @@ -0,0 +1,17 @@ +import { ApiPropertyOptional, OmitType } from '@nestjs/swagger'; +import { Expose } from 'class-transformer'; +import { IsString, IsUUID } from 'class-validator'; +import { ListingFeatures } from './listing-feature.dto'; +import { ValidationsGroupsEnum } from '../../enums/shared/validation-groups-enum'; + +export class ListingFeaturesUpdate extends OmitType(ListingFeatures, [ + 'createdAt', + 'id', + 'updatedAt', +]) { + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @IsUUID(4, { groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + id?: string; +} diff --git a/api/src/dtos/listings/listing-feature.dto.ts b/api/src/dtos/listings/listing-feature.dto.ts index 05129508d6..aff3915078 100644 --- a/api/src/dtos/listings/listing-feature.dto.ts +++ b/api/src/dtos/listings/listing-feature.dto.ts @@ -1,9 +1,10 @@ +import { AbstractDTO } from '../shared/abstract.dto'; 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 { +export class ListingFeatures extends AbstractDTO { @Expose() @IsBoolean({ groups: [ValidationsGroupsEnum.default] }) @ApiPropertyOptional() diff --git a/api/src/dtos/listings/listing-image-create.dto.ts b/api/src/dtos/listings/listing-image-create.dto.ts index 9390ea5979..d428cb506f 100644 --- a/api/src/dtos/listings/listing-image-create.dto.ts +++ b/api/src/dtos/listings/listing-image-create.dto.ts @@ -1,15 +1,30 @@ -import { ApiProperty, OmitType } from '@nestjs/swagger'; +import { ApiProperty, ApiPropertyOptional, 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'; +import { ValidateListingPublish } from '../../decorators/validate-listing-publish.decorator'; -export class ListingImageCreate extends OmitType(ListingImage, ['assets']) { +export class ListingImageCreate extends OmitType(ListingImage, [ + 'assets', + 'description', +]) { @Expose() @ValidateNested({ groups: [ValidationsGroupsEnum.default], each: true }) @Type(() => AssetCreate) @IsDefined({ groups: [ValidationsGroupsEnum.default] }) @ApiProperty({ type: AssetCreate }) assets: AssetCreate; + + @Expose() + @ValidateListingPublish('description', { + groups: [ValidationsGroupsEnum.default], + }) + @ApiPropertyOptional() + description?: string; + + // Needed so class-transformer keeps requiredFields for nested validation + @Expose() + requiredFields?: string[]; } diff --git a/api/src/dtos/listings/listing-image.dto.ts b/api/src/dtos/listings/listing-image.dto.ts index 73d90e8143..dcf5bcb633 100644 --- a/api/src/dtos/listings/listing-image.dto.ts +++ b/api/src/dtos/listings/listing-image.dto.ts @@ -15,4 +15,8 @@ export class ListingImage { @IsNumber({}, { groups: [ValidationsGroupsEnum.default] }) @ApiPropertyOptional() ordinal?: number; + + @Expose() + @ApiPropertyOptional() + description?: string; } diff --git a/api/src/dtos/listings/listing-neighborhood-amenities-create.dto.ts b/api/src/dtos/listings/listing-neighborhood-amenities-create.dto.ts new file mode 100644 index 0000000000..cebbfa3c2d --- /dev/null +++ b/api/src/dtos/listings/listing-neighborhood-amenities-create.dto.ts @@ -0,0 +1,7 @@ +import { OmitType } from '@nestjs/swagger'; +import { ListingNeighborhoodAmenitiesUpdate } from './listing-neighborhood-amenities-update.dto'; + +export class ListingNeighborhoodAmenitiesCreate extends OmitType( + ListingNeighborhoodAmenitiesUpdate, + ['id'], +) {} diff --git a/api/src/dtos/listings/listing-neighborhood-amenities-update.dto.ts b/api/src/dtos/listings/listing-neighborhood-amenities-update.dto.ts new file mode 100644 index 0000000000..a73fa3dd62 --- /dev/null +++ b/api/src/dtos/listings/listing-neighborhood-amenities-update.dto.ts @@ -0,0 +1,16 @@ +import { ApiPropertyOptional, OmitType } from '@nestjs/swagger'; +import { Expose } from 'class-transformer'; +import { IsString, IsUUID } from 'class-validator'; +import { ListingNeighborhoodAmenities } from './listing-neighborhood-amenities.dto'; +import { ValidationsGroupsEnum } from '../../enums/shared/validation-groups-enum'; + +export class ListingNeighborhoodAmenitiesUpdate extends OmitType( + ListingNeighborhoodAmenities, + ['createdAt', 'id', 'updatedAt'], +) { + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @IsUUID(4, { groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + id?: string; +} diff --git a/api/src/dtos/listings/listing-neighborhood-amenities.dto.ts b/api/src/dtos/listings/listing-neighborhood-amenities.dto.ts index 7d131e7b09..8d420e6662 100644 --- a/api/src/dtos/listings/listing-neighborhood-amenities.dto.ts +++ b/api/src/dtos/listings/listing-neighborhood-amenities.dto.ts @@ -1,9 +1,10 @@ +import { AbstractDTO } from '../shared/abstract.dto'; import { ApiPropertyOptional } from '@nestjs/swagger'; import { Expose } from 'class-transformer'; import { IsOptional, IsString } from 'class-validator'; import { ValidationsGroupsEnum } from '../../enums/shared/validation-groups-enum'; -export class ListingNeighborhoodAmenities { +export class ListingNeighborhoodAmenities extends AbstractDTO { @Expose() @IsOptional({ groups: [ValidationsGroupsEnum.default] }) @IsString({ groups: [ValidationsGroupsEnum.default] }) @@ -39,4 +40,40 @@ export class ListingNeighborhoodAmenities { @IsString({ groups: [ValidationsGroupsEnum.default] }) @ApiPropertyOptional() healthCareResources?: string | null; + + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + shoppingVenues?: string | null; + + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + hospitals?: string | null; + + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + seniorCenters?: string | null; + + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + recreationalFacilities?: string | null; + + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + playgrounds?: string | null; + + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + busStops?: string | null; } diff --git a/api/src/dtos/listings/listing-update.dto.ts b/api/src/dtos/listings/listing-update.dto.ts index 00404718d9..8067e947b0 100644 --- a/api/src/dtos/listings/listing-update.dto.ts +++ b/api/src/dtos/listings/listing-update.dto.ts @@ -1,66 +1,66 @@ +import { AddressUpdate } from '../addresses/address-update.dto'; +import { ApiProperty } from '@nestjs/swagger'; import { ApiPropertyOptional, OmitType } from '@nestjs/swagger'; +import { ApplicationMethodUpdate } from '../application-methods/application-method-update.dto'; +import { ArrayMaxSize, Validate, ValidateNested } from 'class-validator'; +import { AssetCreate } from '../assets/asset-create.dto'; import { Expose, Type } from 'class-transformer'; -import { - ArrayMaxSize, - ArrayMinSize, - Validate, - 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 { ListingEventUpdate } from './listing-event-update.dto'; +import { ListingFeaturesUpdate } from './listing-feature-update.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'; +import { ListingNeighborhoodAmenitiesUpdate } from './listing-neighborhood-amenities-update.dto'; +import { ListingUtilitiesUpdate } from './listing-utility-update.dto'; import { LotteryDateParamValidator } from '../../utilities/lottery-date-validator'; -import { UnitGroupCreate } from '../unit-groups/unit-group-create.dto'; -import { ValidateListingPublish } from '../../decorators/validate-listing-publish.decorator'; +import { UnitGroupUpdate } from '../unit-groups/unit-group-update.dto'; +import { UnitsSummaryCreate } from '../units/units-summary-create.dto'; +import { UnitUpdate } from '../units/unit-update.dto'; import { ValidateAtLeastOneUnit, ValidateOnlyUnitsOrUnitGroups, } from '../../decorators/validate-units-required.decorator'; +import { ValidateListingImages } from '../../decorators/validate-listing-images.decorator'; +import { ValidateListingPublish } from '../../decorators/validate-listing-publish.decorator'; +import { ValidationsGroupsEnum } from '../../enums/shared/validation-groups-enum'; export class ListingUpdate extends OmitType(Listing, [ // fields get their type changed - 'listingMultiselectQuestions', - 'units', - 'unitGroups', 'applicationMethods', 'assets', - 'unitsSummary', + 'listingEvents', + 'listingFeatures', 'listingImages', - 'listingsResult', - 'listingsApplicationPickUpAddress', - 'listingsApplicationMailingAddress', + 'listingMultiselectQuestions', + 'listingNeighborhoodAmenities', + 'listingsAccessibleMarketingFlyerFile', 'listingsApplicationDropOffAddress', - 'listingsLeasingAgentAddress', + 'listingsApplicationMailingAddress', + 'listingsApplicationPickUpAddress', 'listingsBuildingAddress', 'listingsBuildingSelectionCriteriaFile', - 'listingEvents', - 'listingFeatures', + 'listingsLeasingAgentAddress', + 'listingsMarketingFlyerFile', + 'listingsResult', 'listingUtilities', 'requestedChangesUser', + 'unitGroups', + 'units', + 'unitsSummary', // fields removed entirely + 'afsLastRunAt', + 'applicationConfig', + 'applicationLotteryTotals', + 'closedAt', 'createdAt', - 'updatedAt', - 'referralApplication', 'publishedAt', + 'referralApplication', 'showWaitlist', - 'unitsSummarized', 'unitGroupsSummarized', - 'closedAt', - 'afsLastRunAt', + 'unitsSummarized', + 'updatedAt', 'urlSlug', - 'applicationConfig', - 'applicationLotteryTotals', ]) { @Expose() @ValidateListingPublish('listingMultiselectQuestions', { @@ -79,10 +79,10 @@ export class ListingUpdate extends OmitType(Listing, [ groups: [ValidationsGroupsEnum.default], }) @ValidateNested({ groups: [ValidationsGroupsEnum.default], each: true }) - @Type(() => UnitCreate) + @Type(() => UnitUpdate) @ArrayMaxSize(256, { groups: [ValidationsGroupsEnum.default] }) - @ApiPropertyOptional({ type: UnitCreate, isArray: true }) - units?: UnitCreate[]; + @ApiPropertyOptional({ type: UnitUpdate, isArray: true }) + units?: UnitUpdate[]; @Expose() @ValidateAtLeastOneUnit({ @@ -92,21 +92,21 @@ export class ListingUpdate extends OmitType(Listing, [ groups: [ValidationsGroupsEnum.default], }) @ValidateNested({ groups: [ValidationsGroupsEnum.default], each: true }) - @Type(() => UnitGroupCreate) - @ApiPropertyOptional({ type: UnitGroupCreate, isArray: true }) - unitGroups?: UnitGroupCreate[]; + @Type(() => UnitGroupUpdate) + @ApiPropertyOptional({ type: UnitGroupUpdate, isArray: true }) + unitGroups?: UnitGroupUpdate[]; @Expose() @ValidateListingPublish('applicationMethods', { groups: [ValidationsGroupsEnum.default], }) @ValidateNested({ groups: [ValidationsGroupsEnum.default], each: true }) - @Type(() => ApplicationMethodCreate) + @Type(() => ApplicationMethodUpdate) @ApiPropertyOptional({ - type: ApplicationMethodCreate, + type: ApplicationMethodUpdate, isArray: true, }) - applicationMethods?: ApplicationMethodCreate[]; + applicationMethods?: ApplicationMethodUpdate[]; @Expose() @ValidateListingPublish('assets', { @@ -132,7 +132,7 @@ export class ListingUpdate extends OmitType(Listing, [ }) @ValidateNested({ groups: [ValidationsGroupsEnum.default], each: true }) @Type(() => ListingImageCreate) - @ArrayMinSize(1, { groups: [ValidationsGroupsEnum.default] }) + @ValidateListingImages({ groups: [ValidationsGroupsEnum.default] }) @ApiPropertyOptional({ type: ListingImageCreate, isArray: true }) listingImages?: ListingImageCreate[]; @@ -141,45 +141,45 @@ export class ListingUpdate extends OmitType(Listing, [ groups: [ValidationsGroupsEnum.default], }) @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) - @Type(() => AddressCreate) - @ApiPropertyOptional({ type: AddressCreate }) - listingsApplicationPickUpAddress?: AddressCreate; + @Type(() => AddressUpdate) + @ApiPropertyOptional({ type: AddressUpdate }) + listingsApplicationPickUpAddress?: AddressUpdate; @Expose() @ValidateListingPublish('listingsApplicationMailingAddress', { groups: [ValidationsGroupsEnum.default], }) @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) - @Type(() => AddressCreate) - @ApiPropertyOptional({ type: AddressCreate }) - listingsApplicationMailingAddress?: AddressCreate; + @Type(() => AddressUpdate) + @ApiPropertyOptional({ type: AddressUpdate }) + listingsApplicationMailingAddress?: AddressUpdate; @Expose() @ValidateListingPublish('listingsApplicationDropOffAddress', { groups: [ValidationsGroupsEnum.default], }) @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) - @Type(() => AddressCreate) - @ApiPropertyOptional({ type: AddressCreate }) - listingsApplicationDropOffAddress?: AddressCreate; + @Type(() => AddressUpdate) + @ApiPropertyOptional({ type: AddressUpdate }) + listingsApplicationDropOffAddress?: AddressUpdate; @Expose() @ValidateListingPublish('listingsLeasingAgentAddress', { groups: [ValidationsGroupsEnum.default], }) @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) - @Type(() => AddressCreate) - @ApiPropertyOptional({ type: AddressCreate }) - listingsLeasingAgentAddress?: AddressCreate; + @Type(() => AddressUpdate) + @ApiPropertyOptional({ type: AddressUpdate }) + listingsLeasingAgentAddress?: AddressUpdate; @Expose() @ValidateListingPublish('listingsBuildingAddress', { groups: [ValidationsGroupsEnum.default], }) @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) - @Type(() => AddressCreate) - @ApiPropertyOptional({ type: AddressCreate }) - listingsBuildingAddress?: AddressCreate; + @Type(() => AddressUpdate) + @ApiPropertyOptional({ type: AddressUpdate }) + listingsBuildingAddress?: AddressUpdate; @Expose() @ValidateListingPublish('listingsBuildingSelectionCriteriaFile', { @@ -190,6 +190,24 @@ export class ListingUpdate extends OmitType(Listing, [ @ApiPropertyOptional({ type: AssetCreate }) listingsBuildingSelectionCriteriaFile?: AssetCreate; + @Expose() + @ValidateListingPublish('listingsMarketingFlyerFile', { + groups: [ValidationsGroupsEnum.default], + }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => AssetCreate) + @ApiPropertyOptional({ type: AssetCreate }) + listingsMarketingFlyerFile?: AssetCreate; + + @Expose() + @ValidateListingPublish('listingsAccessibleMarketingFlyerFile', { + groups: [ValidationsGroupsEnum.default], + }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => AssetCreate) + @ApiPropertyOptional({ type: AssetCreate }) + listingsAccessibleMarketingFlyerFile?: AssetCreate; + @Expose() @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) @Type(() => AssetCreate) @@ -204,27 +222,27 @@ export class ListingUpdate extends OmitType(Listing, [ groups: [ValidationsGroupsEnum.default], }) @ValidateNested({ groups: [ValidationsGroupsEnum.default], each: true }) - @Type(() => ListingEventCreate) - @ApiProperty({ type: ListingEventCreate, isArray: true }) - listingEvents: ListingEventCreate[]; + @Type(() => ListingEventUpdate) + @ApiProperty({ type: ListingEventUpdate, isArray: true }) + listingEvents: ListingEventUpdate[]; @Expose() @ValidateListingPublish('listingFeatures', { groups: [ValidationsGroupsEnum.default], }) @ValidateNested({ groups: [ValidationsGroupsEnum.default], each: true }) - @Type(() => ListingFeatures) - @ApiPropertyOptional({ type: ListingFeatures }) - listingFeatures?: ListingFeatures; + @Type(() => ListingFeaturesUpdate) + @ApiPropertyOptional({ type: ListingFeaturesUpdate }) + listingFeatures?: ListingFeaturesUpdate; @Expose() @ValidateListingPublish('listingUtilities', { groups: [ValidationsGroupsEnum.default], }) @ValidateNested({ groups: [ValidationsGroupsEnum.default], each: true }) - @Type(() => ListingUtilities) - @ApiPropertyOptional({ type: ListingUtilities }) - listingUtilities?: ListingUtilities; + @Type(() => ListingUtilitiesUpdate) + @ApiPropertyOptional({ type: ListingUtilitiesUpdate }) + listingUtilities?: ListingUtilitiesUpdate; @Expose() @ApiPropertyOptional() @@ -234,4 +252,13 @@ export class ListingUpdate extends OmitType(Listing, [ @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) @Type(() => IdDTO) requestedChangesUser?: IdDTO; + + @Expose() + @ValidateListingPublish('listingNeighborhoodAmenities', { + groups: [ValidationsGroupsEnum.default], + }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default], each: true }) + @Type(() => ListingNeighborhoodAmenitiesUpdate) + @ApiPropertyOptional({ type: ListingNeighborhoodAmenitiesUpdate }) + listingNeighborhoodAmenities?: ListingNeighborhoodAmenitiesUpdate; } diff --git a/api/src/dtos/listings/listing-utiliity-create.dto.ts b/api/src/dtos/listings/listing-utiliity-create.dto.ts new file mode 100644 index 0000000000..72c84e92d6 --- /dev/null +++ b/api/src/dtos/listings/listing-utiliity-create.dto.ts @@ -0,0 +1,6 @@ +import { ListingUtilitiesUpdate } from './listing-utility-update.dto'; +import { OmitType } from '@nestjs/swagger'; + +export class ListingUtilitiesCreate extends OmitType(ListingUtilitiesUpdate, [ + 'id', +]) {} diff --git a/api/src/dtos/listings/listing-utility-update.dto.ts b/api/src/dtos/listings/listing-utility-update.dto.ts new file mode 100644 index 0000000000..bbd36afbfe --- /dev/null +++ b/api/src/dtos/listings/listing-utility-update.dto.ts @@ -0,0 +1,17 @@ +import { ApiPropertyOptional, OmitType } from '@nestjs/swagger'; +import { Expose } from 'class-transformer'; +import { IsString, IsUUID } from 'class-validator'; +import { ListingUtilities } from './listing-utility.dto'; +import { ValidationsGroupsEnum } from '../../enums/shared/validation-groups-enum'; + +export class ListingUtilitiesUpdate extends OmitType(ListingUtilities, [ + 'createdAt', + 'id', + 'updatedAt', +]) { + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @IsUUID(4, { groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + id?: string; +} diff --git a/api/src/dtos/listings/listing-utility.dto.ts b/api/src/dtos/listings/listing-utility.dto.ts index 714938cd01..239dce2636 100644 --- a/api/src/dtos/listings/listing-utility.dto.ts +++ b/api/src/dtos/listings/listing-utility.dto.ts @@ -1,9 +1,10 @@ +import { AbstractDTO } from '../shared/abstract.dto'; 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 { +export class ListingUtilities extends AbstractDTO { @Expose() @IsBoolean({ groups: [ValidationsGroupsEnum.default] }) @ApiPropertyOptional() diff --git a/api/src/dtos/listings/listing.dto.ts b/api/src/dtos/listings/listing.dto.ts index e3113e6807..6662a74bba 100644 --- a/api/src/dtos/listings/listing.dto.ts +++ b/api/src/dtos/listings/listing.dto.ts @@ -1,7 +1,6 @@ import { Expose, Transform, TransformFnParams, Type } from 'class-transformer'; import { ArrayMaxSize, - ArrayMinSize, IsArray, IsBoolean, IsDate, @@ -62,6 +61,8 @@ import { ValidateOnlyUnitsOrUnitGroups, } from '../../decorators/validate-units-required.decorator'; import { ValidateListingDeposit } from '../../decorators/validate-listing-deposit.decorator'; +import { ListingDocuments } from './listing-documents.dto'; +import { ValidateListingImages } from '../../decorators/validate-listing-images.decorator'; class Listing extends AbstractDTO { @Expose() @@ -134,6 +135,14 @@ class Listing extends AbstractDTO { @ApiPropertyOptional() developer?: string; + @Expose() + @ValidateListingPublish('listingFileNumber', { + groups: [ValidationsGroupsEnum.default], + }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + listingFileNumber?: string; + @Expose() @IsNumber({}, { groups: [ValidationsGroupsEnum.default] }) @ApiPropertyOptional() @@ -247,6 +256,14 @@ class Listing extends AbstractDTO { @ApiPropertyOptional() applicationFee?: string; + @Expose() + @ValidateListingPublish('creditScreeningFee', { + groups: [ValidationsGroupsEnum.default], + }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + creditScreeningFee?: string; + @Expose() @ValidateListingPublish('applicationOrganization', { groups: [ValidationsGroupsEnum.default], @@ -322,6 +339,30 @@ class Listing extends AbstractDTO { ) buildingSelectionCriteria?: string; + @Expose() + @ValidateListingPublish('marketingFlyer', { + groups: [ValidationsGroupsEnum.default], + }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + @IsUrl( + { require_protocol: true }, + { groups: [ValidationsGroupsEnum.default] }, + ) + marketingFlyer?: string; + + @Expose() + @ValidateListingPublish('accessibleMarketingFlyer', { + groups: [ValidationsGroupsEnum.default], + }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + @IsUrl( + { require_protocol: true }, + { groups: [ValidationsGroupsEnum.default] }, + ) + accessibleMarketingFlyer?: string; + @Expose() @ValidateListingPublish('cocInfo7', { groups: [ValidationsGroupsEnum.default], @@ -390,22 +431,6 @@ class Listing extends AbstractDTO { @ApiPropertyOptional() depositValue?: number; - @Expose() - @ValidateListingPublish('depositRangeMin', { - groups: [ValidationsGroupsEnum.default], - }) - @IsNumber() - @ApiPropertyOptional() - depositRangeMin?: number; - - @Expose() - @ValidateListingPublish('depositRangeMax', { - groups: [ValidationsGroupsEnum.default], - }) - @IsNumber() - @ApiPropertyOptional() - depositRangeMax?: number; - @Expose() @ValidateListingPublish('depositHelperText', { groups: [ValidationsGroupsEnum.default], @@ -504,6 +529,14 @@ class Listing extends AbstractDTO { @ApiProperty() name: string; + @Expose() + @ValidateListingPublish('parkingFee', { + groups: [ValidationsGroupsEnum.default], + }) + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + parkingFee?: string; + @Expose() @ValidateListingPublish('postmarkedApplicationsReceivedByDate', { groups: [ValidationsGroupsEnum.default], @@ -546,6 +579,15 @@ class Listing extends AbstractDTO { @ApiPropertyOptional() requiredDocuments?: string; + @Expose() + @ValidateListingPublish('requiredDocumentsList', { + groups: [ValidationsGroupsEnum.default], + }) + @Type(() => ListingDocuments) + @ValidateNested({ groups: [ValidationsGroupsEnum.default], each: true }) + @ApiPropertyOptional({ type: ListingDocuments }) + requiredDocumentsList?: ListingDocuments; + @Expose() @ValidateListingPublish('specialNotes', { groups: [ValidationsGroupsEnum.default], @@ -842,6 +884,24 @@ class Listing extends AbstractDTO { @ApiPropertyOptional({ type: Asset }) listingsBuildingSelectionCriteriaFile?: Asset; + @Expose() + @ValidateListingPublish('listingsMarketingFlyerFile', { + groups: [ValidationsGroupsEnum.default], + }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => Asset) + @ApiPropertyOptional({ type: Asset }) + listingsMarketingFlyerFile?: Asset; + + @Expose() + @ValidateListingPublish('listingsAccessibleMarketingFlyerFile', { + groups: [ValidationsGroupsEnum.default], + }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => Asset) + @ApiPropertyOptional({ type: Asset }) + listingsAccessibleMarketingFlyerFile?: Asset; + @Expose() @IsDefined({ groups: [ValidationsGroupsEnum.default] }) @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) @@ -870,7 +930,7 @@ class Listing extends AbstractDTO { }) @ValidateNested({ groups: [ValidationsGroupsEnum.default], each: true }) @Type(() => ListingImage) - @ArrayMinSize(1, { groups: [ValidationsGroupsEnum.default] }) + @ValidateListingImages({ groups: [ValidationsGroupsEnum.default] }) @ApiPropertyOptional({ type: ListingImage, isArray: true }) listingImages?: ListingImage[]; @@ -1097,6 +1157,9 @@ class Listing extends AbstractDTO { @Expose() requiredFields?: string[]; + @Expose() + minimumImagesRequired?: number; + @Expose() @ApiPropertyOptional() @Transform( diff --git a/api/src/dtos/listings/listings-filter-params.dto.ts b/api/src/dtos/listings/listings-filter-params.dto.ts index c9fdad61c7..252a6dcfce 100644 --- a/api/src/dtos/listings/listings-filter-params.dto.ts +++ b/api/src/dtos/listings/listings-filter-params.dto.ts @@ -1,5 +1,10 @@ import { ApiPropertyOptional } from '@nestjs/swagger'; -import { HomeTypeEnum, ListingsStatusEnum, RegionEnum } from '@prisma/client'; +import { + HomeTypeEnum, + ListingsStatusEnum, + ListingTypeEnum, + RegionEnum, +} from '@prisma/client'; import { Expose, Type } from 'class-transformer'; import { IsArray, @@ -210,4 +215,12 @@ export class ListingFilterParams extends BaseFilter { example: '48211', }) [ListingFilterKeys.zipCode]?: string; + + @Expose() + @ApiPropertyOptional({ + enum: ListingTypeEnum, + enumName: 'ListingTypeEnum', + example: 'regulated', + }) + [ListingFilterKeys.listingType]?: ListingTypeEnum; } diff --git a/api/src/dtos/listings/listings-query-body.dto.ts b/api/src/dtos/listings/listings-query-body.dto.ts index 393b320f5f..ae5ac165e1 100644 --- a/api/src/dtos/listings/listings-query-body.dto.ts +++ b/api/src/dtos/listings/listings-query-body.dto.ts @@ -54,7 +54,6 @@ export class ListingsQueryBody extends PaginationAllowsAllQueryParams { }) @IsArray({ groups: [ValidationsGroupsEnum.default] }) @ArrayMaxSize(16, { groups: [ValidationsGroupsEnum.default] }) - @Type(() => ListingFilterParams) @IsEnum(ListingOrderByKeys, { groups: [ValidationsGroupsEnum.default], each: true, diff --git a/api/src/dtos/listings/listings-query-params.dto.ts b/api/src/dtos/listings/listings-query-params.dto.ts index 5801b2c448..8c3cb43f90 100644 --- a/api/src/dtos/listings/listings-query-params.dto.ts +++ b/api/src/dtos/listings/listings-query-params.dto.ts @@ -56,7 +56,6 @@ export class ListingsQueryParams extends PaginationAllowsAllQueryParams { }) @IsArray({ groups: [ValidationsGroupsEnum.default] }) @ArrayMaxSize(16, { groups: [ValidationsGroupsEnum.default] }) - @Type(() => ListingFilterParams) @IsEnum(ListingOrderByKeys, { groups: [ValidationsGroupsEnum.default], each: true, diff --git a/api/src/dtos/multiselect-questions/multiselect-option-create.dto.ts b/api/src/dtos/multiselect-questions/multiselect-option-create.dto.ts new file mode 100644 index 0000000000..7bda9c8d90 --- /dev/null +++ b/api/src/dtos/multiselect-questions/multiselect-option-create.dto.ts @@ -0,0 +1,6 @@ +import { OmitType } from '@nestjs/swagger'; +import { MultiselectOptionUpdate } from './multiselect-option-update.dto'; + +export class MultiselectOptionCreate extends OmitType(MultiselectOptionUpdate, [ + 'id', +]) {} diff --git a/api/src/dtos/multiselect-questions/multiselect-option-update.dto.ts b/api/src/dtos/multiselect-questions/multiselect-option-update.dto.ts new file mode 100644 index 0000000000..8dc6902bba --- /dev/null +++ b/api/src/dtos/multiselect-questions/multiselect-option-update.dto.ts @@ -0,0 +1,22 @@ +import { ApiPropertyOptional, OmitType } from '@nestjs/swagger'; +import { MultiselectOption } from './multiselect-option.dto'; +import { Expose } from 'class-transformer'; +import { IsString, IsUUID } from 'class-validator'; +import { ValidationsGroupsEnum } from '../../enums/shared/validation-groups-enum'; + +export class MultiselectOptionUpdate extends OmitType(MultiselectOption, [ + // TODO: Temporarily optional until after MSQ refactor + 'id', + 'createdAt', + 'updatedAt', + 'untranslatedName', + 'untranslatedText', +]) { + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @IsUUID(4, { groups: [ValidationsGroupsEnum.default] }) + // TODO: Temporarily optional until after MSQ refactor + // @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + id?: string; +} diff --git a/api/src/dtos/multiselect-questions/multiselect-option.dto.ts b/api/src/dtos/multiselect-questions/multiselect-option.dto.ts index aa7c425f3f..2a4b89fd6d 100644 --- a/api/src/dtos/multiselect-questions/multiselect-option.dto.ts +++ b/api/src/dtos/multiselect-questions/multiselect-option.dto.ts @@ -8,21 +8,25 @@ import { ValidateNested, } from 'class-validator'; import { MultiselectLink } from './multiselect-link.dto'; +import { AbstractDTO } from '../shared/abstract.dto'; import { IdDTO } from '../shared/id.dto'; import { ValidationMethod } from '../../enums/multiselect-questions/validation-method-enum'; import { ValidationsGroupsEnum } from '../../enums/shared/validation-groups-enum'; -export class MultiselectOption { +export class MultiselectOption extends AbstractDTO { + // TODO: This will be sunseted after MSQ refactor @Expose() @IsBoolean({ groups: [ValidationsGroupsEnum.default] }) @ApiPropertyOptional() collectAddress?: boolean; + // TODO: This will be sunseted after MSQ refactor @Expose() @IsBoolean({ groups: [ValidationsGroupsEnum.default] }) @ApiPropertyOptional() collectName?: boolean; + // TODO: This will be sunseted after MSQ refactor @Expose() @IsBoolean({ groups: [ValidationsGroupsEnum.default] }) @ApiPropertyOptional() @@ -106,6 +110,11 @@ export class MultiselectOption { @ApiProperty() text: string; + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + untranslatedName?: string; + @Expose() @IsString({ groups: [ValidationsGroupsEnum.default] }) @ApiPropertyOptional() diff --git a/api/src/dtos/multiselect-questions/multiselect-question-create.dto.ts b/api/src/dtos/multiselect-questions/multiselect-question-create.dto.ts index 32b8c0ebe1..c3048100f6 100644 --- a/api/src/dtos/multiselect-questions/multiselect-question-create.dto.ts +++ b/api/src/dtos/multiselect-questions/multiselect-question-create.dto.ts @@ -1,7 +1,26 @@ -import { OmitType } from '@nestjs/swagger'; +import { ApiPropertyOptional, 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 { MultiselectOptionCreate } from './multiselect-option-create.dto'; import { MultiselectQuestionUpdate } from './multiselect-question-update.dto'; export class MultiselectQuestionCreate extends OmitType( MultiselectQuestionUpdate, - ['id'], -) {} + ['id', 'multiselectOptions', 'options'], +) { + // TODO: Temporarily optional until after MSQ refactor + @Expose() + @ValidateNested({ groups: [ValidationsGroupsEnum.default], each: true }) + @Type(() => MultiselectOptionCreate) + @ApiPropertyOptional({ type: MultiselectOptionCreate, isArray: true }) + multiselectOptions?: MultiselectOptionCreate[]; + + // TODO: This will be sunseted after MSQ refactor + @Expose() + @ArrayMaxSize(64, { groups: [ValidationsGroupsEnum.default] }) + @Type(() => MultiselectOptionCreate) + @ValidateNested({ groups: [ValidationsGroupsEnum.default], each: true }) + @ApiPropertyOptional({ type: MultiselectOptionCreate, isArray: true }) + options?: MultiselectOptionCreate[]; +} 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 index 6fe92ae659..c36982b817 100644 --- a/api/src/dtos/multiselect-questions/multiselect-question-filter-params.dto.ts +++ b/api/src/dtos/multiselect-questions/multiselect-question-filter-params.dto.ts @@ -1,25 +1,37 @@ -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 { ApiPropertyOptional } from '@nestjs/swagger'; +import { + MultiselectQuestionsApplicationSectionEnum, + MultiselectQuestionsStatusEnum, +} from '@prisma/client'; +import { BaseFilter } from '../shared/base-filter.dto'; import { MultiselectQuestionFilterKeys } from '../../enums/multiselect-questions/filter-key-enum'; -import { MultiselectQuestionsApplicationSectionEnum } from '@prisma/client'; +import { ValidationsGroupsEnum } from '../../enums/shared/validation-groups-enum'; export class MultiselectQuestionFilterParams extends BaseFilter { @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) @ApiPropertyOptional({ - example: 'uuid', + enum: MultiselectQuestionsApplicationSectionEnum, + enumName: 'MultiselectQuestionsApplicationSectionEnum', + example: 'preferences', }) + [MultiselectQuestionFilterKeys.applicationSection]?: MultiselectQuestionsApplicationSectionEnum; + + @Expose() @IsString({ groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional({ + example: 'uuid', + }) [MultiselectQuestionFilterKeys.jurisdiction]?: string; @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) @ApiPropertyOptional({ - enum: MultiselectQuestionsApplicationSectionEnum, - enumName: 'MultiselectQuestionsApplicationSectionEnum', - example: 'preferences', + enum: MultiselectQuestionsStatusEnum, + enumName: 'MultiselectQuestionsStatusEnum', + example: 'active', }) - @IsString({ groups: [ValidationsGroupsEnum.default] }) - [MultiselectQuestionFilterKeys.applicationSection]?: MultiselectQuestionsApplicationSectionEnum; + [MultiselectQuestionFilterKeys.status]?: MultiselectQuestionsStatusEnum; } 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 index 32f4877a17..05d41e59bc 100644 --- a/api/src/dtos/multiselect-questions/multiselect-question-query-params.dto.ts +++ b/api/src/dtos/multiselect-questions/multiselect-question-query-params.dto.ts @@ -1,10 +1,23 @@ -import { Expose, Type } from 'class-transformer'; +import { Expose, Transform, Type } from 'class-transformer'; +import { + ArrayMaxSize, + IsArray, + IsEnum, + IsString, + Validate, + ValidateNested, +} from 'class-validator'; import { ApiPropertyOptional, getSchemaPath } from '@nestjs/swagger'; import { MultiselectQuestionFilterParams } from './multiselect-question-filter-params.dto'; -import { ArrayMaxSize, IsArray, ValidateNested } from 'class-validator'; +import { PaginationAllowsAllQueryParams } from '../shared/pagination.dto'; +import { SearchStringLengthCheck } from '../../decorators/search-string-length-check.decorator'; +import { MultiselectQuestionOrderByKeys } from '../../enums/multiselect-questions/order-by-enum'; +import { MultiselectQuestionViews } from '../../enums/multiselect-questions/view-enum'; +import { OrderByEnum } from '../../enums/shared/order-by-enum'; import { ValidationsGroupsEnum } from '../../enums/shared/validation-groups-enum'; +import { OrderQueryParamValidator } from '../../utilities/order-by-validator'; -export class MultiselectQuestionQueryParams { +export class MultiselectQuestionQueryParams extends PaginationAllowsAllQueryParams { @Expose() @ApiPropertyOptional({ type: [String], @@ -18,4 +31,65 @@ export class MultiselectQuestionQueryParams { @Type(() => MultiselectQuestionFilterParams) @ValidateNested({ groups: [ValidationsGroupsEnum.default], each: true }) filter?: MultiselectQuestionFilterParams[]; + + @Expose() + @ApiPropertyOptional({ + enum: MultiselectQuestionOrderByKeys, + enumName: 'MultiselectQuestionOrderByKeys', + example: ['status'], + default: ['status'], + isArray: true, + }) + @IsArray({ groups: [ValidationsGroupsEnum.default] }) + @ArrayMaxSize(16, { groups: [ValidationsGroupsEnum.default] }) + @IsEnum(MultiselectQuestionOrderByKeys, { + groups: [ValidationsGroupsEnum.default], + each: true, + }) + @Validate(OrderQueryParamValidator, { + groups: [ValidationsGroupsEnum.default], + }) + orderBy?: MultiselectQuestionOrderByKeys[]; + + @Expose() + @ApiPropertyOptional({ + enum: OrderByEnum, + enumName: 'OrderByEnum', + example: ['desc'], + default: ['desc'], + isArray: true, + }) + @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({ + 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({ + enum: MultiselectQuestionViews, + enumName: 'MultiselectQuestionViews', + example: 'base', + }) + @IsEnum(MultiselectQuestionViews, { + groups: [ValidationsGroupsEnum.default], + }) + view?: MultiselectQuestionViews; } diff --git a/api/src/dtos/multiselect-questions/multiselect-question-update.dto.ts b/api/src/dtos/multiselect-questions/multiselect-question-update.dto.ts index 47d55466e1..f0e9742d12 100644 --- a/api/src/dtos/multiselect-questions/multiselect-question-update.dto.ts +++ b/api/src/dtos/multiselect-questions/multiselect-question-update.dto.ts @@ -1,10 +1,30 @@ -import { OmitType } from '@nestjs/swagger'; +import { ApiPropertyOptional, 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 { MultiselectOptionUpdate } from './multiselect-option-update.dto'; import { MultiselectQuestion } from './multiselect-question.dto'; export class MultiselectQuestionUpdate extends OmitType(MultiselectQuestion, [ 'createdAt', 'updatedAt', - 'status', + 'multiselectOptions', + 'options', + 'untranslatedName', 'untranslatedText', - 'untranslatedText', -]) {} +]) { + // TODO: Temporarily optional until after MSQ refactor + @Expose() + @ValidateNested({ groups: [ValidationsGroupsEnum.default], each: true }) + @Type(() => MultiselectOptionUpdate) + @ApiPropertyOptional({ type: MultiselectOptionUpdate, isArray: true }) + multiselectOptions?: MultiselectOptionUpdate[]; + + // TODO: This will be sunseted after MSQ refactor + @Expose() + @ArrayMaxSize(64, { groups: [ValidationsGroupsEnum.default] }) + @Type(() => MultiselectOptionUpdate) + @ValidateNested({ groups: [ValidationsGroupsEnum.default], each: true }) + @ApiPropertyOptional({ type: MultiselectOptionUpdate, isArray: true }) + options?: MultiselectOptionUpdate[]; +} diff --git a/api/src/dtos/multiselect-questions/multiselect-question.dto.ts b/api/src/dtos/multiselect-questions/multiselect-question.dto.ts index b1d6e44230..3953a4d45b 100644 --- a/api/src/dtos/multiselect-questions/multiselect-question.dto.ts +++ b/api/src/dtos/multiselect-questions/multiselect-question.dto.ts @@ -36,7 +36,7 @@ class MultiselectQuestion extends AbstractDTO { description?: string; // TODO: Temporarily optional until after MSQ refactor - // @Expose() + @Expose() @IsBoolean({ groups: [ValidationsGroupsEnum.default] }) @ApiPropertyOptional() isExclusive?: boolean; @@ -47,13 +47,14 @@ class MultiselectQuestion extends AbstractDTO { hideFromListing?: boolean; // TODO: Temporarily optional until after MSQ refactor - // @Expose() + @Expose() // @IsDefined({ groups: [ValidationsGroupsEnum.default] }) @Type(() => IdDTO) @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) @ApiPropertyOptional({ type: IdDTO }) jurisdiction?: IdDTO; + // TODO: This will be sunseted after MSQ refactor but still required at the moment @Expose() @IsDefined({ groups: [ValidationsGroupsEnum.default] }) @Type(() => IdDTO) @@ -68,7 +69,7 @@ class MultiselectQuestion extends AbstractDTO { links?: MultiselectLink[]; // TODO: Temporarily optional until after MSQ refactor - // @Expose() + @Expose() // @IsDefined({ groups: [ValidationsGroupsEnum.default] }) @Type(() => MultiselectOption) @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) @@ -76,12 +77,13 @@ class MultiselectQuestion extends AbstractDTO { multiselectOptions?: MultiselectOption[]; // TODO: Temporarily optional until after MSQ refactor - // @Expose() + @Expose() // @IsDefined({ groups: [ValidationsGroupsEnum.default] }) @IsString({ groups: [ValidationsGroupsEnum.default] }) @ApiPropertyOptional() name?: string; + // TODO: This will be sunseted after MSQ refactor @Expose() @ArrayMaxSize(64, { groups: [ValidationsGroupsEnum.default] }) @Type(() => MultiselectOption) @@ -89,13 +91,13 @@ class MultiselectQuestion extends AbstractDTO { @ApiPropertyOptional({ type: MultiselectOption, isArray: true }) options?: MultiselectOption[]; + // TODO: This will be sunseted after MSQ refactor @Expose() @IsString({ groups: [ValidationsGroupsEnum.default] }) @ApiPropertyOptional() optOutText?: string; - // TODO: Temporarily optional until after MSQ refactor - // @Expose() + @Expose() @IsDefined({ groups: [ValidationsGroupsEnum.default] }) @IsEnum(MultiselectQuestionsStatusEnum, { groups: [ValidationsGroupsEnum.default], @@ -103,6 +105,7 @@ class MultiselectQuestion extends AbstractDTO { @ApiProperty({ enum: MultiselectQuestionsStatusEnum, enumName: 'MultiselectQuestionsStatusEnum', + example: 'draft', }) status: MultiselectQuestionsStatusEnum; @@ -111,12 +114,19 @@ class MultiselectQuestion extends AbstractDTO { @ApiPropertyOptional() subText?: string; + // TODO: This will be sunseted after MSQ refactor but still required at the moment @Expose() @IsDefined({ groups: [ValidationsGroupsEnum.default] }) @IsString({ groups: [ValidationsGroupsEnum.default] }) @ApiProperty() text: string; + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + untranslatedName?: string; + + // TODO: This will be sunseted after MSQ refactor @Expose() @IsString({ groups: [ValidationsGroupsEnum.default] }) @ApiPropertyOptional() diff --git a/api/src/dtos/paper-applications/paper-application-create.dto.ts b/api/src/dtos/paper-applications/paper-application-create.dto.ts index babc6ff015..6c81fa651e 100644 --- a/api/src/dtos/paper-applications/paper-application-create.dto.ts +++ b/api/src/dtos/paper-applications/paper-application-create.dto.ts @@ -1,15 +1,13 @@ import { ApiPropertyOptional, OmitType } from '@nestjs/swagger'; +import { AssetCreate } from '../assets/asset-create.dto'; import { Expose, Type } from 'class-transformer'; +import { PaperApplicationUpdate } from './paper-application-update.dto'; 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', +export class PaperApplicationCreate extends OmitType(PaperApplicationUpdate, [ 'assets', + 'id', ]) { @Expose() @ValidateNested({ groups: [ValidationsGroupsEnum.default], each: true }) diff --git a/api/src/dtos/paper-applications/paper-application-update.dto.ts b/api/src/dtos/paper-applications/paper-application-update.dto.ts new file mode 100644 index 0000000000..e1f9f94804 --- /dev/null +++ b/api/src/dtos/paper-applications/paper-application-update.dto.ts @@ -0,0 +1,25 @@ +import { ApiPropertyOptional, OmitType } from '@nestjs/swagger'; +import { AssetCreate } from '../assets/asset-create.dto'; +import { Expose, Type } from 'class-transformer'; +import { IsString, IsUUID, ValidateNested } from 'class-validator'; +import { PaperApplication } from './paper-application.dto'; +import { ValidationsGroupsEnum } from '../../enums/shared/validation-groups-enum'; + +export class PaperApplicationUpdate extends OmitType(PaperApplication, [ + 'assets', + 'createdAt', + 'id', + 'updatedAt', +]) { + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @IsUUID(4, { groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + id?: string; + + @Expose() + @ValidateNested({ groups: [ValidationsGroupsEnum.default], each: true }) + @Type(() => AssetCreate) + @ApiPropertyOptional({ type: AssetCreate }) + assets?: AssetCreate; +} diff --git a/api/src/dtos/unit-groups/unit-group-ami-level-create.dto.ts b/api/src/dtos/unit-groups/unit-group-ami-level-create.dto.ts index 9071bf6fc3..ba88f5014e 100644 --- a/api/src/dtos/unit-groups/unit-group-ami-level-create.dto.ts +++ b/api/src/dtos/unit-groups/unit-group-ami-level-create.dto.ts @@ -1,19 +1,6 @@ -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 { UnitGroupAmiLevel } from './unit-group-ami-level.dto'; +import { OmitType } from '@nestjs/swagger'; +import { UnitGroupAmiLevelUpdate } from './unit-group-ami-level-update.dto'; -export class UnitGroupAmiLevelCreate extends OmitType(UnitGroupAmiLevel, [ +export class UnitGroupAmiLevelCreate extends OmitType(UnitGroupAmiLevelUpdate, [ 'id', - 'createdAt', - 'updatedAt', - 'amiChart', -]) { - @Expose() - @Type(() => IdDTO) - @ApiPropertyOptional({ type: IdDTO }) - @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) - amiChart?: IdDTO; -} +]) {} diff --git a/api/src/dtos/unit-groups/unit-group-ami-level-update.dto.ts b/api/src/dtos/unit-groups/unit-group-ami-level-update.dto.ts new file mode 100644 index 0000000000..18c228d70e --- /dev/null +++ b/api/src/dtos/unit-groups/unit-group-ami-level-update.dto.ts @@ -0,0 +1,25 @@ +import { ApiPropertyOptional, OmitType } from '@nestjs/swagger'; +import { Expose, Type } from 'class-transformer'; +import { IdDTO } from '../shared/id.dto'; +import { IsString, IsUUID, ValidateNested } from 'class-validator'; +import { UnitGroupAmiLevel } from './unit-group-ami-level.dto'; +import { ValidationsGroupsEnum } from '../../enums/shared/validation-groups-enum'; + +export class UnitGroupAmiLevelUpdate extends OmitType(UnitGroupAmiLevel, [ + 'amiChart', + 'createdAt', + 'id', + 'updatedAt', +]) { + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @IsUUID(4, { groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + id?: string; + + @Expose() + @Type(() => IdDTO) + @ApiPropertyOptional({ type: IdDTO }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + amiChart?: IdDTO; +} diff --git a/api/src/dtos/unit-groups/unit-group-create.dto.ts b/api/src/dtos/unit-groups/unit-group-create.dto.ts index 49e59c7462..1e70698175 100644 --- a/api/src/dtos/unit-groups/unit-group-create.dto.ts +++ b/api/src/dtos/unit-groups/unit-group-create.dto.ts @@ -1,31 +1,14 @@ import { ApiPropertyOptional, OmitType } from '@nestjs/swagger'; import { Expose, Type } from 'class-transformer'; +import { UnitGroupAmiLevelCreate } from './unit-group-ami-level-create.dto'; +import { UnitGroupUpdate } from './unit-group-update.dto'; import { ValidateNested } from 'class-validator'; import { ValidationsGroupsEnum } from '../../enums/shared/validation-groups-enum'; -import { IdDTO } from '../shared/id.dto'; -import UnitGroup from './unit-group.dto'; -import { UnitGroupAmiLevelCreate } from './unit-group-ami-level-create.dto'; -export class UnitGroupCreate extends OmitType(UnitGroup, [ +export class UnitGroupCreate extends OmitType(UnitGroupUpdate, [ 'id', - 'createdAt', - 'updatedAt', - 'unitAccessibilityPriorityTypes', - 'unitTypes', 'unitGroupAmiLevels', ]) { - @Expose() - @Type(() => IdDTO) - @ApiPropertyOptional({ type: IdDTO }) - @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) - unitAccessibilityPriorityTypes?: IdDTO; - - @Expose() - @Type(() => IdDTO) - @ValidateNested({ groups: [ValidationsGroupsEnum.default], each: true }) - @ApiPropertyOptional({ type: [IdDTO] }) - unitTypes?: IdDTO[]; - @Expose() @Type(() => UnitGroupAmiLevelCreate) @ValidateNested({ groups: [ValidationsGroupsEnum.default], each: true }) diff --git a/api/src/dtos/unit-groups/unit-group-update.dto.ts b/api/src/dtos/unit-groups/unit-group-update.dto.ts new file mode 100644 index 0000000000..aa9b191434 --- /dev/null +++ b/api/src/dtos/unit-groups/unit-group-update.dto.ts @@ -0,0 +1,40 @@ +import { ApiPropertyOptional, OmitType } from '@nestjs/swagger'; +import { Expose, Type } from 'class-transformer'; +import { IdDTO } from '../shared/id.dto'; +import { IsString, IsUUID, ValidateNested } from 'class-validator'; +import { UnitGroupAmiLevelUpdate } from './unit-group-ami-level-update.dto'; +import { ValidationsGroupsEnum } from '../../enums/shared/validation-groups-enum'; +import UnitGroup from './unit-group.dto'; + +export class UnitGroupUpdate extends OmitType(UnitGroup, [ + 'createdAt', + 'id', + 'unitAccessibilityPriorityTypes', + 'unitGroupAmiLevels', + 'unitTypes', + 'updatedAt', +]) { + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @IsUUID(4, { groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + id?: string; + + @Expose() + @Type(() => IdDTO) + @ApiPropertyOptional({ type: IdDTO }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) + unitAccessibilityPriorityTypes?: IdDTO; + + @Expose() + @Type(() => IdDTO) + @ValidateNested({ groups: [ValidationsGroupsEnum.default], each: true }) + @ApiPropertyOptional({ type: [IdDTO] }) + unitTypes?: IdDTO[]; + + @Expose() + @Type(() => UnitGroupAmiLevelUpdate) + @ValidateNested({ groups: [ValidationsGroupsEnum.default], each: true }) + @ApiPropertyOptional({ type: [UnitGroupAmiLevelUpdate] }) + unitGroupAmiLevels?: UnitGroupAmiLevelUpdate[]; +} diff --git a/api/src/dtos/unit-groups/unit-group.dto.ts b/api/src/dtos/unit-groups/unit-group.dto.ts index 9ca8ee2372..8c66822c04 100644 --- a/api/src/dtos/unit-groups/unit-group.dto.ts +++ b/api/src/dtos/unit-groups/unit-group.dto.ts @@ -30,6 +30,11 @@ class UnitGroup extends AbstractDTO { @ApiPropertyOptional() flatRentValueTo?: number; + @Expose() + @IsNumber({}, { groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + monthlyRent?: number; + @Expose() @IsNumber({}, { groups: [ValidationsGroupsEnum.default] }) @ApiPropertyOptional() @@ -82,7 +87,10 @@ class UnitGroup extends AbstractDTO { @ValidateUnitGroupRent({ groups: [ValidationsGroupsEnum.default], }) - @ApiPropertyOptional() + @ApiPropertyOptional({ + enum: RentTypeEnum, + enumName: 'RentTypeEnum', + }) rentType?: RentTypeEnum; @Expose() diff --git a/api/src/dtos/units/ami-chart-override-create.dto.ts b/api/src/dtos/units/ami-chart-override-create.dto.ts index 890e883c1d..b129887770 100644 --- a/api/src/dtos/units/ami-chart-override-create.dto.ts +++ b/api/src/dtos/units/ami-chart-override-create.dto.ts @@ -1,8 +1,7 @@ import { OmitType } from '@nestjs/swagger'; -import { UnitAmiChartOverride } from './ami-chart-override.dto'; +import { UnitAmiChartOverrideUpdate } from './ami-chart-override-update.dto'; -export class UnitAmiChartOverrideCreate extends OmitType(UnitAmiChartOverride, [ - 'id', - 'createdAt', - 'updatedAt', -]) {} +export class UnitAmiChartOverrideCreate extends OmitType( + UnitAmiChartOverrideUpdate, + ['id'], +) {} diff --git a/api/src/dtos/units/ami-chart-override-update.dto.ts b/api/src/dtos/units/ami-chart-override-update.dto.ts new file mode 100644 index 0000000000..8620ab732f --- /dev/null +++ b/api/src/dtos/units/ami-chart-override-update.dto.ts @@ -0,0 +1,17 @@ +import { ApiPropertyOptional, OmitType } from '@nestjs/swagger'; +import { Expose } from 'class-transformer'; +import { IsString, IsUUID } from 'class-validator'; +import { UnitAmiChartOverride } from './ami-chart-override.dto'; +import { ValidationsGroupsEnum } from '../../enums/shared/validation-groups-enum'; + +export class UnitAmiChartOverrideUpdate extends OmitType(UnitAmiChartOverride, [ + 'createdAt', + 'id', + 'updatedAt', +]) { + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @IsUUID(4, { groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + id?: string; +} diff --git a/api/src/dtos/units/unit-create.dto.ts b/api/src/dtos/units/unit-create.dto.ts index f8489d15e8..a6f5fba3df 100644 --- a/api/src/dtos/units/unit-create.dto.ts +++ b/api/src/dtos/units/unit-create.dto.ts @@ -1,45 +1,14 @@ import { ApiPropertyOptional, OmitType } from '@nestjs/swagger'; import { Expose, Type } from 'class-transformer'; +import { UnitAmiChartOverrideCreate } from './ami-chart-override-create.dto'; +import { UnitUpdate } from './unit-update.dto'; 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, [ +export class UnitCreate extends OmitType(UnitUpdate, [ '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) diff --git a/api/src/dtos/units/unit-update.dto.ts b/api/src/dtos/units/unit-update.dto.ts new file mode 100644 index 0000000000..ce825bcb2b --- /dev/null +++ b/api/src/dtos/units/unit-update.dto.ts @@ -0,0 +1,54 @@ +import { ApiPropertyOptional, OmitType } from '@nestjs/swagger'; +import { Expose, Type } from 'class-transformer'; +import { IdDTO } from '../shared/id.dto'; +import { IsString, IsUUID, ValidateNested } from 'class-validator'; +import { Unit } from './unit.dto'; +import { UnitAmiChartOverrideUpdate } from './ami-chart-override-update.dto'; +import { ValidationsGroupsEnum } from '../../enums/shared/validation-groups-enum'; + +export class UnitUpdate extends OmitType(Unit, [ + 'amiChart', + 'createdAt', + 'id', + 'unitAccessibilityPriorityTypes', + 'unitAmiChartOverrides', + 'unitRentTypes', + 'unitTypes', + 'updatedAt', +]) { + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @IsUUID(4, { groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + id?: string; + + @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(() => UnitAmiChartOverrideUpdate) + @ApiPropertyOptional({ type: UnitAmiChartOverrideUpdate }) + unitAmiChartOverrides?: UnitAmiChartOverrideUpdate; +} diff --git a/api/src/dtos/units/units-summary.dto.ts b/api/src/dtos/units/units-summary.dto.ts index 2da9eb7817..fd8484c29c 100644 --- a/api/src/dtos/units/units-summary.dto.ts +++ b/api/src/dtos/units/units-summary.dto.ts @@ -112,7 +112,10 @@ class UnitsSummary { @ValidateUnitGroupRent({ groups: [ValidationsGroupsEnum.default], }) - @ApiPropertyOptional() + @ApiPropertyOptional({ + enum: RentTypeEnum, + enumName: 'RentTypeEnum', + }) rentType?: RentTypeEnum; @Expose() @@ -124,6 +127,11 @@ class UnitsSummary { @IsNumber({}, { groups: [ValidationsGroupsEnum.default] }) @ApiPropertyOptional() flatRentValueTo?: number; + + @Expose() + @IsNumber({}, { groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + monthlyRent?: number; } export { UnitsSummary as default, UnitsSummary }; diff --git a/api/src/dtos/users/user-delete.dto.ts b/api/src/dtos/users/user-delete.dto.ts new file mode 100644 index 0000000000..c472b2d7db --- /dev/null +++ b/api/src/dtos/users/user-delete.dto.ts @@ -0,0 +1,18 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Expose } from 'class-transformer'; +import { IsBoolean, IsDefined, IsString, IsUUID } from 'class-validator'; +import { ValidationsGroupsEnum } from '../../enums/shared/validation-groups-enum'; + +export class UserDeleteDTO { + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @IsUUID(4, { groups: [ValidationsGroupsEnum.default] }) + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ApiProperty() + id: string; + + @Expose() + @IsBoolean({ groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + shouldRemoveApplication?: boolean; +} diff --git a/api/src/enums/feature-flags/feature-flags-enum.ts b/api/src/enums/feature-flags/feature-flags-enum.ts index 25779eae9b..3df03e6d35 100644 --- a/api/src/enums/feature-flags/feature-flags-enum.ts +++ b/api/src/enums/feature-flags/feature-flags-enum.ts @@ -2,6 +2,7 @@ // Note, these are just used to keep backend and frontend in sync. We store feature flags as strings so this list might not include every flag. // Keep alphabetized for readability. export enum FeatureFlagEnum { + disableBuildingSelectionCriteria = 'disableBuildingSelectionCriteria', disableCommonApplication = 'disableCommonApplication', disableJurisdictionalAdmin = 'disableJurisdictionalAdmin', disableListingPreferences = 'disableListingPreferences', @@ -9,7 +10,9 @@ export enum FeatureFlagEnum { enableAccessibilityFeatures = 'enableAccessibilityFeatures', enableAdaOtherOption = 'enableAdaOtherOption', enableAdditionalResources = 'enableAdditionalResources', + enableApplicationStatus = 'enableApplicationStatus', enableCompanyWebsite = 'enableCompanyWebsite', + enableCreditScreeningFee = 'enableCreditScreeningFee', enableFullTimeStudentQuestion = 'enableFullTimeStudentQuestion', enableGeocodingPreferences = 'enableGeocodingPreferences', enableGeocodingRadiusMethod = 'enableGeocodingRadiusMethod', @@ -18,20 +21,28 @@ export enum FeatureFlagEnum { enableIsVerified = 'enableIsVerified', enableLimitedHowDidYouHear = 'enableLimitedHowDidYouHear', enableListingFavoriting = 'enableListingFavoriting', + enableListingFileNumber = 'enableListingFileNumber', enableListingFiltering = 'enableListingFiltering', + enableLeasingAgentAltText = 'enableLeasingAgentAltText', + enableListingImageAltText = 'enableListingImageAltText', enableListingOpportunity = 'enableListingOpportunity', enableListingPagination = 'enableListingPagination', enableListingUpdatedAt = 'enableListingUpdatedAt', + enableMarketingFlyer = 'enableMarketingFlyer', enableMarketingStatus = 'enableMarketingStatus', enableMarketingStatusMonths = 'enableMarketingStatusMonths', enableNeighborhoodAmenities = 'enableNeighborhoodAmenities', enableNeighborhoodAmenitiesDropdown = 'enableNeighborhoodAmenitiesDropdown', enableNonRegulatedListings = 'enableNonRegulatedListings', + enableParkingFee = 'enableParkingFee', enablePartnerDemographics = 'enablePartnerDemographics', enablePartnerSettings = 'enablePartnerSettings', + enableProperties = 'enableProperties', + enableReferralQuestionUnits = 'enableReferralQuestionUnits', enableRegions = 'enableRegions', enableSection8Question = 'enableSection8Question', enableSingleUseCode = 'enableSingleUseCode', + enableSmokingPolicyRadio = 'enableSmokingPolicyRadio', enableSupportAdmin = 'enableSupportAdmin', enableUnderConstructionHome = 'enableUnderConstructionHome', enableUnitGroups = 'enableUnitGroups', @@ -52,6 +63,11 @@ export const featureFlagMap: { name: string; description: string; }[] = [ + { + name: FeatureFlagEnum.disableBuildingSelectionCriteria, + description: + 'When true, building selection criteria is not displayed in the listing', + }, { name: FeatureFlagEnum.disableCommonApplication, description: @@ -86,11 +102,20 @@ export const featureFlagMap: { description: "When true, the 'learn more' section is displayed on the home page", }, + { + name: FeatureFlagEnum.enableApplicationStatus, + description: + 'When true, the application status and notifications feature is enabled on public and partners', + }, { name: FeatureFlagEnum.enableCompanyWebsite, description: 'When true, allows partners to add company website information', }, + { + name: FeatureFlagEnum.enableCreditScreeningFee, + description: 'When true, credit screening fee is enabled for listings', + }, { name: FeatureFlagEnum.enableFullTimeStudentQuestion, description: @@ -130,11 +155,24 @@ export const featureFlagMap: { description: 'When true, a Favorite button is shown for public listings and users can view their favorited listings', }, + { + name: FeatureFlagEnum.enableListingFileNumber, + description: + 'When true, partners can enter and export a listing file number', + }, { name: FeatureFlagEnum.enableListingFiltering, description: 'When true, a filter button is shown on listings browse and users can filter with the options in the drawer', }, + { + name: FeatureFlagEnum.enableLeasingAgentAltText, + description: 'When true, shows alternative text for LA users', + }, + { + name: FeatureFlagEnum.enableListingImageAltText, + description: 'When true, allows partners to add alt text to listing images', + }, { name: FeatureFlagEnum.enableListingOpportunity, description: @@ -149,6 +187,11 @@ export const featureFlagMap: { name: FeatureFlagEnum.enableListingUpdatedAt, description: 'When true, listings detail will display an updated at date', }, + { + name: FeatureFlagEnum.enableMarketingFlyer, + description: + "When true, the 'marketing flyer' sub-section is displayed in listing creation/edit and the public listing view", + }, { name: FeatureFlagEnum.enableMarketingStatus, description: @@ -174,6 +217,10 @@ export const featureFlagMap: { description: 'When true, non-regulated listings are displayed in listing creation/edit and public listing view', }, + { + name: FeatureFlagEnum.enableParkingFee, + description: 'When true, the parking fee field should be visible', + }, { name: FeatureFlagEnum.enablePartnerDemographics, description: @@ -183,6 +230,14 @@ export const featureFlagMap: { name: FeatureFlagEnum.enablePartnerSettings, description: "When true, the 'settings' tab in the partner site is visible", }, + { + name: FeatureFlagEnum.enableProperties, + description: 'When true, the properties feature is enabled', + }, + { + name: FeatureFlagEnum.enableReferralQuestionUnits, + description: 'when true, updates the the referral details question labels', + }, { name: FeatureFlagEnum.enableRegions, description: @@ -197,6 +252,11 @@ export const featureFlagMap: { description: 'When true, the backend allows for logging into this jurisdiction using the single use code flow', }, + { + name: FeatureFlagEnum.enableSmokingPolicyRadio, + description: + "When true, the listing 'Smoking policy' field is a radio group", + }, { name: FeatureFlagEnum.enableSupportAdmin, description: 'When true, support admins can be created', diff --git a/api/src/enums/listings/filter-key-enum.ts b/api/src/enums/listings/filter-key-enum.ts index 8496a75997..b16f274435 100644 --- a/api/src/enums/listings/filter-key-enum.ts +++ b/api/src/enums/listings/filter-key-enum.ts @@ -21,4 +21,5 @@ export enum ListingFilterKeys { section8Acceptance = 'section8Acceptance', status = 'status', zipCode = 'zipCode', + listingType = 'listingType', } diff --git a/api/src/enums/listings/order-by-enum.ts b/api/src/enums/listings/order-by-enum.ts index cec0ab5aba..97b9172eb1 100644 --- a/api/src/enums/listings/order-by-enum.ts +++ b/api/src/enums/listings/order-by-enum.ts @@ -10,4 +10,5 @@ export enum ListingOrderByKeys { marketingType = 'marketingType', marketingYear = 'marketingYear', marketingSeason = 'marketingSeason', + listingType = 'listingType', } diff --git a/api/src/enums/multiselect-questions/filter-key-enum.ts b/api/src/enums/multiselect-questions/filter-key-enum.ts index 398269f1eb..a5ef8a9de6 100644 --- a/api/src/enums/multiselect-questions/filter-key-enum.ts +++ b/api/src/enums/multiselect-questions/filter-key-enum.ts @@ -1,4 +1,5 @@ export enum MultiselectQuestionFilterKeys { - jurisdiction = 'jurisdiction', applicationSection = 'applicationSection', + jurisdiction = 'jurisdiction', + status = 'status', } diff --git a/api/src/enums/multiselect-questions/order-by-enum.ts b/api/src/enums/multiselect-questions/order-by-enum.ts new file mode 100644 index 0000000000..934a32be57 --- /dev/null +++ b/api/src/enums/multiselect-questions/order-by-enum.ts @@ -0,0 +1,6 @@ +export enum MultiselectQuestionOrderByKeys { + jurisdiction = 'jurisdiction', + name = 'name', + status = 'status', + updatedAt = 'updatedAt', +} diff --git a/api/src/enums/multiselect-questions/view-enum.ts b/api/src/enums/multiselect-questions/view-enum.ts new file mode 100644 index 0000000000..340245d970 --- /dev/null +++ b/api/src/enums/multiselect-questions/view-enum.ts @@ -0,0 +1,4 @@ +export enum MultiselectQuestionViews { + base = 'base', + fundamentals = 'fundamentals', +} diff --git a/api/src/modules/app.module.ts b/api/src/modules/app.module.ts index 4e0bf51636..58da240cb7 100644 --- a/api/src/modules/app.module.ts +++ b/api/src/modules/app.module.ts @@ -23,6 +23,7 @@ import { ThrottleGuard } from '../guards/throttler.guard'; import { ScriptRunnerModule } from './script-runner.module'; import { LotteryModule } from './lottery.module'; import { FeatureFlagModule } from './feature-flag.module'; +import { CronJobModule } from './cron-job.module'; @Module({ imports: [ @@ -44,6 +45,7 @@ import { FeatureFlagModule } from './feature-flag.module'; ScriptRunnerModule, LotteryModule, FeatureFlagModule, + CronJobModule, ThrottlerModule.forRoot([ { ttl: Number(process.env.THROTTLE_TTL), diff --git a/api/src/modules/application-flagged-set.module.ts b/api/src/modules/application-flagged-set.module.ts index 0482dd32d1..87fbc07f6e 100644 --- a/api/src/modules/application-flagged-set.module.ts +++ b/api/src/modules/application-flagged-set.module.ts @@ -4,9 +4,10 @@ import { ApplicationFlaggedSetController } from '../controllers/application-flag import { ApplicationFlaggedSetService } from '../services/application-flagged-set.service'; import { PrismaModule } from './prisma.module'; import { PermissionModule } from './permission.module'; +import { CronJobModule } from './cron-job.module'; @Module({ - imports: [PrismaModule, PermissionModule], + imports: [PrismaModule, PermissionModule, CronJobModule], controllers: [ApplicationFlaggedSetController], providers: [ApplicationFlaggedSetService, Logger, SchedulerRegistry], exports: [ApplicationFlaggedSetService], diff --git a/api/src/modules/application.module.ts b/api/src/modules/application.module.ts index cdc41a9bdd..c22a7769a9 100644 --- a/api/src/modules/application.module.ts +++ b/api/src/modules/application.module.ts @@ -1,4 +1,4 @@ -import { Module } from '@nestjs/common'; +import { Logger, Module } from '@nestjs/common'; import { ApplicationController } from '../controllers/application.controller'; import { ApplicationExporterModule } from './application-exporter.module'; import { ApplicationService } from '../services/application.service'; @@ -9,6 +9,7 @@ import { MultiselectQuestionModule } from './multiselect-question.module'; import { PermissionModule } from './permission.module'; import { PrismaModule } from './prisma.module'; import { UnitTypeModule } from './unit-type.module'; +import { CronJobModule } from './cron-job.module'; @Module({ imports: [ @@ -19,9 +20,10 @@ import { UnitTypeModule } from './unit-type.module'; MultiselectQuestionModule, PermissionModule, UnitTypeModule, + CronJobModule, ], controllers: [ApplicationController], - providers: [ApplicationService, GeocodingService], + providers: [ApplicationService, GeocodingService, Logger], exports: [ApplicationService], }) export class ApplicationModule {} diff --git a/api/src/modules/cron-job.module.ts b/api/src/modules/cron-job.module.ts new file mode 100644 index 0000000000..35b537411f --- /dev/null +++ b/api/src/modules/cron-job.module.ts @@ -0,0 +1,12 @@ +import { Logger, Module } from '@nestjs/common'; +import { SchedulerRegistry } from '@nestjs/schedule'; +import { PrismaModule } from './prisma.module'; +import { CronJobService } from '../services/cron-job.service'; + +@Module({ + imports: [PrismaModule], + controllers: [], + providers: [CronJobService, Logger, SchedulerRegistry], + exports: [CronJobService], +}) +export class CronJobModule {} diff --git a/api/src/modules/listing.module.ts b/api/src/modules/listing.module.ts index 8f81abc9ab..699c37bb05 100644 --- a/api/src/modules/listing.module.ts +++ b/api/src/modules/listing.module.ts @@ -1,6 +1,5 @@ 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'; @@ -11,6 +10,7 @@ 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'; +import { CronJobModule } from './cron-job.module'; @Module({ imports: [ @@ -19,6 +19,7 @@ import { ListingCsvExporterService } from '../services/listing-csv-export.servic EmailModule, ApplicationFlaggedSetModule, PermissionModule, + CronJobModule, ], controllers: [ListingController], providers: [ @@ -27,7 +28,6 @@ import { ListingCsvExporterService } from '../services/listing-csv-export.servic GoogleTranslateService, ConfigService, Logger, - SchedulerRegistry, ListingCsvExporterService, ], exports: [ListingService], diff --git a/api/src/modules/lottery.module.ts b/api/src/modules/lottery.module.ts index 9617cd4895..b091bcbbcd 100644 --- a/api/src/modules/lottery.module.ts +++ b/api/src/modules/lottery.module.ts @@ -1,5 +1,4 @@ import { Logger, Module } from '@nestjs/common'; -import { SchedulerRegistry } from '@nestjs/schedule'; import { ApplicationExporterModule } from './application-exporter.module'; import { ConfigService } from '@nestjs/config'; import { EmailModule } from './email.module'; @@ -9,6 +8,7 @@ import { LotteryService } from '../services/lottery.service'; import { MultiselectQuestionModule } from './multiselect-question.module'; import { PermissionModule } from './permission.module'; import { PrismaModule } from './prisma.module'; +import { CronJobModule } from './cron-job.module'; @Module({ imports: [ @@ -18,9 +18,10 @@ import { PrismaModule } from './prisma.module'; EmailModule, MultiselectQuestionModule, PermissionModule, + CronJobModule, ], controllers: [LotteryController], - providers: [LotteryService, Logger, SchedulerRegistry, ConfigService], + providers: [LotteryService, Logger, ConfigService], exports: [LotteryService], }) export class LotteryModule {} diff --git a/api/src/modules/multiselect-question.module.ts b/api/src/modules/multiselect-question.module.ts index d4c2b0d713..eba8917289 100644 --- a/api/src/modules/multiselect-question.module.ts +++ b/api/src/modules/multiselect-question.module.ts @@ -1,13 +1,15 @@ -import { Module } from '@nestjs/common'; +import { Logger, Module } from '@nestjs/common'; +import { SchedulerRegistry } from '@nestjs/schedule'; +import { PermissionModule } from './permission.module'; +import { PrismaModule } from './prisma.module'; import { MultiselectQuestionController } from '../controllers/multiselect-question.controller'; import { MultiselectQuestionService } from '../services/multiselect-question.service'; -import { PrismaModule } from './prisma.module'; -import { PermissionModule } from './permission.module'; +import { CronJobModule } from './cron-job.module'; @Module({ - imports: [PrismaModule, PermissionModule], + imports: [PrismaModule, PermissionModule, CronJobModule], controllers: [MultiselectQuestionController], - providers: [MultiselectQuestionService], + providers: [Logger, MultiselectQuestionService, SchedulerRegistry], exports: [MultiselectQuestionService], }) export class MultiselectQuestionModule {} diff --git a/api/src/modules/user.module.ts b/api/src/modules/user.module.ts index ebff0e482a..739aa9723e 100644 --- a/api/src/modules/user.module.ts +++ b/api/src/modules/user.module.ts @@ -1,14 +1,22 @@ import { Logger, Module } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { UserController } from '../controllers/user.controller'; +import { UserCsvExporterService } from '../services/user-csv-export.service'; import { UserService } from '../services/user.service'; -import { PrismaModule } from './prisma.module'; +import { ApplicationModule } from './application.module'; +import { CronJobModule } from './cron-job.module'; import { EmailModule } from './email.module'; import { PermissionModule } from './permission.module'; -import { UserCsvExporterService } from '../services/user-csv-export.service'; +import { PrismaModule } from './prisma.module'; @Module({ - imports: [PrismaModule, EmailModule, PermissionModule], + imports: [ + PrismaModule, + EmailModule, + PermissionModule, + CronJobModule, + ApplicationModule, + ], controllers: [UserController], providers: [Logger, UserService, ConfigService, UserCsvExporterService], exports: [UserService], diff --git a/api/src/passports/jwt.strategy.ts b/api/src/passports/jwt.strategy.ts index 7fe5f57680..e8779b06dd 100644 --- a/api/src/passports/jwt.strategy.ts +++ b/api/src/passports/jwt.strategy.ts @@ -6,7 +6,6 @@ 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; @@ -55,17 +54,7 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') { // 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 (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: { diff --git a/api/src/passports/mfa.strategy.ts b/api/src/passports/mfa.strategy.ts index 19287e1998..186e098eff 100644 --- a/api/src/passports/mfa.strategy.ts +++ b/api/src/passports/mfa.strategy.ts @@ -64,7 +64,18 @@ export class MfaStrategy extends PassportStrategy(Strategy, 'mfa') { Number(process.env.AUTH_LOCK_LOGIN_AFTER_FAILED_ATTEMPTS), Number(process.env.AUTH_LOCK_LOGIN_COOLDOWN), ); - if (!(await isPasswordValid(rawUser.passwordHash, dto.password))) { + + 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, @@ -80,16 +91,6 @@ export class MfaStrategy extends PassportStrategy(Strategy, 'mfa') { 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`, - ); } if (!rawUser.mfaEnabled) { @@ -174,6 +175,9 @@ export class MfaStrategy extends PassportStrategy(Strategy, 'mfa') { data: { failedLoginAttemptsCount: count, lastLoginAt, + // If a user logs in and was previously warned of deletion that needs to be reset so they can be warned again if + // they once again haven't logged in for USERS_DAYS_TILL_EXPIRY - 30 days + wasWarnedOfDeletion: false, }, where: { id: userId, diff --git a/api/src/passports/single-use-code.strategy.ts b/api/src/passports/single-use-code.strategy.ts index 76acdc9c3e..1c09edf322 100644 --- a/api/src/passports/single-use-code.strategy.ts +++ b/api/src/passports/single-use-code.strategy.ts @@ -163,6 +163,9 @@ export class SingleUseCodeStrategy extends PassportStrategy( data: { failedLoginAttemptsCount: count, lastLoginAt, + // If a user logs in and was previously warned of deletion that needs to be reset so they can be warned again if + // they once again haven't logged in for USERS_DAYS_TILL_EXPIRY - 30 days + wasWarnedOfDeletion: false, }, where: { id: userId, diff --git a/api/src/permission-configs/permission_policy.csv b/api/src/permission-configs/permission_policy.csv index e34307b0a9..c169310648 100644 --- a/api/src/permission-configs/permission_policy.csv +++ b/api/src/permission-configs/permission_policy.csv @@ -17,10 +17,10 @@ p, limitedJurisdictionAdmin, asset, true, .* p, partner, asset, true, .* p, admin, multiselectQuestion, true, .* -p, supportAdmin, multiselectQuestion, true, .* -p, jurisdictionAdmin, multiselectQuestion, true, .* -p, limitedJurisdictionAdmin, multiselectQuestion, true, .* -p, partner, multiselectQuestion, true, .* +p, supportAdmin, multiselectQuestion, true, read +p, jurisdictionAdmin, multiselectQuestion, true, read +p, limitedJurisdictionAdmin, multiselectQuestion, true, read +p, partner, multiselectQuestion, true, read p, anonymous, multiselectQuestion, true, read p, admin, applicationMethod, true, .* diff --git a/api/src/services/app.service.ts b/api/src/services/app.service.ts index 2abb439531..e73b5dcc6c 100644 --- a/api/src/services/app.service.ts +++ b/api/src/services/app.service.ts @@ -8,12 +8,11 @@ import { 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'; +import { CronJobService } from './cron-job.service'; -const CRON_JOB_NAME = 'TEMP_FILE_CLEAR_CRON_JOB'; +const TEMP_FILE_CLEAR_CRON_JOB_NAME = 'TEMP_FILE_CLEAR_CRON_JOB'; @Injectable() export class AppService implements OnModuleInit { @@ -21,17 +20,14 @@ export class AppService implements OnModuleInit { private prisma: PrismaService, @Inject(Logger) private logger = new Logger(AppService.name), - private schedulerRegistry: SchedulerRegistry, + private cronJobService: CronJobService, ) {} onModuleInit() { - startCronJob( - this.prisma, - CRON_JOB_NAME, + this.cronJobService.startCronJob( + TEMP_FILE_CLEAR_CRON_JOB_NAME, process.env.TEMP_FILE_CLEAR_CRON_STRING, this.clearTempFiles.bind(this), - this.logger, - this.schedulerRegistry, ); } @@ -47,7 +43,9 @@ export class AppService implements OnModuleInit { */ async clearTempFiles(): Promise { this.logger.warn('listing csv clear job running'); - await this.markCronJobAsStarted(); + await this.cronJobService.markCronJobAsStarted( + TEMP_FILE_CLEAR_CRON_JOB_NAME, + ); let filesDeletedCount = 0; await fs.readdir(join(process.cwd(), 'src/temp/'), (err, files) => { if (err) { @@ -82,37 +80,6 @@ export class AppService implements OnModuleInit { }; } - /** - 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, - }, - }); - } - } - // art pulled from: https://www.asciiart.eu/food-and-drinks/coffee-and-tea async teapot(): Promise { throw new ImATeapotException(` diff --git a/api/src/services/application-flagged-set.service.ts b/api/src/services/application-flagged-set.service.ts index e83540bb06..714fbf77f3 100644 --- a/api/src/services/application-flagged-set.service.ts +++ b/api/src/services/application-flagged-set.service.ts @@ -6,7 +6,6 @@ import { NotFoundException, OnModuleInit, } from '@nestjs/common'; -import { SchedulerRegistry } from '@nestjs/schedule'; import { ApplicationReviewStatusEnum, ApplicationStatusEnum, @@ -15,26 +14,26 @@ import { Prisma, RuleEnum, } from '@prisma/client'; -import { PrismaService } from './prisma.service'; -import { mapTo } from '../utilities/mapTo'; -import { SuccessDTO } from '../dtos/shared/success.dto'; +import dayjs from 'dayjs'; +import { AfsMeta } from '../dtos/application-flagged-sets/afs-meta.dto'; +import { AfsQueryParams } from '../dtos/application-flagged-sets/afs-query-params.dto'; +import { AfsResolve } from '../dtos/application-flagged-sets/afs-resolve.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 { Application } from '../dtos/applications/application.dto'; +import { IdDTO } from '../dtos/shared/id.dto'; +import { SuccessDTO } from '../dtos/shared/success.dto'; +import { User } from '../dtos/users/user.dto'; import { View } from '../enums/application-flagged-sets/view'; +import { OrderByEnum } from '../enums/shared/order-by-enum'; +import { mapTo } from '../utilities/mapTo'; import { buildPaginationMetaInfo, calculateSkip, calculateTake, } 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'; -import dayjs from 'dayjs'; +import { CronJobService } from './cron-job.service'; +import { PrismaService } from './prisma.service'; /* this is the service for application flaged sets @@ -56,25 +55,19 @@ export class ApplicationFlaggedSetService implements OnModuleInit { private prisma: PrismaService, @Inject(Logger) private logger = new Logger(ApplicationFlaggedSetService.name), - private schedulerRegistry: SchedulerRegistry, + private cronJobService: CronJobService, ) {} onModuleInit() { - startCronJob( - this.prisma, + this.cronJobService.startCronJob( OLD_CRON_JOB_NAME, process.env.AFS_PROCESSING_CRON_STRING, this.process.bind(this), - this.logger, - this.schedulerRegistry, ); - startCronJob( - this.prisma, + this.cronJobService.startCronJob( CRON_JOB_NAME, process.env.DUPLICATES_PROCESSING_CRON_STRING, this.processDuplicates.bind(this), - this.logger, - this.schedulerRegistry, ); } @@ -486,7 +479,7 @@ export class ApplicationFlaggedSetService implements OnModuleInit { forceProcess?: boolean, ): Promise { this.logger.warn('running the Application flagged sets version 2 cron job'); - await this.markCronJobAsStarted(CRON_JOB_NAME); + await this.cronJobService.markCronJobAsStarted(CRON_JOB_NAME); const duplicatesCloseDate = !!process.env.DUPLICATES_CLOSE_DATE && dayjs(process.env.DUPLICATES_CLOSE_DATE, 'YYYY-MM-DD HH:mm Z'); @@ -772,7 +765,7 @@ export class ApplicationFlaggedSetService implements OnModuleInit { */ async process(listingId?: string): Promise { this.logger.warn('running the Application flagged sets cron job'); - await this.markCronJobAsStarted(OLD_CRON_JOB_NAME); + await this.cronJobService.markCronJobAsStarted(OLD_CRON_JOB_NAME); const duplicatesCloseDate = process.env.DUPLICATES_CLOSE_DATE && dayjs(process.env.DUPLICATES_CLOSE_DATE, 'YYYY-MM-DD HH:mm Z'); @@ -857,37 +850,6 @@ export class ApplicationFlaggedSetService implements OnModuleInit { }; } - /** - 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(name: string): Promise { - const job = await this.prisma.cronJob.findFirst({ - where: { - name: 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: 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 diff --git a/api/src/services/application.service.ts b/api/src/services/application.service.ts index 05526ac6fc..2bef44c199 100644 --- a/api/src/services/application.service.ts +++ b/api/src/services/application.service.ts @@ -1,41 +1,51 @@ -import { - BadRequestException, - Injectable, - NotFoundException, - ForbiddenException, -} from '@nestjs/common'; import crypto from 'crypto'; +import dayjs from 'dayjs'; import { Request as ExpressRequest } from 'express'; import { + ApplicationSelections, ListingEventsTypeEnum, ListingsStatusEnum, LotteryStatusEnum, Prisma, YesNoEnum, } from '@prisma/client'; +import { + BadRequestException, + Injectable, + NotFoundException, + ForbiddenException, + Logger, + Inject, + HttpException, +} from '@nestjs/common'; +import { EmailService } from './email.service'; +import { GeocodingService } from './geocoding.service'; +import { PermissionService } from './permission.service'; import { PrismaService } from './prisma.service'; import { Application } from '../dtos/applications/application.dto'; -import { mapTo } from '../utilities/mapTo'; +import { ApplicationCreate } from '../dtos/applications/application-create.dto'; 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 { ApplicationSelectionCreate } from '../dtos/applications/application-selection-create.dto'; import { ApplicationUpdate } from '../dtos/applications/application-update.dto'; -import { ApplicationCreate } from '../dtos/applications/application-create.dto'; +import { MostRecentApplicationQueryParams } from '../dtos/applications/most-recent-application-query-params.dto'; import { PaginatedApplicationDto } from '../dtos/applications/paginated-application.dto'; -import { EmailService } from './email.service'; -import { PermissionService } from './permission.service'; +import { PublicAppsViewQueryParams } from '../dtos/applications/public-apps-view-params.dto'; +import { PublicAppsViewResponse } from '../dtos/applications/public-apps-view-response.dto'; +import { Jurisdiction } from '../dtos/jurisdictions/jurisdiction.dto'; import Listing from '../dtos/listings/listing.dto'; +import { IdDTO } from '../dtos/shared/id.dto'; +import { SuccessDTO } from '../dtos/shared/success.dto'; import { User } from '../dtos/users/user.dto'; -import { permissionActions } from '../enums/permissions/permission-actions-enum'; -import { GeocodingService } from './geocoding.service'; -import { MostRecentApplicationQueryParams } from '../dtos/applications/most-recent-application-query-params.dto'; -import { PublicAppsViewQueryParams } from '../dtos/applications/public-apps-view-params.dto'; import { ApplicationsFilterEnum } from '../enums/applications/filter-enum'; -import { PublicAppsViewResponse } from '../dtos/applications/public-apps-view-response.dto'; +import { CronJobService } from './cron-job.service'; +import { ApplicationViews } from '../enums/applications/view-enum'; +import { FeatureFlagEnum } from '../enums/feature-flags/feature-flags-enum'; +import { permissionActions } from '../enums/permissions/permission-actions-enum'; +import { buildOrderByForApplications } from '../utilities/build-order-by'; +import { buildPaginationInfo } from '../utilities/build-pagination-meta'; +import { doJurisdictionHaveFeatureFlagSet } from '../utilities/feature-flag-utilities'; +import { mapTo } from '../utilities/mapTo'; +import { calculateSkip, calculateTake } from '../utilities/pagination-helpers'; export const view: Partial< Record @@ -183,6 +193,30 @@ export const view: Partial< view.base = { ...view.partnerList, + applicationSelections: { + include: { + multiselectQuestion: true, + selections: { + include: { + addressHolderAddress: { + select: { + id: true, + placeName: true, + city: true, + county: true, + state: true, + street: true, + street2: true, + zipCode: true, + latitude: true, + longitude: true, + }, + }, + multiselectOption: true, + }, + }, + }, + }, demographics: { select: { id: true, @@ -205,6 +239,7 @@ view.base = { name: true, }, }, + listingMultiselectQuestions: true, }, }, householdMember: { @@ -265,8 +300,10 @@ view.details = { }, }; +const PII_DELETION_CRON_JOB_NAME = 'PII_DELETION_CRON_STRING'; + /* - this is the service for applicationss + this is the service for applications it handles all the backend's business logic for reading/writing/deleting application data */ @Injectable() @@ -276,7 +313,17 @@ export class ApplicationService { private emailService: EmailService, private permissionService: PermissionService, private geocodingService: GeocodingService, + @Inject(Logger) + private logger = new Logger(ApplicationService.name), + private cronJobService: CronJobService, ) {} + onModuleInit() { + this.cronJobService.startCronJob( + PII_DELETION_CRON_JOB_NAME, + process.env.PII_DELETION_CRON_STRING, + this.removePIICronJob.bind(this), + ); + } /* this will get a set of applications given the params passed in @@ -593,7 +640,7 @@ export class ApplicationService { async create( dto: ApplicationCreate, forPublic: boolean, - requestingUser: User, + requestingUser?: User, ): Promise { if (!forPublic) { await this.authorizeAction( @@ -608,18 +655,39 @@ export class ApplicationService { id: dto.listings.id, }, include: { - jurisdictions: true, - // support unit group availability logic in email - unitGroups: true, - // multiselect questions and address is needed for geocoding + jurisdictions: { include: { featureFlags: true } }, + // address is needed for geocoding + listingsBuildingAddress: true, listingMultiselectQuestions: { - include: { - multiselectQuestions: true, - }, + include: { multiselectQuestions: true }, }, - listingsBuildingAddress: true, + // support unit group availability logic in email + unitGroups: true, }, }); + + const enableV2MSQ = doJurisdictionHaveFeatureFlagSet( + listing?.jurisdictions as unknown as Jurisdiction, + FeatureFlagEnum.enableV2MSQ, + ); + + if (enableV2MSQ) { + const listingMultiselectIds = listing.listingMultiselectQuestions.map( + (msq) => { + return msq.multiselectQuestionId; + }, + ); + if ( + !dto.applicationSelections.every(({ multiselectQuestion }) => { + return listingMultiselectIds.includes(multiselectQuestion.id); + }) + ) { + throw new BadRequestException( + 'Application selections contain multiselect question ids not present on the listing', + ); + } + } + // if its a public submission if (forPublic) { // SubmissionDate is time the application was created for public @@ -636,6 +704,18 @@ export class ApplicationService { } } + // If a new application comes in after close and PII needs to be deleted + let expireAfterDate = undefined; + if ( + listing.status === ListingsStatusEnum.closed && + process.env.APPLICATION_DAYS_TILL_EXPIRY && + !isNaN(Number(process.env.APPLICATION_DAYS_TILL_EXPIRY)) + ) { + expireAfterDate = dayjs(listing.closedAt) + .add(Number(process.env.APPLICATION_DAYS_TILL_EXPIRY), 'days') + .toDate(); + } + const transactions = []; // Set all previous applications for the user to not be the newest if (requestingUser?.id) { @@ -650,8 +730,25 @@ export class ApplicationService { this.prisma.applications.create({ data: { ...dto, - isNewest: true, - confirmationCode: this.generateConfirmationCode(), + accessibility: dto.accessibility + ? { + create: { + ...dto.accessibility, + }, + } + : undefined, + alternateContact: dto.alternateContact + ? { + create: { + ...dto.alternateContact, + address: { + create: { + ...dto.alternateContact.address, + }, + }, + }, + } + : undefined, applicant: dto.applicant ? { create: { @@ -681,25 +778,7 @@ export class ApplicationService { }, } : undefined, - accessibility: dto.accessibility - ? { - create: { - ...dto.accessibility, - }, - } - : undefined, - alternateContact: dto.alternateContact - ? { - create: { - ...dto.alternateContact, - address: { - create: { - ...dto.alternateContact.address, - }, - }, - }, - } - : undefined, + applicationSelections: undefined, applicationsAlternateAddress: dto.applicationsAlternateAddress ? { create: { @@ -714,13 +793,7 @@ export class ApplicationService { }, } : undefined, - listings: dto.listings - ? { - connect: { - id: dto.listings.id, - }, - } - : undefined, + confirmationCode: this.generateConfirmationCode(), demographics: dto.demographics ? { create: { @@ -728,13 +801,7 @@ export class ApplicationService { }, } : undefined, - preferredUnitTypes: dto.preferredUnitTypes - ? { - connect: dto.preferredUnitTypes.map((unitType) => ({ - id: unitType.id, - })), - } - : undefined, + expireAfter: expireAfterDate, householdMember: dto.householdMember ? { create: dto.householdMember.map((member) => ({ @@ -766,8 +833,23 @@ export class ApplicationService { })), } : undefined, - programs: dto.programs as unknown as Prisma.JsonArray, + isNewest: !!requestingUser?.id && forPublic, + listings: dto.listings + ? { + connect: { + id: dto.listings.id, + }, + } + : undefined, preferences: dto.preferences as unknown as Prisma.JsonArray, + preferredUnitTypes: dto.preferredUnitTypes + ? { + connect: dto.preferredUnitTypes.map((unitType) => ({ + id: unitType.id, + })), + } + : undefined, + programs: dto.programs as unknown as Prisma.JsonArray, userAccounts: requestingUser ? { connect: { @@ -775,9 +857,6 @@ export class ApplicationService { }, } : undefined, - - // TODO: Temporary until after MSQ refactor - applicationSelections: undefined, }, include: view.details, }), @@ -786,6 +865,33 @@ export class ApplicationService { const prismaTransactions = await this.prisma.$transaction(transactions); const rawApplication = prismaTransactions[prismaTransactions.length - 1]; + if (!rawApplication) { + throw new HttpException('Application failed to save', 500); + } + + const rawSelections = []; + if (enableV2MSQ) { + // Nested CreateManys are not supported by Prisma, + // thus we must create the subobjects after creating the application + try { + for (const selection of dto.applicationSelections) { + const rawSelection = await this.createApplicationSelection( + selection, + rawApplication.id, + ); + rawSelections.push(rawSelection); + } + } catch (error) { + // On error, all associated records should be delete. + // Deleting the application will cascade delete the other records + await this.prisma.applications.delete({ + where: { id: rawApplication.id }, + }); + throw new BadRequestException(error); + } + } + rawApplication.applicationSelections = rawSelections; + const mappedApplication = mapTo(Application, rawApplication); if (dto.applicant.emailAddress && forPublic) { this.emailService.applicationConfirmation( @@ -798,8 +904,9 @@ export class ApplicationService { await this.updateListingApplicationEditTimestamp(listing.id); // Calculate geocoding preferences after save and email sent - if (listing.jurisdictions?.enableGeocodingPreferences) { + if (!enableV2MSQ && listing.jurisdictions?.enableGeocodingPreferences) { try { + // TODO: Rewrite for V2MSQ void this.geocodingService.validateGeocodingPreferences( mappedApplication, mapTo(Listing, listing), @@ -822,160 +929,184 @@ export class ApplicationService { dto: ApplicationUpdate, requestingUser: User, ): Promise { - const rawApplication = await this.findOrThrow(dto.id); + const rawExistingApplication = await this.findOrThrow( + dto.id, + ApplicationViews.base, + ); await this.authorizeAction( requestingUser, - rawApplication.listingId, + rawExistingApplication.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({ + const listing = await this.prisma.listings.findUnique({ where: { - applicationId: dto.id, + id: dto.listings.id, + }, + include: { + jurisdictions: { include: { featureFlags: true } }, + listingMultiselectQuestions: { + include: { multiselectQuestions: true }, + }, }, }); - 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, - }, + const transactions = []; + + // 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 + transactions.push( + this.prisma.householdMember.deleteMany({ + where: { + applicationId: dto.id, + }, + }), + ); + + transactions.push( + this.prisma.applications.update({ + where: { + id: dto.id, + }, + include: view.details, + data: { + ...dto, + id: undefined, + accessibility: dto.accessibility + ? { + create: { + ...dto.accessibility, }, - applicantWorkAddress: { - create: { - ...dto.applicant.applicantWorkAddress, + } + : undefined, + alternateContact: dto.alternateContact + ? { + create: { + ...dto.alternateContact, + address: { + create: { + ...dto.alternateContact.address, + }, }, }, - firstName: dto.applicant.firstName?.trim(), - lastName: dto.applicant.lastName?.trim(), - birthDay: dto.applicant.birthDay - ? Number(dto.applicant.birthDay) - : undefined, - birthMonth: dto.applicant.birthMonth - ? Number(dto.applicant.birthMonth) - : undefined, - birthYear: dto.applicant.birthYear - ? Number(dto.applicant.birthYear) - : undefined, - fullTimeStudent: dto.applicant.fullTimeStudent, - }, - } - : undefined, - accessibility: dto.accessibility - ? { - create: { - ...dto.accessibility, - }, - } - : undefined, - alternateContact: dto.alternateContact - ? { - create: { - ...dto.alternateContact, - address: { - create: { - ...dto.alternateContact.address, + } + : undefined, + applicant: dto.applicant + ? { + create: { + ...dto.applicant, + applicantAddress: { + create: { + ...dto.applicant.applicantAddress, + }, }, - }, - }, - } - : 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 - ? { - set: 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, + applicantWorkAddress: { + create: { + ...dto.applicant.applicantWorkAddress, + }, }, + firstName: dto.applicant.firstName?.trim(), + lastName: dto.applicant.lastName?.trim(), + birthDay: dto.applicant.birthDay + ? Number(dto.applicant.birthDay) + : undefined, + birthMonth: dto.applicant.birthMonth + ? Number(dto.applicant.birthMonth) + : undefined, + birthYear: dto.applicant.birthYear + ? Number(dto.applicant.birthYear) + : undefined, + fullTimeStudent: dto.applicant.fullTimeStudent, + }, + } + : undefined, + applicationSelections: dto.applicationSelections ? {} : undefined, + applicationsAlternateAddress: dto.applicationsAlternateAddress + ? { + create: { + ...dto.applicationsAlternateAddress, + }, + } + : undefined, + applicationsMailingAddress: dto.applicationsMailingAddress + ? { + create: { + ...dto.applicationsMailingAddress, + }, + } + : undefined, + demographics: dto.demographics + ? { + create: { + ...dto.demographics, }, - householdMemberWorkAddress: { - create: { - ...member.householdMemberWorkAddress, + } + : 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, + }, + }, + firstName: member.firstName?.trim(), + lastName: member.lastName?.trim(), + birthDay: member.birthDay + ? Number(member.birthDay) + : undefined, + birthMonth: member.birthMonth + ? Number(member.birthMonth) + : undefined, + birthYear: member.birthYear + ? Number(member.birthYear) + : undefined, + fullTimeStudent: member.fullTimeStudent, + })), + } + : undefined, + listings: dto.listings + ? { + connect: { + id: dto.listings.id, }, - firstName: member.firstName?.trim(), - lastName: member.lastName?.trim(), - birthDay: member.birthDay ? Number(member.birthDay) : undefined, - birthMonth: member.birthMonth - ? Number(member.birthMonth) - : undefined, - birthYear: member.birthYear - ? Number(member.birthYear) - : undefined, - fullTimeStudent: member.fullTimeStudent, - })), - } - : undefined, - programs: dto.programs as unknown as Prisma.JsonArray, - preferences: dto.preferences as unknown as Prisma.JsonArray, - - // TODO: Temporary until after MSQ refactor - applicationSelections: undefined, - }, - }); + } + : undefined, + preferredUnitTypes: dto.preferredUnitTypes + ? { + set: dto.preferredUnitTypes.map((unitType) => ({ + id: unitType.id, + })), + } + : undefined, - const listing = await this.prisma.listings.findFirst({ - where: { id: dto.listings.id }, - include: { - jurisdictions: true, - listingMultiselectQuestions: { - include: { multiselectQuestions: true }, + // TODO: Can be removed after MSQ refactor + preferences: dto.preferences as unknown as Prisma.JsonArray, + programs: dto.programs as unknown as Prisma.JsonArray, }, - }, - }); - const application = mapTo(Application, res); + }), + ); + + const prismaTransactions = await this.prisma.$transaction(transactions); + const rawApplication = prismaTransactions[prismaTransactions.length - 1]; + + if (!rawApplication) { + throw new HttpException( + `Application ${rawExistingApplication.id} failed to update`, + 500, + ); + } + + const application = mapTo(Application, rawApplication); // Calculate geocoding preferences after save and email sent if (listing?.jurisdictions?.enableGeocodingPreferences) { @@ -991,7 +1122,7 @@ export class ApplicationService { } } - await this.updateListingApplicationEditTimestamp(res.listingId); + await this.updateListingApplicationEditTimestamp(rawApplication.listingId); return application; } @@ -1026,7 +1157,7 @@ export class ApplicationService { } /* - finds the requested listing or throws an error + finds the requested application or throws an error */ async findOrThrow(applicationId: string, includeView?: ApplicationViews) { const res = await this.prisma.applications.findUnique({ @@ -1083,10 +1214,259 @@ export class ApplicationService { }); } + addressDeletionData = (id: string) => { + return this.prisma.address.update({ + data: { + latitude: null, + longitude: null, + street: null, + street2: null, + }, + where: { + id: id, + }, + }); + }; + + /* + * Remove all PII fields from the passed in application + */ + async removePII(applicationId: string): Promise { + const application = await this.prisma.applications.findFirst({ + select: { + id: true, + mailingAddressId: true, + applicant: { + select: { + id: true, + addressId: true, + workAddressId: true, + }, + }, + householdMember: { + select: { + id: true, + addressId: true, + workAddressId: true, + }, + }, + alternateContact: { + select: { + id: true, + mailingAddressId: true, + }, + }, + }, + where: { + id: applicationId, + wasPIICleared: false, + }, + }); + + if (!application) return; + + const transactions = []; + + if (application.mailingAddressId) { + transactions.push(this.addressDeletionData(application.mailingAddressId)); + } + + if (application.applicant?.addressId) { + transactions.push( + this.addressDeletionData(application.applicant?.addressId), + ); + } + + if (application.applicant?.workAddressId) { + transactions.push( + this.addressDeletionData(application.applicant?.workAddressId), + ); + } + + if (application.applicant?.id) { + transactions.push( + this.prisma.applicant.update({ + data: { + birthDay: null, + birthMonth: null, + birthYear: null, + emailAddress: null, + firstName: null, + lastName: null, + middleName: null, + phoneNumber: null, + }, + where: { + id: application.applicant.id, + }, + }), + ); + } + + if (application.alternateContact?.mailingAddressId) { + transactions.push( + this.addressDeletionData(application.alternateContact.mailingAddressId), + ); + } + + if (application.alternateContact) { + transactions.push( + this.prisma.alternateContact.update({ + data: { + emailAddress: null, + firstName: null, + lastName: null, + phoneNumber: null, + }, + where: { + id: application.alternateContact.id, + }, + }), + ); + } + + for (const householdMember of application.householdMember) { + if (householdMember.addressId) { + transactions.push(this.addressDeletionData(householdMember.addressId)); + } + if (householdMember.workAddressId) { + transactions.push( + this.addressDeletionData(householdMember.workAddressId), + ); + } + + transactions.push( + this.prisma.householdMember.update({ + data: { + birthDay: null, + birthMonth: null, + birthYear: null, + firstName: null, + middleName: null, + lastName: null, + }, + where: { + id: householdMember.id, + }, + }), + ); + } + + transactions.push( + this.prisma.applications.update({ + data: { + additionalPhoneNumber: null, + wasPIICleared: true, + }, + where: { + id: application.id, + }, + }), + ); + + await this.prisma.$transaction(transactions); + } + + async removePIICronJob(): Promise { + if (process.env.APPLICATION_DAYS_TILL_EXPIRY) { + this.logger.warn('removePIICron job running'); + await this.cronJobService.markCronJobAsStarted( + PII_DELETION_CRON_JOB_NAME, + ); + // Only delete applications that are scheduled to be expired and is not the most + // recent application for that user + const applications = await this.prisma.applications.findMany({ + select: { id: true }, + where: { + expireAfter: { lte: new Date() }, + isNewest: false, + wasPIICleared: false, + }, + }); + this.logger.warn( + `removing PII information for ${applications.length} applications`, + ); + for (const application of applications) { + await this.removePII(application.id); + } + } else { + this.logger.warn( + 'APPLICATION_DAYS_TILL_EXPIRY variable not set so the cron job will not run', + ); + } + return { + success: true, + }; + } + /* generates a random confirmation code */ generateConfirmationCode(): string { return crypto.randomBytes(4).toString('hex').toUpperCase(); } + + async createApplicationSelection( + selection: ApplicationSelectionCreate, + applicationId: string, + ): Promise { + const selectedOptions = []; + + for (const selectionOption of selection.selections) { + let address; + // If an address is passed, create the address for the selection option + if (selectionOption.addressHolderAddress) { + address = await this.prisma.address.create({ + data: { + ...selectionOption.addressHolderAddress, + }, + }); + } + // Build the create selection option body + const selectedOptionBody = { + addressHolderAddressId: address?.id, + addressHolderName: selectionOption.addressHolderName, + addressHolderRelationship: selectionOption.addressHolderRelationship, + isGeocodingVerified: selectionOption.isGeocodingVerified, + multiselectOptionId: selectionOption.multiselectOption.id, + }; + // Push the selection option to a list for the createMany + selectedOptions.push(selectedOptionBody); + } + // Create the application selection with nested createMany applicationSelectionOptions + return await this.prisma.applicationSelections.create({ + data: { + applicationId: applicationId, + hasOptedOut: selection.hasOptedOut ?? false, + multiselectQuestionId: selection.multiselectQuestion.id, + selections: { + createMany: { + data: selectedOptions, + }, + }, + }, + include: { + multiselectQuestion: true, + selections: { + include: { + addressHolderAddress: { + select: { + id: true, + placeName: true, + city: true, + county: true, + state: true, + street: true, + street2: true, + zipCode: true, + latitude: true, + longitude: true, + }, + }, + multiselectOption: true, + }, + }, + }, + }); + } } diff --git a/api/src/services/cron-job.service.ts b/api/src/services/cron-job.service.ts new file mode 100644 index 0000000000..63d8f6fea8 --- /dev/null +++ b/api/src/services/cron-job.service.ts @@ -0,0 +1,97 @@ +import { Inject, Injectable, Logger } from '@nestjs/common'; +import { PrismaService } from './prisma.service'; +import { CronJob } from 'cron'; +import { SuccessDTO } from '../dtos/shared/success.dto'; +import { SchedulerRegistry } from '@nestjs/schedule'; +import dayjs from 'dayjs'; + +@Injectable() +export class CronJobService { + constructor( + private prisma: PrismaService, + @Inject(Logger) + private logger = new Logger(CronJobService.name), + private schedulerRegistry: SchedulerRegistry, + ) {} + + async startCronJob( + cronName: string, + cronString: string, + functionToCall: () => Promise, + ) { + if (!cronString) { + // If missing cron string an error should throw but not prevent the app from starting up + this.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: CronJob = new CronJob( + newCron, + () => { + void (async () => { + const currentCronJob = await this.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) { + this.logger.error(`${cronName} failed to run. ${e}`); + } + } + })(); + }, + undefined, + undefined, + process.env.TIME_ZONE, + ); + this.schedulerRegistry.addCronJob(cronName, job); + if (process.env.NODE_ENV !== 'test') { + job.start(); + } + } + + /** + 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(cronJobName: string): Promise { + const job = await this.prisma.cronJob.findFirst({ + where: { + name: cronJobName, + }, + }); + 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: cronJobName, + }, + }); + } + } +} diff --git a/api/src/services/email.service.ts b/api/src/services/email.service.ts index fc9ebbfd7d..f8b53aea5a 100644 --- a/api/src/services/email.service.ts +++ b/api/src/services/email.service.ts @@ -315,8 +315,15 @@ export class EmailService { ); } - public async sendSingleUseCode(user: User, singleUseCode: string) { - const jurisdiction = await this.getJurisdiction(user.jurisdictions); + public async sendSingleUseCode( + user: User, + singleUseCode: string, + jurisdictionName?: string, + ) { + const jurisdiction = await this.getJurisdiction( + user.jurisdictions, + jurisdictionName, + ); void (await this.loadTranslations(jurisdiction, user.language)); const emailFromAddress = await this.getEmailToSendFrom( user.jurisdictions, @@ -340,7 +347,7 @@ export class EmailService { public async applicationConfirmation( listing: Listing, - application: ApplicationCreate, + application: Application, appUrl: string, ) { const jurisdiction = await this.getJurisdiction([listing.jurisdictions]); @@ -376,7 +383,13 @@ export class EmailService { 'confirmation.eligible.fcfsPreference', ); } else if (hasUnitGroups) { - eligibleText = this.polyglot.t('confirmation.eligible.waitlist'); + if (listing.reviewOrderType === ReviewOrderTypeEnum.waitlistLottery) { + eligibleText = this.polyglot.t( + 'confirmation.eligible.waitlistLottery', + ); + } else { + eligibleText = this.polyglot.t('confirmation.eligible.waitlist'); + } contactText = this.polyglot.t('confirmation.eligible.waitlistContact'); preferenceText = this.polyglot.t( 'confirmation.eligible.waitlistPreference', @@ -402,6 +415,13 @@ export class EmailService { 'confirmation.eligible.waitlistPreference', ); } + if (listing.reviewOrderType === ReviewOrderTypeEnum.waitlistLottery) { + eligibleText = this.polyglot.t('confirmation.eligible.waitlistLottery'); + contactText = this.polyglot.t('confirmation.eligible.waitlistContact'); + preferenceText = this.polyglot.t( + 'confirmation.eligible.waitlistPreference', + ); + } } const user = { @@ -665,7 +685,10 @@ export class EmailService { ]); for (const language in emails) { - void (await this.loadTranslations(null, language as LanguagesEnum)); + void (await this.loadTranslations( + jurisdiction, + language as LanguagesEnum, + )); this.logger.log( `Sending lottery published ${language} email for listing ${listingInfo.name} to ${emails[language]?.length} emails`, ); @@ -693,6 +716,25 @@ export class EmailService { } } + public async warnOfAccountRemoval(user: User) { + const jurisdiction = await this.getJurisdiction(user.jurisdictions); + void (await this.loadTranslations(jurisdiction, user.language)); + const emailFromAddress = await this.getEmailToSendFrom( + user.jurisdictions, + jurisdiction, + ); + const signInUrl = jurisdiction ? `${jurisdiction.publicUrl}/sign-in` : ''; + await this.send( + user.email, + emailFromAddress, + this.polyglot.t('accountRemoval.subject'), + this.template('warn-removal')({ + user: user, + signInUrl: signInUrl, + }), + ); + } + formatLocalDate(rawDate: string | Date, format: string): string { const utcDate = dayjs.utc(rawDate); return utcDate.format(format); diff --git a/api/src/services/listing-csv-export.service.ts b/api/src/services/listing-csv-export.service.ts index bd4331ed7a..80d5a56ff7 100644 --- a/api/src/services/listing-csv-export.service.ts +++ b/api/src/services/listing-csv-export.service.ts @@ -15,6 +15,7 @@ import { import { ApplicationMethodsTypeEnum, ListingEventsTypeEnum, + ListingTypeEnum, MarketingTypeEnum, NeighborhoodAmenitiesEnum, } from '@prisma/client'; @@ -47,11 +48,13 @@ import { } from '../utilities/unit-utilities'; import { unitTypeToReadable } from '../utilities/application-export-helpers'; import { + doAllJurisdictionHaveFeatureFlagSet, doAnyJurisdictionHaveFalsyFeatureFlagValue, doAnyJurisdictionHaveFeatureFlagSet, } from '../utilities/feature-flag-utilities'; import { UnitGroupSummary } from '../dtos/unit-groups/unit-group-summary.dto'; import { addUnitGroupsSummarized } from '../utilities/unit-groups-transformations'; +import { ListingDocuments } from '../dtos/listings/listing-documents.dto'; includeViews.csv = { listingMultiselectQuestions: { @@ -93,6 +96,10 @@ export const formatCommunityType = { schoolEmployee: 'School Employee', }; +export const formatCloudinaryPdfUrl = (fileId: string): string => { + return `https://res.cloudinary.com/${process.env.CLOUDINARY_CLOUD_NAME}/image/upload/${fileId}.pdf`; +}; + @Injectable() export class ListingCsvExporterService implements CsvExporterServiceInterface { readonly dateFormat: string = 'MM-DD-YYYY hh:mm:ssA z'; @@ -379,15 +386,35 @@ export class ListingCsvExporterService implements CsvExporterServiceInterface { return value ? `$${value}` : ''; } - cloudinaryPdfFromId(publicId: string, listing?: Listing): string { + buildingSelectionCriteria(value: string, listing?: Listing): string { + if (value) return listing.buildingSelectionCriteria; + if (listing?.listingsBuildingSelectionCriteriaFile?.fileId) + return formatCloudinaryPdfUrl( + listing.listingsBuildingSelectionCriteriaFile?.fileId, + ); + return ''; + } + + marketingFlyer(value: string, listing?: Listing): string { + if (value) return listing.marketingFlyer; + if (listing?.listingsMarketingFlyerFile?.fileId) + return formatCloudinaryPdfUrl(listing.listingsMarketingFlyerFile?.fileId); + return ''; + } + + accessibleMarketingFlyer(value: string, listing?: Listing): string { + if (value) return listing.accessibleMarketingFlyer; + if (listing?.listingsAccessibleMarketingFlyerFile?.fileId) + return formatCloudinaryPdfUrl( + listing.listingsAccessibleMarketingFlyerFile?.fileId, + ); + return ''; + } + + cloudinaryPdfFromId(publicId: string): 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 formatCloudinaryPdfUrl(publicId); } - return ''; } @@ -403,7 +430,25 @@ export class ListingCsvExporterService implements CsvExporterServiceInterface { return fieldValue; }; + buildSelectList( + val: ListingUtilities | ListingDocuments | ListingFeatures, + ): string { + if (!val) return ''; + const selectedValues = Object.entries(val).reduce((combined, entry) => { + if (entry[1] === true) { + combined.push(entry[0]); + } + return combined; + }, []); + return selectedValues.join(', '); + } + async getCsvHeaders(user: User): Promise { + const enableNonRegulatedListings = doAnyJurisdictionHaveFeatureFlagSet( + user.jurisdictions, + FeatureFlagEnum.enableNonRegulatedListings, + ); + const headers: CsvHeader[] = [ { path: 'id', @@ -423,6 +468,23 @@ export class ListingCsvExporterService implements CsvExporterServiceInterface { path: 'name', label: 'Listing Name', }, + ...(enableNonRegulatedListings + ? [ + { + path: 'listingType', + label: 'Listing Type', + format: (val: ListingTypeEnum) => { + if (!val) { + return ''; + } + + return val === ListingTypeEnum.regulated + ? 'Regulated' + : 'Non-regulated'; + }, + }, + ] + : []), { path: 'status', label: 'Listing Status', @@ -457,8 +519,28 @@ export class ListingCsvExporterService implements CsvExporterServiceInterface { FeatureFlagEnum.enableHousingDeveloperOwner, ) ? 'Housing developer / owner' - : 'Developer', + : 'Housing Provider', }, + ...(enableNonRegulatedListings + ? [ + { + path: 'hasHudEbllClearance', + label: 'Has HUD EBLL Clearance', + format: this.formatYesNo, + }, + ] + : []), + ...(doAnyJurisdictionHaveFeatureFlagSet( + user.jurisdictions, + FeatureFlagEnum.enableListingFileNumber, + ) + ? [ + { + path: 'listingFileNumber', + label: 'Listing File Number', + }, + ] + : []), { path: 'listingsBuildingAddress.street', label: 'Building Street Address', @@ -587,19 +669,7 @@ export class ListingCsvExporterService implements CsvExporterServiceInterface { headers.push({ path: 'listingUtilities', label: 'Utilities Included', - format: (val: ListingUtilities): string => { - if (!val) return ''; - const selectedValues = Object.entries(val).reduce( - (combined, entry) => { - if (entry[1] === true) { - combined.push(entry[0]); - } - return combined; - }, - [], - ); - return selectedValues.join(', '); - }, + format: this.buildSelectList, }); } if ( @@ -611,19 +681,7 @@ export class ListingCsvExporterService implements CsvExporterServiceInterface { headers.push({ path: 'listingFeatures', label: 'Property Amenities', - format: (val: ListingFeatures): string => { - if (!val) return ''; - const selectedValues = Object.entries(val).reduce( - (combined, entry) => { - if (entry[1] === true) { - combined.push(entry[0]); - } - return combined; - }, - [], - ); - return selectedValues.join(', '); - }, + format: this.buildSelectList, }); } @@ -746,16 +804,6 @@ export class ListingCsvExporterService implements CsvExporterServiceInterface { path: 'depositHelperText', label: 'Deposit Helper Text', }, - { - path: 'depositMin', - label: 'Deposit Min', - format: this.formatCurrency, - }, - { - path: 'depositMax', - label: 'Deposit Max', - format: this.formatCurrency, - }, { path: 'depositType', label: 'Deposit Type', @@ -766,19 +814,31 @@ export class ListingCsvExporterService implements CsvExporterServiceInterface { format: this.formatCurrency, }, { - path: 'depositRangeMin', - label: 'Deposit Range Min', + path: 'depositMin', + label: 'Deposit Min', format: this.formatCurrency, }, { - path: 'depositRangeMax', - label: 'Deposit Range Max', + path: 'depositMax', + label: 'Deposit Max', format: this.formatCurrency, }, { path: 'costsNotIncluded', label: 'Costs Not Included', }, + ...(doAnyJurisdictionHaveFeatureFlagSet( + user.jurisdictions, + FeatureFlagEnum.enableCreditScreeningFee, + ) + ? [ + { + path: 'creditScreeningFee', + label: 'Credit Screening Fee', + format: this.formatCurrency, + }, + ] + : []), { path: 'amenities', label: 'Property Amenities', @@ -791,10 +851,6 @@ export class ListingCsvExporterService implements CsvExporterServiceInterface { path: 'unitAmenities', label: 'Unit Amenities', }, - { - path: 'smokingPolicy', - label: 'Smoking Policy', - }, { path: 'petPolicy', label: 'Pets Policy', @@ -803,6 +859,19 @@ export class ListingCsvExporterService implements CsvExporterServiceInterface { path: 'servicesOffered', label: 'Services Offered', }, + { + path: 'smokingPolicy', + label: 'Smoking Policy', + format: (val: string): string => { + const enableSmokingPolicyRadio = + doAllJurisdictionHaveFeatureFlagSet( + user.jurisdictions, + FeatureFlagEnum.enableSmokingPolicyRadio, + ); + if (!val) return enableSmokingPolicyRadio ? 'Policy unknown' : ''; + return val; + }, + }, ], ); @@ -843,6 +912,30 @@ export class ListingCsvExporterService implements CsvExporterServiceInterface { path: 'listingNeighborhoodAmenities.healthCareResources', label: 'Neighborhood Amenities - Health Care Resources', }, + [NeighborhoodAmenitiesEnum.shoppingVenues]: { + path: 'listingNeighborhoodAmenities.shoppingVenues', + label: 'Neighborhood Amenities - Shopping Venues', + }, + [NeighborhoodAmenitiesEnum.hospitals]: { + path: 'listingNeighborhoodAmenities.hospitals', + label: 'Neighborhood Amenities - Hospitals', + }, + [NeighborhoodAmenitiesEnum.seniorCenters]: { + path: 'listingNeighborhoodAmenities.seniorCenters', + label: 'Neighborhood Amenities - Senior Centers', + }, + [NeighborhoodAmenitiesEnum.recreationalFacilities]: { + path: 'listingNeighborhoodAmenities.recreationalFacilities', + label: 'Neighborhood Amenities - Recreational Facilities', + }, + [NeighborhoodAmenitiesEnum.playgrounds]: { + path: 'listingNeighborhoodAmenities.playgrounds', + label: 'Neighborhood Amenities - Playgrounds', + }, + [NeighborhoodAmenitiesEnum.busStops]: { + path: 'listingNeighborhoodAmenities.busStops', + label: 'Neighborhood Amenities - Bus Stops', + }, }; Object.keys(amenityHeaderMap).forEach((key) => { @@ -852,6 +945,18 @@ export class ListingCsvExporterService implements CsvExporterServiceInterface { }); } + if ( + doAnyJurisdictionHaveFeatureFlagSet( + user.jurisdictions, + FeatureFlagEnum.enableParkingFee, + ) + ) { + headers.push({ + path: 'parkingFee', + label: 'Parking Fee', + }); + } + headers.push( ...[ { @@ -870,15 +975,34 @@ export class ListingCsvExporterService implements CsvExporterServiceInterface { path: 'rentalAssistance', label: 'Eligibility Rules - Rental Assistance', }, - { - path: 'buildingSelectionCriteriaFileId', - label: 'Building Selection Criteria', - format: this.cloudinaryPdfFromId, - }, + ...(doAnyJurisdictionHaveFalsyFeatureFlagValue( + user.jurisdictions, + FeatureFlagEnum.disableBuildingSelectionCriteria, + ) + ? [ + { + path: 'buildingSelectionCriteria', + label: 'Building Selection Criteria', + format: this.buildingSelectionCriteria, + }, + ] + : []), { path: 'programRules', label: 'Important Program Rules', }, + ...(doAnyJurisdictionHaveFeatureFlagSet( + user.jurisdictions, + FeatureFlagEnum.enableNonRegulatedListings, + ) + ? [ + { + path: 'requiredDocumentsList', + label: 'Required documents List', + format: this.buildSelectList, + }, + ] + : []), { path: 'requiredDocuments', label: 'Required Documents', @@ -920,38 +1044,62 @@ export class ListingCsvExporterService implements CsvExporterServiceInterface { FeatureFlagEnum.enableMarketingStatus, ) ) { - headers.push( - ...[ - { - path: 'marketingType', - label: 'Marketing Status', - format: (val: string): string => { - if (!val) return ''; - return val === MarketingTypeEnum.marketing - ? 'Marketing' - : 'Under Construction'; - }, - }, - { - path: 'marketingSeason', - label: 'Marketing Season', - format: (val: string): string => { - if (!val) return ''; - return val.charAt(0).toUpperCase() + val.slice(1); - }, + headers.push({ + path: 'marketingType', + label: 'Marketing Status', + format: (val: string): string => { + if (!val) return ''; + return val === MarketingTypeEnum.marketing + ? 'Marketing' + : 'Under Construction'; + }, + }); + + if ( + doAnyJurisdictionHaveFeatureFlagSet( + user.jurisdictions, + FeatureFlagEnum.enableMarketingStatusMonths, + ) + ) + headers.push({ + path: 'marketingMonth', + label: 'Marketing Month', + format: (val: string): string => { + if (!val) return ''; + return val.charAt(0).toUpperCase() + val.slice(1); }, - { - path: 'marketingYear', - label: 'Marketing Year', + }); + + if ( + doAnyJurisdictionHaveFalsyFeatureFlagValue( + user.jurisdictions, + FeatureFlagEnum.enableMarketingStatusMonths, + ) + ) + headers.push({ + path: 'marketingSeason', + label: 'Marketing Season', + format: (val: string): string => { + if (!val) return ''; + return val.charAt(0).toUpperCase() + val.slice(1); }, - ], - ); + }); + + headers.push({ + path: 'marketingYear', + label: 'Marketing Year', + }); } headers.push( ...[ { path: 'leasingAgentName', - label: 'Leasing Agent Name', + label: doAnyJurisdictionHaveFeatureFlagSet( + user.jurisdictions, + FeatureFlagEnum.enableLeasingAgentAltText, + ) + ? 'Leasing agent or property manager name' + : 'Leasing Agent Name', }, { path: 'leasingAgentEmail', @@ -963,7 +1111,12 @@ export class ListingCsvExporterService implements CsvExporterServiceInterface { }, { path: 'leasingAgentTitle', - label: 'Leasing Agent Title', + label: doAnyJurisdictionHaveFeatureFlagSet( + user.jurisdictions, + FeatureFlagEnum.enableLeasingAgentAltText, + ) + ? 'Leasing agent or property manager title' + : 'Leasing Agent Title', }, { path: 'leasingAgentOfficeHours', @@ -1071,7 +1224,12 @@ export class ListingCsvExporterService implements CsvExporterServiceInterface { }, { path: 'referralOpportunity', - label: 'Referral Opportunity', + label: doAllJurisdictionHaveFeatureFlagSet( + user.jurisdictions, + FeatureFlagEnum.enableReferralQuestionUnits, + ) + ? 'Referral Only Units' + : 'Referral Opportunity', format: this.formatYesNo, }, { @@ -1148,6 +1306,23 @@ export class ListingCsvExporterService implements CsvExporterServiceInterface { .join(', '); }, }, + ...(doAnyJurisdictionHaveFeatureFlagSet( + user.jurisdictions, + FeatureFlagEnum.enableMarketingFlyer, + ) + ? [ + { + path: 'marketingFlyer', + label: 'Marketing Flyer', + format: this.marketingFlyer, + }, + { + path: 'accessibleMarketingFlyer', + label: 'Accessible Marketing Flyer', + format: this.accessibleMarketingFlyer, + }, + ] + : []), { path: 'userAccounts', label: 'Partners Who Have Access', diff --git a/api/src/services/listing.service.ts b/api/src/services/listing.service.ts index 12c948eba1..ec05561fce 100644 --- a/api/src/services/listing.service.ts +++ b/api/src/services/listing.service.ts @@ -10,7 +10,6 @@ import { } from '@nestjs/common'; import { HttpService } from '@nestjs/axios'; import { ConfigService } from '@nestjs/config'; -import { SchedulerRegistry } from '@nestjs/schedule'; import { LanguagesEnum, ListingEventsTypeEnum, @@ -48,7 +47,6 @@ import { ListingFilterKeys } from '../enums/listings/filter-key-enum'; import { permissionActions } from '../enums/permissions/permission-actions-enum'; import { buildFilter } from '../utilities/build-filter'; import { buildOrderByForListings } from '../utilities/build-order-by'; -import { startCronJob } from '../utilities/cron-job-starter'; import { mapTo } from '../utilities/mapTo'; import { buildPaginationMetaInfo, @@ -62,6 +60,7 @@ import { import { fillModelStringFields } from '../utilities/model-fields'; import { doJurisdictionHaveFeatureFlagSet } from '../utilities/feature-flag-utilities'; import { addUnitGroupsSummarized } from '../utilities/unit-groups-transformations'; +import { CronJobService } from './cron-job.service'; export type getListingsArgs = { skip: number; @@ -160,6 +159,8 @@ includeViews.full = { }, }, listingsBuildingSelectionCriteriaFile: true, + listingsMarketingFlyerFile: true, + listingsAccessibleMarketingFlyerFile: true, listingEvents: { include: { assets: true, @@ -172,6 +173,7 @@ includeViews.full = { listingsApplicationDropOffAddress: true, listingsApplicationMailingAddress: true, requestedChangesUser: true, + requiredDocumentsList: true, units: { include: { unitAmiChartOverrides: true, @@ -204,18 +206,15 @@ export class ListingService implements OnModuleInit { private configService: ConfigService, @Inject(Logger) private logger = new Logger(ListingService.name), - private schedulerRegistry: SchedulerRegistry, private permissionService: PermissionService, + private cronJobService: CronJobService, ) {} onModuleInit() { - startCronJob( - this.prisma, + this.cronJobService.startCronJob( LISTING_CRON_JOB_NAME, process.env.LISTING_PROCESSING_CRON_STRING, this.closeListings.bind(this), - this.logger, - this.schedulerRegistry, ); } @@ -1046,6 +1045,20 @@ export class ListingService implements OnModuleInit { })), }); } + if (filter[ListingFilterKeys.listingType]) { + const builtFilter = buildFilter({ + $comparison: filter.$comparison, + $include_nulls: false, + value: filter[ListingFilterKeys.listingType], + key: ListingFilterKeys.listingType, + caseSensitive: true, + }); + filters.push({ + OR: builtFilter.map((filt) => ({ + [ListingFilterKeys.listingType]: filt, + })), + }); + } }); } @@ -1283,9 +1296,9 @@ export class ListingService implements OnModuleInit { dto.unitGroups, ); - // Remove requiredFields property before saving to database + // Remove requiredFields and minimumImagesRequired properties before saving to database // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { requiredFields, ...listingData } = dto; + const { requiredFields, minimumImagesRequired, ...listingData } = dto; const rawListing = await this.prisma.listings.create({ include: includeViews.full, @@ -1321,6 +1334,11 @@ export class ListingService implements OnModuleInit { })), } : undefined, + requiredDocumentsList: dto.requiredDocumentsList + ? { + create: { ...dto.requiredDocumentsList }, + } + : undefined, listingEvents: dto.listingEvents ? { create: dto.listingEvents.map((event) => ({ @@ -1350,6 +1368,7 @@ export class ListingService implements OnModuleInit { }, }, ordinal: image.ordinal, + description: image.description, })), } : undefined, @@ -1389,6 +1408,21 @@ export class ListingService implements OnModuleInit { }, } : undefined, + listingsMarketingFlyerFile: dto.listingsMarketingFlyerFile + ? { + create: { + ...dto.listingsMarketingFlyerFile, + }, + } + : undefined, + listingsAccessibleMarketingFlyerFile: + dto.listingsAccessibleMarketingFlyerFile + ? { + create: { + ...dto.listingsAccessibleMarketingFlyerFile, + }, + } + : undefined, listingUtilities: dto.listingUtilities ? { create: { @@ -1509,6 +1543,7 @@ export class ListingService implements OnModuleInit { rentType: group.rentType, flatRentValueFrom: group.flatRentValueFrom, flatRentValueTo: group.flatRentValueTo, + monthlyRent: group.monthlyRent, totalAvailable: group.totalAvailable, totalCount: group.totalCount, unitGroupAmiLevels: { @@ -1695,6 +1730,7 @@ export class ListingService implements OnModuleInit { label: unsavedImage.assets.label, }, ordinal: unsavedImage.ordinal, + description: unsavedImage.description, })); const applicationMethods = mappedListing.applicationMethods?.map( @@ -1935,9 +1971,9 @@ export class ListingService implements OnModuleInit { update a listing */ async update(dto: ListingUpdate, requestingUser: User): Promise { - // Remove requiredFields property before saving to database + // Remove requiredFields and minimumImagesRequired properties before saving to database // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { requiredFields, ...incomingDto } = dto; + const { requiredFields, minimumImagesRequired, ...incomingDto } = dto; const storedListing = await this.findOrThrow( incomingDto.id, ListingViews.full, @@ -2006,7 +2042,11 @@ export class ListingService implements OnModuleInit { allAssets = [ ...allAssets, ...uploadedImages.map((image, index) => { - return { assets: image, ordinal: unsavedImages[index].ordinal }; + return { + assets: image, + ordinal: unsavedImages[index].ordinal, + description: unsavedImages[index].description, + }; }), ]; } @@ -2160,6 +2200,7 @@ export class ListingService implements OnModuleInit { create: allAssets.map((asset) => { return { ordinal: asset.ordinal, + description: asset.description, assets: { connect: { id: asset.assets.id, @@ -2199,7 +2240,18 @@ export class ListingService implements OnModuleInit { }, } : undefined, - // Three options for the building selection criteria file + requiredDocumentsList: dto.requiredDocumentsList + ? { + upsert: { + where: { + id: storedListing.requiredDocumentsList?.id, + }, + create: { ...incomingDto.requiredDocumentsList }, + update: { ...incomingDto.requiredDocumentsList }, + }, + } + : undefined, + // Three options for the building selection criteria and marketing Flyers files // create new one, connect existing one, or deleted (disconnect) listingsBuildingSelectionCriteriaFile: incomingDto.listingsBuildingSelectionCriteriaFile @@ -2223,6 +2275,47 @@ export class ListingService implements OnModuleInit { : { disconnect: true, }, + listingsMarketingFlyerFile: incomingDto.listingsMarketingFlyerFile + ? incomingDto.listingsMarketingFlyerFile.id + ? { + connectOrCreate: { + where: { + id: incomingDto.listingsMarketingFlyerFile.id, + }, + create: { + ...incomingDto.listingsMarketingFlyerFile, + }, + }, + } + : { + create: { + ...incomingDto.listingsMarketingFlyerFile, + }, + } + : { + disconnect: true, + }, + listingsAccessibleMarketingFlyerFile: + incomingDto.listingsAccessibleMarketingFlyerFile + ? incomingDto.listingsAccessibleMarketingFlyerFile.id + ? { + connectOrCreate: { + where: { + id: incomingDto.listingsAccessibleMarketingFlyerFile.id, + }, + create: { + ...incomingDto.listingsAccessibleMarketingFlyerFile, + }, + }, + } + : { + create: { + ...incomingDto.listingsAccessibleMarketingFlyerFile, + }, + } + : { + disconnect: true, + }, listingUtilities: incomingDto.listingUtilities ? { upsert: { @@ -2358,6 +2451,7 @@ export class ListingService implements OnModuleInit { rentType: group.rentType, flatRentValueFrom: group.flatRentValueFrom, flatRentValueTo: group.flatRentValueTo, + monthlyRent: group.monthlyRent, sqFeetMin: group.sqFeetMin, sqFeetMax: group.sqFeetMax, totalCount: group.totalCount, @@ -2484,6 +2578,28 @@ export class ListingService implements OnModuleInit { throw new HttpException('listing failed to save', 500); } + // Incoming update removes the requiredDocumentsList. Need to disconnect before deleting + if ( + !incomingDto.requiredDocumentsList && + storedListing.requiredDocumentsList?.id + ) { + await this.prisma.listings.update({ + data: { + requiredDocumentsList: { + disconnect: { + id: storedListing.requiredDocumentsList.id, + }, + }, + }, + where: { id: storedListing.id }, + }); + await this.prisma.listingDocuments.delete({ + where: { + id: storedListing.requiredDocumentsList.id, + }, + }); + } + const listingApprovalPermissions = ( await this.prisma.jurisdictions.findFirst({ where: { id: incomingDto.jurisdictions.id }, @@ -2500,11 +2616,11 @@ export class ListingService implements OnModuleInit { jurisId: incomingDto.jurisdictions.id, }); - // if listing is closed for the first time the application flag set job needs to run if ( storedListing.status === ListingsStatusEnum.active && incomingDto.status === ListingsStatusEnum.closed ) { + // if listing is closed for the first time the application flag set job needs to run if ( process.env.DUPLICATES_CLOSE_DATE && dayjs(process.env.DUPLICATES_CLOSE_DATE, 'YYYY-MM-DD HH:mm Z') < @@ -2514,6 +2630,9 @@ export class ListingService implements OnModuleInit { } else { await this.afsService.process(incomingDto.id); } + + // if the listing is closed for the first time the expire_after value should be set on all applications + void this.setExpireAfterValueOnApplications(rawListing.id); } await this.cachePurge( @@ -2625,13 +2744,33 @@ export class ListingService implements OnModuleInit { return mapTo(Listing, listingsRaw); }; + setExpireAfterValueOnApplications = async (listingId: string) => { + if ( + process.env.APPLICATION_DAYS_TILL_EXPIRY && + !isNaN(Number(process.env.APPLICATION_DAYS_TILL_EXPIRY)) + ) { + const expireAfterDate = dayjs(new Date()) + .add(Number(process.env.APPLICATION_DAYS_TILL_EXPIRY), 'days') + .toDate(); + const expiredApplications = await this.prisma.applications.updateMany({ + data: { expireAfter: expireAfterDate }, + where: { listingId: listingId }, + }); + this.logger.warn( + `setting expireAfter of ${expireAfterDate.toDateString()} on ${ + expiredApplications.count + } applications for listing ${listingId}`, + ); + } + }; + /** 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 closeListings(): Promise { this.logger.warn('changeOverdueListingsStatusCron job running'); - await this.markCronJobAsStarted(LISTING_CRON_JOB_NAME); + await this.cronJobService.markCronJobAsStarted(LISTING_CRON_JOB_NAME); const listings = await this.prisma.listings.findMany({ select: { @@ -2684,6 +2823,9 @@ export class ListingService implements OnModuleInit { ListingsStatusEnum.active, '', ); + for (const listing of listingIds) { + await this.setExpireAfterValueOnApplications(listing); + } } return { @@ -2691,37 +2833,6 @@ export class ListingService implements OnModuleInit { }; } - /** - 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(cronJobName: string): Promise { - const job = await this.prisma.cronJob.findFirst({ - where: { - name: cronJobName, - }, - }); - 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: cronJobName, - }, - }); - } - } - /** * * @param listingId diff --git a/api/src/services/lottery.service.ts b/api/src/services/lottery.service.ts index 981cddc718..a191c4bfcc 100644 --- a/api/src/services/lottery.service.ts +++ b/api/src/services/lottery.service.ts @@ -6,7 +6,6 @@ import { Injectable, Logger, } from '@nestjs/common'; -import { SchedulerRegistry } from '@nestjs/schedule'; import { ConfigService } from '@nestjs/config'; import { LanguagesEnum, @@ -36,10 +35,10 @@ import { mapTo } from '../utilities/mapTo'; import { LotteryActivityLogItem } from '../dtos/lottery/lottery-activity-log-item.dto'; import { ListingLotteryStatus } from '../../src/dtos/listings/listing-lottery-status.dto'; import { ListingViews } from '../../src/enums/listings/view-enum'; -import { startCronJob } from '../utilities/cron-job-starter'; import { EmailService } from './email.service'; import { PublicLotteryResult } from '../../src/dtos/lottery/lottery-public-result.dto'; import { PublicLotteryTotal } from '../../src/dtos/lottery/lottery-public-total.dto'; +import { CronJobService } from './cron-job.service'; const LOTTERY_CRON_JOB_NAME = 'LOTTERY_CRON_JOB'; const LOTTERY_PUBLISH_CRON_JOB_NAME = 'LOTTERY_PUBLISH_CRON_JOB'; @@ -61,26 +60,20 @@ export class LotteryService { private configService: ConfigService, @Inject(Logger) private logger = new Logger(LotteryService.name), - private schedulerRegistry: SchedulerRegistry, private permissionService: PermissionService, + private cronJobService: CronJobService, ) {} onModuleInit() { - startCronJob( - this.prisma, + this.cronJobService.startCronJob( LOTTERY_CRON_JOB_NAME, process.env.LOTTERY_PROCESSING_CRON_STRING, this.expireLotteries.bind(this), - this.logger, - this.schedulerRegistry, ); - startCronJob( - this.prisma, + this.cronJobService.startCronJob( LOTTERY_PUBLISH_CRON_JOB_NAME, process.env.LOTTERY_PUBLISH_PROCESSING_CRON_STRING, this.autoPublishResults.bind(this), - this.logger, - this.schedulerRegistry, ); } @@ -659,7 +652,7 @@ export class LotteryService { */ async autoPublishResults(): Promise { this.logger.warn('autoPublishLotteryResults job running'); - await this.listingService.markCronJobAsStarted( + await this.cronJobService.markCronJobAsStarted( LOTTERY_PUBLISH_CRON_JOB_NAME, ); const tomorrow = dayjs( @@ -722,7 +715,7 @@ export class LotteryService { async expireLotteries(): Promise { if (process.env.LOTTERY_DAYS_TILL_EXPIRY) { this.logger.warn('changeExpiredLotteryStatusCron job running'); - await this.listingService.markCronJobAsStarted(LOTTERY_CRON_JOB_NAME); + await this.cronJobService.markCronJobAsStarted(LOTTERY_CRON_JOB_NAME); const expiration_date = dayjs(new Date()) .subtract(Number(process.env.LOTTERY_DAYS_TILL_EXPIRY), 'days') .toDate(); diff --git a/api/src/services/multiselect-question.service.ts b/api/src/services/multiselect-question.service.ts index aa42ac4374..fbc102bce7 100644 --- a/api/src/services/multiselect-question.service.ts +++ b/api/src/services/multiselect-question.service.ts @@ -1,26 +1,74 @@ -import { Injectable, NotFoundException } from '@nestjs/common'; +import { + BadRequestException, + Inject, + Injectable, + Logger, + NotFoundException, +} from '@nestjs/common'; +import { SchedulerRegistry } from '@nestjs/schedule'; +import { + ListingsStatusEnum, + MultiselectQuestionsStatusEnum, + Prisma, +} from '@prisma/client'; +import { CronJobService } from './cron-job.service'; +import { PermissionService } from './permission.service'; import { PrismaService } from './prisma.service'; +import { Jurisdiction } from '../dtos/jurisdictions/jurisdiction.dto'; 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 { MultiselectQuestionUpdate } from '../dtos/multiselect-questions/multiselect-question-update.dto'; +import { MultiselectQuestionQueryParams } from '../dtos/multiselect-questions/multiselect-question-query-params.dto'; import { SuccessDTO } from '../dtos/shared/success.dto'; -import { MultiselectQuestionsStatusEnum, Prisma } from '@prisma/client'; -import { buildFilter } from '../utilities/build-filter'; +import { User } from '../dtos/users/user.dto'; +import { FeatureFlagEnum } from '../enums/feature-flags/feature-flags-enum'; import { MultiselectQuestionFilterKeys } from '../enums/multiselect-questions/filter-key-enum'; -import { MultiselectQuestionQueryParams } from '../dtos/multiselect-questions/multiselect-question-query-params.dto'; +import { MultiselectQuestionViews } from '../enums/multiselect-questions/view-enum'; +import { permissionActions } from '../enums/permissions/permission-actions-enum'; +import { buildFilter } from '../utilities/build-filter'; +import { buildOrderByForMultiselectQuestions } from '../utilities/build-order-by'; +import { doJurisdictionHaveFeatureFlagSet } from '../utilities/feature-flag-utilities'; +import { mapTo } from '../utilities/mapTo'; +import { calculateSkip, calculateTake } from '../utilities/pagination-helpers'; + +export const includeViews: Partial< + Record +> = { + fundamentals: { + jurisdiction: true, + multiselectOptions: true, + }, +}; -const view: Prisma.MultiselectQuestionsInclude = { - jurisdiction: true, +includeViews.base = { + ...includeViews.fundamentals, + listings: true, }; +const MSQ_RETIRE_CRON_JOB_NAME = 'MSQ_RETIRE_CRON_JOB'; + /* this is the service for multiselect questions - it handles all the backend's business logic for reading/writing/deleting multiselect questione data + it handles all the backend's business logic for reading/writing/deleting multiselect question data */ @Injectable() export class MultiselectQuestionService { - constructor(private prisma: PrismaService) {} + constructor( + private prisma: PrismaService, + @Inject(Logger) + private logger = new Logger(MultiselectQuestionService.name), + private permissionService: PermissionService, + private schedulerRegistry: SchedulerRegistry, + private cronJobService: CronJobService, + ) {} + + onModuleInit() { + this.cronJobService.startCronJob( + MSQ_RETIRE_CRON_JOB_NAME, + process.env.MSQ_RETIRE_CRON_STRING, + this.retireMultiselectQuestions.bind(this), + ); + } /* this will get a set of multiselect questions given the params passed in @@ -28,18 +76,47 @@ export class MultiselectQuestionService { async list( params: MultiselectQuestionQueryParams, ): Promise { - let rawMultiselectQuestions = + const whereClause = this.buildWhere(params); + + const count = await this.prisma.multiselectQuestions.count({ + where: whereClause, + }); + + // if passed in page and limit would result in no results because there aren't that many + // multiselectQuestions 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 query = { + skip: calculateSkip(params.limit, page), + take: calculateTake(params.limit), + orderBy: buildOrderByForMultiselectQuestions( + params.orderBy, + params.orderDir, + ), + where: whereClause, + }; + + const rawMultiselectQuestions = await this.prisma.multiselectQuestions.findMany({ - include: view, - where: this.buildWhere(params), + ...query, + include: includeViews[params.view ?? 'fundamentals'], }); - // TODO: Temporary until front end accepts MSQ refactor - rawMultiselectQuestions = rawMultiselectQuestions.map((msq) => ({ - ...msq, - jurisdictions: [msq.jurisdiction], - })); - return mapTo(MultiselectQuestion, rawMultiselectQuestions); + // TODO: Can be removed after MSQ refactor + const multiselectQuestionsWithJurisdictions = rawMultiselectQuestions.map( + (msq) => { + return { + ...msq, + jurisdictions: [msq.jurisdiction], + }; + }, + ); + return mapTo(MultiselectQuestion, multiselectQuestionsWithJurisdictions); } /* @@ -49,42 +126,63 @@ export class MultiselectQuestionService { params: MultiselectQuestionQueryParams, ): Prisma.MultiselectQuestionsWhereInput { const filters: Prisma.MultiselectQuestionsWhereInput[] = []; - if (!params?.filter?.length) { - return { - AND: filters, - }; + if (params?.filter?.length) { + params.filter.forEach((filter) => { + 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, + })), + }); + } else 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) => ({ + jurisdiction: { + id: filt, + }, + })), + }); + } else if (filter[MultiselectQuestionFilterKeys.status]) { + console.log(filter[MultiselectQuestionFilterKeys.status]); + const builtFilter = buildFilter({ + $comparison: filter.$comparison, + $include_nulls: false, + value: filter[MultiselectQuestionFilterKeys.status], + key: MultiselectQuestionFilterKeys.status, + caseSensitive: true, + }); + filters.push({ + OR: builtFilter.map((filt) => ({ + status: filt, + })), + }); + } + }); } - 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) => ({ - jurisdiction: { - 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, - })), - }); - } - }); + + if (params?.search) { + filters.push({ + name: { + contains: params.search, + mode: Prisma.QueryMode.insensitive, + }, + }); + } + return { AND: filters, }; @@ -93,27 +191,29 @@ export class MultiselectQuestionService { /* this will return 1 multiselect question or error */ - async findOne(multiSelectQuestionId: string): Promise { + async findOne( + multiselectQuestionId: string, + view: MultiselectQuestionViews = MultiselectQuestionViews.fundamentals, + ): Promise { const rawMultiselectQuestion = - await this.prisma.multiselectQuestions.findFirst({ + await this.prisma.multiselectQuestions.findUnique({ + include: includeViews[view], where: { - id: { - equals: multiSelectQuestionId, - }, + id: multiselectQuestionId, }, - include: view, }); if (!rawMultiselectQuestion) { throw new NotFoundException( - `multiselectQuestionId ${multiSelectQuestionId} was requested but not found`, + `multiselectQuestionId ${multiselectQuestionId} was requested but not found`, ); } - // TODO: Temporary until front end accepts MSQ refactor + // TODO: Can be removed after MSQ refactor rawMultiselectQuestion['jurisdictions'] = [ rawMultiselectQuestion.jurisdiction, ]; + return mapTo(MultiselectQuestion, rawMultiselectQuestion); } @@ -122,35 +222,105 @@ export class MultiselectQuestionService { */ async create( incomingData: MultiselectQuestionCreate, + requestingUser: User, ): Promise { - const { jurisdictions, links, options, ...createData } = incomingData; + const { + isExclusive, + jurisdiction, + jurisdictions, + links, + multiselectOptions, + name, + options, + status, + ...createData + } = incomingData; + + const rawJurisdiction = await this.prisma.jurisdictions.findFirstOrThrow({ + select: { + featureFlags: true, + id: true, + }, + where: { + id: jurisdiction + ? jurisdiction.id + : jurisdictions?.at(0) + ? jurisdictions?.at(0)?.id + : undefined, + }, + }); + + await this.permissionService.canOrThrow( + requestingUser, + 'multiselectQuestion', + permissionActions.create, + { + jurisdictionId: rawJurisdiction.id, + }, + ); + + const enableV2MSQ = doJurisdictionHaveFeatureFlagSet( + rawJurisdiction as Jurisdiction, + FeatureFlagEnum.enableV2MSQ, + ); + + if ( + status && + !( + status === MultiselectQuestionsStatusEnum.draft || + status === MultiselectQuestionsStatusEnum.visible + ) + ) { + throw new BadRequestException( + "status must be 'draft' or 'visible' on create", + ); + } const rawMultiselectQuestion = await this.prisma.multiselectQuestions.create({ data: { ...createData, jurisdiction: { - connect: jurisdictions?.at(0)?.id - ? { id: jurisdictions?.at(0)?.id } - : undefined, + connect: { id: rawJurisdiction.id }, }, links: links ? (links as unknown as Prisma.InputJsonArray) : undefined, + + // TODO: Can be removed after MSQ refactor options: options ? (options as unknown as Prisma.InputJsonArray) : undefined, - status: MultiselectQuestionsStatusEnum.draft, - // TODO: Temporary until after MSQ refactor - isExclusive: false, - multiselectOptions: undefined, - name: createData.text, + // TODO: Use of the feature flag is temporary until after MSQ refactor + isExclusive: enableV2MSQ ? isExclusive : false, + name: enableV2MSQ ? name : createData.text, + status: enableV2MSQ ? status : MultiselectQuestionsStatusEnum.draft, + + multiselectOptions: enableV2MSQ + ? { + createMany: { + data: multiselectOptions?.map((option) => { + // TODO: Can be removed after MSQ refactor + delete option['collectAddress']; + delete option['collectName']; + delete option['collectRelationship']; + delete option['exclusive']; + delete option['text']; + return { + ...option, + links: option.links as unknown as Prisma.InputJsonArray, + name: option.name, + }; + }), + }, + } + : undefined, }, - include: view, + include: includeViews.fundamentals, }); - // TODO: Temporary until front end accepts MSQ refactor + // TODO: Can be removed after MSQ refactor rawMultiselectQuestion['jurisdictions'] = [ rawMultiselectQuestion.jurisdiction, ]; @@ -163,54 +333,164 @@ export class MultiselectQuestionService { */ async update( incomingData: MultiselectQuestionUpdate, + requestingUser: User, ): Promise { - const { id, jurisdictions, links, options, ...updateData } = incomingData; + const { + id, + isExclusive, + jurisdiction, + jurisdictions, + links, + multiselectOptions, + name, + options, + status, + ...updateData + } = incomingData; - await this.findOrThrow(id); + const currentMultiselectQuestion = await this.findOne(id); - const rawMultiselectQuestion = - await this.prisma.multiselectQuestions.update({ + const rawJurisdiction = await this.prisma.jurisdictions.findFirstOrThrow({ + select: { + featureFlags: true, + id: true, + }, + where: { + id: jurisdiction + ? jurisdiction.id + : jurisdictions?.at(0) + ? jurisdictions?.at(0)?.id + : undefined, + }, + }); + + await this.permissionService.canOrThrow( + requestingUser, + 'multiselectQuestion', + permissionActions.update, + { + id: id, + jurisdictionId: rawJurisdiction.id, + }, + ); + + const enableV2MSQ = doJurisdictionHaveFeatureFlagSet( + rawJurisdiction as Jurisdiction, + FeatureFlagEnum.enableV2MSQ, + ); + + if (enableV2MSQ) { + this.validateStatusStateTransition( + currentMultiselectQuestion.status, + status, + ); + } + + // Wrap the deletion and update in one transaction so that multiselectOptions aren't lost if update fails + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const transactions = await this.prisma.$transaction([ + // delete the multiselect options + this.prisma.multiselectOptions.deleteMany({ + where: { + multiselectQuestionId: id, + }, + }), + // update the multiselect question + this.prisma.multiselectQuestions.update({ data: { ...updateData, id: undefined, jurisdiction: { - connect: jurisdictions?.at(0)?.id - ? { id: jurisdictions?.at(0)?.id } - : undefined, + connect: { id: rawJurisdiction.id }, }, links: links ? (links as unknown as Prisma.InputJsonArray) : undefined, + // TODO: Can be removed after MSQ refactor options: options ? (options as unknown as Prisma.InputJsonArray) : undefined, - // TODO: Temporary until after MSQ refactor - isExclusive: false, - multiselectOptions: undefined, - name: updateData.text, + // TODO: Use of the feature flag is temporary until after MSQ refactor + isExclusive: enableV2MSQ ? isExclusive : false, + name: enableV2MSQ ? name : updateData.text, + status: enableV2MSQ ? status : MultiselectQuestionsStatusEnum.draft, + + multiselectOptions: enableV2MSQ + ? { + createMany: { + data: multiselectOptions?.map((option) => { + delete option['id']; + // TODO: The following 5 deletes can be removed after MSQ refactor + delete option['collectAddress']; + delete option['collectName']; + delete option['collectRelationship']; + delete option['exclusive']; + delete option['text']; + return { + ...option, + links: option.links as unknown as Prisma.InputJsonArray, + name: option.name, + }; + }), + }, + } + : undefined, }, where: { id: id, }, - include: view, - }); + include: includeViews.fundamentals, + }), + ]); + const rawMultiselectQuestion = transactions[ + transactions.length - 1 + ] as unknown as MultiselectQuestion; - // TODO: Temporary until front end accepts MSQ refactor + // TODO: Can be removed after MSQ refactor rawMultiselectQuestion['jurisdictions'] = [ - rawMultiselectQuestion?.jurisdiction, + rawMultiselectQuestion.jurisdiction, ]; + return mapTo(MultiselectQuestion, rawMultiselectQuestion); } /* this will delete a multiselect question */ - async delete(multiSelectQuestionId: string): Promise { - await this.findOrThrow(multiSelectQuestionId); + async delete( + multiselectQuestionId: string, + requestingUser: User, + ): Promise { + const currentMultiselectQuestion = await this.findOne( + multiselectQuestionId, + ); + + const enableV2MSQ = doJurisdictionHaveFeatureFlagSet( + currentMultiselectQuestion.jurisdiction as Jurisdiction, + FeatureFlagEnum.enableV2MSQ, + ); + + await this.permissionService.canOrThrow( + requestingUser, + 'multiselectQuestion', + permissionActions.delete, + { + id: multiselectQuestionId, + jurisdictionId: currentMultiselectQuestion.jurisdiction.id, + }, + ); + + if (enableV2MSQ) { + this.validateStatusStateTransition( + currentMultiselectQuestion.status, + currentMultiselectQuestion.status, + ); + } + await this.prisma.multiselectQuestions.delete({ where: { - id: multiSelectQuestionId, + id: multiselectQuestionId, }, }); return { @@ -218,16 +498,197 @@ export class MultiselectQuestionService { } as SuccessDTO; } - /* - this will either find a record or throw a customized error + async findByListingId(listingId: string): Promise { + let rawMultiselectQuestions = + await this.prisma.multiselectQuestions.findMany({ + include: includeViews.base, + where: { + listings: { + some: { + listingId, + }, + }, + }, + }); + + // TODO: Temporary until front end accepts MSQ refactor + rawMultiselectQuestions = rawMultiselectQuestions.map((msq) => ({ + ...msq, + jurisdictions: [{ id: msq.jurisdictionId }], + })); + return mapTo(MultiselectQuestion, rawMultiselectQuestions); + } + + /** + validates that the attempted status state transition is allowed in the state machine, + if not it throws a custom error + */ + validateStatusStateTransition( + currentState: MultiselectQuestionsStatusEnum, + nextState: MultiselectQuestionsStatusEnum, + ) { + if (currentState === nextState) { + if ( + nextState === MultiselectQuestionsStatusEnum.active || + nextState === MultiselectQuestionsStatusEnum.toRetire || + nextState === MultiselectQuestionsStatusEnum.retired + ) { + throw new BadRequestException( + `A multiselect question of status '${nextState}' cannot be edited or deleted`, + ); + } + return; + } + + switch (currentState) { + case MultiselectQuestionsStatusEnum.draft: + if (nextState !== MultiselectQuestionsStatusEnum.visible) { + throw new BadRequestException( + "status 'draft' can only change to 'visible'", + ); + } + break; + case MultiselectQuestionsStatusEnum.visible: + if ( + nextState !== MultiselectQuestionsStatusEnum.draft && + nextState !== MultiselectQuestionsStatusEnum.active + ) { + throw new BadRequestException( + "status 'visible' can only change to 'draft' or 'active'", + ); + } + break; + case MultiselectQuestionsStatusEnum.active: + if ( + nextState !== MultiselectQuestionsStatusEnum.toRetire && + nextState !== MultiselectQuestionsStatusEnum.retired + ) { + throw new BadRequestException( + "status 'active' can only change to 'toRetire' or 'retired'", + ); + } + break; + + case MultiselectQuestionsStatusEnum.toRetire: + if ( + nextState !== MultiselectQuestionsStatusEnum.retired && + nextState !== MultiselectQuestionsStatusEnum.active + ) { + throw new BadRequestException( + "status 'toRetire' can only change to 'retired'", + ); + } + break; + + case MultiselectQuestionsStatusEnum.retired: + throw new BadRequestException("status 'retired' cannot be changed"); + + default: + throw new BadRequestException( + `current status is not of type MultiselectQuestionsStatusEnum: ${currentState}`, + ); + } + } + + /** + moves a multiselect question to a new status state + */ + async statusStateTransition( + multiselectQuestion: MultiselectQuestion, + status: MultiselectQuestionsStatusEnum, + ) { + this.validateStatusStateTransition(multiselectQuestion.status, status); + + await this.prisma.multiselectQuestions.update({ + data: { + status: status, + }, + where: { + id: multiselectQuestion.id, + }, + }); + } + + /** + actives any visible multiselect questions */ - async findOrThrow( + async activateMany( + multiselectQuestions: MultiselectQuestion[], + ): Promise { + if ( + multiselectQuestions.some( + (multiselectQuestion) => + multiselectQuestion.status === MultiselectQuestionsStatusEnum.draft || + multiselectQuestion.status === MultiselectQuestionsStatusEnum.retired, + ) + ) { + throw new BadRequestException( + 'only multiselect questions in visible, active or toRetire status can be associated with a listing being published', + ); + } + // What if one fails? + for (const multiselectQuestion of multiselectQuestions) { + if ( + multiselectQuestion.status === MultiselectQuestionsStatusEnum.visible + ) { + await this.statusStateTransition( + multiselectQuestion, + MultiselectQuestionsStatusEnum.active, + ); + } + } + return { + success: true, + } as SuccessDTO; + } + + async reActivate( multiselectQuestionId: string, - ): Promise { + requestingUser: User, + ): Promise { + const multiselectQuestion = await this.findOne(multiselectQuestionId); + + await this.permissionService.canOrThrow( + requestingUser, + 'multiselectQuestion', + permissionActions.update, + { + id: multiselectQuestionId, + jurisdictionId: multiselectQuestion.jurisdiction.id, + }, + ); + + await this.statusStateTransition( + multiselectQuestion, + MultiselectQuestionsStatusEnum.active, + ); + + return { + success: true, + } as SuccessDTO; + } + + /** + attempts to move a multiselect question to retired status, + if it is still associated with open listings it is moved to toRetire + */ + async retire( + multiselectQuestionId: string, + requestingUser: User, + ): Promise { const rawMultiselectQuestion = - await this.prisma.multiselectQuestions.findFirst({ + await this.prisma.multiselectQuestions.findUnique({ include: { jurisdiction: true, + listings: { + include: { + listings: { + select: { + status: true, + }, + }, + }, + }, }, where: { id: multiselectQuestionId, @@ -240,33 +701,71 @@ export class MultiselectQuestionService { ); } - // TODO: Temporary until front end accepts MSQ refactor - rawMultiselectQuestion['jurisdictions'] = [ - rawMultiselectQuestion.jurisdiction, - ]; - return mapTo(MultiselectQuestion, rawMultiselectQuestion); + await this.permissionService.canOrThrow( + requestingUser, + 'multiselectQuestion', + permissionActions.update, + { + id: multiselectQuestionId, + jurisdictionId: rawMultiselectQuestion.jurisdiction.id, + }, + ); + + const multiselectQuestion = mapTo( + MultiselectQuestion, + rawMultiselectQuestion, + ); + + if ( + rawMultiselectQuestion.listings.every( + ({ listings }) => listings.status === ListingsStatusEnum.closed, + ) + ) { + await this.statusStateTransition( + multiselectQuestion, + MultiselectQuestionsStatusEnum.retired, + ); + } else { + await this.statusStateTransition( + multiselectQuestion, + MultiselectQuestionsStatusEnum.toRetire, + ); + } + + return { + success: true, + } as SuccessDTO; } - async findByListingId(listingId: string): Promise { - let rawMultiselectQuestions = - await this.prisma.multiselectQuestions.findMany({ - include: { - listings: true, - }, - where: { - listings: { - some: { - listingId, + /** + runs the job to auto retire multiselect questions that are waiting to be retired + */ + async retireMultiselectQuestions(): Promise { + this.logger.warn('retireMultiselectQuestionsCron job running'); + await this.cronJobService.markCronJobAsStarted('MSQ_RETIRE_CRON_JOB'); + + const res = await this.prisma.multiselectQuestions.updateMany({ + data: { + status: MultiselectQuestionsStatusEnum.retired, + }, + where: { + listings: { + every: { + listings: { + status: ListingsStatusEnum.closed, }, }, }, - }); + status: MultiselectQuestionsStatusEnum.toRetire, + }, + }); - // TODO: Temporary until front end accepts MSQ refactor - rawMultiselectQuestions = rawMultiselectQuestions.map((msq) => ({ - ...msq, - jurisdictions: [{ id: msq.jurisdictionId }], - })); - return mapTo(MultiselectQuestion, rawMultiselectQuestions); + this.logger.warn( + `Changed the status of ${res?.count} multiselect questions`, + ); + + return { + success: true, + }; } } diff --git a/api/src/services/permission.service.ts b/api/src/services/permission.service.ts index 5f9aa6e8df..f91204f974 100644 --- a/api/src/services/permission.service.ts +++ b/api/src/services/permission.service.ts @@ -98,6 +98,12 @@ export class PermissionService { `r.obj.jurisdictionId == '${adminInJurisdiction.id}'`, `(${permissionActions.read}|${permissionActions.create}|${permissionActions.update}|${permissionActions.delete})`, ); + await enforcer.addPermissionForUser( + user.id, + 'multiselectQuestion', + `r.obj.jurisdictionId == '${adminInJurisdiction.id}'`, + `(${permissionActions.read}|${permissionActions.create}|${permissionActions.update}|${permissionActions.delete})`, + ); await enforcer.addPermissionForUser( user.id, 'user', diff --git a/api/src/services/script-runner.service.ts b/api/src/services/script-runner.service.ts index 8ca046ce40..71c00731bf 100644 --- a/api/src/services/script-runner.service.ts +++ b/api/src/services/script-runner.service.ts @@ -573,20 +573,24 @@ export class ScriptRunnerService { jurisInfo?.length ? jurisInfo[0].name : '', translations, ); - await this.multiselectQuestionService.create({ - text: pref.title, - subText: pref.subtitle, - description: pref.description, - links: pref.links ?? null, - hideFromListing: this.resolveHideFromListings(pref), - optOutText: optOutText ?? null, - options: options, - applicationSection: - MultiselectQuestionsApplicationSectionEnum.preferences, - jurisdictions: jurisInfo.map((juris) => { - return { id: juris.id }; - }), - }); + await this.multiselectQuestionService.create( + { + text: pref.title, + subText: pref.subtitle, + description: pref.description, + links: pref.links ?? null, + hideFromListing: this.resolveHideFromListings(pref), + optOutText: optOutText ?? null, + options: options, + applicationSection: + MultiselectQuestionsApplicationSectionEnum.preferences, + jurisdictions: jurisInfo.map((juris) => { + return { id: juris.id }; + }), + status: 'draft', + }, + requestingUser, + ); } // begin migration from programs @@ -629,31 +633,24 @@ export class ScriptRunnerService { const description = updatedProgInfo[prog.title] || prog.description; const res: MultiselectQuestion = - await this.multiselectQuestionService.create({ - text: prog.title, - subText: prog.subtitle, - description: description, - links: null, - hideFromListing: this.resolveHideFromListings(prog), - optOutText: null, - options: [ - { - text: 'I am a part of this community', - ordinal: 1, - exclusive: true, - }, - { - text: 'I am not a part of this community', - ordinal: 2, - exclusive: true, - }, - ], - applicationSection: - MultiselectQuestionsApplicationSectionEnum.programs, - jurisdictions: jurisInfo.map((juris) => { - return { id: juris.id }; - }), - }); + await this.multiselectQuestionService.create( + { + text: prog.title, + subText: prog.subtitle, + description: prog.description, + links: null, + hideFromListing: this.resolveHideFromListings(prog), + optOutText: null, + options: null, + applicationSection: + MultiselectQuestionsApplicationSectionEnum.programs, + jurisdictions: jurisInfo.map((juris) => { + return { id: juris.id }; + }), + status: 'draft', + }, + requestingUser, + ); const listingsInfo: { ordinal; listing_id }[] = await this.prisma .$queryRawUnsafe(` diff --git a/api/src/services/translation.service.ts b/api/src/services/translation.service.ts index 674d56b3be..c8441dccec 100644 --- a/api/src/services/translation.service.ts +++ b/api/src/services/translation.service.ts @@ -23,28 +23,27 @@ export class TranslationService { if (language && language !== LanguagesEnum.en) { if (jurisdictionId) { jurisdictionalTranslations = - this.getTranslationByLanguageAndJurisdictionOrDefaultEn( + this.getTranslationByLanguageAndJurisdiction( language, jurisdictionId, ); } - genericTranslations = - this.getTranslationByLanguageAndJurisdictionOrDefaultEn(language, null); + genericTranslations = this.getTranslationByLanguageAndJurisdiction( + language, + null, + ); } if (jurisdictionId) { jurisdictionalDefaultTranslations = - this.getTranslationByLanguageAndJurisdictionOrDefaultEn( + this.getTranslationByLanguageAndJurisdiction( LanguagesEnum.en, jurisdictionId, ); } const genericDefaultTranslations = - this.getTranslationByLanguageAndJurisdictionOrDefaultEn( - LanguagesEnum.en, - null, - ); + this.getTranslationByLanguageAndJurisdiction(LanguagesEnum.en, null); const [genericDefault, generic, jurisdictionalDefault, jurisdictional] = await Promise.all([ @@ -53,6 +52,7 @@ export class TranslationService { jurisdictionalDefaultTranslations, jurisdictionalTranslations, ]); + // Deep merge const translations = lodash.merge( genericDefault?.translations, @@ -64,23 +64,13 @@ export class TranslationService { return translations; } - public async getTranslationByLanguageAndJurisdictionOrDefaultEn( + public async getTranslationByLanguageAndJurisdiction( language: LanguagesEnum, jurisdictionId: string | null, - ) { - let translations = await this.prisma.translations.findFirst({ + ): Promise { + return 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) { @@ -138,6 +128,15 @@ export class TranslationService { listing.listingEvents[index].label; }); + if (listing.listingImages) { + listing.listingImages.forEach((image, index) => { + if (image.description) { + pathsToFilter[`listingImages[${index}].description`] = + image.description; + } + }); + } + if (listing.listingMultiselectQuestions) { listing.listingMultiselectQuestions.map((multiselectQuestion, index) => { multiselectQuestion.multiselectQuestions.untranslatedText = diff --git a/api/src/services/user.service.ts b/api/src/services/user.service.ts index 2d320e7930..ce2ca28597 100644 --- a/api/src/services/user.service.ts +++ b/api/src/services/user.service.ts @@ -5,6 +5,8 @@ import { Injectable, NotFoundException, UnauthorizedException, + Logger, + Inject, } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { Prisma } from '@prisma/client'; @@ -27,7 +29,11 @@ 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 { + isPasswordOutdated, + 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'; @@ -45,6 +51,8 @@ import { RequestSingleUseCode } from '../dtos/single-use-code/request-single-use import { getSingleUseCode } from '../utilities/get-single-use-code'; import { UserFavoriteListing } from '../dtos/users/user-favorite-listing.dto'; import { ModificationEnum } from '../enums/shared/modification-enum'; +import { CronJobService } from './cron-job.service'; +import { ApplicationService } from './application.service'; /* this is the service for users @@ -79,6 +87,9 @@ type findByOptions = { resetToken?: string; }; +const USER_DELETION_CRON_JOB_NAME = 'USER_DELETION_CRON_STRING'; +const USER_DELETION_WARN_CRON_JOB_NAME = 'USER_DELETION_WARN_CRON_JOB'; + @Injectable() export class UserService { constructor( @@ -86,9 +97,25 @@ export class UserService { private emailService: EmailService, private readonly configService: ConfigService, private permissionService: PermissionService, + private applicationService: ApplicationService, + @Inject(Logger) + private logger = new Logger(UserService.name), + private cronJobService: CronJobService, ) { dayjs.extend(advancedFormat); } + onModuleInit() { + this.cronJobService.startCronJob( + USER_DELETION_CRON_JOB_NAME, + process.env.USER_DELETION_CRON_STRING, + this.deleteAfterInactivity.bind(this), + ); + this.cronJobService.startCronJob( + USER_DELETION_WARN_CRON_JOB_NAME, + process.env.USER_DELETION_WARN_CRON_STRING, + this.warnUserOfDeletionCronJob.bind(this), + ); + } /* this will get a set of users given the params passed in @@ -313,38 +340,6 @@ export class UserService { 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 }, - UserViews.base, - ); - - 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 @@ -943,6 +938,12 @@ export class UserService { if (!user) { return { success: true }; } + if (isPasswordOutdated(user.passwordValidForDays, user.passwordUpdatedAt)) { + // if password TTL is expired + throw new UnauthorizedException( + `user ${user.id} attempted to login, but password is no longer valid`, + ); + } const jurisdictionName = req?.headers?.jurisdictionname; if (!jurisdictionName) { @@ -955,6 +956,7 @@ export class UserService { select: { id: true, allowSingleUseCodeLogin: true, + name: true, }, where: { name: jurisdictionName as string, @@ -986,7 +988,11 @@ export class UserService { }, }); - await this.emailService.sendSingleUseCode(mapTo(User, user), singleUseCode); + await this.emailService.sendSingleUseCode( + mapTo(User, user), + singleUseCode, + juris.name, + ); return { success: true }; } @@ -1044,4 +1050,152 @@ export class UserService { return mapTo(User, rawResults); } + + private async deleteUserAndRelatedInfo( + user: User, + removePIIFromApplications?: boolean, + ) { + if (removePIIFromApplications) { + const applications = await this.prisma.applications.findMany({ + select: { id: true }, + where: { userId: user.id }, + }); + for (const application of applications) { + await this.applicationService.removePII(application.id); + } + } + + if (user.userRoles) { + await this.prisma.userRoles.delete({ + where: { + userId: user.id, + }, + }); + } + + await this.prisma.userAccounts.delete({ + where: { + id: user.id, + }, + }); + } + + /* + this will delete a user or error if no user is found with the Id + */ + async delete( + userId: string, + requestingUser: User, + shouldDeleteApplications?: boolean, + ): Promise { + const targetUser = await this.findUserOrError( + { userId: userId }, + UserViews.base, + ); + + this.authorizeAction( + requestingUser, + mapTo(User, targetUser), + permissionActions.delete, + ); + + await this.deleteUserAndRelatedInfo( + mapTo(User, targetUser), + shouldDeleteApplications, + ); + + return { + success: true, + } as SuccessDTO; + } + + async deleteAfterInactivity(): Promise { + if ( + !this.configService.get('USERS_DAYS_TILL_EXPIRY') || + isNaN(Number(this.configService.get('USERS_DAYS_TILL_EXPIRY'))) + ) { + this.logger.warn( + 'USERS_DAYS_TILL_EXPIRY variable is not set so deleteAfterInactivity will not run', + ); + return { success: false } as SuccessDTO; + } + const deleteBeforeDate = dayjs(new Date()) + .subtract( + Number(this.configService.get('USERS_DAYS_TILL_EXPIRY')), + 'days', + ) + .toDate(); + const usersToBeDeleted = await this.prisma.userAccounts.findMany({ + select: { id: true, wasWarnedOfDeletion: true }, + where: { lastLoginAt: { lt: deleteBeforeDate }, userRoles: null }, + }); + + for (const user of usersToBeDeleted) { + if (!user.wasWarnedOfDeletion) { + this.logger.warn( + `Unable to delete user ${user.id} because they have not been warned by email`, + ); + } else { + await this.deleteUserAndRelatedInfo(mapTo(User, user), true); + } + } + + return { success: true } as SuccessDTO; + } + + /** + * Cron job for sending emails to users that have not logged in to the system in USERS_DAYS_TILL_EXPIRY + * informing them their account will be deleted in 30 days + */ + async warnUserOfDeletionCronJob(): Promise { + if ( + this.configService.get('USERS_DAYS_TILL_EXPIRY') && + !isNaN(Number(this.configService.get('USERS_DAYS_TILL_EXPIRY'))) + ) { + await this.cronJobService.markCronJobAsStarted( + USER_DELETION_WARN_CRON_JOB_NAME, + ); + // warning the user 30 days before the user account will be deleted + const warnDateNumber = + Number(this.configService.get('USERS_DAYS_TILL_EXPIRY')) - 30; + const warnDate = dayjs(new Date()) + .subtract(warnDateNumber, 'days') + .toDate(); + const users = await this.prisma.userAccounts.findMany({ + include: { + jurisdictions: true, + }, + where: { + lastLoginAt: { lte: warnDate }, + userRoles: null, + wasWarnedOfDeletion: false, + }, + }); + this.logger.warn(`warning ${users.length} users of account deletion`); + for (const user of users) { + try { + await this.emailService.warnOfAccountRemoval(mapTo(User, user)); + await this.prisma.userAccounts.update({ + data: { wasWarnedOfDeletion: true }, + where: { id: user.id }, + }); + } catch (e) { + this.logger.error(e); + this.logger.error( + `warnUserOfDeletion email failed for user ${user.id}`, + ); + } + } + } else { + this.logger.warn( + 'USERS_DAYS_TILL_EXPIRY not set so warnUserOfDeletion cron job not run', + ); + return { + success: false, + }; + } + return { + success: true, + }; + } } diff --git a/api/src/utilities/application-export-helpers.ts b/api/src/utilities/application-export-helpers.ts index afd1154cc4..cbcdfb497d 100644 --- a/api/src/utilities/application-export-helpers.ts +++ b/api/src/utilities/application-export-helpers.ts @@ -8,7 +8,7 @@ import { MultiselectQuestion } from '../dtos/multiselect-questions/multiselect-q import { UnitType } from '../dtos/unit-types/unit-type.dto'; import { CsvHeader } from '../types/CsvExportInterface'; import { formatLocalDate } from '../utilities/format-local-date'; -import { User } from 'src/dtos/users/user.dto'; +import { User } from '../dtos/users/user.dto'; import { doAnyJurisdictionHaveFeatureFlagSet } from './feature-flag-utilities'; /** * diff --git a/api/src/utilities/build-order-by.ts b/api/src/utilities/build-order-by.ts index 2a755ff85e..de0ea6ce6a 100644 --- a/api/src/utilities/build-order-by.ts +++ b/api/src/utilities/build-order-by.ts @@ -1,7 +1,48 @@ +import { Prisma } from '@prisma/client'; import { ApplicationOrderByKeys } from '../enums/applications/order-by-enum'; import { ListingOrderByKeys } from '../enums/listings/order-by-enum'; +import { MultiselectQuestionOrderByKeys } from '../enums/multiselect-questions/order-by-enum'; import { OrderByEnum } from '../enums/shared/order-by-enum'; -import { Prisma } from '@prisma/client'; + +/* + 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], + })); +}; + +/* + 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[]; +}; /* Constructs the "orderBy" part of the prisma query and maps the values to @@ -44,6 +85,8 @@ export const buildOrderByForListings = ( return { marketingYear: orderDir[index] }; case ListingOrderByKeys.marketingSeason: return { marketingSeason: orderDir[index] }; + case ListingOrderByKeys.listingType: + return { listingType: orderDir[index] }; case ListingOrderByKeys.applicationDates: case undefined: // Default to ordering by applicationDates (i.e. applicationDueDate @@ -55,40 +98,30 @@ export const buildOrderByForListings = ( /* Constructs the "orderBy" part of the prisma query and maps the values to - the appropriate application field + the appropriate multiselectQuestion field */ -export const buildOrderByForApplications = ( +export const buildOrderByForMultiselectQuestions = ( orderBy?: string[], orderDir?: OrderByEnum[], -): Prisma.ApplicationsOrderByWithRelationInput[] => { +): Prisma.MultiselectQuestionsOrderByWithRelationInput[] => { if (!orderBy?.length || orderBy.length !== orderDir?.length) { return undefined; } + orderBy.push(ListingOrderByKeys.name); + orderDir.push(OrderByEnum.ASC); + 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 MultiselectQuestionOrderByKeys.jurisdiction: + return { jurisdiction: { name: orderDir[index] } }; + case MultiselectQuestionOrderByKeys.status: + return { status: orderDir[index] }; + case MultiselectQuestionOrderByKeys.updatedAt: + return { updatedAt: orderDir[index] }; + case ListingOrderByKeys.name: case undefined: - return { submissionDate: orderDir[index] }; + return { name: 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], - })); + }) as Prisma.MultiselectQuestionsOrderByWithRelationInput[]; }; diff --git a/api/src/utilities/cron-job-starter.ts b/api/src/utilities/cron-job-starter.ts deleted file mode 100644 index 2e83a8a1b2..0000000000 --- a/api/src/utilities/cron-job-starter.ts +++ /dev/null @@ -1,69 +0,0 @@ -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: CronJob = 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}`); - } - } - })(); - }, - undefined, - undefined, - process.env.TIME_ZONE, - ); - schedulerRegistry.addCronJob(cronName, job); - if (process.env.NODE_ENV !== 'test') { - job.start(); - } -}; diff --git a/api/src/utilities/feature-flag-utilities.ts b/api/src/utilities/feature-flag-utilities.ts index ae93efda3d..816ebcc5e2 100644 --- a/api/src/utilities/feature-flag-utilities.ts +++ b/api/src/utilities/feature-flag-utilities.ts @@ -12,6 +12,17 @@ export const doAnyJurisdictionHaveFeatureFlagSet = ( }); }; +export const doAllJurisdictionHaveFeatureFlagSet = ( + jurisdictions: Jurisdiction[], + featureFlagName: FeatureFlagEnum, +) => { + return jurisdictions.every((juris) => { + return juris.featureFlags.some( + (flag) => flag.name === featureFlagName && flag.active, + ); + }); +}; + export const doJurisdictionHaveFeatureFlagSet = ( jurisdiction: Jurisdiction, featureFlagName: FeatureFlagEnum, diff --git a/api/src/utilities/unit-groups-transformations.ts b/api/src/utilities/unit-groups-transformations.ts index 6128971fb3..8bc4673491 100644 --- a/api/src/utilities/unit-groups-transformations.ts +++ b/api/src/utilities/unit-groups-transformations.ts @@ -4,7 +4,11 @@ import { UnitGroupsSummarized } from '../dtos/unit-groups/unit-groups-summarized import { AmiChart } from '../dtos/ami-charts/ami-chart.dto'; import { UnitGroup } from '../dtos/unit-groups/unit-group.dto'; import { MinMax } from '../dtos/shared/min-max.dto'; -import { MonthlyRentDeterminationTypeEnum } from '@prisma/client'; +import { + ListingTypeEnum, + MonthlyRentDeterminationTypeEnum, + RentTypeEnum, +} from '@prisma/client'; import { AmiChartItem } from '../dtos/units/ami-chart-item.dto'; import Listing from '../dtos/listings/listing.dto'; import { usd } from './unit-utilities'; @@ -27,6 +31,7 @@ export const setMinMax = (range: MinMax, value: number): MinMax => { // Used to display the main pricing table export const getUnitGroupSummary = ( unitGroups: UnitGroup[] = [], + isNonRegulated?: boolean, ): UnitGroupSummary[] => { const summary: UnitGroupSummary[] = []; @@ -55,21 +60,37 @@ export const getUnitGroupSummary = ( let rentAsPercentIncomeRange: MinMax, rentRange: MinMax, amiPercentageRange: MinMax; - group.unitGroupAmiLevels.forEach((level) => { - if ( - level.monthlyRentDeterminationType === - MonthlyRentDeterminationTypeEnum.flatRent - ) { - rentRange = setMinMax(rentRange, level.flatRentValue); + + if (!isNonRegulated) { + group.unitGroupAmiLevels.forEach((level) => { + if ( + level.monthlyRentDeterminationType === + MonthlyRentDeterminationTypeEnum.flatRent + ) { + rentRange = setMinMax(rentRange, level.flatRentValue); + } else { + rentAsPercentIncomeRange = setMinMax( + rentAsPercentIncomeRange, + level.percentageOfIncomeValue, + ); + } + + amiPercentageRange = setMinMax(amiPercentageRange, level.amiPercentage); + }); + } else { + if (group.rentType === RentTypeEnum.fixedRent) { + rentRange = { + max: group.monthlyRent, + min: group.monthlyRent, + }; } else { - rentAsPercentIncomeRange = setMinMax( - rentAsPercentIncomeRange, - level.percentageOfIncomeValue, - ); + rentRange = { + min: group.flatRentValueFrom, + max: group.flatRentValueTo, + }; } + } - amiPercentageRange = setMinMax(amiPercentageRange, level.amiPercentage); - }); const groupSummary: UnitGroupSummary = { unitTypes: group.unitTypes.sort((a, b) => a.numBedrooms < b.numBedrooms ? -1 : 1, @@ -241,6 +262,7 @@ export const getHouseholdMaxIncomeSummary = ( export const summarizeUnitGroups = ( unitGroups: UnitGroup[] = [], amiCharts: AmiChart[] = [], + isNonRegulated?: boolean, ): UnitGroupsSummarized => { const data = {} as UnitGroupsSummarized; @@ -248,7 +270,7 @@ export const summarizeUnitGroups = ( return data; } - data.unitGroupSummary = getUnitGroupSummary(unitGroups); + data.unitGroupSummary = getUnitGroupSummary(unitGroups, isNonRegulated); data.householdMaxIncomeSummary = getHouseholdMaxIncomeSummary( unitGroups, amiCharts, @@ -286,6 +308,7 @@ export const addUnitGroupsSummarized = ( listing.unitGroupsSummarized = summarizeUnitGroups( listing.unitGroups, amiCharts, + listing.listingType === ListingTypeEnum.nonRegulated, ); } return listing; @@ -303,6 +326,7 @@ export const addUnitGroupsSummarized = ( listing.unitGroupsSummarized = summarizeUnitGroups( listing.unitGroups, amiCharts, + listing.listingType === ListingTypeEnum.nonRegulated, ); } }); diff --git a/api/src/validation-pipes/listing-create-update-pipe.ts b/api/src/validation-pipes/listing-create-update-pipe.ts index 8a9d2d6f26..41543ce1d7 100644 --- a/api/src/validation-pipes/listing-create-update-pipe.ts +++ b/api/src/validation-pipes/listing-create-update-pipe.ts @@ -46,10 +46,13 @@ export class ListingCreateUpdateValidationPipe extends ValidationPipe { }); } - // Get jurisdiction's required fields + // Get jurisdiction's listing configuration fields const jurisdiction = await this.prisma.jurisdictions.findFirst({ where: { id: value.jurisdictions.id }, - select: { requiredListingFields: true }, + select: { + requiredListingFields: true, + minimumListingPublishImagesRequired: true, + }, }); // Use jurisdiction's required fields, falling back to defaults if none specified @@ -57,14 +60,52 @@ export class ListingCreateUpdateValidationPipe extends ValidationPipe { ? jurisdiction.requiredListingFields : this.defaultRequiredFields; + const minimumImagesRequired = + jurisdiction?.minimumListingPublishImagesRequired || 0; + // Add required fields to the value being validated const transformedValue = { ...value, units: value.units || [], unitGroups: value.unitGroups || [], requiredFields, + minimumImagesRequired, }; + // Check for nested required fields + // Only works when `requiredFields` property is in nested object dto + const hasNestedRequired = requiredFields.some((f) => f.includes('.')); + if (hasNestedRequired) { + const relevantForPath = (path: string) => + (path + ? requiredFields.filter((f) => f.startsWith(`${path}.`)) + : requiredFields + ) + .map((f) => (path ? f.replace(`${path}.`, '') : f)) + .filter(Boolean); + + const injectRequiredFields = (node: any, path = '') => { + if (!node) return; + const currentRequired = relevantForPath(path); + if (currentRequired.length === 0 && path) return; + + if (Array.isArray(node)) { + node.forEach((item) => injectRequiredFields(item, path)); + return; + } + + if (typeof node === 'object') { + node.requiredFields ??= currentRequired; + Object.entries(node).forEach(([key, child]) => { + const childPath = path ? `${path}.${key}` : key; + injectRequiredFields(child, childPath); + }); + } + }; + + injectRequiredFields(transformedValue); + } + // Transform using the appropriate DTO with validation groups return await super.transform(transformedValue, { ...metadata, diff --git a/api/src/views/warn-removal.hbs b/api/src/views/warn-removal.hbs new file mode 100644 index 0000000000..85216dd3d4 --- /dev/null +++ b/api/src/views/warn-removal.hbs @@ -0,0 +1,46 @@ +{{#> layout_default }} + +

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

+ + + + + + +
+

+ {{t "accountRemoval.courtesyText"}} +

+
+ {{#if signInUrl}} + + + + + + +
+ + {{t "accountRemoval.signIn"}} + +
+ {{/if}} + + + + + + +
+

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

+
+ +{{/layout_default }} diff --git a/api/test/integration/application-flagged-set.e2e-spec.ts b/api/test/integration/application-flagged-set.e2e-spec.ts index 2fbae998a8..e8e7ffdc18 100644 --- a/api/test/integration/application-flagged-set.e2e-spec.ts +++ b/api/test/integration/application-flagged-set.e2e-spec.ts @@ -17,7 +17,7 @@ import { PrismaService } from '../../src/services/prisma.service'; import { jurisdictionFactory } from '../../prisma/seed-helpers/jurisdiction-factory'; import { listingFactory } from '../../prisma/seed-helpers/listing-factory'; import { applicationFactory } from '../../prisma/seed-helpers/application-factory'; -import { AfsQueryParams } from 'src/dtos/application-flagged-sets/afs-query-params.dto'; +import { AfsQueryParams } from '../../src/dtos/application-flagged-sets/afs-query-params.dto'; import { View } from '../../src/enums/application-flagged-sets/view'; import { AfsResolve } from '../../src/dtos/application-flagged-sets/afs-resolve.dto'; import { IdDTO } from '../../src/dtos/shared/id.dto'; @@ -2019,14 +2019,13 @@ describe('Application flagged set Controller Tests', () => { `${listing}-email1@email.com-${listing}-firstname3-${listing}-lastname3-3-3-3-${listing}-firstname1-${listing}-lastname1-1-1-1`, ); expect(combinationAFS.applications).toHaveLength(6); - expect(combinationAFS.applications).toEqual([ - { id: app2.id }, - { id: app3.id }, - { id: app4.id }, - { id: app6.id }, - { id: app7.id }, - { id: app8.id }, - ]); + expect(combinationAFS.applications).toContainEqual({ id: app2.id }); + expect(combinationAFS.applications).toContainEqual({ id: app3.id }); + expect(combinationAFS.applications).toContainEqual({ id: app4.id }); + expect(combinationAFS.applications).toContainEqual({ id: app6.id }); + expect(combinationAFS.applications).toContainEqual({ id: app7.id }); + expect(combinationAFS.applications).toContainEqual({ id: app8.id }); + expect(combinationAFS.rule).toEqual(RuleEnum.combination); expect(combinationAFS.ruleKey).toEqual( `${listing}-email1@email.com-${listing}-firstname3-${listing}-lastname3-3-3-3-${listing}-firstname1-${listing}-lastname1-1-1-1`, diff --git a/api/test/integration/application.e2e-spec.ts b/api/test/integration/application.e2e-spec.ts index ed64b65288..493bbdc532 100644 --- a/api/test/integration/application.e2e-spec.ts +++ b/api/test/integration/application.e2e-spec.ts @@ -1,5 +1,10 @@ +import cookieParser from 'cookie-parser'; +import { randomUUID } from 'crypto'; +import dayjs from 'dayjs'; +import { stringify } from 'qs'; +import request from 'supertest'; import { Test, TestingModule } from '@nestjs/testing'; -import { INestApplication } from '@nestjs/common'; +import { INestApplication, Logger } from '@nestjs/common'; import { ApplicationReviewStatusEnum, ApplicationStatusEnum, @@ -13,43 +18,44 @@ import { UnitTypeEnum, YesNoEnum, } from '@prisma/client'; -import { randomUUID } from 'crypto'; -import { stringify } from 'qs'; -import request from 'supertest'; -import cookieParser from 'cookie-parser'; -import { AppModule } from '../../src/modules/app.module'; -import { PrismaService } from '../../src/services/prisma.service'; +import { addressFactory } from '../../prisma/seed-helpers/address-factory'; import { applicationFactory } from '../../prisma/seed-helpers/application-factory'; +import { createAllFeatureFlags } from '../../prisma/seed-helpers/feature-flag-factory'; +import { jurisdictionFactory } from '../../prisma/seed-helpers/jurisdiction-factory'; +import { listingFactory } from '../../prisma/seed-helpers/listing-factory'; +import { multiselectQuestionFactory } from '../../prisma/seed-helpers/multiselect-question-factory'; +import { reservedCommunityTypeFactoryAll } from '../../prisma/seed-helpers/reserved-community-type-factory'; +import { translationFactory } from '../../prisma/seed-helpers/translation-factory'; +import { userFactory } from '../../prisma/seed-helpers/user-factory'; import { unitTypeFactoryAll, unitTypeFactorySingle, } from '../../prisma/seed-helpers/unit-type-factory'; -import { ApplicationQueryParams } from '../../src/dtos/applications/application-query-params.dto'; -import { OrderByEnum } from '../../src/enums/shared/order-by-enum'; -import { ApplicationOrderByKeys } from '../../src/enums/applications/order-by-enum'; -import { listingFactory } from '../../prisma/seed-helpers/listing-factory'; -import { jurisdictionFactory } from '../../prisma/seed-helpers/jurisdiction-factory'; -import { ApplicationCreate } from '../../src/dtos/applications/application-create.dto'; -import { InputType } from '../../src/enums/shared/input-type-enum'; -import { addressFactory } from '../../prisma/seed-helpers/address-factory'; import { AddressCreate } from '../../src/dtos/addresses/address-create.dto'; +import { ApplicationCreate } from '../../src/dtos/applications/application-create.dto'; +import { ApplicationMultiselectQuestion } from '../../src/dtos/applications/application-multiselect-question.dto'; +import { ApplicationQueryParams } from '../../src/dtos/applications/application-query-params.dto'; +import { ApplicationSelectionCreate } from '../../src/dtos/applications/application-selection-create.dto'; import { ApplicationUpdate } from '../../src/dtos/applications/application-update.dto'; -import { translationFactory } from '../../prisma/seed-helpers/translation-factory'; -import { EmailService } from '../../src/services/email.service'; -import { userFactory } from '../../prisma/seed-helpers/user-factory'; +import { PublicAppsViewQueryParams } from '../../src/dtos/applications/public-apps-view-params.dto'; import { Login } from '../../src/dtos/auth/login.dto'; -import { multiselectQuestionFactory } from '../../prisma/seed-helpers/multiselect-question-factory'; -import { reservedCommunityTypeFactoryAll } from '../../prisma/seed-helpers/reserved-community-type-factory'; -import { ValidationMethod } from '../../src/enums/multiselect-questions/validation-method-enum'; import { AlternateContactRelationship } from '../../src/enums/applications/alternate-contact-relationship-enum'; -import { HouseholdMemberRelationship } from '../../src/enums/applications/household-member-relationship-enum'; +import { FeatureFlagEnum } from '../../src/enums/feature-flags/feature-flags-enum'; import { ApplicationsFilterEnum } from '../../src/enums/applications/filter-enum'; -import { PublicAppsViewQueryParams } from '../../src/dtos/applications/public-apps-view-params.dto'; +import { HouseholdMemberRelationship } from '../../src/enums/applications/household-member-relationship-enum'; +import { ApplicationOrderByKeys } from '../../src/enums/applications/order-by-enum'; +import { ValidationMethod } from '../../src/enums/multiselect-questions/validation-method-enum'; +import { OrderByEnum } from '../../src/enums/shared/order-by-enum'; +import { InputType } from '../../src/enums/shared/input-type-enum'; +import { AppModule } from '../../src/modules/app.module'; +import { EmailService } from '../../src/services/email.service'; +import { PrismaService } from '../../src/services/prisma.service'; describe('Application Controller Tests', () => { let app: INestApplication; let prisma: PrismaService; let adminCookies = ''; + let logger: Logger; const testEmailService = { /* eslint-disable @typescript-eslint/no-empty-function */ @@ -65,21 +71,127 @@ describe('Application Controller Tests', () => { jurisdictionId: string, listingId: string, section: MultiselectQuestionsApplicationSectionEnum, + version2 = false, ) => { - const res = await prisma.multiselectQuestions.create({ - data: multiselectQuestionFactory(jurisdictionId, { - multiselectQuestion: { - applicationSection: section, - listings: { - create: { - listingId: listingId, + return await prisma.multiselectQuestions.create({ + data: multiselectQuestionFactory( + jurisdictionId, + { + multiselectQuestion: { + applicationSection: section, + listings: { + create: { + listingId: listingId, + }, }, }, }, - }), + version2, + ), + include: { + multiselectOptions: true, + }, }); + }; - return res.id; + const applicationCreate = ( + exampleAddress: AddressCreate, + listingId: string, + submissionDate: Date, + unitTypeId: string, + applicationSelections?: ApplicationSelectionCreate[], + preferences?: ApplicationMultiselectQuestion[], + programs?: ApplicationMultiselectQuestion[], + submissionType: ApplicationSubmissionTypeEnum = ApplicationSubmissionTypeEnum.electronical, + ) => { + return { + acceptedTerms: true, + additionalPhone: true, + additionalPhoneNumber: '111-111-1111', + additionalPhoneNumberType: 'example type', + appUrl: 'http://www.example.com', + householdSize: 2, + housingStatus: 'example status', + householdExpectingChanges: false, + householdStudent: false, + incomeVouchers: false, + income: '36000', + incomePeriod: IncomePeriodEnum.perYear, + language: LanguagesEnum.en, + reviewStatus: ApplicationReviewStatusEnum.valid, + sendMailToMailingAddress: true, + status: ApplicationStatusEnum.submitted, + submissionDate: submissionDate, + submissionType: submissionType, + accessibility: { + mobility: false, + vision: false, + hearing: false, + }, + alternateContact: { + type: AlternateContactRelationship.friend, + otherType: 'example other type', + firstName: 'example first name', + lastName: 'example last name', + agency: 'example agency', + phoneNumber: '111-111-1111', + emailAddress: 'example@email.com', + address: exampleAddress, + }, + applicant: { + firstName: 'applicant first name', + middleName: 'applicant middle name', + lastName: 'applicant last name', + birthMonth: '12', + birthDay: '17', + birthYear: '1993', + emailAddress: 'example@email.com', + noEmail: false, + phoneNumber: '111-111-1111', + phoneNumberType: 'Cell', + noPhone: false, + workInRegion: YesNoEnum.yes, + applicantWorkAddress: exampleAddress, + applicantAddress: exampleAddress, + }, + applicationSelections: applicationSelections ?? [], + applicationsAlternateAddress: exampleAddress, + applicationsMailingAddress: exampleAddress, + contactPreferences: ['example contact preference'], + demographics: { + ethnicity: 'example ethnicity', + gender: 'example gender', + sexualOrientation: 'example sexual orientation', + howDidYouHear: ['example how did you hear'], + race: ['example race'], + }, + householdMember: [ + { + orderId: 0, + firstName: 'example first name', + middleName: 'example middle name', + lastName: 'example last name', + birthMonth: '12', + birthDay: '17', + birthYear: '1993', + sameAddress: YesNoEnum.yes, + relationship: HouseholdMemberRelationship.friend, + workInRegion: YesNoEnum.yes, + householdMemberWorkAddress: exampleAddress, + householdMemberAddress: exampleAddress, + }, + ], + listings: { + id: listingId, + }, + preferences: preferences ?? [], + preferredUnitTypes: [ + { + id: unitTypeId, + }, + ], + programs: programs ?? [], + }; }; beforeAll(async () => { @@ -88,12 +200,20 @@ describe('Application Controller Tests', () => { }) .overrideProvider(EmailService) .useValue(testEmailService) + .overrideProvider(Logger) + .useValue({ + log: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }) .compile(); app = moduleFixture.createNestApplication(); prisma = moduleFixture.get(PrismaService); + logger = moduleFixture.get(Logger); app.use(cookieParser()); await app.init(); + await createAllFeatureFlags(prisma); await unitTypeFactoryAll(prisma); await prisma.translations.create({ data: translationFactory(), @@ -243,7 +363,7 @@ describe('Application Controller Tests', () => { }); }); - describe('retreive endpoint', () => { + describe('retrieve endpoint', () => { it('should retrieve an application when one exists', async () => { const unitTypeA = await unitTypeFactorySingle( prisma, @@ -347,9 +467,13 @@ describe('Application Controller Tests', () => { }); }); - describe('submit endpoint', () => { + describe('submit endpoint with MSQ V1', () => { let publicUserCookies = ''; let storedUser = { id: '', email: '' }; + let unitTypeA; + let jurisdiction; + let listing1; + beforeAll(async () => { storedUser = await prisma.userAccounts.create({ data: await userFactory({ @@ -367,61 +491,131 @@ describe('Application Controller Tests', () => { .expect(201); publicUserCookies = resLogIn.headers['set-cookie']; - }); - it('should create application from public site', async () => { - const unitTypeA = await unitTypeFactorySingle( - prisma, - UnitTypeEnum.oneBdrm, - ); - const jurisdiction = await prisma.jurisdictions.create({ + unitTypeA = await unitTypeFactorySingle(prisma, UnitTypeEnum.oneBdrm); + jurisdiction = await prisma.jurisdictions.create({ data: jurisdictionFactory(), }); await reservedCommunityTypeFactoryAll(jurisdiction.id, prisma); - const listing1 = await listingFactory(jurisdiction.id, prisma, { - digitalApp: true, - }); - const listing1Created = await prisma.listings.create({ - data: listing1, + listing1 = await prisma.listings.create({ + data: await listingFactory(jurisdiction.id, prisma, { + digitalApp: true, + }), }); + }); - const multiselectQuestionProgram = await createMultiselectQuestion( - jurisdiction.id, - listing1Created.id, - MultiselectQuestionsApplicationSectionEnum.programs, - ); - const multiselectQuestionPreference = await createMultiselectQuestion( - jurisdiction.id, - listing1Created.id, - MultiselectQuestionsApplicationSectionEnum.preferences, - ); + it('should create application from public site', async () => { + const multiselectQuestionPreferenceId = ( + await createMultiselectQuestion( + jurisdiction.id, + listing1.id, + MultiselectQuestionsApplicationSectionEnum.preferences, + ) + )?.id; + const multiselectQuestionProgramId = ( + await createMultiselectQuestion( + jurisdiction.id, + listing1.id, + MultiselectQuestionsApplicationSectionEnum.programs, + ) + ).id; - const submissionDate = new Date(); const exampleAddress = addressFactory() as AddressCreate; - const dto: ApplicationCreate = { - contactPreferences: ['example contact preference'], - preferences: [ - { - multiselectQuestionId: multiselectQuestionPreference, - key: 'example key', - claimed: true, - options: [ - { - key: 'example key', - checked: true, - extraData: [ - { - type: InputType.boolean, - key: 'example key', - value: true, - }, - ], - }, - ], + const preferences = [ + { + multiselectQuestionId: multiselectQuestionPreferenceId, + key: 'example key', + claimed: true, + options: [ + { + key: 'example key', + checked: true, + extraData: [ + { + type: InputType.boolean, + key: 'example key', + value: true, + }, + ], + }, + ], + }, + ]; + + const programs = [ + { + multiselectQuestionId: multiselectQuestionProgramId, + key: 'example key', + claimed: true, + options: [ + { + key: 'example key', + checked: true, + extraData: [ + { + type: InputType.boolean, + key: 'example key', + value: true, + }, + ], + }, + ], + }, + ]; + const submissionDate = new Date(); + const dto: ApplicationCreate = applicationCreate( + exampleAddress, + listing1.id, + submissionDate, + unitTypeA.id, + [], + preferences, + programs, + ); + const res = await request(app.getHttpServer()) + .post(`/applications/submit`) + .set({ passkey: process.env.API_PASS_KEY || '' }) + .send(dto) + .set('Cookie', publicUserCookies) + .expect(201); + + expect(res.body.id).not.toBeNull(); + expect(res.body).toEqual({ + ...dto, + id: expect.any(String), + createdAt: expect.any(String), + updatedAt: expect.any(String), + deletedAt: null, + confirmationCode: expect.any(String), + accessibleUnitWaitlistNumber: null, + conventionalUnitWaitlistNumber: null, + isNewest: true, + markedAsDuplicate: false, + manualLotteryPositionNumber: null, + submissionDate: expect.any(String), + accessibility: { + id: expect.any(String), + mobility: false, + vision: false, + hearing: false, + other: null, + }, + alternateContact: { + id: expect.any(String), + type: 'friend', + otherType: 'example other type', + firstName: 'example first name', + lastName: 'example last name', + agency: 'example agency', + phoneNumber: '111-111-1111', + emailAddress: 'example@email.com', + address: { + ...exampleAddress, + id: expect.any(String), + street2: null, }, - ], - status: ApplicationStatusEnum.submitted, - submissionType: ApplicationSubmissionTypeEnum.electronical, + }, applicant: { + id: expect.any(String), firstName: 'applicant first name', middleName: 'applicant middle name', lastName: 'applicant last name', @@ -433,170 +627,90 @@ describe('Application Controller Tests', () => { phoneNumber: '111-111-1111', phoneNumberType: 'Cell', noPhone: false, - workInRegion: YesNoEnum.yes, - applicantWorkAddress: exampleAddress, - applicantAddress: exampleAddress, - }, - accessibility: { - mobility: false, - vision: false, - hearing: false, + workInRegion: 'yes', + fullTimeStudent: null, + applicantWorkAddress: { + ...exampleAddress, + id: expect.any(String), + street2: null, + }, + applicantAddress: { + ...exampleAddress, + id: expect.any(String), + street2: null, + }, }, - alternateContact: { - type: AlternateContactRelationship.friend, - otherType: 'example other type', - firstName: 'example first name', - lastName: 'example last name', - agency: 'example agency', - phoneNumber: '111-111-1111', - emailAddress: 'example@email.com', - address: exampleAddress, + applicationSelections: [], + applicationsAlternateAddress: { + ...exampleAddress, + id: expect.any(String), + street2: null, }, - applicationsAlternateAddress: exampleAddress, - applicationsMailingAddress: exampleAddress, - listings: { - id: listing1Created.id, + applicationsMailingAddress: { + ...exampleAddress, + id: expect.any(String), + street2: null, }, demographics: { + id: expect.any(String), + createdAt: expect.any(String), + updatedAt: expect.any(String), ethnicity: 'example ethnicity', gender: 'example gender', sexualOrientation: 'example sexual orientation', howDidYouHear: ['example how did you hear'], race: ['example race'], }, - preferredUnitTypes: [ - { - id: unitTypeA.id, - }, - ], householdMember: [ { - orderId: 0, - firstName: 'example first name', - middleName: 'example middle name', - lastName: 'example last name', - birthMonth: '12', birthDay: '17', + birthMonth: '12', birthYear: '1993', - sameAddress: YesNoEnum.yes, - relationship: HouseholdMemberRelationship.friend, - workInRegion: YesNoEnum.yes, - householdMemberWorkAddress: exampleAddress, - householdMemberAddress: exampleAddress, + firstName: 'example first name', + fullTimeStudent: null, + householdMemberAddress: { + ...exampleAddress, + id: expect.any(String), + street2: null, + }, + householdMemberWorkAddress: { + ...exampleAddress, + id: expect.any(String), + street2: null, + }, + id: expect.any(String), + lastName: 'example last name', + middleName: 'example middle name', + orderId: 0, + relationship: 'friend', + sameAddress: 'yes', + workInRegion: 'yes', }, ], - appUrl: 'http://www.example.com', - additionalPhone: true, - additionalPhoneNumber: '111-111-1111', - additionalPhoneNumberType: 'example type', - householdSize: 2, - housingStatus: 'example status', - sendMailToMailingAddress: true, - householdExpectingChanges: false, - householdStudent: false, - incomeVouchers: false, - income: '36000', - incomePeriod: IncomePeriodEnum.perYear, - language: LanguagesEnum.en, - acceptedTerms: true, - submissionDate: submissionDate, - reviewStatus: ApplicationReviewStatusEnum.valid, - programs: [ + listings: { + id: listing1.id, + name: listing1.name, + }, + preferences: [ { - multiselectQuestionId: multiselectQuestionProgram, - key: 'example key', claimed: true, + key: 'example key', + multiselectQuestionId: expect.any(String), options: [ { - key: 'example key', checked: true, extraData: [ { - type: InputType.boolean, key: 'example key', + type: 'boolean', value: true, }, ], + key: 'example key', }, ], }, ], - }; - const res = await request(app.getHttpServer()) - .post(`/applications/submit`) - .set({ passkey: process.env.API_PASS_KEY || '' }) - .send(dto) - .set('Cookie', publicUserCookies) - .expect(201); - - expect(res.body.id).not.toBeNull(); - expect(res.body).toEqual({ - id: expect.any(String), - createdAt: expect.any(String), - updatedAt: expect.any(String), - deletedAt: null, - appUrl: 'http://www.example.com', - additionalPhone: true, - additionalPhoneNumber: '111-111-1111', - additionalPhoneNumberType: 'example type', - contactPreferences: ['example contact preference'], - householdSize: 2, - housingStatus: 'example status', - sendMailToMailingAddress: true, - householdExpectingChanges: false, - householdStudent: false, - incomeVouchers: false, - income: '36000', - incomePeriod: 'perYear', - status: 'submitted', - language: 'en', - acceptedTerms: true, - submissionType: 'electronical', - submissionDate: expect.any(String), - markedAsDuplicate: false, - confirmationCode: expect.any(String), - reviewStatus: 'valid', - applicationsMailingAddress: { - id: expect.any(String), - placeName: exampleAddress.placeName, - city: exampleAddress.city, - county: exampleAddress.county, - state: exampleAddress.state, - street: exampleAddress.street, - street2: null, - zipCode: exampleAddress.zipCode, - latitude: exampleAddress.latitude, - longitude: exampleAddress.longitude, - }, - applicationsAlternateAddress: { - id: expect.any(String), - placeName: exampleAddress.placeName, - city: exampleAddress.city, - county: exampleAddress.county, - state: exampleAddress.state, - street: exampleAddress.street, - street2: null, - zipCode: exampleAddress.zipCode, - latitude: exampleAddress.latitude, - longitude: exampleAddress.longitude, - }, - accessibility: { - id: expect.any(String), - mobility: false, - vision: false, - hearing: false, - other: null, - }, - demographics: { - id: expect.any(String), - createdAt: expect.any(String), - updatedAt: expect.any(String), - ethnicity: 'example ethnicity', - gender: 'example gender', - sexualOrientation: 'example sexual orientation', - howDidYouHear: ['example how did you hear'], - race: ['example race'], - }, preferredUnitTypes: [ { id: unitTypeA.id, @@ -604,128 +718,6 @@ describe('Application Controller Tests', () => { numBedrooms: 1, }, ], - applicant: { - id: expect.any(String), - firstName: 'applicant first name', - middleName: 'applicant middle name', - lastName: 'applicant last name', - birthMonth: '12', - birthDay: '17', - birthYear: '1993', - emailAddress: 'example@email.com', - noEmail: false, - phoneNumber: '111-111-1111', - phoneNumberType: 'Cell', - noPhone: false, - workInRegion: 'yes', - fullTimeStudent: null, - applicantWorkAddress: { - id: expect.any(String), - placeName: exampleAddress.placeName, - city: exampleAddress.city, - county: exampleAddress.county, - state: exampleAddress.state, - street: exampleAddress.street, - street2: null, - zipCode: exampleAddress.zipCode, - latitude: exampleAddress.latitude, - longitude: exampleAddress.longitude, - }, - applicantAddress: { - id: expect.any(String), - placeName: exampleAddress.placeName, - city: exampleAddress.city, - county: exampleAddress.county, - state: exampleAddress.state, - street: exampleAddress.street, - street2: null, - zipCode: exampleAddress.zipCode, - latitude: exampleAddress.latitude, - longitude: exampleAddress.longitude, - }, - }, - alternateContact: { - id: expect.any(String), - type: 'friend', - otherType: 'example other type', - firstName: 'example first name', - lastName: 'example last name', - agency: 'example agency', - phoneNumber: '111-111-1111', - emailAddress: 'example@email.com', - address: { - id: expect.any(String), - placeName: exampleAddress.placeName, - city: exampleAddress.city, - county: exampleAddress.county, - state: exampleAddress.state, - street: exampleAddress.street, - street2: null, - zipCode: exampleAddress.zipCode, - latitude: exampleAddress.latitude, - longitude: exampleAddress.longitude, - }, - }, - householdMember: [ - { - birthDay: '17', - birthMonth: '12', - birthYear: '1993', - firstName: 'example first name', - fullTimeStudent: null, - householdMemberAddress: { - id: expect.any(String), - placeName: exampleAddress.placeName, - city: exampleAddress.city, - county: exampleAddress.county, - state: exampleAddress.state, - street: exampleAddress.street, - street2: null, - zipCode: exampleAddress.zipCode, - latitude: exampleAddress.latitude, - longitude: exampleAddress.longitude, - }, - householdMemberWorkAddress: { - id: expect.any(String), - placeName: exampleAddress.placeName, - city: exampleAddress.city, - county: exampleAddress.county, - state: exampleAddress.state, - street: exampleAddress.street, - street2: null, - zipCode: exampleAddress.zipCode, - latitude: exampleAddress.latitude, - longitude: exampleAddress.longitude, - }, - id: expect.any(String), - lastName: 'example last name', - middleName: 'example middle name', - orderId: 0, - relationship: 'friend', - sameAddress: 'yes', - workInRegion: 'yes', - }, - ], - preferences: [ - { - claimed: true, - key: 'example key', - multiselectQuestionId: expect.any(String), - options: [ - { - checked: true, - extraData: [ - { - key: 'example key', - type: 'boolean', - value: true, - }, - ], - key: 'example key', - }, - ], - }, - ], programs: [ { claimed: true, @@ -746,69 +738,309 @@ describe('Application Controller Tests', () => { ], }, ], - listings: { - id: listing1Created.id, - name: listing1.name, - }, - isNewest: true, }); expect(mockApplicationConfirmation).toBeCalledTimes(1); }); it('should throw an error when submitting an application from the public site on a listing with no common app', async () => { - const unitTypeA = await unitTypeFactorySingle( - prisma, - UnitTypeEnum.oneBdrm, + const listingNoDigitalApp = await prisma.listings.create({ + data: await listingFactory(jurisdiction.id, prisma, { + digitalApp: false, + }), + }); + + const exampleAddress = addressFactory() as AddressCreate; + const submissionDate = new Date(); + + const dto: ApplicationCreate = applicationCreate( + exampleAddress, + listingNoDigitalApp.id, + submissionDate, + unitTypeA.id, ); - const jurisdiction = await prisma.jurisdictions.create({ - data: jurisdictionFactory(), + const res = await request(app.getHttpServer()) + .post(`/applications/submit`) + .set({ passkey: process.env.API_PASS_KEY || '' }) + .send(dto) + .set('Cookie', publicUserCookies) + .expect(400); + expect(res.body.message).toEqual( + `Listing is not open for application submission`, + ); + }); + + it('should set the isNewest flag', async () => { + const listing2 = await prisma.listings.create({ + data: await listingFactory(jurisdiction.id, prisma, { + digitalApp: true, + }), }); - await reservedCommunityTypeFactoryAll(jurisdiction.id, prisma); - const listing1 = await listingFactory(jurisdiction.id, prisma, { - digitalApp: false, + + // create previous applications for the user with one being the newest + await prisma.applications.create({ + data: { + listings: { connect: { id: listing2.id } }, + confirmationCode: randomUUID(), + preferences: [], + submissionType: ApplicationSubmissionTypeEnum.electronical, + status: ApplicationStatusEnum.submitted, + isNewest: true, + }, }); - const listing1Created = await prisma.listings.create({ - data: listing1, + + await prisma.applications.create({ + data: { + listings: { connect: { id: listing2.id } }, + confirmationCode: randomUUID(), + preferences: [], + submissionType: ApplicationSubmissionTypeEnum.electronical, + status: ApplicationStatusEnum.submitted, + isNewest: false, + }, }); - const multiselectQuestionProgram = await createMultiselectQuestion( - jurisdiction.id, - listing1Created.id, - MultiselectQuestionsApplicationSectionEnum.programs, + const exampleAddress = addressFactory() as AddressCreate; + const submissionDate = new Date(); + + const dto: ApplicationCreate = applicationCreate( + exampleAddress, + listing1.id, + submissionDate, + unitTypeA.id, + ); + const res = await request(app.getHttpServer()) + .post(`/applications/submit`) + .set({ passkey: process.env.API_PASS_KEY || '' }) + .send(dto) + .set('Cookie', publicUserCookies) + .expect(201); + + expect(res.body.id).not.toBeNull(); + expect(res.body.isNewest).toBe(true); + expect(mockApplicationConfirmation).toBeCalledTimes(1); + + const otherUserApplications = await prisma.applications.findMany({ + select: { id: true, isNewest: true }, + where: { userId: storedUser.id, id: { not: res.body.id } }, + }); + otherUserApplications.forEach((application) => { + expect(application.isNewest).toBe(false); + }); + }); + + it('should calculate geocoding on application', async () => { + const exampleAddress = addressFactory() as AddressCreate; + + const listingGeocoding = await prisma.listings.create({ + data: await listingFactory(jurisdiction.id, prisma, { + digitalApp: true, + listing: { + listingsBuildingAddress: { create: exampleAddress }, + } as unknown as Prisma.ListingsCreateInput, + }), + }); + const multiselectQuestionPreference = + await prisma.multiselectQuestions.create({ + data: multiselectQuestionFactory(jurisdiction.id, { + multiselectQuestion: { + applicationSection: + MultiselectQuestionsApplicationSectionEnum.preferences, + listings: { + create: { + listingId: listingGeocoding.id, + }, + }, + options: [ + { + text: 'geocoding preference', + collectAddress: true, + validationMethod: ValidationMethod.radius, + radiusSize: 5, + }, + ], + }, + }), + }); + + const preferences = [ + { + multiselectQuestionId: multiselectQuestionPreference.id, + key: multiselectQuestionPreference.text, + claimed: true, + options: [ + { + key: 'geocoding preference', + checked: true, + extraData: [ + { + type: InputType.address, + key: 'address', + value: exampleAddress, + }, + ], + }, + ], + }, + ]; + const submissionDate = new Date(); + + const dto: ApplicationCreate = applicationCreate( + exampleAddress, + listingGeocoding.id, + submissionDate, + unitTypeA.id, + [], + preferences, ); - const multiselectQuestionPreference = await createMultiselectQuestion( + const res = await request(app.getHttpServer()) + .post(`/applications/submit`) + .set({ passkey: process.env.API_PASS_KEY || '' }) + .send(dto) + .expect(201); + + expect(res.body.id).not.toBeNull(); + + let savedApplication = await prisma.applications.findMany({ + where: { + id: res.body.id, + }, + }); + const savedPreferences = savedApplication[0].preferences; + expect(savedPreferences).toHaveLength(1); + let geocodingOptions = savedPreferences[0].options[0]; + // This catches the edge case where the geocoding hasn't completed yet + if (geocodingOptions.extraData.length === 1) { + // I'm unsure why removing this console log makes this test fail. This should be looked into + console.log(''); + savedApplication = await prisma.applications.findMany({ + where: { + id: res.body.id, + }, + }); + } + geocodingOptions = savedApplication[0].preferences[0].options[0]; + expect(geocodingOptions.extraData).toHaveLength(2); + expect(geocodingOptions.extraData).toContainEqual({ + key: 'geocodingVerified', + type: 'text', + value: true, + }); + }); + }); + + describe('submit endpoint with MSQ V2', () => { + let publicUserCookies = ''; + let storedUser = { id: '', email: '' }; + let unitTypeA; + let jurisdiction; + let listing1; + + beforeAll(async () => { + storedUser = await prisma.userAccounts.create({ + data: await userFactory({ + mfaEnabled: false, + confirmedAt: new Date(), + }), + }); + const resLogIn = await request(app.getHttpServer()) + .post('/auth/login') + .set({ passkey: process.env.API_PASS_KEY || '' }) + .send({ + email: storedUser.email, + password: 'Abcdef12345!', + } as Login) + .expect(201); + + publicUserCookies = resLogIn.headers['set-cookie']; + unitTypeA = await unitTypeFactorySingle(prisma, UnitTypeEnum.oneBdrm); + jurisdiction = await prisma.jurisdictions.create({ + data: jurisdictionFactory('applicationSubmitWithV2MSQ', { + featureFlags: [FeatureFlagEnum.enableV2MSQ], + }), + }); + await reservedCommunityTypeFactoryAll(jurisdiction.id, prisma); + listing1 = await prisma.listings.create({ + data: await listingFactory(jurisdiction.id, prisma, { + digitalApp: true, + }), + }); + }); + + it('should create application from public site', async () => { + const multiselectQuestion = await createMultiselectQuestion( jurisdiction.id, - listing1Created.id, + listing1.id, MultiselectQuestionsApplicationSectionEnum.preferences, + true, ); - - const submissionDate = new Date(); const exampleAddress = addressFactory() as AddressCreate; - const dto: ApplicationCreate = { - contactPreferences: ['example contact preference'], - preferences: [ - { - multiselectQuestionId: multiselectQuestionPreference, - key: 'example key', - claimed: true, - options: [ - { - key: 'example key', - checked: true, - extraData: [ - { - type: InputType.boolean, - key: 'example key', - value: true, - }, - ], + const submissionDate = new Date(); + const applicationSelections = [ + { + multiselectQuestion: { id: multiselectQuestion.id }, + selections: [ + { + addressHolderAddress: exampleAddress, + multiselectOption: { + id: multiselectQuestion.multiselectOptions[0].id, }, - ], + }, + ], + }, + ]; + + const dto: ApplicationCreate = applicationCreate( + exampleAddress, + listing1.id, + submissionDate, + unitTypeA.id, + applicationSelections, + ); + const res = await request(app.getHttpServer()) + .post(`/applications/submit`) + .set({ passkey: process.env.API_PASS_KEY || '' }) + .send(dto) + .set('Cookie', publicUserCookies) + .expect(201); + + expect(res.body.id).not.toBeNull(); + expect(res.body).toEqual({ + ...dto, + id: expect.any(String), + createdAt: expect.any(String), + updatedAt: expect.any(String), + deletedAt: null, + confirmationCode: expect.any(String), + accessibleUnitWaitlistNumber: null, + conventionalUnitWaitlistNumber: null, + isNewest: true, + markedAsDuplicate: false, + manualLotteryPositionNumber: null, + submissionDate: expect.any(String), + accessibility: { + id: expect.any(String), + mobility: false, + vision: false, + hearing: false, + other: null, + }, + alternateContact: { + id: expect.any(String), + type: 'friend', + otherType: 'example other type', + firstName: 'example first name', + lastName: 'example last name', + agency: 'example agency', + phoneNumber: '111-111-1111', + emailAddress: 'example@email.com', + address: { + ...exampleAddress, + id: expect.any(String), + street2: null, }, - ], - status: ApplicationStatusEnum.submitted, - submissionType: ApplicationSubmissionTypeEnum.electronical, + }, applicant: { + id: expect.any(String), firstName: 'applicant first name', middleName: 'applicant middle name', lastName: 'applicant last name', @@ -820,95 +1052,129 @@ describe('Application Controller Tests', () => { phoneNumber: '111-111-1111', phoneNumberType: 'Cell', noPhone: false, - workInRegion: YesNoEnum.yes, - applicantWorkAddress: exampleAddress, - applicantAddress: exampleAddress, - }, - accessibility: { - mobility: false, - vision: false, - hearing: false, + workInRegion: 'yes', + fullTimeStudent: null, + applicantWorkAddress: { + ...exampleAddress, + id: expect.any(String), + street2: null, + }, + applicantAddress: { + ...exampleAddress, + id: expect.any(String), + street2: null, + }, }, - alternateContact: { - type: AlternateContactRelationship.friend, - otherType: 'example other type', - firstName: 'example first name', - lastName: 'example last name', - agency: 'example agency', - phoneNumber: '111-111-1111', - emailAddress: 'example@email.com', - address: exampleAddress, + applicationSelections: [ + { + id: expect.any(String), + createdAt: expect.any(String), + updatedAt: expect.any(String), + hasOptedOut: false, + multiselectQuestion: { + id: multiselectQuestion.id, + name: multiselectQuestion.name, + }, + selections: [ + { + id: expect.any(String), + createdAt: expect.any(String), + updatedAt: expect.any(String), + addressHolderAddress: { + ...exampleAddress, + id: expect.any(String), + street2: null, + }, + addressHolderName: null, + addressHolderRelationship: null, + isGeocodingVerified: null, + multiselectOption: { + id: multiselectQuestion.multiselectOptions[0].id, + name: multiselectQuestion.multiselectOptions[0].name, + ordinal: multiselectQuestion.multiselectOptions[0].ordinal, + }, + }, + ], + }, + ], + applicationsAlternateAddress: { + ...exampleAddress, + id: expect.any(String), + street2: null, }, - applicationsAlternateAddress: exampleAddress, - applicationsMailingAddress: exampleAddress, - listings: { - id: listing1Created.id, + applicationsMailingAddress: { + ...exampleAddress, + id: expect.any(String), + street2: null, }, demographics: { + id: expect.any(String), + createdAt: expect.any(String), + updatedAt: expect.any(String), ethnicity: 'example ethnicity', gender: 'example gender', sexualOrientation: 'example sexual orientation', howDidYouHear: ['example how did you hear'], race: ['example race'], }, - preferredUnitTypes: [ - { - id: unitTypeA.id, - }, - ], householdMember: [ { - orderId: 0, - firstName: 'example first name', - middleName: 'example middle name', - lastName: 'example last name', - birthMonth: '12', birthDay: '17', + birthMonth: '12', birthYear: '1993', - sameAddress: YesNoEnum.yes, - relationship: HouseholdMemberRelationship.friend, - workInRegion: YesNoEnum.yes, - householdMemberWorkAddress: exampleAddress, - householdMemberAddress: exampleAddress, - }, - ], - appUrl: 'http://www.example.com', - additionalPhone: true, - additionalPhoneNumber: '111-111-1111', - additionalPhoneNumberType: 'example type', - householdSize: 2, - housingStatus: 'example status', - sendMailToMailingAddress: true, - householdExpectingChanges: false, - householdStudent: false, - incomeVouchers: false, - income: '36000', - incomePeriod: IncomePeriodEnum.perYear, - language: LanguagesEnum.en, - acceptedTerms: true, - submissionDate: submissionDate, - reviewStatus: ApplicationReviewStatusEnum.valid, - programs: [ - { - multiselectQuestionId: multiselectQuestionProgram, - key: 'example key', - claimed: true, - options: [ - { - key: 'example key', - checked: true, - extraData: [ - { - type: InputType.boolean, - key: 'example key', - value: true, - }, - ], - }, - ], + firstName: 'example first name', + fullTimeStudent: null, + householdMemberAddress: { + ...exampleAddress, + id: expect.any(String), + street2: null, + }, + householdMemberWorkAddress: { + ...exampleAddress, + id: expect.any(String), + street2: null, + }, + id: expect.any(String), + lastName: 'example last name', + middleName: 'example middle name', + orderId: 0, + relationship: 'friend', + sameAddress: 'yes', + workInRegion: 'yes', }, ], - }; + listings: { + id: listing1.id, + name: listing1.name, + }, + preferences: [], + preferredUnitTypes: [ + { + id: unitTypeA.id, + name: 'oneBdrm', + numBedrooms: 1, + }, + ], + programs: [], + }); + expect(mockApplicationConfirmation).toBeCalledTimes(1); + }); + + it('should throw an error when submitting an application from the public site on a listing with no common app', async () => { + const listingNoDigitalApp = await prisma.listings.create({ + data: await listingFactory(jurisdiction.id, prisma, { + digitalApp: false, + }), + }); + + const submissionDate = new Date(); + const exampleAddress = addressFactory() as AddressCreate; + const dto: ApplicationCreate = applicationCreate( + exampleAddress, + listingNoDigitalApp.id, + submissionDate, + unitTypeA.id, + ); const res = await request(app.getHttpServer()) .post(`/applications/submit`) .set({ passkey: process.env.API_PASS_KEY || '' }) @@ -921,32 +1187,16 @@ describe('Application Controller Tests', () => { }); it('should set the isNewest flag', async () => { - const unitTypeA = await unitTypeFactorySingle( - prisma, - UnitTypeEnum.oneBdrm, - ); - const jurisdiction = await prisma.jurisdictions.create({ - data: jurisdictionFactory(), - }); - await reservedCommunityTypeFactoryAll(jurisdiction.id, prisma); - const listing1 = await listingFactory(jurisdiction.id, prisma, { - digitalApp: true, - }); - const listing1Created = await prisma.listings.create({ - data: listing1, - }); - - const listing2 = await listingFactory(jurisdiction.id, prisma, { - digitalApp: true, - }); - const listing2Created = await prisma.listings.create({ - data: listing2, + const listing2 = await prisma.listings.create({ + data: await listingFactory(jurisdiction.id, prisma, { + digitalApp: true, + }), }); // create previous applications for the user with one being the newest await prisma.applications.create({ data: { - listings: { connect: { id: listing2Created.id } }, + listings: { connect: { id: listing2.id } }, confirmationCode: randomUUID(), preferences: [], submissionType: ApplicationSubmissionTypeEnum.electronical, @@ -957,7 +1207,7 @@ describe('Application Controller Tests', () => { await prisma.applications.create({ data: { - listings: { connect: { id: listing2Created.id } }, + listings: { connect: { id: listing2.id } }, confirmationCode: randomUUID(), preferences: [], submissionType: ApplicationSubmissionTypeEnum.electronical, @@ -968,78 +1218,12 @@ describe('Application Controller Tests', () => { const submissionDate = new Date(); const exampleAddress = addressFactory() as AddressCreate; - const dto: ApplicationCreate = { - contactPreferences: ['example contact preference'], - preferences: [], - status: ApplicationStatusEnum.submitted, - submissionType: ApplicationSubmissionTypeEnum.electronical, - applicant: { - firstName: 'applicant first name', - middleName: 'applicant middle name', - lastName: 'applicant last name', - birthMonth: '12', - birthDay: '17', - birthYear: '1993', - emailAddress: 'example@email.com', - noEmail: false, - phoneNumber: '111-111-1111', - phoneNumberType: 'Cell', - noPhone: false, - workInRegion: YesNoEnum.yes, - applicantWorkAddress: exampleAddress, - applicantAddress: exampleAddress, - }, - accessibility: { - mobility: false, - vision: false, - hearing: false, - }, - alternateContact: { - type: AlternateContactRelationship.friend, - otherType: 'example other type', - firstName: 'example first name', - lastName: 'example last name', - agency: 'example agency', - phoneNumber: '111-111-1111', - emailAddress: 'example@email.com', - address: exampleAddress, - }, - applicationsAlternateAddress: exampleAddress, - applicationsMailingAddress: exampleAddress, - listings: { - id: listing1Created.id, - }, - demographics: { - ethnicity: 'example ethnicity', - gender: 'example gender', - sexualOrientation: 'example sexual orientation', - howDidYouHear: ['example how did you hear'], - race: ['example race'], - }, - preferredUnitTypes: [ - { - id: unitTypeA.id, - }, - ], - householdMember: [], - appUrl: 'http://www.example.com', - additionalPhone: true, - additionalPhoneNumber: '111-111-1111', - additionalPhoneNumberType: 'example type', - householdSize: 2, - housingStatus: 'example status', - sendMailToMailingAddress: true, - householdExpectingChanges: false, - householdStudent: false, - incomeVouchers: false, - income: '36000', - incomePeriod: IncomePeriodEnum.perYear, - language: LanguagesEnum.en, - acceptedTerms: true, - submissionDate: submissionDate, - reviewStatus: ApplicationReviewStatusEnum.valid, - programs: [], - }; + const dto: ApplicationCreate = applicationCreate( + exampleAddress, + listing1.id, + submissionDate, + unitTypeA.id, + ); const res = await request(app.getHttpServer()) .post(`/applications/submit`) .set({ passkey: process.env.API_PASS_KEY || '' }) @@ -1060,156 +1244,69 @@ describe('Application Controller Tests', () => { }); }); - it('should calculate geocoding on application', async () => { - const unitTypeA = await unitTypeFactorySingle( - prisma, - UnitTypeEnum.oneBdrm, - ); - const jurisdiction = await prisma.jurisdictions.create({ - data: jurisdictionFactory(), - }); - await reservedCommunityTypeFactoryAll(jurisdiction.id, prisma); - const exampleAddress = addressFactory() as AddressCreate; - const listing1 = await listingFactory(jurisdiction.id, prisma, { - digitalApp: true, - listing: { - listingsBuildingAddress: { create: exampleAddress }, - } as unknown as Prisma.ListingsCreateInput, - }); - const listing1Created = await prisma.listings.create({ - data: listing1, - }); - - const multiselectQuestionPreference = - await prisma.multiselectQuestions.create({ - data: multiselectQuestionFactory(jurisdiction.id, { - multiselectQuestion: { - applicationSection: - MultiselectQuestionsApplicationSectionEnum.preferences, - listings: { - create: { - listingId: listing1Created.id, - }, - }, - options: [ - { - text: 'geocoding preference', - collectAddress: true, - validationMethod: ValidationMethod.radius, - radiusSize: 5, - }, - ], - }, - }), - }); - - const submissionDate = new Date(); - const dto: ApplicationCreate = { - contactPreferences: ['example contact preference'], - preferences: [ - { - multiselectQuestionId: multiselectQuestionPreference.id, - key: multiselectQuestionPreference.text, - claimed: true, - options: [ - { - key: 'geocoding preference', - checked: true, - extraData: [ - { - type: InputType.address, - key: 'address', - value: exampleAddress, - }, - ], - }, - ], - }, - ], - status: ApplicationStatusEnum.submitted, - submissionType: ApplicationSubmissionTypeEnum.electronical, - applicant: { - firstName: 'applicant first name', - middleName: 'applicant middle name', - lastName: 'applicant last name', - birthMonth: '12', - birthDay: '17', - birthYear: '1993', - emailAddress: 'example@email.com', - noEmail: false, - phoneNumber: '111-111-1111', - phoneNumberType: 'Cell', - noPhone: false, - workInRegion: YesNoEnum.yes, - applicantWorkAddress: exampleAddress, - applicantAddress: exampleAddress, - }, - accessibility: { - mobility: false, - vision: false, - hearing: false, - }, - alternateContact: { - type: AlternateContactRelationship.friend, - otherType: 'example other type', - firstName: 'example first name', - lastName: 'example last name', - agency: 'example agency', - phoneNumber: '111-111-1111', - emailAddress: 'example@email.com', - address: exampleAddress, - }, - applicationsAlternateAddress: exampleAddress, - applicationsMailingAddress: exampleAddress, - listings: { - id: listing1Created.id, - }, - demographics: { - ethnicity: 'example ethnicity', - gender: 'example gender', - sexualOrientation: 'example sexual orientation', - howDidYouHear: ['example how did you hear'], - race: ['example race'], - }, - preferredUnitTypes: [ - { - id: unitTypeA.id, - }, - ], - householdMember: [ - { - orderId: 0, - firstName: 'example first name', - middleName: 'example middle name', - lastName: 'example last name', - birthMonth: '12', - birthDay: '17', - birthYear: '1993', - sameAddress: YesNoEnum.yes, - relationship: HouseholdMemberRelationship.friend, - workInRegion: YesNoEnum.yes, - householdMemberWorkAddress: exampleAddress, - householdMemberAddress: exampleAddress, - }, - ], - appUrl: 'http://www.example.com', - additionalPhone: true, - additionalPhoneNumber: '111-111-1111', - additionalPhoneNumberType: 'example type', - householdSize: 2, - housingStatus: 'example status', - sendMailToMailingAddress: true, - householdExpectingChanges: false, - householdStudent: false, - incomeVouchers: false, - income: '36000', - incomePeriod: IncomePeriodEnum.perYear, - language: LanguagesEnum.en, - acceptedTerms: true, - submissionDate: submissionDate, - reviewStatus: ApplicationReviewStatusEnum.valid, - programs: [], - }; + it.skip('should calculate geocoding on application', async () => { + const exampleAddress = addressFactory() as AddressCreate; + const listingGeocoding = await prisma.listings.create({ + data: await listingFactory(jurisdiction.id, prisma, { + digitalApp: true, + listing: { + listingsBuildingAddress: { create: exampleAddress }, + } as unknown as Prisma.ListingsCreateInput, + }), + }); + + const multiselectQuestionPreference = + await prisma.multiselectQuestions.create({ + data: multiselectQuestionFactory(jurisdiction.id, { + multiselectQuestion: { + applicationSection: + MultiselectQuestionsApplicationSectionEnum.preferences, + listings: { + create: { + listingId: listingGeocoding.id, + }, + }, + options: [ + { + text: 'geocoding preference', + collectAddress: true, + validationMethod: ValidationMethod.radius, + radiusSize: 5, + }, + ], + }, + }), + }); + const preferences = [ + { + multiselectQuestionId: multiselectQuestionPreference.id, + key: multiselectQuestionPreference.text, + claimed: true, + options: [ + { + key: 'geocoding preference', + checked: true, + extraData: [ + { + type: InputType.address, + key: 'address', + value: exampleAddress, + }, + ], + }, + ], + }, + ]; + + const submissionDate = new Date(); + const dto: ApplicationCreate = applicationCreate( + exampleAddress, + listingGeocoding.id, + submissionDate, + unitTypeA.id, + [], + preferences, + ); const res = await request(app.getHttpServer()) .post(`/applications/submit`) .set({ passkey: process.env.API_PASS_KEY || '' }) @@ -1247,7 +1344,7 @@ describe('Application Controller Tests', () => { }); describe('create endpoint', () => { - it('should create application from partner site', async () => { + it('should create application from partner site with MSQ V1', async () => { const unitTypeA = await unitTypeFactorySingle( prisma, UnitTypeEnum.oneBdrm, @@ -1256,149 +1353,133 @@ describe('Application Controller Tests', () => { data: jurisdictionFactory(), }); await reservedCommunityTypeFactoryAll(jurisdiction.id, prisma); - const listing1 = await listingFactory(jurisdiction.id, prisma); - const listing1Created = await prisma.listings.create({ - data: listing1, + const listing1 = await prisma.listings.create({ + data: await listingFactory(jurisdiction.id, prisma), }); + const multiselectQuestionPreferenceId = ( + await createMultiselectQuestion( + jurisdiction.id, + listing1.id, + MultiselectQuestionsApplicationSectionEnum.preferences, + ) + ).id; + const multiselectQuestionProgramId = ( + await createMultiselectQuestion( + jurisdiction.id, + listing1.id, + MultiselectQuestionsApplicationSectionEnum.programs, + ) + ).id; - const multiselectQuestionProgram = await createMultiselectQuestion( - jurisdiction.id, - listing1Created.id, - MultiselectQuestionsApplicationSectionEnum.programs, + const exampleAddress = addressFactory() as AddressCreate; + const preferences = [ + { + multiselectQuestionId: multiselectQuestionPreferenceId, + key: 'example key', + claimed: true, + options: [ + { + key: 'example key', + checked: true, + extraData: [ + { + type: InputType.boolean, + key: 'example key', + value: true, + }, + ], + }, + ], + }, + ]; + const programs = [ + { + multiselectQuestionId: multiselectQuestionProgramId, + key: 'example key', + claimed: true, + options: [ + { + key: 'example key', + checked: true, + extraData: [ + { + type: InputType.boolean, + key: 'example key', + value: true, + }, + ], + }, + ], + }, + ]; + const submissionDate = new Date(); + + const dto: ApplicationCreate = applicationCreate( + exampleAddress, + listing1.id, + submissionDate, + unitTypeA.id, + [], + preferences, + programs, + ApplicationSubmissionTypeEnum.paper, + ); + const res = await request(app.getHttpServer()) + .post(`/applications/`) + .set({ passkey: process.env.API_PASS_KEY || '' }) + .send(dto) + .set('Cookie', adminCookies) + .expect(201); + + expect(res.body.id).not.toBeNull(); + }); + it('should create application from partner site with MSQ V2', async () => { + const unitTypeA = await unitTypeFactorySingle( + prisma, + UnitTypeEnum.oneBdrm, ); - const multiselectQuestionPreference = await createMultiselectQuestion( + const jurisdiction = await prisma.jurisdictions.create({ + data: jurisdictionFactory('applicationPostWithV2MSQ', { + featureFlags: [FeatureFlagEnum.enableV2MSQ], + }), + }); + await reservedCommunityTypeFactoryAll(jurisdiction.id, prisma); + const listing1 = await prisma.listings.create({ + data: await listingFactory(jurisdiction.id, prisma), + }); + const multiselectQuestion = await createMultiselectQuestion( jurisdiction.id, - listing1Created.id, + listing1.id, MultiselectQuestionsApplicationSectionEnum.preferences, + true, ); - const submissionDate = new Date(); const exampleAddress = addressFactory() as AddressCreate; - const dto: ApplicationCreate = { - contactPreferences: ['example contact preference'], - preferences: [ - { - multiselectQuestionId: multiselectQuestionPreference, - key: 'example key', - claimed: true, - options: [ - { - key: 'example key', - checked: true, - extraData: [ - { - type: InputType.boolean, - key: 'example key', - value: true, - }, - ], + const submissionDate = new Date(); + const applicationSelections = [ + { + multiselectQuestion: { id: multiselectQuestion.id }, + selections: [ + { + addressHolderAddress: exampleAddress, + multiselectOption: { + id: multiselectQuestion.multiselectOptions[0].id, }, - ], - }, - ], - status: ApplicationStatusEnum.submitted, - submissionType: ApplicationSubmissionTypeEnum.electronical, - applicant: { - firstName: 'applicant first name', - middleName: 'applicant middle name', - lastName: 'applicant last name', - birthMonth: '12', - birthDay: '17', - birthYear: '1993', - emailAddress: 'example@email.com', - noEmail: false, - phoneNumber: '111-111-1111', - phoneNumberType: 'Cell', - noPhone: false, - workInRegion: YesNoEnum.yes, - applicantWorkAddress: exampleAddress, - applicantAddress: exampleAddress, - }, - accessibility: { - mobility: false, - vision: false, - hearing: false, - }, - alternateContact: { - type: AlternateContactRelationship.friend, - otherType: 'example other type', - firstName: 'example first name', - lastName: 'example last name', - agency: 'example agency', - phoneNumber: '111-111-1111', - emailAddress: 'example@email.com', - address: exampleAddress, - }, - applicationsAlternateAddress: exampleAddress, - applicationsMailingAddress: exampleAddress, - listings: { - id: listing1Created.id, - }, - demographics: { - ethnicity: 'example ethnicity', - gender: 'example gender', - sexualOrientation: 'example sexual orientation', - howDidYouHear: ['example how did you hear'], - race: ['example race'], + }, + ], }, - preferredUnitTypes: [ - { - id: unitTypeA.id, - }, - ], - householdMember: [ - { - orderId: 0, - firstName: 'example first name', - middleName: 'example middle name', - lastName: 'example last name', - birthMonth: '12', - birthDay: '17', - birthYear: '1993', - sameAddress: YesNoEnum.yes, - relationship: HouseholdMemberRelationship.friend, - workInRegion: YesNoEnum.yes, - householdMemberWorkAddress: exampleAddress, - householdMemberAddress: exampleAddress, - }, - ], - appUrl: 'http://www.example.com', - additionalPhone: true, - additionalPhoneNumber: '111-111-1111', - additionalPhoneNumberType: 'example type', - householdSize: 2, - housingStatus: 'example status', - sendMailToMailingAddress: true, - householdExpectingChanges: false, - householdStudent: false, - incomeVouchers: false, - income: '36000', - incomePeriod: IncomePeriodEnum.perYear, - language: LanguagesEnum.en, - acceptedTerms: true, - submissionDate: submissionDate, - reviewStatus: ApplicationReviewStatusEnum.valid, - programs: [ - { - multiselectQuestionId: multiselectQuestionProgram, - key: 'example key', - claimed: true, - options: [ - { - key: 'example key', - checked: true, - extraData: [ - { - type: InputType.boolean, - key: 'example key', - value: true, - }, - ], - }, - ], - }, - ], - }; + ]; + + const dto: ApplicationCreate = applicationCreate( + exampleAddress, + listing1.id, + submissionDate, + unitTypeA.id, + applicationSelections, + [], + [], + ApplicationSubmissionTypeEnum.paper, + ); const res = await request(app.getHttpServer()) .post(`/applications/`) .set({ passkey: process.env.API_PASS_KEY || '' }) @@ -1425,16 +1506,20 @@ describe('Application Controller Tests', () => { data: listing1, }); - const multiselectQuestionProgram = await createMultiselectQuestion( - jurisdiction.id, - listing1Created.id, - MultiselectQuestionsApplicationSectionEnum.programs, - ); - const multiselectQuestionPreference = await createMultiselectQuestion( - jurisdiction.id, - listing1Created.id, - MultiselectQuestionsApplicationSectionEnum.preferences, - ); + const multiselectQuestionProgram = ( + await createMultiselectQuestion( + jurisdiction.id, + listing1Created.id, + MultiselectQuestionsApplicationSectionEnum.programs, + ) + ).id; + const multiselectQuestionPreference = ( + await createMultiselectQuestion( + jurisdiction.id, + listing1Created.id, + MultiselectQuestionsApplicationSectionEnum.preferences, + ) + ).id; const appA = await applicationFactory({ unitTypeId: unitTypeA.id, @@ -1603,16 +1688,20 @@ describe('Application Controller Tests', () => { data: listing1, }); - const multiselectQuestionProgram = await createMultiselectQuestion( - jurisdiction.id, - listing1Created.id, - MultiselectQuestionsApplicationSectionEnum.programs, - ); - const multiselectQuestionPreference = await createMultiselectQuestion( - jurisdiction.id, - listing1Created.id, - MultiselectQuestionsApplicationSectionEnum.preferences, - ); + const multiselectQuestionProgram = ( + await createMultiselectQuestion( + jurisdiction.id, + listing1Created.id, + MultiselectQuestionsApplicationSectionEnum.programs, + ) + ).id; + const multiselectQuestionPreference = ( + await createMultiselectQuestion( + jurisdiction.id, + listing1Created.id, + MultiselectQuestionsApplicationSectionEnum.preferences, + ) + ).id; const submissionDate = new Date(); const exampleAddress = addressFactory() as AddressCreate; @@ -1770,16 +1859,20 @@ describe('Application Controller Tests', () => { data: listing1, }); - const multiselectQuestionProgram = await createMultiselectQuestion( - jurisdiction.id, - listing1Created.id, - MultiselectQuestionsApplicationSectionEnum.programs, - ); - const multiselectQuestionPreference = await createMultiselectQuestion( - jurisdiction.id, - listing1Created.id, - MultiselectQuestionsApplicationSectionEnum.preferences, - ); + const multiselectQuestionProgram = ( + await createMultiselectQuestion( + jurisdiction.id, + listing1Created.id, + MultiselectQuestionsApplicationSectionEnum.programs, + ) + ).id; + const multiselectQuestionPreference = ( + await createMultiselectQuestion( + jurisdiction.id, + listing1Created.id, + MultiselectQuestionsApplicationSectionEnum.preferences, + ) + ).id; const submissionDate = new Date(); const exampleAddress = addressFactory() as AddressCreate; @@ -1933,6 +2026,7 @@ describe('Application Controller Tests', () => { ]); }); }); + describe('publicAppsView endpoint', () => { it('should retrieve applications and counts when they exist', async () => { const unitTypeA = await unitTypeFactorySingle( @@ -2040,4 +2134,166 @@ describe('Application Controller Tests', () => { expect(res.body.displayApplications.length).toEqual(0); }); }); + + describe('removePIICronJob endpoint', () => { + it('should run the removePII cron job for expired applications', async () => { + process.env.APPLICATION_DAYS_TILL_EXPIRY = '90'; + const juris = await prisma.jurisdictions.create({ + data: jurisdictionFactory(), + }); + await reservedCommunityTypeFactoryAll(juris.id, prisma); + const listing = await prisma.listings.create({ + data: await listingFactory(juris.id, prisma, { + status: ListingsStatusEnum.active, + }), + }); + + // Application that is the newest for a user + const newestApplication = await prisma.applications.create({ + data: await applicationFactory({ + listingId: listing.id, + isNewest: true, + expireAfter: dayjs(new Date()).subtract(180, 'days').toDate(), + }), + }); + // Application that expires in the future + const futureApplication = await prisma.applications.create({ + data: await applicationFactory({ + listingId: listing.id, + isNewest: false, + expireAfter: dayjs(new Date()).add(3, 'days').toDate(), + }), + }); + // This application should have the PII script run against it + const applicationToBeCleaned = await prisma.applications.create({ + data: await applicationFactory({ + listingId: listing.id, + isNewest: false, + additionalPhone: '(123) 456-7890', + expireAfter: dayjs(new Date()).subtract(2, 'days').toDate(), + householdMember: [ + { + firstName: 'firstNameMember', + lastName: 'lastNameMember', + birthDay: 2, + birthMonth: 2, + birthYear: 2002, + householdMemberAddress: { + create: addressFactory(), + }, + }, + ], + }), + }); + // Application that already had PII cleared + await prisma.applications.create({ + data: await applicationFactory({ + listingId: listing.id, + isNewest: false, + wasPIICleared: true, + expireAfter: dayjs(new Date()).subtract(2, 'days').toDate(), + }), + }); + + await request(app.getHttpServer()) + .put(`/applications/removePIICronJob`) + .set({ passkey: process.env.API_PASS_KEY || '' }) + .set('Cookie', adminCookies) + .expect(200); + + expect(logger.warn).toBeCalledWith( + expect.stringContaining('removing PII information for '), + ); + + const applicant1 = await prisma.applicant.findFirst({ + where: { id: applicationToBeCleaned.applicantId }, + }); + expect(applicant1).toEqual({ + id: applicationToBeCleaned.applicantId, + createdAt: expect.anything(), + updatedAt: expect.anything(), + firstName: null, + middleName: null, + lastName: null, + birthMonth: null, + birthDay: null, + birthYear: null, + emailAddress: null, + noEmail: false, + phoneNumber: null, + phoneNumberType: 'home', + noPhone: false, + workInRegion: 'no', + fullTimeStudent: null, + workAddressId: expect.anything(), + addressId: expect.anything(), + }); + const applicant1WorkAddress = await prisma.address.findFirst({ + where: { id: applicant1.workAddressId }, + }); + expect(applicant1WorkAddress).toEqual({ + id: applicant1.workAddressId, + createdAt: expect.anything(), + updatedAt: expect.anything(), + latitude: null, + longitude: null, + placeName: expect.anything(), + street: null, + street2: null, + city: expect.anything(), + county: expect.anything(), + state: expect.anything(), + zipCode: expect.anything(), + }); + const applicant1AlternateContact = + await prisma.alternateContact.findFirst({ + where: { id: applicationToBeCleaned.alternateContactId }, + }); + expect(applicant1AlternateContact.emailAddress).toBeNull(); + expect(applicant1AlternateContact.firstName).toBeNull(); + expect(applicant1AlternateContact.lastName).toBeNull(); + expect(applicant1AlternateContact.phoneNumber).toBeNull(); + + const applicant1HouseholdMember = await prisma.householdMember.findFirst({ + where: { applicationId: applicationToBeCleaned.id }, + }); + expect(applicant1HouseholdMember).toEqual({ + id: expect.anything(), + createdAt: expect.anything(), + updatedAt: expect.anything(), + addressId: expect.anything(), + applicationId: applicationToBeCleaned.id, + birthDay: null, + birthMonth: null, + birthYear: null, + firstName: null, + fullTimeStudent: null, + lastName: null, + middleName: null, + orderId: null, + relationship: null, + sameAddress: null, + workAddressId: null, + workInRegion: null, + }); + + const application1 = await prisma.applications.findFirst({ + where: { id: applicationToBeCleaned.id }, + }); + expect(application1.wasPIICleared).toBe(true); + expect(application1.additionalPhoneNumber).toBeNull(); + + // Verify that the other applications didn't have their data cleared + const application2 = await prisma.applications.findFirst({ + select: { wasPIICleared: true }, + where: { id: newestApplication.id }, + }); + expect(application2.wasPIICleared).toBe(false); + const application3 = await prisma.applications.findFirst({ + select: { wasPIICleared: true }, + where: { id: futureApplication.id }, + }); + expect(application3.wasPIICleared).toBe(false); + }); + }); }); diff --git a/api/test/integration/listing.e2e-spec.ts b/api/test/integration/listing.e2e-spec.ts index 112db780bc..13af69c3e7 100644 --- a/api/test/integration/listing.e2e-spec.ts +++ b/api/test/integration/listing.e2e-spec.ts @@ -8,6 +8,7 @@ import { LanguagesEnum, ListingEventsTypeEnum, ListingsStatusEnum, + ListingTypeEnum, MarketingTypeEnum, MultiselectQuestionsApplicationSectionEnum, Prisma, @@ -2231,7 +2232,7 @@ describe('Listing Controller Tests', () => { }); describe('listing deposit type validation', () => { - it("should create listing when deposit is 'fixedDeposit', 'depositValue' is set and 'depositRangeMin' and 'depositRangeMax' are missing", async () => { + it("should create listing when deposit is 'fixedDeposit', and 'depositMin' and 'depositMax' are missing", async () => { const val = await constructFullListingData( undefined, undefined, @@ -2243,7 +2244,10 @@ describe('Listing Controller Tests', () => { .set({ passkey: process.env.API_PASS_KEY || '' }) .send({ ...val, + listingType: ListingTypeEnum.nonRegulated, depositType: DepositTypeEnum.fixedDeposit, + depositMin: null, + depositMax: null, depositValue: 1000, }) .set('Cookie', adminAccessToken) @@ -2257,12 +2261,12 @@ describe('Listing Controller Tests', () => { expect(newDBValues).toBeDefined(); expect(newDBValues.depositType).toEqual(DepositTypeEnum.fixedDeposit); - expect(newDBValues.depositRangeMax).toBeNull(); - expect(newDBValues.depositRangeMin).toBeNull(); + expect(newDBValues.depositMin).toBeNull(); + expect(newDBValues.depositMax).toBeNull(); expect(Number(newDBValues.depositValue)).toEqual(1000); }); - it("should fail when deposit is 'fixedDeposit' but 'depositRangeMin' and 'depositRangeMax' are set", async () => { + it("should fail when deposit is 'fixedDeposit' but 'depositMin' and 'depositMax' are set", async () => { const val = await constructFullListingData( undefined, undefined, @@ -2274,42 +2278,21 @@ describe('Listing Controller Tests', () => { .set({ passkey: process.env.API_PASS_KEY || '' }) .send({ ...val, + listingType: ListingTypeEnum.nonRegulated, depositType: DepositTypeEnum.fixedDeposit, depositValue: 1000, - depositRangeMin: 100, - depositRangeMax: 500, + depositMin: '100', + depositMax: '500', }) .set('Cookie', adminAccessToken) .expect(400); expect(res.body.message[0]).toEqual( - 'When deposit is of type "fixedDeposit" the "depositValue" must be filled and the "depositRangeMin"|"depositRangeMax" fields must be null', + 'When deposit is of type "fixedDeposit" the "depositValue" must be filled and the "depositMin"|"depositMax" fields must be null', ); }); - it("should fail when deposit is 'fixedDeposit' but 'depositValue' is missing", async () => { - const val = await constructFullListingData( - undefined, - undefined, - `create listing ${randomName()}`, - ); - - const res = await request(app.getHttpServer()) - .post('/listings') - .set({ passkey: process.env.API_PASS_KEY || '' }) - .send({ - ...val, - depositType: DepositTypeEnum.fixedDeposit, - }) - .set('Cookie', adminAccessToken) - .expect(400); - - expect(res.body.message[0]).toEqual( - 'When deposit is of type "fixedDeposit" the "depositValue" must be filled and the "depositRangeMin"|"depositRangeMax" fields must be null', - ); - }); - - it("should create listing when deposit is 'rangeDeposit', 'depositRangeMin' and 'depositRangeMax' are set and 'depositValue' is missing", async () => { + it("should create listing when deposit is 'rangeDeposit', and 'depositValue' is missing", async () => { const val = await constructFullListingData( undefined, undefined, @@ -2321,9 +2304,10 @@ describe('Listing Controller Tests', () => { .set({ passkey: process.env.API_PASS_KEY || '' }) .send({ ...val, + listingType: ListingTypeEnum.nonRegulated, depositType: DepositTypeEnum.depositRange, - depositRangeMin: 100, - depositRangeMax: 500, + depositMin: '100', + depositMax: '500', depositValue: null, }) .set('Cookie', adminAccessToken) @@ -2337,8 +2321,8 @@ describe('Listing Controller Tests', () => { expect(newDBValues).toBeDefined(); expect(newDBValues.depositType).toEqual(DepositTypeEnum.depositRange); - expect(Number(newDBValues.depositRangeMax)).toEqual(500); - expect(Number(newDBValues.depositRangeMin)).toEqual(100); + expect(Number(newDBValues.depositMax)).toEqual(500); + expect(Number(newDBValues.depositMin)).toEqual(100); expect(newDBValues.depositValue).toBeNull(); }); @@ -2354,41 +2338,17 @@ describe('Listing Controller Tests', () => { .set({ passkey: process.env.API_PASS_KEY || '' }) .send({ ...val, + listingType: ListingTypeEnum.nonRegulated, depositType: DepositTypeEnum.depositRange, depositValue: 1000, - depositRangeMin: 100, - depositRangeMax: 500, - }) - .set('Cookie', adminAccessToken) - .expect(400); - - expect(res.body.message[0]).toEqual( - 'When deposit is of type "depositRange" the "depositRangeMin" and "depositRangeMax" fields must be filled and "depositValue" must be null', - ); - }); - - it("should fail when deposit is 'rangeDeposit' but 'depositRangeMin' and 'depositRangeMax' are missing", async () => { - const val = await constructFullListingData( - undefined, - undefined, - `create listing ${randomName()}`, - ); - - const res = await request(app.getHttpServer()) - .post('/listings') - .set({ passkey: process.env.API_PASS_KEY || '' }) - .send({ - ...val, - depositType: DepositTypeEnum.depositRange, - depositValue: null, - depositRangeMin: null, - depositRangeMax: null, + depositMin: '100', + depositMax: '500', }) .set('Cookie', adminAccessToken) .expect(400); expect(res.body.message[0]).toEqual( - 'When deposit is of type "depositRange" the "depositRangeMin" and "depositRangeMax" fields must be filled and "depositValue" must be null', + 'When deposit is of type "depositRange" the "depositMin" and "depositMax" fields must be filled and "depositValue" must be null', ); }); }); diff --git a/api/test/integration/multiselect-question.e2e-spec.ts b/api/test/integration/multiselect-question.e2e-spec.ts index de20dc2338..682642b7c8 100644 --- a/api/test/integration/multiselect-question.e2e-spec.ts +++ b/api/test/integration/multiselect-question.e2e-spec.ts @@ -1,27 +1,34 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { INestApplication } from '@nestjs/common'; -import { MultiselectQuestionsApplicationSectionEnum } from '@prisma/client'; +import cookieParser from 'cookie-parser'; import { randomUUID } from 'crypto'; import { stringify } from 'qs'; import request from 'supertest'; -import cookieParser from 'cookie-parser'; -import { AppModule } from '../../src/modules/app.module'; -import { PrismaService } from '../../src/services/prisma.service'; -import { multiselectQuestionFactory } from '../../prisma/seed-helpers/multiselect-question-factory'; +import { INestApplication } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { + ListingsStatusEnum, + MultiselectQuestionsApplicationSectionEnum, + MultiselectQuestionsStatusEnum, +} from '@prisma/client'; +import { createAllFeatureFlags } from '../../prisma/seed-helpers/feature-flag-factory'; import { jurisdictionFactory } from '../../prisma/seed-helpers/jurisdiction-factory'; +import { listingFactory } from '../../prisma/seed-helpers/listing-factory'; +import { multiselectQuestionFactory } from '../../prisma/seed-helpers/multiselect-question-factory'; +import { userFactory } from '../../prisma/seed-helpers/user-factory'; +import { Login } from '../../src/dtos/auth/login.dto'; import { MultiselectQuestionCreate } from '../../src/dtos/multiselect-questions/multiselect-question-create.dto'; -import { MultiselectQuestionUpdate } from '../../src/dtos/multiselect-questions/multiselect-question-update.dto'; -import { IdDTO } from '../../src/dtos/shared/id.dto'; import { MultiselectQuestionQueryParams } from '../../src/dtos/multiselect-questions/multiselect-question-query-params.dto'; +import { MultiselectQuestionUpdate } from '../../src/dtos/multiselect-questions/multiselect-question-update.dto'; import { Compare } from '../../src/dtos/shared/base-filter.dto'; -import { userFactory } from '../../prisma/seed-helpers/user-factory'; -import { Login } from '../../src/dtos/auth/login.dto'; +import { IdDTO } from '../../src/dtos/shared/id.dto'; +import { FeatureFlagEnum } from '../../src/enums/feature-flags/feature-flags-enum'; +import { MultiselectQuestionOrderByKeys } from '../../src/enums/multiselect-questions/order-by-enum'; +import { OrderByEnum } from '../../src/enums/shared/order-by-enum'; +import { AppModule } from '../../src/modules/app.module'; +import { PrismaService } from '../../src/services/prisma.service'; describe('MultiselectQuestion Controller Tests', () => { let app: INestApplication; let prisma: PrismaService; - let jurisdictionId: string; - let cookies = ''; beforeAll(async () => { const moduleFixture: TestingModule = await Test.createTestingModule({ @@ -32,28 +39,7 @@ describe('MultiselectQuestion Controller Tests', () => { prisma = moduleFixture.get(PrismaService); app.use(cookieParser()); await app.init(); - const jurisdiction = await prisma.jurisdictions.create({ - data: jurisdictionFactory(), - }); - jurisdictionId = jurisdiction.id; - - const storedUser = await prisma.userAccounts.create({ - data: await userFactory({ - roles: { isAdmin: true }, - mfaEnabled: false, - confirmedAt: new Date(), - }), - }); - const resLogIn = await request(app.getHttpServer()) - .post('/auth/login') - .set({ passkey: process.env.API_PASS_KEY || '' }) - .send({ - email: storedUser.email, - password: 'Abcdef12345!', - } as Login) - .expect(201); - - cookies = resLogIn.headers['set-cookie']; + await createAllFeatureFlags(prisma); }); afterAll(async () => { @@ -61,294 +47,1058 @@ describe('MultiselectQuestion Controller Tests', () => { await app.close(); }); - it('should get multiselect questions from list endpoint when no params are sent', async () => { - const jurisdictionB = await prisma.jurisdictions.create({ - data: jurisdictionFactory(), - }); + // TODO: Can be removed after MSQ refactor + describe('current msq implementation', () => { + let jurisdictionId: string; + let cookies = ''; + beforeAll(async () => { + const jurisdiction = await prisma.jurisdictions.create({ + data: jurisdictionFactory(), + }); + jurisdictionId = jurisdiction.id; - const multiselectQuestionA = await prisma.multiselectQuestions.create({ - data: multiselectQuestionFactory(jurisdictionB.id), - }); - const multiselectQuestionB = await prisma.multiselectQuestions.create({ - data: multiselectQuestionFactory(jurisdictionB.id), + const storedUser = await prisma.userAccounts.create({ + data: await userFactory({ + roles: { isAdmin: true }, + mfaEnabled: false, + confirmedAt: new Date(), + }), + }); + const resLogIn = await request(app.getHttpServer()) + .post('/auth/login') + .set({ passkey: process.env.API_PASS_KEY || '' }) + .send({ + email: storedUser.email, + password: 'Abcdef12345!', + } as Login) + .expect(201); + + cookies = resLogIn.headers['set-cookie']; }); - const res = await request(app.getHttpServer()) - .get(`/multiselectQuestions?`) - .set({ passkey: process.env.API_PASS_KEY || '' }) - .set('Cookie', cookies) - .expect(200); + describe('list', () => { + it('should get multiselect questions from list endpoint when no params are sent', async () => { + const jurisdictionB = await prisma.jurisdictions.create({ + data: jurisdictionFactory(), + }); - expect(res.body.length).toBeGreaterThanOrEqual(2); - const multiselectQuestions = res.body.map((value) => value.text); - expect(multiselectQuestions).toContain(multiselectQuestionA.text); - expect(multiselectQuestions).toContain(multiselectQuestionB.text); - }); + await prisma.multiselectQuestions.create({ + data: multiselectQuestionFactory(jurisdictionB.id), + }); + await prisma.multiselectQuestions.create({ + data: multiselectQuestionFactory(jurisdictionB.id), + }); - it('should get multiselect questions from list endpoint when params are sent', async () => { - const multiselectQuestionA = await prisma.multiselectQuestions.create({ - data: multiselectQuestionFactory(jurisdictionId), - }); - const multiselectQuestionB = await prisma.multiselectQuestions.create({ - data: multiselectQuestionFactory(jurisdictionId), - }); + const res = await request(app.getHttpServer()) + .get(`/multiselectQuestions`) + .set({ passkey: process.env.API_PASS_KEY || '' }) + .set('Cookie', cookies) + .expect(200); - const queryParams: MultiselectQuestionQueryParams = { - filter: [ - { - $comparison: Compare['='], - jurisdiction: jurisdictionId, - }, - ], - }; - const query = stringify(queryParams as any); - - const res = await request(app.getHttpServer()) - .get(`/multiselectQuestions?${query}`) - .set({ passkey: process.env.API_PASS_KEY || '' }) - .set('Cookie', cookies) - .expect(200); - - expect(res.body.length).toBeGreaterThanOrEqual(2); - const multiselectQuestions = res.body.map((value) => value.text); - expect(multiselectQuestions).toContain(multiselectQuestionA.text); - expect(multiselectQuestions).toContain(multiselectQuestionB.text); - }); + expect(res.body.length).toBeGreaterThanOrEqual(2); + }); - it('should throw error when retrieve endpoint is hit with nonexistent id', async () => { - const id = randomUUID(); - const res = await request(app.getHttpServer()) - .get(`/multiselectQuestions/${id}`) - .set({ passkey: process.env.API_PASS_KEY || '' }) - .set('Cookie', cookies) - .expect(404); - expect(res.body.message).toEqual( - `multiselectQuestionId ${id} was requested but not found`, - ); - }); + it('should get multiselect questions from list endpoint when params are sent', async () => { + const multiselectQuestion = await prisma.multiselectQuestions.create({ + data: multiselectQuestionFactory(jurisdictionId), + }); + const multiselectQuestionB = await prisma.multiselectQuestions.create({ + data: multiselectQuestionFactory(jurisdictionId), + }); - it('should get multiselect question when retrieve endpoint is called and id exists', async () => { - const multiselectQuestionA = await prisma.multiselectQuestions.create({ - data: multiselectQuestionFactory(jurisdictionId), - }); + const queryParams: MultiselectQuestionQueryParams = { + filter: [ + { + $comparison: Compare['='], + jurisdiction: jurisdictionId, + }, + ], + }; + const query = stringify(queryParams as any); - const res = await request(app.getHttpServer()) - .get(`/multiselectQuestions/${multiselectQuestionA.id}`) - .set({ passkey: process.env.API_PASS_KEY || '' }) - .set('Cookie', cookies) - .expect(200); + const res = await request(app.getHttpServer()) + .get(`/multiselectQuestions?${query}`) + .set({ passkey: process.env.API_PASS_KEY || '' }) + .set('Cookie', cookies) + .expect(200); - expect(res.body.text).toEqual(multiselectQuestionA.text); - }); + expect(res.body.length).toBeGreaterThanOrEqual(2); + const multiselectQuestions = res.body.map((value) => value.text); + expect(multiselectQuestions).toContain(multiselectQuestion.text); + expect(multiselectQuestions).toContain(multiselectQuestionB.text); + }); + }); - it('should create a multiselect question', async () => { - const res = await request(app.getHttpServer()) - .post('/multiselectQuestions') - .set({ passkey: process.env.API_PASS_KEY || '' }) - .send({ - text: 'example text', - subText: 'example subText', - description: 'example description', - links: [ - { - title: 'title 1', - url: 'https://title-1.com', - }, - { - title: 'title 2', - url: 'https://title-2.com', - }, - ], - jurisdictions: [{ id: jurisdictionId }], - options: [ - { - text: 'example option text 1', - ordinal: 1, - description: 'example option description 1', + describe('create', () => { + it('should create a multiselect question', async () => { + const res = await request(app.getHttpServer()) + .post('/multiselectQuestions') + .set({ passkey: process.env.API_PASS_KEY || '' }) + .send({ + text: 'example text', + subText: 'example subText', + description: 'example description', links: [ { - title: 'title 3', - url: 'https://title-3.com', + title: 'title 1', + url: 'https://title-1.com', + }, + { + title: 'title 2', + url: 'https://title-2.com', }, ], - collectAddress: true, - exclusive: false, - }, - { - text: 'example option text 2', - ordinal: 2, - description: 'example option description 2', - links: [ + jurisdictions: [{ id: jurisdictionId }], + options: [ + { + text: 'example option text 1', + ordinal: 1, + description: 'example option description 1', + links: [ + { + title: 'title 3', + url: 'https://title-3.com', + }, + ], + collectAddress: true, + exclusive: false, + }, { - title: 'title 4', - url: 'https://title-4.com', + text: 'example option text 2', + ordinal: 2, + description: 'example option description 2', + links: [ + { + title: 'title 4', + url: 'https://title-4.com', + }, + ], + collectAddress: true, + exclusive: false, }, ], - collectAddress: true, - exclusive: false, - }, - ], - optOutText: 'example optOutText', - hideFromListing: false, - applicationSection: MultiselectQuestionsApplicationSectionEnum.programs, - } as MultiselectQuestionCreate) - .set('Cookie', cookies) - .expect(201); - - expect(res.body.text).toEqual('example text'); - }); + optOutText: 'example optOutText', + hideFromListing: false, + applicationSection: + MultiselectQuestionsApplicationSectionEnum.programs, + status: MultiselectQuestionsStatusEnum.draft, + } as MultiselectQuestionCreate) + .set('Cookie', cookies) + .expect(201); - it('should throw error when update endpoint is hit with nonexistent id', async () => { - const id = randomUUID(); - const res = await request(app.getHttpServer()) - .put(`/multiselectQuestions/${id}`) - .set({ passkey: process.env.API_PASS_KEY || '' }) - .send({ - id: id, - text: 'example text', - subText: 'example subText', - description: 'example description', - links: [ - { - title: 'title 1', - url: 'https://title-1.com', - }, - { - title: 'title 2', - url: 'https://title-2.com', - }, - ], - jurisdictions: [{ id: jurisdictionId }], - options: [ - { - text: 'example option text 1', - ordinal: 1, - description: 'example option description 1', + expect(res.body.text).toEqual('example text'); + }); + }); + + describe('update', () => { + it('should throw error when update endpoint is hit with nonexistent id', async () => { + const id = randomUUID(); + const res = await request(app.getHttpServer()) + .put('/multiselectQuestions') + .set({ passkey: process.env.API_PASS_KEY || '' }) + .send({ + id: id, + text: 'example text', + subText: 'example subText', + description: 'example description', links: [ { - title: 'title 3', - url: 'https://title-3.com', + title: 'title 1', + url: 'https://title-1.com', + }, + { + title: 'title 2', + url: 'https://title-2.com', + }, + ], + jurisdictions: [{ id: jurisdictionId }], + options: [ + { + text: 'example option text 1', + ordinal: 1, + description: 'example option description 1', + links: [ + { + title: 'title 3', + url: 'https://title-3.com', + }, + ], + collectAddress: true, + exclusive: false, + }, + { + text: 'example option text 2', + ordinal: 2, + description: 'example option description 2', + links: [ + { + title: 'title 4', + url: 'https://title-4.com', + }, + ], + collectAddress: true, + exclusive: false, }, ], - collectAddress: true, - exclusive: false, - }, - { - text: 'example option text 2', - ordinal: 2, - description: 'example option description 2', + optOutText: 'example optOutText', + hideFromListing: false, + applicationSection: + MultiselectQuestionsApplicationSectionEnum.programs, + status: MultiselectQuestionsStatusEnum.draft, + } as MultiselectQuestionUpdate) + .set('Cookie', cookies) + .expect(404); + expect(res.body.message).toEqual( + `multiselectQuestionId ${id} was requested but not found`, + ); + }); + + it('should update multiselect question', async () => { + const multiselectQuestion = await prisma.multiselectQuestions.create({ + data: multiselectQuestionFactory(jurisdictionId), + }); + + const res = await request(app.getHttpServer()) + .put('/multiselectQuestions') + .set({ passkey: process.env.API_PASS_KEY || '' }) + .send({ + id: multiselectQuestion.id, + text: 'example text', + subText: 'example subText', + description: 'example description', links: [ { - title: 'title 4', - url: 'https://title-4.com', + title: 'title 1', + url: 'https://title-1.com', + }, + { + title: 'title 2', + url: 'https://title-2.com', }, ], - collectAddress: true, - exclusive: false, - }, - ], - optOutText: 'example optOutText', - hideFromListing: false, - applicationSection: MultiselectQuestionsApplicationSectionEnum.programs, - } as MultiselectQuestionUpdate) - .set('Cookie', cookies) - .expect(404); - expect(res.body.message).toEqual( - `multiselectQuestionId ${id} was requested but not found`, - ); + jurisdictions: [{ id: jurisdictionId }], + options: [ + { + text: 'example option text 1', + ordinal: 1, + description: 'example option description 1', + links: [ + { + title: 'title 3', + url: 'https://title-3.com', + }, + ], + collectAddress: true, + exclusive: false, + }, + { + text: 'example option text 2', + ordinal: 2, + description: 'example option description 2', + links: [ + { + title: 'title 4', + url: 'https://title-4.com', + }, + ], + collectAddress: true, + exclusive: false, + }, + ], + optOutText: 'example optOutText', + hideFromListing: false, + applicationSection: + MultiselectQuestionsApplicationSectionEnum.programs, + status: MultiselectQuestionsStatusEnum.draft, + } as MultiselectQuestionUpdate) + .set('Cookie', cookies) + .expect(200); + + expect(res.body.text).toEqual('example text'); + }); + }); + + describe('delete', () => { + it('should throw error when delete endpoint is hit with nonexistent id', async () => { + const id = randomUUID(); + const res = await request(app.getHttpServer()) + .delete(`/multiselectQuestions`) + .set({ passkey: process.env.API_PASS_KEY || '' }) + .send({ + id: id, + } as IdDTO) + .set('Cookie', cookies) + .expect(404); + expect(res.body.message).toEqual( + `multiselectQuestionId ${id} was requested but not found`, + ); + }); + + it('should delete multiselect question', async () => { + const multiselectQuestion = await prisma.multiselectQuestions.create({ + data: multiselectQuestionFactory(jurisdictionId), + }); + + const res = await request(app.getHttpServer()) + .delete(`/multiselectQuestions`) + .set({ passkey: process.env.API_PASS_KEY || '' }) + .send({ + id: multiselectQuestion.id, + } as IdDTO) + .set('Cookie', cookies) + .expect(200); + + expect(res.body.success).toEqual(true); + }); + }); + + describe('retrieve', () => { + it('should throw error when retrieve endpoint is hit with nonexistent id', async () => { + const id = randomUUID(); + const res = await request(app.getHttpServer()) + .get(`/multiselectQuestions/${id}`) + .set({ passkey: process.env.API_PASS_KEY || '' }) + .set('Cookie', cookies) + .expect(404); + expect(res.body.message).toEqual( + `multiselectQuestionId ${id} was requested but not found`, + ); + }); + + it('should get multiselect question when retrieve endpoint is called and id exists', async () => { + const multiselectQuestion = await prisma.multiselectQuestions.create({ + data: multiselectQuestionFactory(jurisdictionId), + }); + + const res = await request(app.getHttpServer()) + .get(`/multiselectQuestions/${multiselectQuestion.id}`) + .set({ passkey: process.env.API_PASS_KEY || '' }) + .set('Cookie', cookies) + .expect(200); + + expect(res.body.text).toEqual(multiselectQuestion.text); + }); + }); }); - it('should update multiselect question', async () => { - const multiselectQuestionA = await prisma.multiselectQuestions.create({ - data: multiselectQuestionFactory(jurisdictionId), + describe('v2 msq implementation enabled', () => { + let jurisdictionId: string; + let cookies = ''; + beforeAll(async () => { + const jurisdiction = await prisma.jurisdictions.create({ + data: jurisdictionFactory('enableV2', { + featureFlags: [FeatureFlagEnum.enableV2MSQ], + }), + }); + jurisdictionId = jurisdiction.id; + + const storedUser = await prisma.userAccounts.create({ + data: await userFactory({ + roles: { isAdmin: true }, + mfaEnabled: false, + confirmedAt: new Date(), + }), + }); + const resLogIn = await request(app.getHttpServer()) + .post('/auth/login') + .set({ passkey: process.env.API_PASS_KEY || '' }) + .send({ + email: storedUser.email, + password: 'Abcdef12345!', + } as Login) + .expect(201); + + cookies = resLogIn.headers['set-cookie']; }); - const res = await request(app.getHttpServer()) - .put(`/multiselectQuestions/${multiselectQuestionA.id}`) - .set({ passkey: process.env.API_PASS_KEY || '' }) - .send({ - id: multiselectQuestionA.id, - text: 'example text', - subText: 'example subText', - description: 'example description', - links: [ - { - title: 'title 1', - url: 'https://title-1.com', - }, - { - title: 'title 2', - url: 'https://title-2.com', - }, - ], - jurisdictions: [{ id: jurisdictionId }], - options: [ - { - text: 'example option text 1', - ordinal: 1, - description: 'example option description 1', + describe('list', () => { + let listJurisdictionId: string; + beforeAll(async () => { + const jurisdiction = await prisma.jurisdictions.create({ + data: jurisdictionFactory('enableV2 juris list', { + featureFlags: [FeatureFlagEnum.enableV2MSQ], + }), + }); + listJurisdictionId = jurisdiction.id; + }); + + it('should get multiselect questions from list endpoint when jurisdiction filter is sent', async () => { + const jurisdictionB = await prisma.jurisdictions.create({ + data: jurisdictionFactory('enableV2 juris list test', { + featureFlags: [FeatureFlagEnum.enableV2MSQ], + }), + }); + + const multiselectQuestionA = await prisma.multiselectQuestions.create({ + data: multiselectQuestionFactory(jurisdictionB.id, {}, true), + }); + const multiselectQuestionB = await prisma.multiselectQuestions.create({ + data: multiselectQuestionFactory(jurisdictionB.id, {}, true), + }); + + const queryParams: MultiselectQuestionQueryParams = { + filter: [ + { + $comparison: Compare['='], + jurisdiction: jurisdictionB.id, + }, + ], + }; + const query = stringify(queryParams as any); + + const res = await request(app.getHttpServer()) + .get(`/multiselectQuestions?${query}`) + .set({ passkey: process.env.API_PASS_KEY || '' }) + .set('Cookie', cookies) + .expect(200); + + expect(res.body.length).toEqual(2); + const multiselectQuestions = res.body.map((value) => value.name); + expect(multiselectQuestions).toContain(multiselectQuestionA.name); + expect(multiselectQuestions).toContain(multiselectQuestionB.name); + }); + + it('should get multiselect questions from list endpoint when multiple filter params are sent', async () => { + const multiselectQuestionA = await prisma.multiselectQuestions.create({ + data: multiselectQuestionFactory(listJurisdictionId, {}, true), + }); + const multiselectQuestionB = await prisma.multiselectQuestions.create({ + data: multiselectQuestionFactory( + listJurisdictionId, + { + multiselectQuestion: { + applicationSection: + MultiselectQuestionsApplicationSectionEnum.programs, + status: MultiselectQuestionsStatusEnum.active, + }, + }, + true, + ), + }); + const multiselectQuestionC = await prisma.multiselectQuestions.create({ + data: multiselectQuestionFactory( + listJurisdictionId, + { + multiselectQuestion: { + applicationSection: + MultiselectQuestionsApplicationSectionEnum.preferences, + status: MultiselectQuestionsStatusEnum.active, + }, + }, + true, + ), + }); + + const queryParams: MultiselectQuestionQueryParams = { + filter: [ + { + $comparison: Compare['='], + applicationSection: + MultiselectQuestionsApplicationSectionEnum.preferences, + }, + { + $comparison: Compare['='], + jurisdiction: listJurisdictionId, + }, + { + $comparison: Compare['='], + status: MultiselectQuestionsStatusEnum.active, + }, + ], + }; + const query = stringify(queryParams as any); + + const res = await request(app.getHttpServer()) + .get(`/multiselectQuestions?${query}`) + .set({ passkey: process.env.API_PASS_KEY || '' }) + .set('Cookie', cookies) + .expect(200); + + expect(res.body.length).toBeGreaterThanOrEqual(1); + const multiselectQuestions = res.body.map((value) => value.name); + expect(multiselectQuestions).not.toContain(multiselectQuestionA.name); + expect(multiselectQuestions).not.toContain(multiselectQuestionB.name); + expect(multiselectQuestions).toContain(multiselectQuestionC.name); + }); + + it('should get multiselect questions in correct order from list endpoint when orderBy and search are sent', async () => { + const multiselectQuestionA = await prisma.multiselectQuestions.create({ + data: multiselectQuestionFactory( + listJurisdictionId, + { + multiselectQuestion: { + name: 'MSQ A1', + }, + }, + true, + ), + }); + const multiselectQuestionB = await prisma.multiselectQuestions.create({ + data: multiselectQuestionFactory( + listJurisdictionId, + { + multiselectQuestion: { + name: 'MSQ B2', + }, + }, + true, + ), + }); + const multiselectQuestionC = await prisma.multiselectQuestions.create({ + data: multiselectQuestionFactory( + listJurisdictionId, + { + multiselectQuestion: { + name: 'MSQ C3', + }, + }, + true, + ), + }); + + const queryParams: MultiselectQuestionQueryParams = { + filter: [ + { + $comparison: Compare['='], + jurisdiction: listJurisdictionId, + }, + ], + orderBy: [MultiselectQuestionOrderByKeys.name], + orderDir: [OrderByEnum.DESC], + search: 'MSQ', + }; + const query = stringify(queryParams as any); + + const res = await request(app.getHttpServer()) + .get(`/multiselectQuestions?${query}`) + .set({ passkey: process.env.API_PASS_KEY || '' }) + .set('Cookie', cookies) + .expect(200); + + expect(res.body.length).toEqual(3); + expect(res.body[0].name).toEqual(multiselectQuestionC.name); + expect(res.body[1].name).toEqual(multiselectQuestionB.name); + expect(res.body[2].name).toEqual(multiselectQuestionA.name); + }); + }); + + describe('create', () => { + it('should create a multiselect question', async () => { + const res = await request(app.getHttpServer()) + .post('/multiselectQuestions') + .set({ passkey: process.env.API_PASS_KEY || '' }) + .send({ + applicationSection: + MultiselectQuestionsApplicationSectionEnum.programs, + description: 'example description', + isExclusive: true, + jurisdiction: { id: jurisdictionId }, links: [ { - title: 'title 3', - url: 'https://title-3.com', + title: 'title 1', + url: 'https://title-1.com', + }, + { + title: 'title 2', + url: 'https://title-2.com', }, ], - collectAddress: true, - exclusive: false, - }, - { - text: 'example option text 2', - ordinal: 2, - description: 'example option description 2', - links: [ + multiselectOptions: [ { - title: 'title 4', - url: 'https://title-4.com', + description: 'example option description', + name: 'example option name', + ordinal: 1, + // TODO: Can be removed after MSQ refactor + text: 'example option text', }, ], - collectAddress: true, - exclusive: false, - }, - ], - optOutText: 'example optOutText', - hideFromListing: false, - applicationSection: MultiselectQuestionsApplicationSectionEnum.programs, - } as MultiselectQuestionUpdate) - .set('Cookie', cookies) - .expect(200); - - expect(res.body.text).toEqual('example text'); - }); + name: 'example name', + status: MultiselectQuestionsStatusEnum.draft, + subText: 'example subText', - it('should throw error when delete endpoint is hit with nonexistent id', async () => { - const id = randomUUID(); - const res = await request(app.getHttpServer()) - .delete(`/multiselectQuestions`) - .set({ passkey: process.env.API_PASS_KEY || '' }) - .send({ - id: id, - } as IdDTO) - .set('Cookie', cookies) - .expect(404); - expect(res.body.message).toEqual( - `multiselectQuestionId ${id} was requested but not found`, - ); - }); + // TODO: Can be removed after MSQ refactor + jurisdictions: [{ id: jurisdictionId }], + text: 'example text', + } as MultiselectQuestionCreate) + .set('Cookie', cookies) + .expect(201); + + expect(res.body.name).toEqual('example name'); + }); + }); + + describe('update', () => { + it('should throw error when update endpoint is hit with nonexistent id', async () => { + const id = randomUUID(); + const res = await request(app.getHttpServer()) + .put('/multiselectQuestions') + .set({ passkey: process.env.API_PASS_KEY || '' }) + .send({ + id: id, + applicationSection: + MultiselectQuestionsApplicationSectionEnum.programs, + isExclusive: true, + jurisdiction: { id: jurisdictionId }, + name: 'example name', + status: MultiselectQuestionsStatusEnum.visible, + + // TODO: Can be removed after MSQ refactor + jurisdictions: [{ id: jurisdictionId }], + text: 'example text', + } as MultiselectQuestionUpdate) + .set('Cookie', cookies) + .expect(404); + expect(res.body.message).toEqual( + `multiselectQuestionId ${id} was requested but not found`, + ); + }); + + it('should update multiselect question', async () => { + const multiselectQuestion = await prisma.multiselectQuestions.create({ + data: multiselectQuestionFactory( + jurisdictionId, + { + multiselectQuestion: { + multiselectOptions: { + createMany: { + data: [ + { + name: 'example option name1', + ordinal: 1, + }, + { + isOptOut: true, + name: 'example option name2', + ordinal: 2, + }, + ], + }, + }, + }, + }, + true, + ), + }); + + const res = await request(app.getHttpServer()) + .put('/multiselectQuestions') + .set({ passkey: process.env.API_PASS_KEY || '' }) + .send({ + id: multiselectQuestion.id, + applicationSection: multiselectQuestion.applicationSection, + isExclusive: multiselectQuestion.isExclusive, + jurisdiction: { id: multiselectQuestion.jurisdictionId }, + multiselectOptions: [ + { + description: 'example option description', + name: 'example option name', + ordinal: 1, + // TODO: Can be removed after MSQ refactor + text: 'example option text', + }, + ], + name: 'example name', + status: MultiselectQuestionsStatusEnum.visible, + + // TODO: Can be removed after MSQ refactor + jurisdictions: [{ id: multiselectQuestion.jurisdictionId }], + text: multiselectQuestion.text, + } as MultiselectQuestionUpdate) + .set('Cookie', cookies) + .expect(200); - it('should delete multiselect question', async () => { - const multiselectQuestionA = await prisma.multiselectQuestions.create({ - data: multiselectQuestionFactory(jurisdictionId), + expect(res.body.name).toEqual('example name'); + }); }); - const res = await request(app.getHttpServer()) - .delete(`/multiselectQuestions`) - .set({ passkey: process.env.API_PASS_KEY || '' }) - .send({ - id: multiselectQuestionA.id, - } as IdDTO) - .set('Cookie', cookies) - .expect(200); + describe('delete', () => { + it('should throw error when delete endpoint is hit with nonexistent id', async () => { + const id = randomUUID(); + const res = await request(app.getHttpServer()) + .delete(`/multiselectQuestions`) + .set({ passkey: process.env.API_PASS_KEY || '' }) + .send({ + id: id, + } as IdDTO) + .set('Cookie', cookies) + .expect(404); + expect(res.body.message).toEqual( + `multiselectQuestionId ${id} was requested but not found`, + ); + }); + + it('should delete multiselect question', async () => { + const multiselectQuestion = await prisma.multiselectQuestions.create({ + data: multiselectQuestionFactory(jurisdictionId, {}, true), + }); + + const res = await request(app.getHttpServer()) + .delete(`/multiselectQuestions`) + .set({ passkey: process.env.API_PASS_KEY || '' }) + .send({ + id: multiselectQuestion.id, + } as IdDTO) + .set('Cookie', cookies) + .expect(200); + + expect(res.body.success).toEqual(true); + }); + }); + + describe('reActivate', () => { + it('should re-activate a multiselectQuestion in the toRetire status', async () => { + const multiselectQuestion = await prisma.multiselectQuestions.create({ + data: multiselectQuestionFactory( + jurisdictionId, + { + multiselectQuestion: { + status: MultiselectQuestionsStatusEnum.toRetire, + }, + }, + true, + ), + }); + + const res = await request(app.getHttpServer()) + .put('/multiselectQuestions/reActivate') + .set({ passkey: process.env.API_PASS_KEY || '' }) + .send({ + id: multiselectQuestion.id, + } as IdDTO) + .set('Cookie', cookies) + .expect(200); + + expect(res.body.success).toEqual(true); + + const updatedData = await prisma.multiselectQuestions.findUnique({ + select: { status: true }, + where: { id: multiselectQuestion.id }, + }); + expect(updatedData.status).toEqual( + MultiselectQuestionsStatusEnum.active, + ); + }); + + it('should throw error when reActivate endpoint is hit with an multiselectQuestion not in toRetire status', async () => { + const multiselectQuestion = await prisma.multiselectQuestions.create({ + data: multiselectQuestionFactory( + jurisdictionId, + { + multiselectQuestion: { + status: MultiselectQuestionsStatusEnum.retired, + }, + }, + true, + ), + }); + + const res = await request(app.getHttpServer()) + .put('/multiselectQuestions/reActivate') + .set({ passkey: process.env.API_PASS_KEY || '' }) + .send({ + id: multiselectQuestion.id, + } as IdDTO) + .set('Cookie', cookies) + .expect(400); + + expect(res.body.message).toEqual("status 'retired' cannot be changed"); + }); + + it('should throw error when reActivate endpoint is hit with nonexistent id', async () => { + const id = randomUUID(); + const res = await request(app.getHttpServer()) + .put('/multiselectQuestions/reActivate') + .set({ passkey: process.env.API_PASS_KEY || '' }) + .send({ + id: id, + } as IdDTO) + .set('Cookie', cookies) + .expect(404); + + expect(res.body.message).toEqual( + `multiselectQuestionId ${id} was requested but not found`, + ); + }); + }); + + describe('retire', () => { + it('should retire a multiselectQuestion in the active status with no active listings', async () => { + const multiselectQuestion = await prisma.multiselectQuestions.create({ + data: multiselectQuestionFactory( + jurisdictionId, + { + multiselectQuestion: { + status: MultiselectQuestionsStatusEnum.active, + }, + }, + true, + ), + }); + + const listingData = await listingFactory(jurisdictionId, prisma, { + multiselectQuestions: [multiselectQuestion], + status: ListingsStatusEnum.closed, + }); + await prisma.listings.create({ + data: listingData, + }); - expect(res.body.success).toEqual(true); + const res = await request(app.getHttpServer()) + .put('/multiselectQuestions/retire') + .set({ passkey: process.env.API_PASS_KEY || '' }) + .send({ + id: multiselectQuestion.id, + } as IdDTO) + .set('Cookie', cookies) + .expect(200); + + expect(res.body.success).toEqual(true); + + const updatedData = await prisma.multiselectQuestions.findUnique({ + select: { status: true }, + where: { id: multiselectQuestion.id }, + }); + expect(updatedData.status).toEqual( + MultiselectQuestionsStatusEnum.retired, + ); + }); + + it('should set toRetire a multiselectQuestion in the active status with active listings', async () => { + const multiselectQuestion = await prisma.multiselectQuestions.create({ + data: multiselectQuestionFactory( + jurisdictionId, + { + multiselectQuestion: { + status: MultiselectQuestionsStatusEnum.active, + }, + }, + true, + ), + }); + + const listingData = await listingFactory(jurisdictionId, prisma, { + multiselectQuestions: [multiselectQuestion], + status: ListingsStatusEnum.active, + }); + await prisma.listings.create({ + data: listingData, + }); + + const res = await request(app.getHttpServer()) + .put('/multiselectQuestions/retire') + .set({ passkey: process.env.API_PASS_KEY || '' }) + .send({ + id: multiselectQuestion.id, + } as IdDTO) + .set('Cookie', cookies) + .expect(200); + + expect(res.body.success).toEqual(true); + + const updatedData = await prisma.multiselectQuestions.findUnique({ + select: { status: true }, + where: { id: multiselectQuestion.id }, + }); + expect(updatedData.status).toEqual( + MultiselectQuestionsStatusEnum.toRetire, + ); + }); + + it('should throw error when retire endpoint is hit with an multiselectQuestion not in active status', async () => { + const multiselectQuestion = await prisma.multiselectQuestions.create({ + data: multiselectQuestionFactory(jurisdictionId, {}, true), + }); + + const res = await request(app.getHttpServer()) + .put('/multiselectQuestions/retire') + .set({ passkey: process.env.API_PASS_KEY || '' }) + .send({ + id: multiselectQuestion.id, + } as IdDTO) + .set('Cookie', cookies) + .expect(400); + + expect(res.body.message).toEqual( + "status 'draft' can only change to 'visible'", + ); + }); + + it('should throw error when retire endpoint is hit with nonexistent id', async () => { + const id = randomUUID(); + const res = await request(app.getHttpServer()) + .put('/multiselectQuestions/retire') + .set({ passkey: process.env.API_PASS_KEY || '' }) + .send({ + id: id, + } as IdDTO) + .set('Cookie', cookies) + .expect(404); + + expect(res.body.message).toEqual( + `multiselectQuestionId ${id} was requested but not found`, + ); + }); + }); + + describe('retireMultiselectQuestions', () => { + it('should retire multiselectQuestions in toRetire status with no active listings', async () => { + const jurisdictionAll = await prisma.jurisdictions.create({ + data: jurisdictionFactory('enableV2 juris retire all', { + featureFlags: [FeatureFlagEnum.enableV2MSQ], + }), + }); + const multiselectQuestionClosedListing = + await prisma.multiselectQuestions.create({ + data: multiselectQuestionFactory( + jurisdictionAll.id, + { + multiselectQuestion: { + status: MultiselectQuestionsStatusEnum.toRetire, + }, + }, + true, + ), + }); + const multiselectQuestionNoListing = + await prisma.multiselectQuestions.create({ + data: multiselectQuestionFactory( + jurisdictionAll.id, + { + multiselectQuestion: { + status: MultiselectQuestionsStatusEnum.toRetire, + }, + }, + true, + ), + }); + + const listingData = await listingFactory(jurisdictionId, prisma, { + multiselectQuestions: [multiselectQuestionClosedListing], + status: ListingsStatusEnum.closed, + }); + await prisma.listings.create({ + data: listingData, + }); + + const res = await request(app.getHttpServer()) + .put('/multiselectQuestions/retireMultiselectQuestions') + .set({ passkey: process.env.API_PASS_KEY || '' }) + .set('Cookie', cookies) + .expect(200); + + expect(res.body.success).toEqual(true); + + const updatedDataA = await prisma.multiselectQuestions.findUnique({ + select: { status: true }, + where: { id: multiselectQuestionClosedListing.id }, + }); + expect(updatedDataA.status).toEqual( + MultiselectQuestionsStatusEnum.retired, + ); + + const updatedDataB = await prisma.multiselectQuestions.findUnique({ + select: { status: true }, + where: { id: multiselectQuestionNoListing.id }, + }); + expect(updatedDataB.status).toEqual( + MultiselectQuestionsStatusEnum.retired, + ); + }); + + it('should not retire multiselectQuestions in toRetire status with active listings', async () => { + const jurisdictionSome = await prisma.jurisdictions.create({ + data: jurisdictionFactory('enableV2 juris retire some', { + featureFlags: [FeatureFlagEnum.enableV2MSQ], + }), + }); + const multiselectQuestionActiveListing = + await prisma.multiselectQuestions.create({ + data: multiselectQuestionFactory( + jurisdictionSome.id, + { + multiselectQuestion: { + status: MultiselectQuestionsStatusEnum.toRetire, + }, + }, + true, + ), + }); + const multiselectQuestionNoListing = + await prisma.multiselectQuestions.create({ + data: multiselectQuestionFactory( + jurisdictionSome.id, + { + multiselectQuestion: { + status: MultiselectQuestionsStatusEnum.toRetire, + }, + }, + true, + ), + }); + + const listingData = await listingFactory(jurisdictionId, prisma, { + multiselectQuestions: [multiselectQuestionActiveListing], + status: ListingsStatusEnum.active, + }); + await prisma.listings.create({ + data: listingData, + }); + + const res = await request(app.getHttpServer()) + .put('/multiselectQuestions/retireMultiselectQuestions') + .set({ passkey: process.env.API_PASS_KEY || '' }) + .set('Cookie', cookies) + .expect(200); + + expect(res.body.success).toEqual(true); + + const updatedDataActiveListing = + await prisma.multiselectQuestions.findUnique({ + select: { status: true }, + where: { id: multiselectQuestionActiveListing.id }, + }); + expect(updatedDataActiveListing.status).toEqual( + MultiselectQuestionsStatusEnum.toRetire, + ); + + const updatedDataNoListing = + await prisma.multiselectQuestions.findUnique({ + select: { status: true }, + where: { id: multiselectQuestionNoListing.id }, + }); + expect(updatedDataNoListing.status).toEqual( + MultiselectQuestionsStatusEnum.retired, + ); + }); + }); + + describe('retrieve', () => { + it('should throw error when retrieve endpoint is hit with nonexistent id', async () => { + const id = randomUUID(); + const res = await request(app.getHttpServer()) + .get(`/multiselectQuestions/${id}`) + .set({ passkey: process.env.API_PASS_KEY || '' }) + .set('Cookie', cookies) + .expect(404); + + expect(res.body.message).toEqual( + `multiselectQuestionId ${id} was requested but not found`, + ); + }); + + it('should get multiselect question when retrieve endpoint is called and id exists', async () => { + const multiselectQuestion = await prisma.multiselectQuestions.create({ + data: multiselectQuestionFactory(jurisdictionId, {}, true), + }); + + const res = await request(app.getHttpServer()) + .get(`/multiselectQuestions/${multiselectQuestion.id}`) + .set({ passkey: process.env.API_PASS_KEY || '' }) + .set('Cookie', cookies) + .expect(200); + + expect(res.body.name).toEqual(multiselectQuestion.name); + }); + }); }); }); diff --git a/api/test/integration/permission-tests/helpers.ts b/api/test/integration/permission-tests/helpers.ts index 10dbf61f19..a1953abcb3 100644 --- a/api/test/integration/permission-tests/helpers.ts +++ b/api/test/integration/permission-tests/helpers.ts @@ -10,6 +10,7 @@ import { ListingEventsTypeEnum, ListingsStatusEnum, MultiselectQuestionsApplicationSectionEnum, + MultiselectQuestionsStatusEnum, Prisma, ReviewOrderTypeEnum, UnitTypeEnum, @@ -169,9 +170,10 @@ export const buildMultiselectQuestionCreateMock = ( jurisId: string, ): MultiselectQuestionCreate => { return { - text: 'example text', - subText: 'example subText', + applicationSection: MultiselectQuestionsApplicationSectionEnum.programs, description: 'example description', + hideFromListing: false, + jurisdictions: [{ id: jurisId }], links: [ { title: 'title 1', @@ -182,7 +184,6 @@ export const buildMultiselectQuestionCreateMock = ( url: 'https://title-2.com', }, ], - jurisdictions: [{ id: jurisId }], options: [ { text: 'example option text 1', @@ -212,8 +213,9 @@ export const buildMultiselectQuestionCreateMock = ( }, ], optOutText: 'example optOutText', - hideFromListing: false, - applicationSection: MultiselectQuestionsApplicationSectionEnum.programs, + status: MultiselectQuestionsStatusEnum.draft, + subText: 'example subText', + text: 'example text', }; }; @@ -223,51 +225,7 @@ export const buildMultiselectQuestionUpdateMock = ( ): MultiselectQuestionUpdate => { return { id, - text: 'example text', - subText: 'example subText', - description: 'example description', - links: [ - { - title: 'title 1', - url: 'https://title-1.com', - }, - { - title: 'title 2', - url: 'https://title-2.com', - }, - ], - jurisdictions: [{ id: jurisId }], - options: [ - { - text: 'example option text 1', - ordinal: 1, - description: 'example option description 1', - links: [ - { - title: 'title 3', - url: 'https://title-3.com', - }, - ], - collectAddress: true, - exclusive: false, - }, - { - text: 'example option text 2', - ordinal: 2, - description: 'example option description 2', - links: [ - { - title: 'title 4', - url: 'https://title-4.com', - }, - ], - collectAddress: true, - exclusive: false, - }, - ], - optOutText: 'example optOutText', - hideFromListing: false, - applicationSection: MultiselectQuestionsApplicationSectionEnum.programs, + ...buildMultiselectQuestionCreateMock(jurisId), }; }; diff --git a/api/test/integration/permission-tests/permission-as-admin.e2e-spec.ts b/api/test/integration/permission-tests/permission-as-admin.e2e-spec.ts index dd6a48ad1d..ed383716c0 100644 --- a/api/test/integration/permission-tests/permission-as-admin.e2e-spec.ts +++ b/api/test/integration/permission-tests/permission-as-admin.e2e-spec.ts @@ -3,7 +3,12 @@ import { INestApplication } from '@nestjs/common'; import request from 'supertest'; import cookieParser from 'cookie-parser'; import { stringify } from 'qs'; -import { FlaggedSetStatusEnum, RuleEnum, UnitTypeEnum } from '@prisma/client'; +import { + FlaggedSetStatusEnum, + MultiselectQuestionsStatusEnum, + RuleEnum, + UnitTypeEnum, +} from '@prisma/client'; import { AppModule } from '../../../src/modules/app.module'; import { PrismaService } from '../../../src/services/prisma.service'; import { userFactory } from '../../../prisma/seed-helpers/user-factory'; @@ -828,7 +833,7 @@ describe('Testing Permissioning of endpoints as Admin User', () => { }); await request(app.getHttpServer()) - .put(`/multiselectQuestions/${multiselectQuestionA.id}`) + .put('/multiselectQuestions') .set({ passkey: process.env.API_PASS_KEY || '' }) .send( buildMultiselectQuestionUpdateMock( @@ -864,6 +869,60 @@ describe('Testing Permissioning of endpoints as Admin User', () => { expect(activityLogResult).not.toBeNull(); }); + + it('should succeed for reActivate endpoint', async () => { + const multiselectQuestionA = await prisma.multiselectQuestions.create({ + data: multiselectQuestionFactory( + jurisdictionId, + { + multiselectQuestion: { + status: MultiselectQuestionsStatusEnum.toRetire, + }, + }, + true, + ), + }); + + await request(app.getHttpServer()) + .put('/multiselectQuestions/reActivate') + .set({ passkey: process.env.API_PASS_KEY || '' }) + .send({ + id: multiselectQuestionA.id, + } as IdDTO) + .set('Cookie', cookies) + .expect(200); + }); + + it('should succeed for retire endpoint', async () => { + const multiselectQuestionA = await prisma.multiselectQuestions.create({ + data: multiselectQuestionFactory( + jurisdictionId, + { + multiselectQuestion: { + status: MultiselectQuestionsStatusEnum.active, + }, + }, + true, + ), + }); + + await request(app.getHttpServer()) + .put('/multiselectQuestions/retire') + .set({ passkey: process.env.API_PASS_KEY || '' }) + .send({ + id: multiselectQuestionA.id, + } as IdDTO) + .set('Cookie', cookies) + .expect(200); + }); + + it('should succeed for retireMultiselectQuestions endpoint', async () => { + await request(app.getHttpServer()) + .put('/multiselectQuestions/retireMultiselectQuestions') + .set({ passkey: process.env.API_PASS_KEY || '' }) + .set('Cookie', cookies) + .expect(200); + }); }); describe('Testing user endpoints', () => { diff --git a/api/test/integration/permission-tests/permission-as-juris-admin-correct-juris.e2e-spec.ts b/api/test/integration/permission-tests/permission-as-juris-admin-correct-juris.e2e-spec.ts index a3537f6e87..295124eff9 100644 --- a/api/test/integration/permission-tests/permission-as-juris-admin-correct-juris.e2e-spec.ts +++ b/api/test/integration/permission-tests/permission-as-juris-admin-correct-juris.e2e-spec.ts @@ -3,7 +3,12 @@ import { INestApplication } from '@nestjs/common'; import request from 'supertest'; import cookieParser from 'cookie-parser'; import { stringify } from 'qs'; -import { FlaggedSetStatusEnum, RuleEnum, UnitTypeEnum } from '@prisma/client'; +import { + FlaggedSetStatusEnum, + MultiselectQuestionsStatusEnum, + RuleEnum, + UnitTypeEnum, +} from '@prisma/client'; import { AppModule } from '../../../src/modules/app.module'; import { PrismaService } from '../../../src/services/prisma.service'; import { userFactory } from '../../../prisma/seed-helpers/user-factory'; @@ -81,7 +86,7 @@ describe('Testing Permissioning of endpoints as Jurisdictional Admin in the corr let userService: UserService; let applicationFlaggedSetService: ApplicationFlaggedSetService; let cookies = ''; - let jurisId = ''; + let jurisdictionId = ''; beforeAll(async () => { const moduleFixture: TestingModule = await Test.createTestingModule({ imports: [AppModule], @@ -100,17 +105,17 @@ describe('Testing Permissioning of endpoints as Jurisdictional Admin in the corr app.use(cookieParser()); await app.init(); - jurisId = await generateJurisdiction( + jurisdictionId = await generateJurisdiction( prisma, 'correct jadmin permission juris', ); - await reservedCommunityTypeFactoryAll(jurisId, prisma); + await reservedCommunityTypeFactoryAll(jurisdictionId, prisma); await unitAccessibilityPriorityTypeFactoryAll(prisma); const storedUser = await prisma.userAccounts.create({ data: await userFactory({ roles: { isJurisdictionalAdmin: true }, - jurisdictionIds: [jurisId], + jurisdictionIds: [jurisdictionId], mfaEnabled: false, confirmedAt: new Date(), }), @@ -135,10 +140,10 @@ describe('Testing Permissioning of endpoints as Jurisdictional Admin in the corr describe('Testing ami-chart endpoints', () => { it('should succeed for list endpoint', async () => { await prisma.amiChart.create({ - data: amiChartFactory(10, jurisId), + data: amiChartFactory(10, jurisdictionId), }); const queryParams: AmiChartQueryParams = { - jurisdictionId: jurisId, + jurisdictionId: jurisdictionId, }; const query = stringify(queryParams as any); @@ -151,7 +156,7 @@ describe('Testing Permissioning of endpoints as Jurisdictional Admin in the corr it('should succeed for retrieve endpoint', async () => { const amiChartA = await prisma.amiChart.create({ - data: amiChartFactory(10, jurisId), + data: amiChartFactory(10, jurisdictionId), }); await request(app.getHttpServer()) @@ -165,14 +170,14 @@ describe('Testing Permissioning of endpoints as Jurisdictional Admin in the corr await request(app.getHttpServer()) .post('/amiCharts') .set({ passkey: process.env.API_PASS_KEY || '' }) - .send(buildAmiChartCreateMock(jurisId)) + .send(buildAmiChartCreateMock(jurisdictionId)) .set('Cookie', cookies) .expect(201); }); it('should succeed for update endpoint', async () => { const amiChartA = await prisma.amiChart.create({ - data: amiChartFactory(10, jurisId), + data: amiChartFactory(10, jurisdictionId), }); await request(app.getHttpServer()) @@ -185,7 +190,7 @@ describe('Testing Permissioning of endpoints as Jurisdictional Admin in the corr it('should succeed for delete endpoint', async () => { const amiChartA = await prisma.amiChart.create({ - data: amiChartFactory(10, jurisId), + data: amiChartFactory(10, jurisdictionId), }); await request(app.getHttpServer()) @@ -214,7 +219,7 @@ describe('Testing Permissioning of endpoints as Jurisdictional Admin in the corr }); it('should succeed for list endpoint', async () => { - const listing1 = await listingFactory(jurisId, prisma); + const listing1 = await listingFactory(jurisdictionId, prisma); const listing1Created = await prisma.listings.create({ data: listing1, }); @@ -232,7 +237,7 @@ describe('Testing Permissioning of endpoints as Jurisdictional Admin in the corr UnitTypeEnum.oneBdrm, ); - const listing1 = await listingFactory(jurisId, prisma); + const listing1 = await listingFactory(jurisdictionId, prisma); const listing1Created = await prisma.listings.create({ data: listing1, }); @@ -259,7 +264,7 @@ describe('Testing Permissioning of endpoints as Jurisdictional Admin in the corr prisma, UnitTypeEnum.oneBdrm, ); - const listing1 = await listingFactory(jurisId, prisma); + const listing1 = await listingFactory(jurisdictionId, prisma); const listing1Created = await prisma.listings.create({ data: listing1, }); @@ -299,7 +304,7 @@ describe('Testing Permissioning of endpoints as Jurisdictional Admin in the corr UnitTypeEnum.oneBdrm, ); - const listing1 = await listingFactory(jurisId, prisma, { + const listing1 = await listingFactory(jurisdictionId, prisma, { digitalApp: true, }); const listing1Created = await prisma.listings.create({ @@ -328,7 +333,7 @@ describe('Testing Permissioning of endpoints as Jurisdictional Admin in the corr UnitTypeEnum.oneBdrm, ); - const listing1 = await listingFactory(jurisId, prisma, { + const listing1 = await listingFactory(jurisdictionId, prisma, { digitalApp: true, }); const listing1Created = await prisma.listings.create({ @@ -366,7 +371,7 @@ describe('Testing Permissioning of endpoints as Jurisdictional Admin in the corr prisma, UnitTypeEnum.oneBdrm, ); - const listing1 = await listingFactory(jurisId, prisma); + const listing1 = await listingFactory(jurisdictionId, prisma); const listing1Created = await prisma.listings.create({ data: listing1, }); @@ -412,7 +417,7 @@ describe('Testing Permissioning of endpoints as Jurisdictional Admin in the corr prisma, UnitTypeEnum.oneBdrm, ); - const listing1 = await listingFactory(jurisId, prisma); + const listing1 = await listingFactory(jurisdictionId, prisma); const listing1Created = await prisma.listings.create({ data: listing1, }); @@ -436,7 +441,7 @@ describe('Testing Permissioning of endpoints as Jurisdictional Admin in the corr it('should succeed for csv endpoint & create an activity log entry', async () => { const application = await applicationFactory(); - const listing1 = await listingFactory(jurisId, prisma, { + const listing1 = await listingFactory(jurisdictionId, prisma, { applications: [application], }); const listing1Created = await prisma.listings.create({ @@ -481,7 +486,7 @@ describe('Testing Permissioning of endpoints as Jurisdictional Admin in the corr it('should succeed for retrieve endpoint', async () => { await request(app.getHttpServer()) - .get(`/jurisdictions/${jurisId}`) + .get(`/jurisdictions/${jurisdictionId}`) .set({ passkey: process.env.API_PASS_KEY || '' }) .set('Cookie', cookies) .expect(200); @@ -490,7 +495,7 @@ describe('Testing Permissioning of endpoints as Jurisdictional Admin in the corr it('should succeed for retrieve by name endpoint', async () => { const jurisdictionA = await prisma.jurisdictions.findFirst({ where: { - id: jurisId, + id: jurisdictionId, }, }); @@ -512,9 +517,11 @@ describe('Testing Permissioning of endpoints as Jurisdictional Admin in the corr it('should error as forbidden for update endpoint', async () => { await request(app.getHttpServer()) - .put(`/jurisdictions/${jurisId}`) + .put(`/jurisdictions/${jurisdictionId}`) .set({ passkey: process.env.API_PASS_KEY || '' }) - .send(buildJurisdictionUpdateMock(jurisId, 'permission juris 9:3')) + .send( + buildJurisdictionUpdateMock(jurisdictionId, 'permission juris 9:3'), + ) .set('Cookie', cookies) .expect(403); }); @@ -548,7 +555,7 @@ describe('Testing Permissioning of endpoints as Jurisdictional Admin in the corr it('should succeed for retrieve endpoint', async () => { const reservedCommunityTypeA = await reservedCommunityTypeFactoryGet( prisma, - jurisId, + jurisdictionId, ); await request(app.getHttpServer()) @@ -562,7 +569,7 @@ describe('Testing Permissioning of endpoints as Jurisdictional Admin in the corr await request(app.getHttpServer()) .post('/reservedCommunityTypes') .set({ passkey: process.env.API_PASS_KEY || '' }) - .send(buildReservedCommunityTypeCreateMock(jurisId)) + .send(buildReservedCommunityTypeCreateMock(jurisdictionId)) .set('Cookie', cookies) .expect(403); }); @@ -570,7 +577,7 @@ describe('Testing Permissioning of endpoints as Jurisdictional Admin in the corr it('should error as forbidden for update endpoint', async () => { const reservedCommunityTypeA = await reservedCommunityTypeFactoryGet( prisma, - jurisId, + jurisdictionId, ); await request(app.getHttpServer()) @@ -584,7 +591,7 @@ describe('Testing Permissioning of endpoints as Jurisdictional Admin in the corr it('should error as forbidden for delete endpoint', async () => { const reservedCommunityTypeA = await reservedCommunityTypeFactoryGet( prisma, - jurisId, + jurisdictionId, ); await request(app.getHttpServer()) @@ -804,7 +811,7 @@ describe('Testing Permissioning of endpoints as Jurisdictional Admin in the corr it('should succeed for retrieve endpoint', async () => { const multiselectQuestionA = await prisma.multiselectQuestions.create({ - data: multiselectQuestionFactory(jurisId), + data: multiselectQuestionFactory(jurisdictionId), }); await request(app.getHttpServer()) @@ -818,21 +825,24 @@ describe('Testing Permissioning of endpoints as Jurisdictional Admin in the corr await request(app.getHttpServer()) .post('/multiselectQuestions') .set({ passkey: process.env.API_PASS_KEY || '' }) - .send(buildMultiselectQuestionCreateMock(jurisId)) + .send(buildMultiselectQuestionCreateMock(jurisdictionId)) .set('Cookie', cookies) .expect(201); }); it('should succeed for update endpoint', async () => { const multiselectQuestionA = await prisma.multiselectQuestions.create({ - data: multiselectQuestionFactory(jurisId), + data: multiselectQuestionFactory(jurisdictionId), }); await request(app.getHttpServer()) - .put(`/multiselectQuestions/${multiselectQuestionA.id}`) + .put('/multiselectQuestions') .set({ passkey: process.env.API_PASS_KEY || '' }) .send( - buildMultiselectQuestionUpdateMock(jurisId, multiselectQuestionA.id), + buildMultiselectQuestionUpdateMock( + jurisdictionId, + multiselectQuestionA.id, + ), ) .set('Cookie', cookies) .expect(200); @@ -840,7 +850,7 @@ describe('Testing Permissioning of endpoints as Jurisdictional Admin in the corr it('should succeed for delete endpoint & create an activity log entry', async () => { const multiselectQuestionA = await prisma.multiselectQuestions.create({ - data: multiselectQuestionFactory(jurisId), + data: multiselectQuestionFactory(jurisdictionId), }); await request(app.getHttpServer()) @@ -862,6 +872,60 @@ describe('Testing Permissioning of endpoints as Jurisdictional Admin in the corr expect(activityLogResult).not.toBeNull(); }); + + it('should succeed for reActivate endpoint', async () => { + const multiselectQuestionA = await prisma.multiselectQuestions.create({ + data: multiselectQuestionFactory( + jurisdictionId, + { + multiselectQuestion: { + status: MultiselectQuestionsStatusEnum.toRetire, + }, + }, + true, + ), + }); + + await request(app.getHttpServer()) + .put('/multiselectQuestions/reActivate') + .set({ passkey: process.env.API_PASS_KEY || '' }) + .send({ + id: multiselectQuestionA.id, + } as IdDTO) + .set('Cookie', cookies) + .expect(200); + }); + + it('should succeed for retire endpoint', async () => { + const multiselectQuestionA = await prisma.multiselectQuestions.create({ + data: multiselectQuestionFactory( + jurisdictionId, + { + multiselectQuestion: { + status: MultiselectQuestionsStatusEnum.active, + }, + }, + true, + ), + }); + + await request(app.getHttpServer()) + .put('/multiselectQuestions/retire') + .set({ passkey: process.env.API_PASS_KEY || '' }) + .send({ + id: multiselectQuestionA.id, + } as IdDTO) + .set('Cookie', cookies) + .expect(200); + }); + + it('should succeed for retireMultiselectQuestions endpoint', async () => { + await request(app.getHttpServer()) + .put('/multiselectQuestions/retireMultiselectQuestions') + .set({ passkey: process.env.API_PASS_KEY || '' }) + .set('Cookie', cookies) + .expect(200); + }); }); describe('Testing user endpoints', () => { @@ -875,7 +939,7 @@ describe('Testing Permissioning of endpoints as Jurisdictional Admin in the corr it('should succeed for retrieve endpoint', async () => { const userA = await prisma.userAccounts.create({ - data: await userFactory({ jurisdictionIds: [jurisId] }), + data: await userFactory({ jurisdictionIds: [jurisdictionId] }), }); await request(app.getHttpServer()) @@ -887,7 +951,7 @@ describe('Testing Permissioning of endpoints as Jurisdictional Admin in the corr it('should succeed for update endpoint', async () => { const userA = await prisma.userAccounts.create({ - data: await userFactory({ jurisdictionIds: [jurisId] }), + data: await userFactory({ jurisdictionIds: [jurisdictionId] }), }); await request(app.getHttpServer()) @@ -897,7 +961,7 @@ describe('Testing Permissioning of endpoints as Jurisdictional Admin in the corr id: userA.id, firstName: 'New User First Name', lastName: 'New User Last Name', - jurisdictions: [{ id: jurisId } as IdDTO], + jurisdictions: [{ id: jurisdictionId } as IdDTO], } as UserUpdate) .set('Cookie', cookies) .expect(200); @@ -906,7 +970,7 @@ describe('Testing Permissioning of endpoints as Jurisdictional Admin in the corr it('should succeed for delete endpoint', async () => { const userA = await prisma.userAccounts.create({ data: await userFactory({ - jurisdictionIds: [jurisId], + jurisdictionIds: [jurisdictionId], roles: { isJurisdictionalAdmin: true }, }), }); @@ -1019,7 +1083,10 @@ describe('Testing Permissioning of endpoints as Jurisdictional Admin in the corr .post(`/user/invite`) .send( // builds an invite for an admin - buildUserInviteMock(jurisId, 'partnerUser+jurisCorrect@email.com'), + buildUserInviteMock( + jurisdictionId, + 'partnerUser+jurisCorrect@email.com', + ), ) .set('Cookie', cookies) .set({ passkey: process.env.API_PASS_KEY || '' }) @@ -1064,13 +1131,13 @@ describe('Testing Permissioning of endpoints as Jurisdictional Admin in the corr it('should succeed for retrieveListings endpoint', async () => { const multiselectQuestion1 = await prisma.multiselectQuestions.create({ - data: multiselectQuestionFactory(jurisId, { + data: multiselectQuestionFactory(jurisdictionId, { multiselectQuestion: { text: 'example a', }, }), }); - const listingA = await listingFactory(jurisId, prisma, { + const listingA = await listingFactory(jurisdictionId, prisma, { multiselectQuestions: [multiselectQuestion1], }); const listingACreated = await prisma.listings.create({ @@ -1089,7 +1156,7 @@ describe('Testing Permissioning of endpoints as Jurisdictional Admin in the corr }); it('should succeed for external listing endpoint', async () => { - const listingA = await listingFactory(jurisId, prisma); + const listingA = await listingFactory(jurisdictionId, prisma); const listingACreated = await prisma.listings.create({ data: listingA, include: { @@ -1104,7 +1171,7 @@ describe('Testing Permissioning of endpoints as Jurisdictional Admin in the corr }); it('should succeed for delete endpoint & create an activity log entry', async () => { - const listingData = await listingFactory(jurisId, prisma, { + const listingData = await listingFactory(jurisdictionId, prisma, { noImage: true, }); const listing = await prisma.listings.create({ @@ -1132,12 +1199,16 @@ describe('Testing Permissioning of endpoints as Jurisdictional Admin in the corr }); it('should succeed for update endpoint & create an activity log entry', async () => { - const listingData = await listingFactory(jurisId, prisma); + const listingData = await listingFactory(jurisdictionId, prisma); const listing = await prisma.listings.create({ data: listingData, }); - const val = await constructFullListingData(prisma, listing.id, jurisId); + const val = await constructFullListingData( + prisma, + listing.id, + jurisdictionId, + ); await request(app.getHttpServer()) .put(`/listings/${listing.id}`) @@ -1158,7 +1229,11 @@ describe('Testing Permissioning of endpoints as Jurisdictional Admin in the corr }); it('should succeed for create endpoint & create an activity log entry', async () => { - const val = await constructFullListingData(prisma, undefined, jurisId); + const val = await constructFullListingData( + prisma, + undefined, + jurisdictionId, + ); const res = await request(app.getHttpServer()) .post('/listings') @@ -1179,7 +1254,7 @@ describe('Testing Permissioning of endpoints as Jurisdictional Admin in the corr }); it('should succeed for duplicate endpoint', async () => { - const listingData = await listingFactory(jurisId, prisma); + const listingData = await listingFactory(jurisdictionId, prisma); const listing = await prisma.listings.create({ data: listingData, }); @@ -1250,7 +1325,7 @@ describe('Testing Permissioning of endpoints as Jurisdictional Admin in the corr }); it('should succeed for retrieve endpoint', async () => { - const listing = await createSimpleListing(prisma, jurisId); + const listing = await createSimpleListing(prisma, jurisdictionId); const applicationA = await createSimpleApplication(prisma); const applicationB = await createSimpleApplication(prisma); @@ -1279,7 +1354,7 @@ describe('Testing Permissioning of endpoints as Jurisdictional Admin in the corr }); it('should succeed for resolve endpoint', async () => { - const listing = await createSimpleListing(prisma, jurisId); + const listing = await createSimpleListing(prisma, jurisdictionId); const applicationA = await createSimpleApplication(prisma); const applicationB = await createSimpleApplication(prisma); @@ -1317,7 +1392,7 @@ describe('Testing Permissioning of endpoints as Jurisdictional Admin in the corr }); it('should succeed for reset confirmation endpoint', async () => { - const listing = await createSimpleListing(prisma, jurisId); + const listing = await createSimpleListing(prisma, jurisdictionId); const applicationA = await createSimpleApplication(prisma); const applicationB = await createSimpleApplication(prisma); @@ -1372,7 +1447,7 @@ describe('Testing Permissioning of endpoints as Jurisdictional Admin in the corr }); it('should error as forbidden for lottery status endpoint', async () => { - const listingData = await listingFactory(jurisId, prisma, { + const listingData = await listingFactory(jurisdictionId, prisma, { status: 'closed', }); const listing = await prisma.listings.create({ diff --git a/api/test/integration/permission-tests/permission-as-juris-admin-wrong-juris.e2e-spec.ts b/api/test/integration/permission-tests/permission-as-juris-admin-wrong-juris.e2e-spec.ts index b09f8864fe..503f96eccc 100644 --- a/api/test/integration/permission-tests/permission-as-juris-admin-wrong-juris.e2e-spec.ts +++ b/api/test/integration/permission-tests/permission-as-juris-admin-wrong-juris.e2e-spec.ts @@ -3,7 +3,12 @@ import { INestApplication } from '@nestjs/common'; import request from 'supertest'; import cookieParser from 'cookie-parser'; import { stringify } from 'qs'; -import { FlaggedSetStatusEnum, RuleEnum, UnitTypeEnum } from '@prisma/client'; +import { + FlaggedSetStatusEnum, + MultiselectQuestionsStatusEnum, + RuleEnum, + UnitTypeEnum, +} from '@prisma/client'; import { AppModule } from '../../../src/modules/app.module'; import { PrismaService } from '../../../src/services/prisma.service'; import { userFactory } from '../../../prisma/seed-helpers/user-factory'; @@ -41,7 +46,6 @@ import { EmailAndAppUrl } from '../../../src/dtos/users/email-and-app-url.dto'; import { ConfirmationRequest } from '../../../src/dtos/users/confirmation-request.dto'; import { UserService } from '../../../src/services/user.service'; import { EmailService } from '../../../src/services/email.service'; -import { permissionActions } from '../../../src/enums/permissions/permission-actions-enum'; import { AfsResolve } from '../../../src/dtos/application-flagged-sets/afs-resolve.dto'; import { generateJurisdiction, @@ -81,7 +85,7 @@ describe('Testing Permissioning of endpoints as Jurisdictional Admin in the wron let userService: UserService; let applicationFlaggedSetService: ApplicationFlaggedSetService; let cookies = ''; - let jurisId = ''; + let jurisdictionId = ''; beforeAll(async () => { const moduleFixture: TestingModule = await Test.createTestingModule({ @@ -106,8 +110,11 @@ describe('Testing Permissioning of endpoints as Jurisdictional Admin in the wron 'jadmin permission juris', ); - jurisId = await generateJurisdiction(prisma, 'wrong permission juris'); - await reservedCommunityTypeFactoryAll(jurisId, prisma); + jurisdictionId = await generateJurisdiction( + prisma, + 'wrong permission juris', + ); + await reservedCommunityTypeFactoryAll(jurisdictionId, prisma); const storedUser = await prisma.userAccounts.create({ data: await userFactory({ @@ -126,7 +133,7 @@ describe('Testing Permissioning of endpoints as Jurisdictional Admin in the wron } as Login) .expect(201); - await reservedCommunityTypeFactoryAll(jurisId, prisma); + await reservedCommunityTypeFactoryAll(jurisdictionId, prisma); await unitAccessibilityPriorityTypeFactoryAll(prisma); cookies = resLogIn.headers['set-cookie']; }); @@ -139,10 +146,10 @@ describe('Testing Permissioning of endpoints as Jurisdictional Admin in the wron describe('Testing ami-chart endpoints', () => { it('should succeed for list endpoint', async () => { await prisma.amiChart.create({ - data: amiChartFactory(10, jurisId), + data: amiChartFactory(10, jurisdictionId), }); const queryParams: AmiChartQueryParams = { - jurisdictionId: jurisId, + jurisdictionId: jurisdictionId, }; const query = stringify(queryParams as any); @@ -155,7 +162,7 @@ describe('Testing Permissioning of endpoints as Jurisdictional Admin in the wron it('should succeed for retrieve endpoint', async () => { const amiChartA = await prisma.amiChart.create({ - data: amiChartFactory(10, jurisId), + data: amiChartFactory(10, jurisdictionId), }); await request(app.getHttpServer()) @@ -169,14 +176,14 @@ describe('Testing Permissioning of endpoints as Jurisdictional Admin in the wron await request(app.getHttpServer()) .post('/amiCharts') .set({ passkey: process.env.API_PASS_KEY || '' }) - .send(buildAmiChartCreateMock(jurisId)) + .send(buildAmiChartCreateMock(jurisdictionId)) .set('Cookie', cookies) .expect(201); }); it('should succeed for update endpoint', async () => { const amiChartA = await prisma.amiChart.create({ - data: amiChartFactory(10, jurisId), + data: amiChartFactory(10, jurisdictionId), }); await request(app.getHttpServer()) @@ -189,7 +196,7 @@ describe('Testing Permissioning of endpoints as Jurisdictional Admin in the wron it('should succeed for delete endpoint', async () => { const amiChartA = await prisma.amiChart.create({ - data: amiChartFactory(10, jurisId), + data: amiChartFactory(10, jurisdictionId), }); await request(app.getHttpServer()) @@ -218,7 +225,7 @@ describe('Testing Permissioning of endpoints as Jurisdictional Admin in the wron }); it('should succeed for list endpoint', async () => { - const listing1 = await listingFactory(jurisId, prisma); + const listing1 = await listingFactory(jurisdictionId, prisma); const listing1Created = await prisma.listings.create({ data: listing1, }); @@ -236,7 +243,7 @@ describe('Testing Permissioning of endpoints as Jurisdictional Admin in the wron UnitTypeEnum.oneBdrm, ); - const listing1 = await listingFactory(jurisId, prisma); + const listing1 = await listingFactory(jurisdictionId, prisma); const listing1Created = await prisma.listings.create({ data: listing1, }); @@ -263,7 +270,7 @@ describe('Testing Permissioning of endpoints as Jurisdictional Admin in the wron prisma, UnitTypeEnum.oneBdrm, ); - const listing1 = await listingFactory(jurisId, prisma); + const listing1 = await listingFactory(jurisdictionId, prisma); const listing1Created = await prisma.listings.create({ data: listing1, }); @@ -293,7 +300,7 @@ describe('Testing Permissioning of endpoints as Jurisdictional Admin in the wron UnitTypeEnum.oneBdrm, ); - const listing1 = await listingFactory(jurisId, prisma, { + const listing1 = await listingFactory(jurisdictionId, prisma, { digitalApp: true, }); const listing1Created = await prisma.listings.create({ @@ -322,7 +329,7 @@ describe('Testing Permissioning of endpoints as Jurisdictional Admin in the wron UnitTypeEnum.oneBdrm, ); - const listing1 = await listingFactory(jurisId, prisma, { + const listing1 = await listingFactory(jurisdictionId, prisma, { digitalApp: true, }); const listing1Created = await prisma.listings.create({ @@ -350,7 +357,7 @@ describe('Testing Permissioning of endpoints as Jurisdictional Admin in the wron prisma, UnitTypeEnum.oneBdrm, ); - const listing1 = await listingFactory(jurisId, prisma); + const listing1 = await listingFactory(jurisdictionId, prisma); const listing1Created = await prisma.listings.create({ data: listing1, }); @@ -387,7 +394,7 @@ describe('Testing Permissioning of endpoints as Jurisdictional Admin in the wron prisma, UnitTypeEnum.oneBdrm, ); - const listing1 = await listingFactory(jurisId, prisma); + const listing1 = await listingFactory(jurisdictionId, prisma); const listing1Created = await prisma.listings.create({ data: listing1, }); @@ -451,7 +458,7 @@ describe('Testing Permissioning of endpoints as Jurisdictional Admin in the wron it('should succeed for retrieve endpoint', async () => { await request(app.getHttpServer()) - .get(`/jurisdictions/${jurisId}`) + .get(`/jurisdictions/${jurisdictionId}`) .set({ passkey: process.env.API_PASS_KEY || '' }) .set('Cookie', cookies) .expect(200); @@ -460,7 +467,7 @@ describe('Testing Permissioning of endpoints as Jurisdictional Admin in the wron it('should succeed for retrieve by name endpoint', async () => { const jurisdictionA = await prisma.jurisdictions.findFirst({ where: { - id: jurisId, + id: jurisdictionId, }, }); @@ -482,9 +489,11 @@ describe('Testing Permissioning of endpoints as Jurisdictional Admin in the wron it('should error as forbidden for update endpoint', async () => { await request(app.getHttpServer()) - .put(`/jurisdictions/${jurisId}`) + .put(`/jurisdictions/${jurisdictionId}`) .set({ passkey: process.env.API_PASS_KEY || '' }) - .send(buildJurisdictionUpdateMock(jurisId, 'permission juris 9:4')) + .send( + buildJurisdictionUpdateMock(jurisdictionId, 'permission juris 9:4'), + ) .set('Cookie', cookies) .expect(403); }); @@ -518,7 +527,7 @@ describe('Testing Permissioning of endpoints as Jurisdictional Admin in the wron it('should succeed for retrieve endpoint', async () => { const reservedCommunityTypeA = await reservedCommunityTypeFactoryGet( prisma, - jurisId, + jurisdictionId, ); await request(app.getHttpServer()) @@ -532,7 +541,7 @@ describe('Testing Permissioning of endpoints as Jurisdictional Admin in the wron await request(app.getHttpServer()) .post('/reservedCommunityTypes') .set({ passkey: process.env.API_PASS_KEY || '' }) - .send(buildReservedCommunityTypeCreateMock(jurisId)) + .send(buildReservedCommunityTypeCreateMock(jurisdictionId)) .set('Cookie', cookies) .expect(403); }); @@ -540,7 +549,7 @@ describe('Testing Permissioning of endpoints as Jurisdictional Admin in the wron it('should error as forbidden for update endpoint', async () => { const reservedCommunityTypeA = await reservedCommunityTypeFactoryGet( prisma, - jurisId, + jurisdictionId, ); await request(app.getHttpServer()) .put(`/reservedCommunityTypes/${reservedCommunityTypeA.id}`) @@ -553,7 +562,7 @@ describe('Testing Permissioning of endpoints as Jurisdictional Admin in the wron it('should error as forbidden for delete endpoint', async () => { const reservedCommunityTypeA = await reservedCommunityTypeFactoryGet( prisma, - jurisId, + jurisdictionId, ); await request(app.getHttpServer()) @@ -773,7 +782,7 @@ describe('Testing Permissioning of endpoints as Jurisdictional Admin in the wron it('should succeed for retrieve endpoint', async () => { const multiselectQuestionA = await prisma.multiselectQuestions.create({ - data: multiselectQuestionFactory(jurisId), + data: multiselectQuestionFactory(jurisdictionId), }); await request(app.getHttpServer()) @@ -783,33 +792,36 @@ describe('Testing Permissioning of endpoints as Jurisdictional Admin in the wron .expect(200); }); - it('should succed for create endpoint', async () => { + it('should error as forbidden for create endpoint', async () => { await request(app.getHttpServer()) .post('/multiselectQuestions') .set({ passkey: process.env.API_PASS_KEY || '' }) - .send(buildMultiselectQuestionCreateMock(jurisId)) + .send(buildMultiselectQuestionCreateMock(jurisdictionId)) .set('Cookie', cookies) - .expect(201); + .expect(403); }); - it('should succeed for update endpoint', async () => { + it('should error as forbidden for update endpoint', async () => { const multiselectQuestionA = await prisma.multiselectQuestions.create({ - data: multiselectQuestionFactory(jurisId), + data: multiselectQuestionFactory(jurisdictionId), }); await request(app.getHttpServer()) - .put(`/multiselectQuestions/${multiselectQuestionA.id}`) + .put('/multiselectQuestions') .set({ passkey: process.env.API_PASS_KEY || '' }) .send( - buildMultiselectQuestionUpdateMock(jurisId, multiselectQuestionA.id), + buildMultiselectQuestionUpdateMock( + jurisdictionId, + multiselectQuestionA.id, + ), ) .set('Cookie', cookies) - .expect(200); + .expect(403); }); - it('should succeed for delete endpoint & create an activity log entry', async () => { + it('should error as forbidden for delete endpoint', async () => { const multiselectQuestionA = await prisma.multiselectQuestions.create({ - data: multiselectQuestionFactory(jurisId), + data: multiselectQuestionFactory(jurisdictionId), }); await request(app.getHttpServer()) @@ -819,17 +831,61 @@ describe('Testing Permissioning of endpoints as Jurisdictional Admin in the wron id: multiselectQuestionA.id, } as IdDTO) .set('Cookie', cookies) - .expect(200); + .expect(403); + }); - const activityLogResult = await prisma.activityLog.findFirst({ - where: { - module: 'multiselectQuestion', - action: permissionActions.delete, - recordId: multiselectQuestionA.id, - }, + it('should error as forbidden for reActivate endpoint', async () => { + const multiselectQuestionA = await prisma.multiselectQuestions.create({ + data: multiselectQuestionFactory( + jurisdictionId, + { + multiselectQuestion: { + status: MultiselectQuestionsStatusEnum.toRetire, + }, + }, + true, + ), }); - expect(activityLogResult).not.toBeNull(); + await request(app.getHttpServer()) + .put('/multiselectQuestions/reActivate') + .set({ passkey: process.env.API_PASS_KEY || '' }) + .send({ + id: multiselectQuestionA.id, + } as IdDTO) + .set('Cookie', cookies) + .expect(403); + }); + + it('should error as forbidden for retire endpoint', async () => { + const multiselectQuestionA = await prisma.multiselectQuestions.create({ + data: multiselectQuestionFactory( + jurisdictionId, + { + multiselectQuestion: { + status: MultiselectQuestionsStatusEnum.active, + }, + }, + true, + ), + }); + + await request(app.getHttpServer()) + .put('/multiselectQuestions/retire') + .set({ passkey: process.env.API_PASS_KEY || '' }) + .send({ + id: multiselectQuestionA.id, + } as IdDTO) + .set('Cookie', cookies) + .expect(403); + }); + + it('should succeed for retireMultiselectQuestions endpoint', async () => { + await request(app.getHttpServer()) + .put('/multiselectQuestions/retireMultiselectQuestions') + .set({ passkey: process.env.API_PASS_KEY || '' }) + .set('Cookie', cookies) + .expect(200); }); }); @@ -1031,13 +1087,13 @@ describe('Testing Permissioning of endpoints as Jurisdictional Admin in the wron it('should succeed for retrieveListings endpoint', async () => { const multiselectQuestion1 = await prisma.multiselectQuestions.create({ - data: multiselectQuestionFactory(jurisId, { + data: multiselectQuestionFactory(jurisdictionId, { multiselectQuestion: { text: 'example a', }, }), }); - const listingA = await listingFactory(jurisId, prisma, { + const listingA = await listingFactory(jurisdictionId, prisma, { multiselectQuestions: [multiselectQuestion1], }); const listingACreated = await prisma.listings.create({ @@ -1056,7 +1112,7 @@ describe('Testing Permissioning of endpoints as Jurisdictional Admin in the wron }); it('should succeed for external listing endpoint', async () => { - const listingA = await listingFactory(jurisId, prisma); + const listingA = await listingFactory(jurisdictionId, prisma); const listingACreated = await prisma.listings.create({ data: listingA, include: { @@ -1071,7 +1127,7 @@ describe('Testing Permissioning of endpoints as Jurisdictional Admin in the wron }); it('should error as forbidden for delete endpoint', async () => { - const listingData = await listingFactory(jurisId, prisma); + const listingData = await listingFactory(jurisdictionId, prisma); const listing = await prisma.listings.create({ data: listingData, }); @@ -1087,12 +1143,16 @@ describe('Testing Permissioning of endpoints as Jurisdictional Admin in the wron }); it('should error as forbidden for update endpoint', async () => { - const listingData = await listingFactory(jurisId, prisma); + const listingData = await listingFactory(jurisdictionId, prisma); const listing = await prisma.listings.create({ data: listingData, }); - const val = await constructFullListingData(prisma, listing.id, jurisId); + const val = await constructFullListingData( + prisma, + listing.id, + jurisdictionId, + ); await request(app.getHttpServer()) .put(`/listings/${listing.id}`) @@ -1103,7 +1163,11 @@ describe('Testing Permissioning of endpoints as Jurisdictional Admin in the wron }); it('should error as forbidden for create endpoint', async () => { - const val = await constructFullListingData(prisma, undefined, jurisId); + const val = await constructFullListingData( + prisma, + undefined, + jurisdictionId, + ); await request(app.getHttpServer()) .post('/listings') @@ -1114,7 +1178,7 @@ describe('Testing Permissioning of endpoints as Jurisdictional Admin in the wron }); it('should error as forbidden for duplicate endpoint', async () => { - const listingData = await listingFactory(jurisId, prisma); + const listingData = await listingFactory(jurisdictionId, prisma); const listing = await prisma.listings.create({ data: listingData, }); @@ -1185,7 +1249,7 @@ describe('Testing Permissioning of endpoints as Jurisdictional Admin in the wron }); it('should succeed for retrieve endpoint', async () => { - const listing = await createSimpleListing(prisma, jurisId); + const listing = await createSimpleListing(prisma, jurisdictionId); const applicationA = await createSimpleApplication(prisma); const applicationB = await createSimpleApplication(prisma); @@ -1214,7 +1278,7 @@ describe('Testing Permissioning of endpoints as Jurisdictional Admin in the wron }); it('should succeed for resolve endpoint', async () => { - const listing = await createSimpleListing(prisma, jurisId); + const listing = await createSimpleListing(prisma, jurisdictionId); const applicationA = await createSimpleApplication(prisma); const applicationB = await createSimpleApplication(prisma); @@ -1252,7 +1316,7 @@ describe('Testing Permissioning of endpoints as Jurisdictional Admin in the wron }); it('should succeed for reset confirmation endpoint', async () => { - const listing = await createSimpleListing(prisma, jurisId); + const listing = await createSimpleListing(prisma, jurisdictionId); const applicationA = await createSimpleApplication(prisma); const applicationB = await createSimpleApplication(prisma); @@ -1308,7 +1372,7 @@ describe('Testing Permissioning of endpoints as Jurisdictional Admin in the wron }); it('should error as forbidden for lottery status endpoint', async () => { - const listingData = await listingFactory(jurisId, prisma, { + const listingData = await listingFactory(jurisdictionId, prisma, { status: 'closed', }); const listing = await prisma.listings.create({ diff --git a/api/test/integration/permission-tests/permission-as-limited-juris-admin-correct-juris.e2e-spec.ts b/api/test/integration/permission-tests/permission-as-limited-juris-admin-correct-juris.e2e-spec.ts index 17bf1a1f5e..fd0fd67345 100644 --- a/api/test/integration/permission-tests/permission-as-limited-juris-admin-correct-juris.e2e-spec.ts +++ b/api/test/integration/permission-tests/permission-as-limited-juris-admin-correct-juris.e2e-spec.ts @@ -3,7 +3,12 @@ import { INestApplication } from '@nestjs/common'; import request from 'supertest'; import cookieParser from 'cookie-parser'; import { stringify } from 'qs'; -import { FlaggedSetStatusEnum, RuleEnum, UnitTypeEnum } from '@prisma/client'; +import { + FlaggedSetStatusEnum, + MultiselectQuestionsStatusEnum, + RuleEnum, + UnitTypeEnum, +} from '@prisma/client'; import { AppModule } from '../../../src/modules/app.module'; import { PrismaService } from '../../../src/services/prisma.service'; import { userFactory } from '../../../prisma/seed-helpers/user-factory'; @@ -80,7 +85,7 @@ describe('Testing Permissioning of endpoints as Limited Jurisdictional Admin in let userService: UserService; let applicationFlaggedSetService: ApplicationFlaggedSetService; let cookies = ''; - let jurisId = ''; + let jurisdictionId = ''; beforeAll(async () => { const moduleFixture: TestingModule = await Test.createTestingModule({ imports: [AppModule], @@ -99,14 +104,14 @@ describe('Testing Permissioning of endpoints as Limited Jurisdictional Admin in app.use(cookieParser()); await app.init(); - jurisId = await generateJurisdiction(prisma, 'permission juris 80'); - await reservedCommunityTypeFactoryAll(jurisId, prisma); + jurisdictionId = await generateJurisdiction(prisma, 'permission juris 80'); + await reservedCommunityTypeFactoryAll(jurisdictionId, prisma); await unitAccessibilityPriorityTypeFactoryAll(prisma); const storedUser = await prisma.userAccounts.create({ data: await userFactory({ roles: { isLimitedJurisdictionalAdmin: true }, - jurisdictionIds: [jurisId], + jurisdictionIds: [jurisdictionId], mfaEnabled: false, confirmedAt: new Date(), }), @@ -131,10 +136,10 @@ describe('Testing Permissioning of endpoints as Limited Jurisdictional Admin in describe('Testing ami-chart endpoints', () => { it('should succeed for list endpoint', async () => { await prisma.amiChart.create({ - data: amiChartFactory(10, jurisId), + data: amiChartFactory(10, jurisdictionId), }); const queryParams: AmiChartQueryParams = { - jurisdictionId: jurisId, + jurisdictionId: jurisdictionId, }; const query = stringify(queryParams as any); @@ -147,7 +152,7 @@ describe('Testing Permissioning of endpoints as Limited Jurisdictional Admin in it('should succeed for retrieve endpoint', async () => { const amiChartA = await prisma.amiChart.create({ - data: amiChartFactory(10, jurisId), + data: amiChartFactory(10, jurisdictionId), }); await request(app.getHttpServer()) @@ -161,14 +166,14 @@ describe('Testing Permissioning of endpoints as Limited Jurisdictional Admin in await request(app.getHttpServer()) .post('/amiCharts') .set({ passkey: process.env.API_PASS_KEY || '' }) - .send(buildAmiChartCreateMock(jurisId)) + .send(buildAmiChartCreateMock(jurisdictionId)) .set('Cookie', cookies) .expect(201); }); it('should succeed for update endpoint', async () => { const amiChartA = await prisma.amiChart.create({ - data: amiChartFactory(10, jurisId), + data: amiChartFactory(10, jurisdictionId), }); await request(app.getHttpServer()) @@ -181,7 +186,7 @@ describe('Testing Permissioning of endpoints as Limited Jurisdictional Admin in it('should succeed for delete endpoint', async () => { const amiChartA = await prisma.amiChart.create({ - data: amiChartFactory(10, jurisId), + data: amiChartFactory(10, jurisdictionId), }); await request(app.getHttpServer()) @@ -210,7 +215,7 @@ describe('Testing Permissioning of endpoints as Limited Jurisdictional Admin in }); it('should succeed for list endpoint for listing with no applications', async () => { - const listing1 = await listingFactory(jurisId, prisma); + const listing1 = await listingFactory(jurisdictionId, prisma); const listing1Created = await prisma.listings.create({ data: listing1, }); @@ -228,7 +233,7 @@ describe('Testing Permissioning of endpoints as Limited Jurisdictional Admin in UnitTypeEnum.oneBdrm, ); - const listing1 = await listingFactory(jurisId, prisma); + const listing1 = await listingFactory(jurisdictionId, prisma); const listing1Created = await prisma.listings.create({ data: listing1, }); @@ -255,7 +260,7 @@ describe('Testing Permissioning of endpoints as Limited Jurisdictional Admin in prisma, UnitTypeEnum.oneBdrm, ); - const listing1 = await listingFactory(jurisId, prisma); + const listing1 = await listingFactory(jurisdictionId, prisma); const listing1Created = await prisma.listings.create({ data: listing1, }); @@ -285,7 +290,7 @@ describe('Testing Permissioning of endpoints as Limited Jurisdictional Admin in UnitTypeEnum.oneBdrm, ); - const listing1 = await listingFactory(jurisId, prisma, { + const listing1 = await listingFactory(jurisdictionId, prisma, { digitalApp: true, }); const listing1Created = await prisma.listings.create({ @@ -314,7 +319,7 @@ describe('Testing Permissioning of endpoints as Limited Jurisdictional Admin in UnitTypeEnum.oneBdrm, ); - const listing1 = await listingFactory(jurisId, prisma); + const listing1 = await listingFactory(jurisdictionId, prisma); const listing1Created = await prisma.listings.create({ data: listing1, }); @@ -340,7 +345,7 @@ describe('Testing Permissioning of endpoints as Limited Jurisdictional Admin in prisma, UnitTypeEnum.oneBdrm, ); - const listing1 = await listingFactory(jurisId, prisma); + const listing1 = await listingFactory(jurisdictionId, prisma); const listing1Created = await prisma.listings.create({ data: listing1, }); @@ -376,7 +381,7 @@ describe('Testing Permissioning of endpoints as Limited Jurisdictional Admin in prisma, UnitTypeEnum.oneBdrm, ); - const listing1 = await listingFactory(jurisId, prisma); + const listing1 = await listingFactory(jurisdictionId, prisma); const listing1Created = await prisma.listings.create({ data: listing1, }); @@ -400,7 +405,7 @@ describe('Testing Permissioning of endpoints as Limited Jurisdictional Admin in it('should error as forbidden for csv endpoint', async () => { const application = await applicationFactory(); - const listing1 = await listingFactory(jurisId, prisma, { + const listing1 = await listingFactory(jurisdictionId, prisma, { applications: [application], }); const listing1Created = await prisma.listings.create({ @@ -436,7 +441,7 @@ describe('Testing Permissioning of endpoints as Limited Jurisdictional Admin in it('should succeed for retrieve endpoint', async () => { await request(app.getHttpServer()) - .get(`/jurisdictions/${jurisId}`) + .get(`/jurisdictions/${jurisdictionId}`) .set({ passkey: process.env.API_PASS_KEY || '' }) .set('Cookie', cookies) .expect(200); @@ -445,7 +450,7 @@ describe('Testing Permissioning of endpoints as Limited Jurisdictional Admin in it('should succeed for retrieve by name endpoint', async () => { const jurisdictionA = await prisma.jurisdictions.findFirst({ where: { - id: jurisId, + id: jurisdictionId, }, }); @@ -467,9 +472,11 @@ describe('Testing Permissioning of endpoints as Limited Jurisdictional Admin in it('should error as forbidden for update endpoint', async () => { await request(app.getHttpServer()) - .put(`/jurisdictions/${jurisId}`) + .put(`/jurisdictions/${jurisdictionId}`) .set({ passkey: process.env.API_PASS_KEY || '' }) - .send(buildJurisdictionUpdateMock(jurisId, 'permission juris 9:3')) + .send( + buildJurisdictionUpdateMock(jurisdictionId, 'permission juris 9:3'), + ) .set('Cookie', cookies) .expect(403); }); @@ -503,7 +510,7 @@ describe('Testing Permissioning of endpoints as Limited Jurisdictional Admin in it('should succeed for retrieve endpoint', async () => { const reservedCommunityTypeA = await reservedCommunityTypeFactoryGet( prisma, - jurisId, + jurisdictionId, ); await request(app.getHttpServer()) @@ -517,7 +524,7 @@ describe('Testing Permissioning of endpoints as Limited Jurisdictional Admin in await request(app.getHttpServer()) .post('/reservedCommunityTypes') .set({ passkey: process.env.API_PASS_KEY || '' }) - .send(buildReservedCommunityTypeCreateMock(jurisId)) + .send(buildReservedCommunityTypeCreateMock(jurisdictionId)) .set('Cookie', cookies) .expect(403); }); @@ -525,7 +532,7 @@ describe('Testing Permissioning of endpoints as Limited Jurisdictional Admin in it('should error as forbidden for update endpoint', async () => { const reservedCommunityTypeA = await reservedCommunityTypeFactoryGet( prisma, - jurisId, + jurisdictionId, ); await request(app.getHttpServer()) @@ -539,7 +546,7 @@ describe('Testing Permissioning of endpoints as Limited Jurisdictional Admin in it('should error as forbidden for delete endpoint', async () => { const reservedCommunityTypeA = await reservedCommunityTypeFactoryGet( prisma, - jurisId, + jurisdictionId, ); await request(app.getHttpServer()) @@ -759,7 +766,7 @@ describe('Testing Permissioning of endpoints as Limited Jurisdictional Admin in it('should succeed for retrieve endpoint', async () => { const multiselectQuestionA = await prisma.multiselectQuestions.create({ - data: multiselectQuestionFactory(jurisId), + data: multiselectQuestionFactory(jurisdictionId), }); await request(app.getHttpServer()) @@ -773,21 +780,24 @@ describe('Testing Permissioning of endpoints as Limited Jurisdictional Admin in await request(app.getHttpServer()) .post('/multiselectQuestions') .set({ passkey: process.env.API_PASS_KEY || '' }) - .send(buildMultiselectQuestionCreateMock(jurisId)) + .send(buildMultiselectQuestionCreateMock(jurisdictionId)) .set('Cookie', cookies) .expect(403); }); it('should error as forbidden for update endpoint', async () => { const multiselectQuestionA = await prisma.multiselectQuestions.create({ - data: multiselectQuestionFactory(jurisId), + data: multiselectQuestionFactory(jurisdictionId), }); await request(app.getHttpServer()) - .put(`/multiselectQuestions/${multiselectQuestionA.id}`) + .put('/multiselectQuestions') .set({ passkey: process.env.API_PASS_KEY || '' }) .send( - buildMultiselectQuestionUpdateMock(jurisId, multiselectQuestionA.id), + buildMultiselectQuestionUpdateMock( + jurisdictionId, + multiselectQuestionA.id, + ), ) .set('Cookie', cookies) .expect(403); @@ -795,7 +805,7 @@ describe('Testing Permissioning of endpoints as Limited Jurisdictional Admin in it('should error as forbidden for delete endpoint', async () => { const multiselectQuestionA = await prisma.multiselectQuestions.create({ - data: multiselectQuestionFactory(jurisId), + data: multiselectQuestionFactory(jurisdictionId), }); await request(app.getHttpServer()) @@ -807,6 +817,60 @@ describe('Testing Permissioning of endpoints as Limited Jurisdictional Admin in .set('Cookie', cookies) .expect(403); }); + + it('should error as forbidden for reActivate endpoint', async () => { + const multiselectQuestionA = await prisma.multiselectQuestions.create({ + data: multiselectQuestionFactory( + jurisdictionId, + { + multiselectQuestion: { + status: MultiselectQuestionsStatusEnum.toRetire, + }, + }, + true, + ), + }); + + await request(app.getHttpServer()) + .put('/multiselectQuestions/reActivate') + .set({ passkey: process.env.API_PASS_KEY || '' }) + .send({ + id: multiselectQuestionA.id, + } as IdDTO) + .set('Cookie', cookies) + .expect(403); + }); + + it('should error as forbidden for retire endpoint', async () => { + const multiselectQuestionA = await prisma.multiselectQuestions.create({ + data: multiselectQuestionFactory( + jurisdictionId, + { + multiselectQuestion: { + status: MultiselectQuestionsStatusEnum.active, + }, + }, + true, + ), + }); + + await request(app.getHttpServer()) + .put('/multiselectQuestions/retire') + .set({ passkey: process.env.API_PASS_KEY || '' }) + .send({ + id: multiselectQuestionA.id, + } as IdDTO) + .set('Cookie', cookies) + .expect(403); + }); + + it('should error as forbidden for retireMultiselectQuestions endpoint', async () => { + await request(app.getHttpServer()) + .put('/multiselectQuestions/retireMultiselectQuestions') + .set({ passkey: process.env.API_PASS_KEY || '' }) + .set('Cookie', cookies) + .expect(403); + }); }); describe('Testing user endpoints', () => { @@ -820,7 +884,7 @@ describe('Testing Permissioning of endpoints as Limited Jurisdictional Admin in it('should error as forbidden for retrieve endpoint', async () => { const userA = await prisma.userAccounts.create({ - data: await userFactory({ jurisdictionIds: [jurisId] }), + data: await userFactory({ jurisdictionIds: [jurisdictionId] }), }); await request(app.getHttpServer()) @@ -832,7 +896,7 @@ describe('Testing Permissioning of endpoints as Limited Jurisdictional Admin in it('should error as forbidden for update endpoint', async () => { const userA = await prisma.userAccounts.create({ - data: await userFactory({ jurisdictionIds: [jurisId] }), + data: await userFactory({ jurisdictionIds: [jurisdictionId] }), }); await request(app.getHttpServer()) @@ -842,7 +906,7 @@ describe('Testing Permissioning of endpoints as Limited Jurisdictional Admin in id: userA.id, firstName: 'New User First Name', lastName: 'New User Last Name', - jurisdictions: [{ id: jurisId } as IdDTO], + jurisdictions: [{ id: jurisdictionId } as IdDTO], } as UserUpdate) .set('Cookie', cookies) .expect(403); @@ -850,7 +914,7 @@ describe('Testing Permissioning of endpoints as Limited Jurisdictional Admin in it('should error as forbidden for delete endpoint', async () => { const userA = await prisma.userAccounts.create({ - data: await userFactory({ jurisdictionIds: [jurisId] }), + data: await userFactory({ jurisdictionIds: [jurisdictionId] }), }); await request(app.getHttpServer()) @@ -958,7 +1022,10 @@ describe('Testing Permissioning of endpoints as Limited Jurisdictional Admin in .post(`/user/invite`) .send( // builds an invite for an admin - buildUserInviteMock(jurisId, 'partnerUser+jurisCorrect@email.com'), + buildUserInviteMock( + jurisdictionId, + 'partnerUser+jurisCorrect@email.com', + ), ) .set('Cookie', cookies) .set({ passkey: process.env.API_PASS_KEY || '' }) @@ -985,13 +1052,13 @@ describe('Testing Permissioning of endpoints as Limited Jurisdictional Admin in it('should succeed for retrieveListings endpoint', async () => { const multiselectQuestion1 = await prisma.multiselectQuestions.create({ - data: multiselectQuestionFactory(jurisId, { + data: multiselectQuestionFactory(jurisdictionId, { multiselectQuestion: { text: 'example a', }, }), }); - const listingA = await listingFactory(jurisId, prisma, { + const listingA = await listingFactory(jurisdictionId, prisma, { multiselectQuestions: [multiselectQuestion1], }); const listingACreated = await prisma.listings.create({ @@ -1010,7 +1077,7 @@ describe('Testing Permissioning of endpoints as Limited Jurisdictional Admin in }); it('should succeed for external listing endpoint', async () => { - const listingA = await listingFactory(jurisId, prisma); + const listingA = await listingFactory(jurisdictionId, prisma); const listingACreated = await prisma.listings.create({ data: listingA, include: { @@ -1025,7 +1092,7 @@ describe('Testing Permissioning of endpoints as Limited Jurisdictional Admin in }); it('should succeed for delete endpoint & create an activity log entry', async () => { - const listingData = await listingFactory(jurisId, prisma, { + const listingData = await listingFactory(jurisdictionId, prisma, { noImage: true, }); const listing = await prisma.listings.create({ @@ -1053,14 +1120,18 @@ describe('Testing Permissioning of endpoints as Limited Jurisdictional Admin in }); it('should succeed for update endpoint & create an activity log entry when user is not updating dates', async () => { - const listingData = await listingFactory(jurisId, prisma, { + const listingData = await listingFactory(jurisdictionId, prisma, { applicationDueDate: new Date(), }); const listing = await prisma.listings.create({ data: listingData, }); - const val = await constructFullListingData(prisma, listing.id, jurisId); + const val = await constructFullListingData( + prisma, + listing.id, + jurisdictionId, + ); val.reviewOrderType = listing.reviewOrderType; val.applicationDueDate = listing.applicationDueDate; @@ -1083,7 +1154,11 @@ describe('Testing Permissioning of endpoints as Limited Jurisdictional Admin in }); it('should succeed for create endpoint & create an activity log entry', async () => { - const val = await constructFullListingData(prisma, undefined, jurisId); + const val = await constructFullListingData( + prisma, + undefined, + jurisdictionId, + ); const res = await request(app.getHttpServer()) .post('/listings') @@ -1138,7 +1213,7 @@ describe('Testing Permissioning of endpoints as Limited Jurisdictional Admin in }); it('should error as forbidden for lottery status endpoint', async () => { - const listingData = await listingFactory(jurisId, prisma, { + const listingData = await listingFactory(jurisdictionId, prisma, { status: 'closed', }); const listing = await prisma.listings.create({ @@ -1200,7 +1275,7 @@ describe('Testing Permissioning of endpoints as Limited Jurisdictional Admin in }); it('should error as forbidden for retrieve endpoint', async () => { - const listing = await createSimpleListing(prisma, jurisId); + const listing = await createSimpleListing(prisma, jurisdictionId); const applicationA = await createSimpleApplication(prisma); const applicationB = await createSimpleApplication(prisma); @@ -1229,7 +1304,7 @@ describe('Testing Permissioning of endpoints as Limited Jurisdictional Admin in }); it('should error as forbidden for resolve endpoint', async () => { - const listing = await createSimpleListing(prisma, jurisId); + const listing = await createSimpleListing(prisma, jurisdictionId); const applicationA = await createSimpleApplication(prisma); const applicationB = await createSimpleApplication(prisma); @@ -1267,7 +1342,7 @@ describe('Testing Permissioning of endpoints as Limited Jurisdictional Admin in }); it('should error as forbidden for reset confirmation endpoint', async () => { - const listing = await createSimpleListing(prisma, jurisId); + const listing = await createSimpleListing(prisma, jurisdictionId); const applicationA = await createSimpleApplication(prisma); const applicationB = await createSimpleApplication(prisma); diff --git a/api/test/integration/permission-tests/permission-as-limited-juris-admin-wrong-juris.e2e-spec.ts b/api/test/integration/permission-tests/permission-as-limited-juris-admin-wrong-juris.e2e-spec.ts index 1d2872414e..43e2c8e58c 100644 --- a/api/test/integration/permission-tests/permission-as-limited-juris-admin-wrong-juris.e2e-spec.ts +++ b/api/test/integration/permission-tests/permission-as-limited-juris-admin-wrong-juris.e2e-spec.ts @@ -3,7 +3,12 @@ import { INestApplication } from '@nestjs/common'; import request from 'supertest'; import cookieParser from 'cookie-parser'; import { stringify } from 'qs'; -import { FlaggedSetStatusEnum, RuleEnum, UnitTypeEnum } from '@prisma/client'; +import { + FlaggedSetStatusEnum, + MultiselectQuestionsStatusEnum, + RuleEnum, + UnitTypeEnum, +} from '@prisma/client'; import { AppModule } from '../../../src/modules/app.module'; import { PrismaService } from '../../../src/services/prisma.service'; import { userFactory } from '../../../prisma/seed-helpers/user-factory'; @@ -80,7 +85,7 @@ describe('Testing Permissioning of endpoints as Limited Jurisdictional Admin in let userService: UserService; let applicationFlaggedSetService: ApplicationFlaggedSetService; let cookies = ''; - let jurisId = ''; + let jurisdictionId = ''; beforeAll(async () => { const moduleFixture: TestingModule = await Test.createTestingModule({ @@ -105,11 +110,11 @@ describe('Testing Permissioning of endpoints as Limited Jurisdictional Admin in 'wrong limited jadmin permission juris', ); - jurisId = await generateJurisdiction( + jurisdictionId = await generateJurisdiction( prisma, 'wrong permission limited juris', ); - await reservedCommunityTypeFactoryAll(jurisId, prisma); + await reservedCommunityTypeFactoryAll(jurisdictionId, prisma); const storedUser = await prisma.userAccounts.create({ data: await userFactory({ @@ -128,7 +133,7 @@ describe('Testing Permissioning of endpoints as Limited Jurisdictional Admin in } as Login) .expect(201); - await reservedCommunityTypeFactoryAll(jurisId, prisma); + await reservedCommunityTypeFactoryAll(jurisdictionId, prisma); await unitAccessibilityPriorityTypeFactoryAll(prisma); cookies = resLogIn.headers['set-cookie']; }); @@ -141,10 +146,10 @@ describe('Testing Permissioning of endpoints as Limited Jurisdictional Admin in describe('Testing ami-chart endpoints', () => { it('should succeed for list endpoint', async () => { await prisma.amiChart.create({ - data: amiChartFactory(10, jurisId), + data: amiChartFactory(10, jurisdictionId), }); const queryParams: AmiChartQueryParams = { - jurisdictionId: jurisId, + jurisdictionId: jurisdictionId, }; const query = stringify(queryParams as any); @@ -157,7 +162,7 @@ describe('Testing Permissioning of endpoints as Limited Jurisdictional Admin in it('should succeed for retrieve endpoint', async () => { const amiChartA = await prisma.amiChart.create({ - data: amiChartFactory(10, jurisId), + data: amiChartFactory(10, jurisdictionId), }); await request(app.getHttpServer()) @@ -171,14 +176,14 @@ describe('Testing Permissioning of endpoints as Limited Jurisdictional Admin in await request(app.getHttpServer()) .post('/amiCharts') .set({ passkey: process.env.API_PASS_KEY || '' }) - .send(buildAmiChartCreateMock(jurisId)) + .send(buildAmiChartCreateMock(jurisdictionId)) .set('Cookie', cookies) .expect(201); }); it('should succeed for update endpoint', async () => { const amiChartA = await prisma.amiChart.create({ - data: amiChartFactory(10, jurisId), + data: amiChartFactory(10, jurisdictionId), }); await request(app.getHttpServer()) @@ -191,7 +196,7 @@ describe('Testing Permissioning of endpoints as Limited Jurisdictional Admin in it('should succeed for delete endpoint', async () => { const amiChartA = await prisma.amiChart.create({ - data: amiChartFactory(10, jurisId), + data: amiChartFactory(10, jurisdictionId), }); await request(app.getHttpServer()) @@ -220,7 +225,7 @@ describe('Testing Permissioning of endpoints as Limited Jurisdictional Admin in }); it('should succeed for list endpoint', async () => { - const listing1 = await listingFactory(jurisId, prisma); + const listing1 = await listingFactory(jurisdictionId, prisma); const listing1Created = await prisma.listings.create({ data: listing1, }); @@ -238,7 +243,7 @@ describe('Testing Permissioning of endpoints as Limited Jurisdictional Admin in UnitTypeEnum.oneBdrm, ); - const listing1 = await listingFactory(jurisId, prisma); + const listing1 = await listingFactory(jurisdictionId, prisma); const listing1Created = await prisma.listings.create({ data: listing1, }); @@ -265,7 +270,7 @@ describe('Testing Permissioning of endpoints as Limited Jurisdictional Admin in prisma, UnitTypeEnum.oneBdrm, ); - const listing1 = await listingFactory(jurisId, prisma); + const listing1 = await listingFactory(jurisdictionId, prisma); const listing1Created = await prisma.listings.create({ data: listing1, }); @@ -295,7 +300,7 @@ describe('Testing Permissioning of endpoints as Limited Jurisdictional Admin in UnitTypeEnum.oneBdrm, ); - const listing1 = await listingFactory(jurisId, prisma, { + const listing1 = await listingFactory(jurisdictionId, prisma, { digitalApp: true, }); const listing1Created = await prisma.listings.create({ @@ -324,7 +329,7 @@ describe('Testing Permissioning of endpoints as Limited Jurisdictional Admin in UnitTypeEnum.oneBdrm, ); - const listing1 = await listingFactory(jurisId, prisma, { + const listing1 = await listingFactory(jurisdictionId, prisma, { digitalApp: true, }); const listing1Created = await prisma.listings.create({ @@ -352,7 +357,7 @@ describe('Testing Permissioning of endpoints as Limited Jurisdictional Admin in prisma, UnitTypeEnum.oneBdrm, ); - const listing1 = await listingFactory(jurisId, prisma); + const listing1 = await listingFactory(jurisdictionId, prisma); const listing1Created = await prisma.listings.create({ data: listing1, }); @@ -389,7 +394,7 @@ describe('Testing Permissioning of endpoints as Limited Jurisdictional Admin in prisma, UnitTypeEnum.oneBdrm, ); - const listing1 = await listingFactory(jurisId, prisma); + const listing1 = await listingFactory(jurisdictionId, prisma); const listing1Created = await prisma.listings.create({ data: listing1, }); @@ -453,7 +458,7 @@ describe('Testing Permissioning of endpoints as Limited Jurisdictional Admin in it('should succeed for retrieve endpoint', async () => { await request(app.getHttpServer()) - .get(`/jurisdictions/${jurisId}`) + .get(`/jurisdictions/${jurisdictionId}`) .set({ passkey: process.env.API_PASS_KEY || '' }) .set('Cookie', cookies) .expect(200); @@ -462,7 +467,7 @@ describe('Testing Permissioning of endpoints as Limited Jurisdictional Admin in it('should succeed for retrieve by name endpoint', async () => { const jurisdictionA = await prisma.jurisdictions.findFirst({ where: { - id: jurisId, + id: jurisdictionId, }, }); @@ -484,9 +489,11 @@ describe('Testing Permissioning of endpoints as Limited Jurisdictional Admin in it('should error as forbidden for update endpoint', async () => { await request(app.getHttpServer()) - .put(`/jurisdictions/${jurisId}`) + .put(`/jurisdictions/${jurisdictionId}`) .set({ passkey: process.env.API_PASS_KEY || '' }) - .send(buildJurisdictionUpdateMock(jurisId, 'permission juris 9:4')) + .send( + buildJurisdictionUpdateMock(jurisdictionId, 'permission juris 9:4'), + ) .set('Cookie', cookies) .expect(403); }); @@ -520,7 +527,7 @@ describe('Testing Permissioning of endpoints as Limited Jurisdictional Admin in it('should succeed for retrieve endpoint', async () => { const reservedCommunityTypeA = await reservedCommunityTypeFactoryGet( prisma, - jurisId, + jurisdictionId, ); await request(app.getHttpServer()) @@ -534,7 +541,7 @@ describe('Testing Permissioning of endpoints as Limited Jurisdictional Admin in await request(app.getHttpServer()) .post('/reservedCommunityTypes') .set({ passkey: process.env.API_PASS_KEY || '' }) - .send(buildReservedCommunityTypeCreateMock(jurisId)) + .send(buildReservedCommunityTypeCreateMock(jurisdictionId)) .set('Cookie', cookies) .expect(403); }); @@ -542,7 +549,7 @@ describe('Testing Permissioning of endpoints as Limited Jurisdictional Admin in it('should error as forbidden for update endpoint', async () => { const reservedCommunityTypeA = await reservedCommunityTypeFactoryGet( prisma, - jurisId, + jurisdictionId, ); await request(app.getHttpServer()) .put(`/reservedCommunityTypes/${reservedCommunityTypeA.id}`) @@ -555,7 +562,7 @@ describe('Testing Permissioning of endpoints as Limited Jurisdictional Admin in it('should error as forbidden for delete endpoint', async () => { const reservedCommunityTypeA = await reservedCommunityTypeFactoryGet( prisma, - jurisId, + jurisdictionId, ); await request(app.getHttpServer()) @@ -775,7 +782,7 @@ describe('Testing Permissioning of endpoints as Limited Jurisdictional Admin in it('should succeed for retrieve endpoint', async () => { const multiselectQuestionA = await prisma.multiselectQuestions.create({ - data: multiselectQuestionFactory(jurisId), + data: multiselectQuestionFactory(jurisdictionId), }); await request(app.getHttpServer()) @@ -789,21 +796,24 @@ describe('Testing Permissioning of endpoints as Limited Jurisdictional Admin in await request(app.getHttpServer()) .post('/multiselectQuestions') .set({ passkey: process.env.API_PASS_KEY || '' }) - .send(buildMultiselectQuestionCreateMock(jurisId)) + .send(buildMultiselectQuestionCreateMock(jurisdictionId)) .set('Cookie', cookies) .expect(403); }); it('should error as forbidden for update endpoint', async () => { const multiselectQuestionA = await prisma.multiselectQuestions.create({ - data: multiselectQuestionFactory(jurisId), + data: multiselectQuestionFactory(jurisdictionId), }); await request(app.getHttpServer()) - .put(`/multiselectQuestions/${multiselectQuestionA.id}`) + .put('/multiselectQuestions') .set({ passkey: process.env.API_PASS_KEY || '' }) .send( - buildMultiselectQuestionUpdateMock(jurisId, multiselectQuestionA.id), + buildMultiselectQuestionUpdateMock( + jurisdictionId, + multiselectQuestionA.id, + ), ) .set('Cookie', cookies) .expect(403); @@ -811,7 +821,7 @@ describe('Testing Permissioning of endpoints as Limited Jurisdictional Admin in it('should error as forbidden for delete endpoint', async () => { const multiselectQuestionA = await prisma.multiselectQuestions.create({ - data: multiselectQuestionFactory(jurisId), + data: multiselectQuestionFactory(jurisdictionId), }); await request(app.getHttpServer()) @@ -823,6 +833,60 @@ describe('Testing Permissioning of endpoints as Limited Jurisdictional Admin in .set('Cookie', cookies) .expect(403); }); + + it('should error as forbidden for reActivate endpoint', async () => { + const multiselectQuestionA = await prisma.multiselectQuestions.create({ + data: multiselectQuestionFactory( + jurisdictionId, + { + multiselectQuestion: { + status: MultiselectQuestionsStatusEnum.toRetire, + }, + }, + true, + ), + }); + + await request(app.getHttpServer()) + .put('/multiselectQuestions/reActivate') + .set({ passkey: process.env.API_PASS_KEY || '' }) + .send({ + id: multiselectQuestionA.id, + } as IdDTO) + .set('Cookie', cookies) + .expect(403); + }); + + it('should error as forbidden for retire endpoint', async () => { + const multiselectQuestionA = await prisma.multiselectQuestions.create({ + data: multiselectQuestionFactory( + jurisdictionId, + { + multiselectQuestion: { + status: MultiselectQuestionsStatusEnum.active, + }, + }, + true, + ), + }); + + await request(app.getHttpServer()) + .put('/multiselectQuestions/retire') + .set({ passkey: process.env.API_PASS_KEY || '' }) + .send({ + id: multiselectQuestionA.id, + } as IdDTO) + .set('Cookie', cookies) + .expect(403); + }); + + it('should error as forbidden for retireMultiselectQuestions endpoint', async () => { + await request(app.getHttpServer()) + .put('/multiselectQuestions/retireMultiselectQuestions') + .set({ passkey: process.env.API_PASS_KEY || '' }) + .set('Cookie', cookies) + .expect(403); + }); }); describe('Testing user endpoints', () => { @@ -1011,13 +1075,13 @@ describe('Testing Permissioning of endpoints as Limited Jurisdictional Admin in it('should succeed for retrieveListings endpoint', async () => { const multiselectQuestion1 = await prisma.multiselectQuestions.create({ - data: multiselectQuestionFactory(jurisId, { + data: multiselectQuestionFactory(jurisdictionId, { multiselectQuestion: { text: 'example a', }, }), }); - const listingA = await listingFactory(jurisId, prisma, { + const listingA = await listingFactory(jurisdictionId, prisma, { multiselectQuestions: [multiselectQuestion1], }); const listingACreated = await prisma.listings.create({ @@ -1036,7 +1100,7 @@ describe('Testing Permissioning of endpoints as Limited Jurisdictional Admin in }); it('should succeed for external listing endpoint', async () => { - const listingA = await listingFactory(jurisId, prisma); + const listingA = await listingFactory(jurisdictionId, prisma); const listingACreated = await prisma.listings.create({ data: listingA, include: { @@ -1051,7 +1115,7 @@ describe('Testing Permissioning of endpoints as Limited Jurisdictional Admin in }); it('should error as forbidden for delete endpoint', async () => { - const listingData = await listingFactory(jurisId, prisma); + const listingData = await listingFactory(jurisdictionId, prisma); const listing = await prisma.listings.create({ data: listingData, }); @@ -1067,12 +1131,16 @@ describe('Testing Permissioning of endpoints as Limited Jurisdictional Admin in }); it('should error as forbidden for update endpoint', async () => { - const listingData = await listingFactory(jurisId, prisma); + const listingData = await listingFactory(jurisdictionId, prisma); const listing = await prisma.listings.create({ data: listingData, }); - const val = await constructFullListingData(prisma, listing.id, jurisId); + const val = await constructFullListingData( + prisma, + listing.id, + jurisdictionId, + ); await request(app.getHttpServer()) .put(`/listings/${listing.id}`) @@ -1083,7 +1151,11 @@ describe('Testing Permissioning of endpoints as Limited Jurisdictional Admin in }); it('should error as forbidden for create endpoint', async () => { - const val = await constructFullListingData(prisma, undefined, jurisId); + const val = await constructFullListingData( + prisma, + undefined, + jurisdictionId, + ); await request(app.getHttpServer()) .post('/listings') @@ -1094,7 +1166,7 @@ describe('Testing Permissioning of endpoints as Limited Jurisdictional Admin in }); it('should error as forbidden for duplicate endpoint', async () => { - const listingData = await listingFactory(jurisId, prisma); + const listingData = await listingFactory(jurisdictionId, prisma); const listing = await prisma.listings.create({ data: listingData, }); @@ -1165,7 +1237,7 @@ describe('Testing Permissioning of endpoints as Limited Jurisdictional Admin in }); it('should error as forbidden for retrieve endpoint', async () => { - const listing = await createSimpleListing(prisma, jurisId); + const listing = await createSimpleListing(prisma, jurisdictionId); const applicationA = await createSimpleApplication(prisma); const applicationB = await createSimpleApplication(prisma); @@ -1194,7 +1266,7 @@ describe('Testing Permissioning of endpoints as Limited Jurisdictional Admin in }); it('should error as forbidden for resolve endpoint', async () => { - const listing = await createSimpleListing(prisma, jurisId); + const listing = await createSimpleListing(prisma, jurisdictionId); const applicationA = await createSimpleApplication(prisma); const applicationB = await createSimpleApplication(prisma); @@ -1232,7 +1304,7 @@ describe('Testing Permissioning of endpoints as Limited Jurisdictional Admin in }); it('should error as forbidden for reset confirmation endpoint', async () => { - const listing = await createSimpleListing(prisma, jurisId); + const listing = await createSimpleListing(prisma, jurisdictionId); const applicationA = await createSimpleApplication(prisma); const applicationB = await createSimpleApplication(prisma); @@ -1288,7 +1360,7 @@ describe('Testing Permissioning of endpoints as Limited Jurisdictional Admin in }); it('should error as forbidden for lottery status endpoint', async () => { - const listingData = await listingFactory(jurisId, prisma, { + const listingData = await listingFactory(jurisdictionId, prisma, { status: 'closed', }); const listing = await prisma.listings.create({ diff --git a/api/test/integration/permission-tests/permission-as-no-user.e2e-spec.ts b/api/test/integration/permission-tests/permission-as-no-user.e2e-spec.ts index 679237098a..64cac811bc 100644 --- a/api/test/integration/permission-tests/permission-as-no-user.e2e-spec.ts +++ b/api/test/integration/permission-tests/permission-as-no-user.e2e-spec.ts @@ -3,7 +3,12 @@ import { INestApplication } from '@nestjs/common'; import request from 'supertest'; import cookieParser from 'cookie-parser'; import { stringify } from 'qs'; -import { FlaggedSetStatusEnum, RuleEnum, UnitTypeEnum } from '@prisma/client'; +import { + FlaggedSetStatusEnum, + MultiselectQuestionsStatusEnum, + RuleEnum, + UnitTypeEnum, +} from '@prisma/client'; import { AppModule } from '../../../src/modules/app.module'; import { PrismaService } from '../../../src/services/prisma.service'; import { userFactory } from '../../../prisma/seed-helpers/user-factory'; @@ -738,7 +743,7 @@ describe('Testing Permissioning of endpoints as logged out user', () => { }); await request(app.getHttpServer()) - .put(`/multiselectQuestions/${multiselectQuestionA.id}`) + .put('/multiselectQuestions') .set({ passkey: process.env.API_PASS_KEY || '' }) .send( buildMultiselectQuestionUpdateMock( @@ -764,6 +769,60 @@ describe('Testing Permissioning of endpoints as logged out user', () => { .set('Cookie', cookies) .expect(403); }); + + it('should error as forbidden for reActivate endpoint', async () => { + const multiselectQuestionA = await prisma.multiselectQuestions.create({ + data: multiselectQuestionFactory( + jurisdictionId, + { + multiselectQuestion: { + status: MultiselectQuestionsStatusEnum.toRetire, + }, + }, + true, + ), + }); + + await request(app.getHttpServer()) + .put('/multiselectQuestions/reActivate') + .set({ passkey: process.env.API_PASS_KEY || '' }) + .send({ + id: multiselectQuestionA.id, + } as IdDTO) + .set('Cookie', cookies) + .expect(403); + }); + + it('should error as forbidden for retire endpoint', async () => { + const multiselectQuestionA = await prisma.multiselectQuestions.create({ + data: multiselectQuestionFactory( + jurisdictionId, + { + multiselectQuestion: { + status: MultiselectQuestionsStatusEnum.active, + }, + }, + true, + ), + }); + + await request(app.getHttpServer()) + .put('/multiselectQuestions/retire') + .set({ passkey: process.env.API_PASS_KEY || '' }) + .send({ + id: multiselectQuestionA.id, + } as IdDTO) + .set('Cookie', cookies) + .expect(403); + }); + + it('should error as forbidden for retireMultiselectQuestions endpoint', async () => { + await request(app.getHttpServer()) + .put('/multiselectQuestions/retireMultiselectQuestions') + .set({ passkey: process.env.API_PASS_KEY || '' }) + .set('Cookie', cookies) + .expect(403); + }); }); describe('Testing user endpoints', () => { diff --git a/api/test/integration/permission-tests/permission-as-partner-correct-listing.e2e-spec.ts b/api/test/integration/permission-tests/permission-as-partner-correct-listing.e2e-spec.ts index ee08ede246..d11775ec51 100644 --- a/api/test/integration/permission-tests/permission-as-partner-correct-listing.e2e-spec.ts +++ b/api/test/integration/permission-tests/permission-as-partner-correct-listing.e2e-spec.ts @@ -6,6 +6,7 @@ import { stringify } from 'qs'; import { FlaggedSetStatusEnum, ListingsStatusEnum, + MultiselectQuestionsStatusEnum, RuleEnum, UnitTypeEnum, } from '@prisma/client'; @@ -87,7 +88,7 @@ describe('Testing Permissioning of endpoints as partner with correct listing', ( let userService: UserService; let applicationFlaggedSetService: ApplicationFlaggedSetService; let cookies = ''; - let jurisId = ''; + let jurisdictionId = ''; let userListingId = ''; let closedUserListingId = ''; let closedUserListingId2 = ''; @@ -112,15 +113,15 @@ describe('Testing Permissioning of endpoints as partner with correct listing', ( app.use(cookieParser()); await app.init(); - jurisId = await generateJurisdiction( + jurisdictionId = await generateJurisdiction( prisma, 'correct partner permission juris', ); - await reservedCommunityTypeFactoryAll(jurisId, prisma); + await reservedCommunityTypeFactoryAll(jurisdictionId, prisma); await unitAccessibilityPriorityTypeFactoryAll(prisma); const msq = await prisma.multiselectQuestions.create({ - data: multiselectQuestionFactory(jurisId, { + data: multiselectQuestionFactory(jurisdictionId, { multiselectQuestion: { text: 'example a', }, @@ -129,7 +130,7 @@ describe('Testing Permissioning of endpoints as partner with correct listing', ( listingMulitselectQuestion = msq.id; - const listingData = await listingFactory(jurisId, prisma, { + const listingData = await listingFactory(jurisdictionId, prisma, { multiselectQuestions: [msq], digitalApp: true, }); @@ -138,7 +139,7 @@ describe('Testing Permissioning of endpoints as partner with correct listing', ( }); userListingId = listing.id; - const closedListingData = await listingFactory(jurisId, prisma, { + const closedListingData = await listingFactory(jurisdictionId, prisma, { multiselectQuestions: [msq], status: ListingsStatusEnum.closed, }); @@ -147,7 +148,7 @@ describe('Testing Permissioning of endpoints as partner with correct listing', ( }); closedUserListingId = closedListing.id; - const listingData2 = await listingFactory(jurisId, prisma, { + const listingData2 = await listingFactory(jurisdictionId, prisma, { multiselectQuestions: [msq], }); const listing2 = await prisma.listings.create({ @@ -155,7 +156,7 @@ describe('Testing Permissioning of endpoints as partner with correct listing', ( }); userListingToBeDeleted = listing2.id; - const closedListingData2 = await listingFactory(jurisId, prisma, { + const closedListingData2 = await listingFactory(jurisdictionId, prisma, { status: ListingsStatusEnum.closed, lotteryStatus: 'releasedToPartners', }); @@ -173,7 +174,7 @@ describe('Testing Permissioning of endpoints as partner with correct listing', ( closedUserListingId, closedUserListingId2, ], - jurisdictionIds: [jurisId], + jurisdictionIds: [jurisdictionId], mfaEnabled: false, confirmedAt: new Date(), }), @@ -200,10 +201,10 @@ describe('Testing Permissioning of endpoints as partner with correct listing', ( describe('Testing ami-chart endpoints', () => { it('should succeed for list endpoint', async () => { await prisma.amiChart.create({ - data: amiChartFactory(10, jurisId), + data: amiChartFactory(10, jurisdictionId), }); const queryParams: AmiChartQueryParams = { - jurisdictionId: jurisId, + jurisdictionId: jurisdictionId, }; const query = stringify(queryParams as any); @@ -216,7 +217,7 @@ describe('Testing Permissioning of endpoints as partner with correct listing', ( it('should succeed for retrieve endpoint', async () => { const amiChartA = await prisma.amiChart.create({ - data: amiChartFactory(10, jurisId), + data: amiChartFactory(10, jurisdictionId), }); await request(app.getHttpServer()) @@ -230,14 +231,14 @@ describe('Testing Permissioning of endpoints as partner with correct listing', ( await request(app.getHttpServer()) .post('/amiCharts') .set({ passkey: process.env.API_PASS_KEY || '' }) - .send(buildAmiChartCreateMock(jurisId)) + .send(buildAmiChartCreateMock(jurisdictionId)) .set('Cookie', cookies) .expect(403); }); it('should error as forbidden for update endpoint', async () => { const amiChartA = await prisma.amiChart.create({ - data: amiChartFactory(10, jurisId), + data: amiChartFactory(10, jurisdictionId), }); await request(app.getHttpServer()) @@ -250,7 +251,7 @@ describe('Testing Permissioning of endpoints as partner with correct listing', ( it('should error as forbidden for delete endpoint', async () => { const amiChartA = await prisma.amiChart.create({ - data: amiChartFactory(10, jurisId), + data: amiChartFactory(10, jurisdictionId), }); await request(app.getHttpServer()) @@ -493,7 +494,7 @@ describe('Testing Permissioning of endpoints as partner with correct listing', ( it('should succeed for retrieve endpoint', async () => { await request(app.getHttpServer()) - .get(`/jurisdictions/${jurisId}`) + .get(`/jurisdictions/${jurisdictionId}`) .set({ passkey: process.env.API_PASS_KEY || '' }) .set('Cookie', cookies) .expect(200); @@ -502,7 +503,7 @@ describe('Testing Permissioning of endpoints as partner with correct listing', ( it('should succeed for retrieve by name endpoint', async () => { const jurisdictionA = await prisma.jurisdictions.findFirst({ where: { - id: jurisId, + id: jurisdictionId, }, }); @@ -524,9 +525,11 @@ describe('Testing Permissioning of endpoints as partner with correct listing', ( it('should error as forbidden for update endpoint', async () => { await request(app.getHttpServer()) - .put(`/jurisdictions/${jurisId}`) + .put(`/jurisdictions/${jurisdictionId}`) .set({ passkey: process.env.API_PASS_KEY || '' }) - .send(buildJurisdictionUpdateMock(jurisId, 'permission juris 9:5')) + .send( + buildJurisdictionUpdateMock(jurisdictionId, 'permission juris 9:5'), + ) .set('Cookie', cookies) .expect(403); }); @@ -560,7 +563,7 @@ describe('Testing Permissioning of endpoints as partner with correct listing', ( it('should succeed for retrieve endpoint', async () => { const reservedCommunityTypeA = await reservedCommunityTypeFactoryGet( prisma, - jurisId, + jurisdictionId, ); await request(app.getHttpServer()) @@ -574,7 +577,7 @@ describe('Testing Permissioning of endpoints as partner with correct listing', ( await request(app.getHttpServer()) .post('/reservedCommunityTypes') .set({ passkey: process.env.API_PASS_KEY || '' }) - .send(buildReservedCommunityTypeCreateMock(jurisId)) + .send(buildReservedCommunityTypeCreateMock(jurisdictionId)) .set('Cookie', cookies) .expect(403); }); @@ -582,7 +585,7 @@ describe('Testing Permissioning of endpoints as partner with correct listing', ( it('should error as forbidden for update endpoint', async () => { const reservedCommunityTypeA = await reservedCommunityTypeFactoryGet( prisma, - jurisId, + jurisdictionId, ); await request(app.getHttpServer()) @@ -596,7 +599,7 @@ describe('Testing Permissioning of endpoints as partner with correct listing', ( it('should error as forbidden for delete endpoint', async () => { const reservedCommunityTypeA = await reservedCommunityTypeFactoryGet( prisma, - jurisId, + jurisdictionId, ); await request(app.getHttpServer()) @@ -816,7 +819,7 @@ describe('Testing Permissioning of endpoints as partner with correct listing', ( it('should succeed for retrieve endpoint', async () => { const multiselectQuestionA = await prisma.multiselectQuestions.create({ - data: multiselectQuestionFactory(jurisId), + data: multiselectQuestionFactory(jurisdictionId), }); await request(app.getHttpServer()) @@ -830,21 +833,24 @@ describe('Testing Permissioning of endpoints as partner with correct listing', ( await request(app.getHttpServer()) .post('/multiselectQuestions') .set({ passkey: process.env.API_PASS_KEY || '' }) - .send(buildMultiselectQuestionCreateMock(jurisId)) + .send(buildMultiselectQuestionCreateMock(jurisdictionId)) .set('Cookie', cookies) .expect(403); }); it('should error as forbidden for update endpoint', async () => { const multiselectQuestionA = await prisma.multiselectQuestions.create({ - data: multiselectQuestionFactory(jurisId), + data: multiselectQuestionFactory(jurisdictionId), }); await request(app.getHttpServer()) - .put(`/multiselectQuestions/${multiselectQuestionA.id}`) + .put('/multiselectQuestions') .set({ passkey: process.env.API_PASS_KEY || '' }) .send( - buildMultiselectQuestionUpdateMock(jurisId, multiselectQuestionA.id), + buildMultiselectQuestionUpdateMock( + jurisdictionId, + multiselectQuestionA.id, + ), ) .set('Cookie', cookies) .expect(403); @@ -852,7 +858,7 @@ describe('Testing Permissioning of endpoints as partner with correct listing', ( it('should error as forbidden for delete endpoint', async () => { const multiselectQuestionA = await prisma.multiselectQuestions.create({ - data: multiselectQuestionFactory(jurisId), + data: multiselectQuestionFactory(jurisdictionId), }); await request(app.getHttpServer()) @@ -864,6 +870,60 @@ describe('Testing Permissioning of endpoints as partner with correct listing', ( .set('Cookie', cookies) .expect(403); }); + + it('should error as forbidden for reActivate endpoint', async () => { + const multiselectQuestionA = await prisma.multiselectQuestions.create({ + data: multiselectQuestionFactory( + jurisdictionId, + { + multiselectQuestion: { + status: MultiselectQuestionsStatusEnum.toRetire, + }, + }, + true, + ), + }); + + await request(app.getHttpServer()) + .put('/multiselectQuestions/reActivate') + .set({ passkey: process.env.API_PASS_KEY || '' }) + .send({ + id: multiselectQuestionA.id, + } as IdDTO) + .set('Cookie', cookies) + .expect(403); + }); + + it('should error as forbidden for retire endpoint', async () => { + const multiselectQuestionA = await prisma.multiselectQuestions.create({ + data: multiselectQuestionFactory( + jurisdictionId, + { + multiselectQuestion: { + status: MultiselectQuestionsStatusEnum.active, + }, + }, + true, + ), + }); + + await request(app.getHttpServer()) + .put('/multiselectQuestions/retire') + .set({ passkey: process.env.API_PASS_KEY || '' }) + .send({ + id: multiselectQuestionA.id, + } as IdDTO) + .set('Cookie', cookies) + .expect(403); + }); + + it('should error as forbidden for retireMultiselectQuestions endpoint', async () => { + await request(app.getHttpServer()) + .put('/multiselectQuestions/retireMultiselectQuestions') + .set({ passkey: process.env.API_PASS_KEY || '' }) + .set('Cookie', cookies) + .expect(403); + }); }); describe('Testing user endpoints', () => { @@ -1085,7 +1145,7 @@ describe('Testing Permissioning of endpoints as partner with correct listing', ( const val = await constructFullListingData( prisma, userListingId, - jurisId, + jurisdictionId, ); await request(app.getHttpServer()) @@ -1107,7 +1167,11 @@ describe('Testing Permissioning of endpoints as partner with correct listing', ( }); it('should error as forbidden for create endpoint', async () => { - const val = await constructFullListingData(prisma, undefined, jurisId); + const val = await constructFullListingData( + prisma, + undefined, + jurisdictionId, + ); await request(app.getHttpServer()) .post('/listings') diff --git a/api/test/integration/permission-tests/permission-as-partner-wrong-listing.e2e-spec.ts b/api/test/integration/permission-tests/permission-as-partner-wrong-listing.e2e-spec.ts index 185ab39311..50a65c6bfc 100644 --- a/api/test/integration/permission-tests/permission-as-partner-wrong-listing.e2e-spec.ts +++ b/api/test/integration/permission-tests/permission-as-partner-wrong-listing.e2e-spec.ts @@ -6,6 +6,7 @@ import { stringify } from 'qs'; import { FlaggedSetStatusEnum, ListingsStatusEnum, + MultiselectQuestionsStatusEnum, RuleEnum, UnitTypeEnum, } from '@prisma/client'; @@ -84,7 +85,7 @@ describe('Testing Permissioning of endpoints as partner with wrong listing', () let userService: UserService; let applicationFlaggedSetService: ApplicationFlaggedSetService; let cookies = ''; - let jurisId = ''; + let jurisdictionId = ''; let listingId = ''; let listingIdToBeDeleted = ''; let listingMulitselectQuestion = ''; @@ -109,15 +110,15 @@ describe('Testing Permissioning of endpoints as partner with wrong listing', () app.use(cookieParser()); await app.init(); - jurisId = await generateJurisdiction( + jurisdictionId = await generateJurisdiction( prisma, 'wrong partner permission juris', ); - await reservedCommunityTypeFactoryAll(jurisId, prisma); + await reservedCommunityTypeFactoryAll(jurisdictionId, prisma); await unitAccessibilityPriorityTypeFactoryAll(prisma); const msq = await prisma.multiselectQuestions.create({ - data: multiselectQuestionFactory(jurisId, { + data: multiselectQuestionFactory(jurisdictionId, { multiselectQuestion: { text: 'example a', }, @@ -126,7 +127,7 @@ describe('Testing Permissioning of endpoints as partner with wrong listing', () listingMulitselectQuestion = msq.id; - const listingData = await listingFactory(jurisId, prisma, { + const listingData = await listingFactory(jurisdictionId, prisma, { multiselectQuestions: [msq], digitalApp: true, }); @@ -135,7 +136,7 @@ describe('Testing Permissioning of endpoints as partner with wrong listing', () }); listingId = listing.id; - const listingData2 = await listingFactory(jurisId, prisma, { + const listingData2 = await listingFactory(jurisdictionId, prisma, { multiselectQuestions: [msq], }); const listing2 = await prisma.listings.create({ @@ -143,14 +144,14 @@ describe('Testing Permissioning of endpoints as partner with wrong listing', () }); listingIdToBeDeleted = listing2.id; - const listingData3 = await listingFactory(jurisId, prisma, { + const listingData3 = await listingFactory(jurisdictionId, prisma, { multiselectQuestions: [msq], }); const listing3 = await prisma.listings.create({ data: listingData3, }); - const closedListingData = await listingFactory(jurisId, prisma, { + const closedListingData = await listingFactory(jurisdictionId, prisma, { multiselectQuestions: [msq], status: ListingsStatusEnum.closed, }); @@ -163,7 +164,7 @@ describe('Testing Permissioning of endpoints as partner with wrong listing', () data: await userFactory({ roles: { isPartner: true }, listings: [listing3.id], - jurisdictionIds: [jurisId], + jurisdictionIds: [jurisdictionId], mfaEnabled: false, confirmedAt: new Date(), }), @@ -188,10 +189,10 @@ describe('Testing Permissioning of endpoints as partner with wrong listing', () describe('Testing ami-chart endpoints', () => { it('should succeed for list endpoint', async () => { await prisma.amiChart.create({ - data: amiChartFactory(10, jurisId), + data: amiChartFactory(10, jurisdictionId), }); const queryParams: AmiChartQueryParams = { - jurisdictionId: jurisId, + jurisdictionId: jurisdictionId, }; const query = stringify(queryParams as any); @@ -204,7 +205,7 @@ describe('Testing Permissioning of endpoints as partner with wrong listing', () it('should succeed for retrieve endpoint', async () => { const amiChartA = await prisma.amiChart.create({ - data: amiChartFactory(10, jurisId), + data: amiChartFactory(10, jurisdictionId), }); await request(app.getHttpServer()) @@ -218,14 +219,14 @@ describe('Testing Permissioning of endpoints as partner with wrong listing', () await request(app.getHttpServer()) .post('/amiCharts') .set({ passkey: process.env.API_PASS_KEY || '' }) - .send(buildAmiChartCreateMock(jurisId)) + .send(buildAmiChartCreateMock(jurisdictionId)) .set('Cookie', cookies) .expect(403); }); it('should error as forbidden for update endpoint', async () => { const amiChartA = await prisma.amiChart.create({ - data: amiChartFactory(10, jurisId), + data: amiChartFactory(10, jurisdictionId), }); await request(app.getHttpServer()) @@ -238,7 +239,7 @@ describe('Testing Permissioning of endpoints as partner with wrong listing', () it('should error as forbidden for delete endpoint', async () => { const amiChartA = await prisma.amiChart.create({ - data: amiChartFactory(10, jurisId), + data: amiChartFactory(10, jurisdictionId), }); await request(app.getHttpServer()) @@ -465,7 +466,7 @@ describe('Testing Permissioning of endpoints as partner with wrong listing', () it('should succeed for retrieve endpoint', async () => { await request(app.getHttpServer()) - .get(`/jurisdictions/${jurisId}`) + .get(`/jurisdictions/${jurisdictionId}`) .set({ passkey: process.env.API_PASS_KEY || '' }) .set('Cookie', cookies) .expect(200); @@ -474,7 +475,7 @@ describe('Testing Permissioning of endpoints as partner with wrong listing', () it('should succeed for retrieve by name endpoint', async () => { const jurisdictionA = await prisma.jurisdictions.findFirst({ where: { - id: jurisId, + id: jurisdictionId, }, }); @@ -496,9 +497,11 @@ describe('Testing Permissioning of endpoints as partner with wrong listing', () it('should error as forbidden for update endpoint', async () => { await request(app.getHttpServer()) - .put(`/jurisdictions/${jurisId}`) + .put(`/jurisdictions/${jurisdictionId}`) .set({ passkey: process.env.API_PASS_KEY || '' }) - .send(buildJurisdictionUpdateMock(jurisId, 'permission juris 9:6')) + .send( + buildJurisdictionUpdateMock(jurisdictionId, 'permission juris 9:6'), + ) .set('Cookie', cookies) .expect(403); }); @@ -532,7 +535,7 @@ describe('Testing Permissioning of endpoints as partner with wrong listing', () it('should succeed for retrieve endpoint', async () => { const reservedCommunityTypeA = await reservedCommunityTypeFactoryGet( prisma, - jurisId, + jurisdictionId, ); await request(app.getHttpServer()) @@ -546,7 +549,7 @@ describe('Testing Permissioning of endpoints as partner with wrong listing', () await request(app.getHttpServer()) .post('/reservedCommunityTypes') .set({ passkey: process.env.API_PASS_KEY || '' }) - .send(buildReservedCommunityTypeCreateMock(jurisId)) + .send(buildReservedCommunityTypeCreateMock(jurisdictionId)) .set('Cookie', cookies) .expect(403); }); @@ -554,7 +557,7 @@ describe('Testing Permissioning of endpoints as partner with wrong listing', () it('should error as forbidden for update endpoint', async () => { const reservedCommunityTypeA = await reservedCommunityTypeFactoryGet( prisma, - jurisId, + jurisdictionId, ); await request(app.getHttpServer()) @@ -568,7 +571,7 @@ describe('Testing Permissioning of endpoints as partner with wrong listing', () it('should error as forbidden for delete endpoint', async () => { const reservedCommunityTypeA = await reservedCommunityTypeFactoryGet( prisma, - jurisId, + jurisdictionId, ); await request(app.getHttpServer()) @@ -788,7 +791,7 @@ describe('Testing Permissioning of endpoints as partner with wrong listing', () it('should succeed for retrieve endpoint', async () => { const multiselectQuestionA = await prisma.multiselectQuestions.create({ - data: multiselectQuestionFactory(jurisId), + data: multiselectQuestionFactory(jurisdictionId), }); await request(app.getHttpServer()) @@ -802,21 +805,24 @@ describe('Testing Permissioning of endpoints as partner with wrong listing', () await request(app.getHttpServer()) .post('/multiselectQuestions') .set({ passkey: process.env.API_PASS_KEY || '' }) - .send(buildMultiselectQuestionCreateMock(jurisId)) + .send(buildMultiselectQuestionCreateMock(jurisdictionId)) .set('Cookie', cookies) .expect(403); }); it('should error as forbidden for update endpoint', async () => { const multiselectQuestionA = await prisma.multiselectQuestions.create({ - data: multiselectQuestionFactory(jurisId), + data: multiselectQuestionFactory(jurisdictionId), }); await request(app.getHttpServer()) - .put(`/multiselectQuestions/${multiselectQuestionA.id}`) + .put('/multiselectQuestions') .set({ passkey: process.env.API_PASS_KEY || '' }) .send( - buildMultiselectQuestionUpdateMock(jurisId, multiselectQuestionA.id), + buildMultiselectQuestionUpdateMock( + jurisdictionId, + multiselectQuestionA.id, + ), ) .set('Cookie', cookies) .expect(403); @@ -824,7 +830,7 @@ describe('Testing Permissioning of endpoints as partner with wrong listing', () it('should error as forbidden for delete endpoint', async () => { const multiselectQuestionA = await prisma.multiselectQuestions.create({ - data: multiselectQuestionFactory(jurisId), + data: multiselectQuestionFactory(jurisdictionId), }); await request(app.getHttpServer()) @@ -836,6 +842,60 @@ describe('Testing Permissioning of endpoints as partner with wrong listing', () .set('Cookie', cookies) .expect(403); }); + + it('should error as forbidden for reActivate endpoint', async () => { + const multiselectQuestionA = await prisma.multiselectQuestions.create({ + data: multiselectQuestionFactory( + jurisdictionId, + { + multiselectQuestion: { + status: MultiselectQuestionsStatusEnum.toRetire, + }, + }, + true, + ), + }); + + await request(app.getHttpServer()) + .put('/multiselectQuestions/reActivate') + .set({ passkey: process.env.API_PASS_KEY || '' }) + .send({ + id: multiselectQuestionA.id, + } as IdDTO) + .set('Cookie', cookies) + .expect(403); + }); + + it('should error as forbidden for retire endpoint', async () => { + const multiselectQuestionA = await prisma.multiselectQuestions.create({ + data: multiselectQuestionFactory( + jurisdictionId, + { + multiselectQuestion: { + status: MultiselectQuestionsStatusEnum.active, + }, + }, + true, + ), + }); + + await request(app.getHttpServer()) + .put('/multiselectQuestions/retire') + .set({ passkey: process.env.API_PASS_KEY || '' }) + .send({ + id: multiselectQuestionA.id, + } as IdDTO) + .set('Cookie', cookies) + .expect(403); + }); + + it('should error as forbidden for retireMultiselectQuestions endpoint', async () => { + await request(app.getHttpServer()) + .put('/multiselectQuestions/retireMultiselectQuestions') + .set({ passkey: process.env.API_PASS_KEY || '' }) + .set('Cookie', cookies) + .expect(403); + }); }); describe('Testing user endpoints', () => { @@ -1052,7 +1112,11 @@ describe('Testing Permissioning of endpoints as partner with wrong listing', () }); it('should error as forbidden for update endpoint', async () => { - const val = await constructFullListingData(prisma, listingId, jurisId); + const val = await constructFullListingData( + prisma, + listingId, + jurisdictionId, + ); await request(app.getHttpServer()) .put(`/listings/${listingId}`) @@ -1063,7 +1127,11 @@ describe('Testing Permissioning of endpoints as partner with wrong listing', () }); it('should error as forbidden for create endpoint', async () => { - const val = await constructFullListingData(prisma, undefined, jurisId); + const val = await constructFullListingData( + prisma, + undefined, + jurisdictionId, + ); await request(app.getHttpServer()) .post('/listings') @@ -1074,7 +1142,7 @@ describe('Testing Permissioning of endpoints as partner with wrong listing', () }); it('should error as forbidden for duplicate endpoint', async () => { - const listingData = await listingFactory(jurisId, prisma); + const listingData = await listingFactory(jurisdictionId, prisma); const listing = await prisma.listings.create({ data: listingData, }); diff --git a/api/test/integration/permission-tests/permission-as-public.e2e-spec.ts b/api/test/integration/permission-tests/permission-as-public.e2e-spec.ts index 58c062e79c..37edda7ef4 100644 --- a/api/test/integration/permission-tests/permission-as-public.e2e-spec.ts +++ b/api/test/integration/permission-tests/permission-as-public.e2e-spec.ts @@ -3,7 +3,12 @@ import { INestApplication } from '@nestjs/common'; import request from 'supertest'; import cookieParser from 'cookie-parser'; import { stringify } from 'qs'; -import { FlaggedSetStatusEnum, RuleEnum, UnitTypeEnum } from '@prisma/client'; +import { + FlaggedSetStatusEnum, + MultiselectQuestionsStatusEnum, + RuleEnum, + UnitTypeEnum, +} from '@prisma/client'; import { AppModule } from '../../../src/modules/app.module'; import { PrismaService } from '../../../src/services/prisma.service'; import { userFactory } from '../../../prisma/seed-helpers/user-factory'; @@ -82,7 +87,7 @@ describe('Testing Permissioning of endpoints as public user', () => { let userService: UserService; let storedUserId: string; let cookies = ''; - let jurisdictionAId = ''; + let jurisdictionId = ''; beforeAll(async () => { const moduleFixture: TestingModule = await Test.createTestingModule({ @@ -98,11 +103,11 @@ describe('Testing Permissioning of endpoints as public user', () => { app.use(cookieParser()); await app.init(); - jurisdictionAId = await generateJurisdiction( + jurisdictionId = await generateJurisdiction( prisma, 'public permission juris', ); - await reservedCommunityTypeFactoryAll(jurisdictionAId, prisma); + await reservedCommunityTypeFactoryAll(jurisdictionId, prisma); const storedUser = await prisma.userAccounts.create({ data: await userFactory({ @@ -131,10 +136,10 @@ describe('Testing Permissioning of endpoints as public user', () => { describe('Testing ami-chart endpoints', () => { it('should error as forbidden for list endpoint', async () => { await prisma.amiChart.create({ - data: amiChartFactory(10, jurisdictionAId), + data: amiChartFactory(10, jurisdictionId), }); const queryParams: AmiChartQueryParams = { - jurisdictionId: jurisdictionAId, + jurisdictionId: jurisdictionId, }; const query = stringify(queryParams as any); @@ -147,7 +152,7 @@ describe('Testing Permissioning of endpoints as public user', () => { it('should error as forbidden for retrieve endpoint', async () => { const amiChartA = await prisma.amiChart.create({ - data: amiChartFactory(10, jurisdictionAId), + data: amiChartFactory(10, jurisdictionId), }); await request(app.getHttpServer()) @@ -161,14 +166,14 @@ describe('Testing Permissioning of endpoints as public user', () => { await request(app.getHttpServer()) .post('/amiCharts') .set({ passkey: process.env.API_PASS_KEY || '' }) - .send(buildAmiChartCreateMock(jurisdictionAId)) + .send(buildAmiChartCreateMock(jurisdictionId)) .set('Cookie', cookies) .expect(403); }); it('should error as forbidden for update endpoint', async () => { const amiChartA = await prisma.amiChart.create({ - data: amiChartFactory(10, jurisdictionAId), + data: amiChartFactory(10, jurisdictionId), }); await request(app.getHttpServer()) @@ -181,7 +186,7 @@ describe('Testing Permissioning of endpoints as public user', () => { it('should error as forbidden for delete endpoint', async () => { const amiChartA = await prisma.amiChart.create({ - data: amiChartFactory(10, jurisdictionAId), + data: amiChartFactory(10, jurisdictionId), }); await request(app.getHttpServer()) @@ -210,7 +215,7 @@ describe('Testing Permissioning of endpoints as public user', () => { }); it('should succeed for list endpoint', async () => { - const listing1 = await listingFactory(jurisdictionAId, prisma); + const listing1 = await listingFactory(jurisdictionId, prisma); const listing1Created = await prisma.listings.create({ data: listing1, }); @@ -227,7 +232,7 @@ describe('Testing Permissioning of endpoints as public user', () => { prisma, UnitTypeEnum.oneBdrm, ); - const listing1 = await listingFactory(jurisdictionAId, prisma); + const listing1 = await listingFactory(jurisdictionId, prisma); const listing1Created = await prisma.listings.create({ data: listing1, }); @@ -255,7 +260,7 @@ describe('Testing Permissioning of endpoints as public user', () => { prisma, UnitTypeEnum.oneBdrm, ); - const listing1 = await listingFactory(jurisdictionAId, prisma); + const listing1 = await listingFactory(jurisdictionId, prisma); const listing1Created = await prisma.listings.create({ data: listing1, }); @@ -284,7 +289,7 @@ describe('Testing Permissioning of endpoints as public user', () => { prisma, UnitTypeEnum.oneBdrm, ); - const listing1 = await listingFactory(jurisdictionAId, prisma, { + const listing1 = await listingFactory(jurisdictionId, prisma, { digitalApp: true, }); const listing1Created = await prisma.listings.create({ @@ -312,7 +317,7 @@ describe('Testing Permissioning of endpoints as public user', () => { prisma, UnitTypeEnum.oneBdrm, ); - const listing1 = await listingFactory(jurisdictionAId, prisma, { + const listing1 = await listingFactory(jurisdictionId, prisma, { digitalApp: true, }); const listing1Created = await prisma.listings.create({ @@ -340,7 +345,7 @@ describe('Testing Permissioning of endpoints as public user', () => { prisma, UnitTypeEnum.oneBdrm, ); - const listing1 = await listingFactory(jurisdictionAId, prisma); + const listing1 = await listingFactory(jurisdictionId, prisma); const listing1Created = await prisma.listings.create({ data: listing1, }); @@ -377,7 +382,7 @@ describe('Testing Permissioning of endpoints as public user', () => { prisma, UnitTypeEnum.oneBdrm, ); - const listing1 = await listingFactory(jurisdictionAId, prisma); + const listing1 = await listingFactory(jurisdictionId, prisma); const listing1Created = await prisma.listings.create({ data: listing1, }); @@ -400,7 +405,7 @@ describe('Testing Permissioning of endpoints as public user', () => { it('should error as forbidden for csv endpoint', async () => { const application = await applicationFactory(); - const listing1 = await listingFactory(jurisdictionAId, prisma, { + const listing1 = await listingFactory(jurisdictionId, prisma, { applications: [application], }); const listing1Created = await prisma.listings.create({ @@ -436,7 +441,7 @@ describe('Testing Permissioning of endpoints as public user', () => { it('should succeed for retrieve endpoint', async () => { await request(app.getHttpServer()) - .get(`/jurisdictions/${jurisdictionAId}`) + .get(`/jurisdictions/${jurisdictionId}`) .set({ passkey: process.env.API_PASS_KEY || '' }) .set('Cookie', cookies) .expect(200); @@ -509,7 +514,7 @@ describe('Testing Permissioning of endpoints as public user', () => { it('should error as forbidden for retrieve endpoint', async () => { const reservedCommunityTypeA = await reservedCommunityTypeFactoryGet( prisma, - jurisdictionAId, + jurisdictionId, ); await request(app.getHttpServer()) @@ -523,7 +528,7 @@ describe('Testing Permissioning of endpoints as public user', () => { await request(app.getHttpServer()) .post('/reservedCommunityTypes') .set({ passkey: process.env.API_PASS_KEY || '' }) - .send(buildReservedCommunityTypeCreateMock(jurisdictionAId)) + .send(buildReservedCommunityTypeCreateMock(jurisdictionId)) .set('Cookie', cookies) .expect(403); }); @@ -531,7 +536,7 @@ describe('Testing Permissioning of endpoints as public user', () => { it('should error as forbidden for update endpoint', async () => { const reservedCommunityTypeA = await reservedCommunityTypeFactoryGet( prisma, - jurisdictionAId, + jurisdictionId, ); await request(app.getHttpServer()) @@ -545,7 +550,7 @@ describe('Testing Permissioning of endpoints as public user', () => { it('should error as forbidden for delete endpoint', async () => { const reservedCommunityTypeA = await reservedCommunityTypeFactoryGet( prisma, - jurisdictionAId, + jurisdictionId, ); await request(app.getHttpServer()) @@ -765,7 +770,7 @@ describe('Testing Permissioning of endpoints as public user', () => { it('should succeed for retrieve endpoint', async () => { const multiselectQuestionA = await prisma.multiselectQuestions.create({ - data: multiselectQuestionFactory(jurisdictionAId), + data: multiselectQuestionFactory(jurisdictionId), }); await request(app.getHttpServer()) @@ -778,7 +783,7 @@ describe('Testing Permissioning of endpoints as public user', () => { it('should error as forbidden for create endpoint', async () => { await request(app.getHttpServer()) .post('/multiselectQuestions') - .send(buildMultiselectQuestionCreateMock(jurisdictionAId)) + .send(buildMultiselectQuestionCreateMock(jurisdictionId)) .set({ passkey: process.env.API_PASS_KEY || '' }) .set('Cookie', cookies) .expect(403); @@ -786,15 +791,15 @@ describe('Testing Permissioning of endpoints as public user', () => { it('should error as forbidden for update endpoint', async () => { const multiselectQuestionA = await prisma.multiselectQuestions.create({ - data: multiselectQuestionFactory(jurisdictionAId), + data: multiselectQuestionFactory(jurisdictionId), }); await request(app.getHttpServer()) - .put(`/multiselectQuestions/${multiselectQuestionA.id}`) + .put('/multiselectQuestions') .set({ passkey: process.env.API_PASS_KEY || '' }) .send( buildMultiselectQuestionUpdateMock( - jurisdictionAId, + jurisdictionId, multiselectQuestionA.id, ), ) @@ -804,7 +809,7 @@ describe('Testing Permissioning of endpoints as public user', () => { it('should error as forbidden for delete endpoint', async () => { const multiselectQuestionA = await prisma.multiselectQuestions.create({ - data: multiselectQuestionFactory(jurisdictionAId), + data: multiselectQuestionFactory(jurisdictionId), }); await request(app.getHttpServer()) @@ -816,6 +821,60 @@ describe('Testing Permissioning of endpoints as public user', () => { .set('Cookie', cookies) .expect(403); }); + + it('should error as forbidden for reActivate endpoint', async () => { + const multiselectQuestionA = await prisma.multiselectQuestions.create({ + data: multiselectQuestionFactory( + jurisdictionId, + { + multiselectQuestion: { + status: MultiselectQuestionsStatusEnum.toRetire, + }, + }, + true, + ), + }); + + await request(app.getHttpServer()) + .put('/multiselectQuestions/reActivate') + .set({ passkey: process.env.API_PASS_KEY || '' }) + .send({ + id: multiselectQuestionA.id, + } as IdDTO) + .set('Cookie', cookies) + .expect(403); + }); + + it('should error as forbidden for retire endpoint', async () => { + const multiselectQuestionA = await prisma.multiselectQuestions.create({ + data: multiselectQuestionFactory( + jurisdictionId, + { + multiselectQuestion: { + status: MultiselectQuestionsStatusEnum.active, + }, + }, + true, + ), + }); + + await request(app.getHttpServer()) + .put('/multiselectQuestions/retire') + .set({ passkey: process.env.API_PASS_KEY || '' }) + .send({ + id: multiselectQuestionA.id, + } as IdDTO) + .set('Cookie', cookies) + .expect(403); + }); + + it('should error as forbidden for retireMultiselectQuestions endpoint', async () => { + await request(app.getHttpServer()) + .put('/multiselectQuestions/retireMultiselectQuestions') + .set({ passkey: process.env.API_PASS_KEY || '' }) + .set('Cookie', cookies) + .expect(403); + }); }); describe('Testing user endpoints', () => { @@ -1019,13 +1078,13 @@ describe('Testing Permissioning of endpoints as public user', () => { it('should succeed for retrieveListings endpoint', async () => { const multiselectQuestion1 = await prisma.multiselectQuestions.create({ - data: multiselectQuestionFactory(jurisdictionAId, { + data: multiselectQuestionFactory(jurisdictionId, { multiselectQuestion: { text: 'example a', }, }), }); - const listingA = await listingFactory(jurisdictionAId, prisma, { + const listingA = await listingFactory(jurisdictionId, prisma, { multiselectQuestions: [multiselectQuestion1], }); const listingACreated = await prisma.listings.create({ @@ -1044,7 +1103,7 @@ describe('Testing Permissioning of endpoints as public user', () => { }); it('should succeed for retrieveListings endpoint', async () => { - const listingA = await listingFactory(jurisdictionAId, prisma); + const listingA = await listingFactory(jurisdictionId, prisma); const listingACreated = await prisma.listings.create({ data: listingA, include: { @@ -1059,7 +1118,7 @@ describe('Testing Permissioning of endpoints as public user', () => { }); it('should error as forbidden for delete endpoint', async () => { - const listingData = await listingFactory(jurisdictionAId, prisma); + const listingData = await listingFactory(jurisdictionId, prisma); const listing = await prisma.listings.create({ data: listingData, }); @@ -1075,7 +1134,7 @@ describe('Testing Permissioning of endpoints as public user', () => { }); it('should error as forbidden for update endpoint', async () => { - const listingData = await listingFactory(jurisdictionAId, prisma); + const listingData = await listingFactory(jurisdictionId, prisma); const listing = await prisma.listings.create({ data: listingData, }); @@ -1083,7 +1142,7 @@ describe('Testing Permissioning of endpoints as public user', () => { const val = await constructFullListingData( prisma, listing.id, - jurisdictionAId, + jurisdictionId, ); await request(app.getHttpServer()) @@ -1106,7 +1165,7 @@ describe('Testing Permissioning of endpoints as public user', () => { }); it('should error as forbidden for duplicate endpoint', async () => { - const listingData = await listingFactory(jurisdictionAId, prisma); + const listingData = await listingFactory(jurisdictionId, prisma); const listing = await prisma.listings.create({ data: listingData, }); @@ -1290,7 +1349,7 @@ describe('Testing Permissioning of endpoints as public user', () => { }); it('should error as forbidden for lottery status endpoint', async () => { - const listingData = await listingFactory(jurisdictionAId, prisma, { + const listingData = await listingFactory(jurisdictionId, prisma, { status: 'closed', }); const listing = await prisma.listings.create({ diff --git a/api/test/integration/permission-tests/permission-as-support-admin.e2e-spec.ts b/api/test/integration/permission-tests/permission-as-support-admin.e2e-spec.ts index f48c0bcc07..b9d6f87dd0 100644 --- a/api/test/integration/permission-tests/permission-as-support-admin.e2e-spec.ts +++ b/api/test/integration/permission-tests/permission-as-support-admin.e2e-spec.ts @@ -3,7 +3,12 @@ import { INestApplication } from '@nestjs/common'; import request from 'supertest'; import cookieParser from 'cookie-parser'; import { stringify } from 'qs'; -import { FlaggedSetStatusEnum, RuleEnum, UnitTypeEnum } from '@prisma/client'; +import { + FlaggedSetStatusEnum, + MultiselectQuestionsStatusEnum, + RuleEnum, + UnitTypeEnum, +} from '@prisma/client'; import { AppModule } from '../../../src/modules/app.module'; import { PrismaService } from '../../../src/services/prisma.service'; import { userFactory } from '../../../prisma/seed-helpers/user-factory'; @@ -353,7 +358,7 @@ describe('Testing Permissioning of endpoints as Support Admin User', () => { .set('Cookie', cookies) .expect(201); }); - it('should succed for update endpoint', async () => { + it('should succeed for update endpoint', async () => { const unitTypeA = await unitTypeFactorySingle( prisma, UnitTypeEnum.oneBdrm, @@ -835,7 +840,7 @@ describe('Testing Permissioning of endpoints as Support Admin User', () => { }); await request(app.getHttpServer()) - .put(`/multiselectQuestions/${multiselectQuestionA.id}`) + .put('/multiselectQuestions') .set({ passkey: process.env.API_PASS_KEY || '' }) .send( buildMultiselectQuestionUpdateMock( @@ -861,6 +866,60 @@ describe('Testing Permissioning of endpoints as Support Admin User', () => { .set('Cookie', cookies) .expect(403); }); + + it('should error as forbidden for reActivate endpoint', async () => { + const multiselectQuestionA = await prisma.multiselectQuestions.create({ + data: multiselectQuestionFactory( + jurisdictionId, + { + multiselectQuestion: { + status: MultiselectQuestionsStatusEnum.toRetire, + }, + }, + true, + ), + }); + + await request(app.getHttpServer()) + .put('/multiselectQuestions/reActivate') + .set({ passkey: process.env.API_PASS_KEY || '' }) + .send({ + id: multiselectQuestionA.id, + } as IdDTO) + .set('Cookie', cookies) + .expect(403); + }); + + it('should error as forbidden for retire endpoint', async () => { + const multiselectQuestionA = await prisma.multiselectQuestions.create({ + data: multiselectQuestionFactory( + jurisdictionId, + { + multiselectQuestion: { + status: MultiselectQuestionsStatusEnum.active, + }, + }, + true, + ), + }); + + await request(app.getHttpServer()) + .put('/multiselectQuestions/retire') + .set({ passkey: process.env.API_PASS_KEY || '' }) + .send({ + id: multiselectQuestionA.id, + } as IdDTO) + .set('Cookie', cookies) + .expect(403); + }); + + it('should error as forbidden for retireMultiselectQuestions endpoint', async () => { + await request(app.getHttpServer()) + .put('/multiselectQuestions/retireMultiselectQuestions') + .set({ passkey: process.env.API_PASS_KEY || '' }) + .set('Cookie', cookies) + .expect(403); + }); }); describe('Testing user endpoints', () => { diff --git a/api/test/integration/reserved-community-type.e2e-spec.ts b/api/test/integration/reserved-community-type.e2e-spec.ts index f023b30c70..dd697c37ec 100644 --- a/api/test/integration/reserved-community-type.e2e-spec.ts +++ b/api/test/integration/reserved-community-type.e2e-spec.ts @@ -7,6 +7,7 @@ import cookieParser from 'cookie-parser'; import { AppModule } from '../../src/modules/app.module'; import { PrismaService } from '../../src/services/prisma.service'; import { jurisdictionFactory } from '../../prisma/seed-helpers/jurisdiction-factory'; +import { randomName } from '../../prisma/seed-helpers/word-generator'; import { ReservedCommunityTypeQueryParams } from '../../src/dtos/reserved-community-types/reserved-community-type-query-params.dto'; import { reservedCommunityTypeFactory, @@ -65,11 +66,11 @@ describe('ReservedCommunityType Controller Tests', () => { it('testing list endpoint without params', async () => { const jurisdictionA = await prisma.jurisdictions.create({ - data: jurisdictionFactory(), + data: jurisdictionFactory(`reservedCommunityTypeA-${randomName()}`), }); await reservedCommunityTypeFactoryAll(jurisdictionA.id, prisma); const jurisdictionB = await prisma.jurisdictions.create({ - data: jurisdictionFactory(), + data: jurisdictionFactory(`reservedCommunityTypeB-${randomName()}`), }); await reservedCommunityTypeFactoryAll(jurisdictionB.id, prisma); diff --git a/api/test/integration/user.e2e-spec.ts b/api/test/integration/user.e2e-spec.ts index 878d67d051..f82c7f3973 100644 --- a/api/test/integration/user.e2e-spec.ts +++ b/api/test/integration/user.e2e-spec.ts @@ -1,5 +1,6 @@ import { Test, TestingModule } from '@nestjs/testing'; -import { INestApplication } from '@nestjs/common'; +import { INestApplication, Logger } from '@nestjs/common'; +import { LanguagesEnum } from '@prisma/client'; import { randomUUID } from 'crypto'; import { stringify } from 'qs'; import request from 'supertest'; @@ -23,6 +24,7 @@ import { EmailService } from '../../src/services/email.service'; import { Login } from '../../src/dtos/auth/login.dto'; import { RequestMfaCode } from '../../src/dtos/mfa/request-mfa-code.dto'; import { ModificationEnum } from '../../src/enums/shared/modification-enum'; +import dayjs from 'dayjs'; describe('User Controller Tests', () => { let app: INestApplication; @@ -30,6 +32,7 @@ describe('User Controller Tests', () => { let userService: UserService; let emailService: EmailService; let cookies = ''; + let logger: Logger; const invitePartnerUserMock = jest.fn(); const testEmailService = { @@ -40,6 +43,7 @@ describe('User Controller Tests', () => { forgotPassword: jest.fn(), sendMfaCode: jest.fn(), sendCSV: jest.fn(), + warnOfAccountRemoval: jest.fn(), }; beforeEach(() => { @@ -53,6 +57,12 @@ describe('User Controller Tests', () => { }) .overrideProvider(EmailService) .useValue(testEmailService) + .overrideProvider(Logger) + .useValue({ + log: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }) .compile(); app = moduleFixture.createNestApplication(); @@ -60,6 +70,7 @@ describe('User Controller Tests', () => { prisma = moduleFixture.get(PrismaService); userService = moduleFixture.get(UserService); emailService = moduleFixture.get(EmailService); + logger = moduleFixture.get(Logger); await app.init(); @@ -224,7 +235,7 @@ describe('User Controller Tests', () => { }); describe('delete endpoint', () => { - it('should delete user when user exists', async () => { + it('should delete admin user when user exists', async () => { const userA = await prisma.userAccounts.create({ data: await userFactory({ roles: { isAdmin: true } }), }); @@ -241,6 +252,23 @@ describe('User Controller Tests', () => { expect(res.body.success).toEqual(true); }); + it('should delete public user when user exists', async () => { + const userA = await prisma.userAccounts.create({ + data: await userFactory(), + }); + + const res = await request(app.getHttpServer()) + .delete(`/user/`) + .set({ passkey: process.env.API_PASS_KEY || '' }) + .send({ + id: userA.id, + } as IdDTO) + .set('Cookie', cookies) + .expect(200); + + expect(res.body.success).toEqual(true); + }); + it("should error when deleting user that doesn't exist", async () => { const randomId = randomUUID(); const res = await request(app.getHttpServer()) @@ -465,6 +493,9 @@ describe('User Controller Tests', () => { }); it('should fail to verify token when incorrect user id is provided', async () => { + const mockConsoleError = jest + .spyOn(console, 'error') + .mockImplementation(); const userA = await prisma.userAccounts.create({ data: await userFactory(), }); @@ -506,6 +537,10 @@ describe('User Controller Tests', () => { }); expect(userPostResend.hitConfirmationUrl).toBeNull(); + expect(mockConsoleError).toHaveBeenCalledWith( + 'isUserConfirmationTokenValid error = ', + expect.anything(), + ); }); it('should fail to verify token when token mismatch', async () => { @@ -944,4 +979,175 @@ describe('User Controller Tests', () => { ); }); }); + + describe('delete after inactivity endpoint', () => { + it('should delete user after inactivity', async () => { + process.env.USERS_DAYS_TILL_EXPIRY = '1095'; + const publicUserStillActive = await prisma.userAccounts.create({ + data: await userFactory({ + lastLoginAt: dayjs(new Date()).subtract(60, 'days').toDate(), + wasWarnedOfDeletion: false, + }), + }); + const partnerUserInactive = await prisma.userAccounts.create({ + data: await userFactory({ + lastLoginAt: dayjs(new Date()).subtract(6000, 'days').toDate(), + wasWarnedOfDeletion: false, + roles: { isPartner: true }, + }), + }); + const publicUserInactiveNotWarned = await prisma.userAccounts.create({ + data: await userFactory({ + lastLoginAt: dayjs(new Date()).subtract(1100, 'days').toDate(), + wasWarnedOfDeletion: false, + }), + }); + const publicUserInactiveWarned = await prisma.userAccounts.create({ + data: await userFactory({ + lastLoginAt: dayjs(new Date()).subtract(1100, 'days').toDate(), + wasWarnedOfDeletion: true, + }), + }); + const data = await applicationFactory({ + userId: publicUserInactiveWarned.id, + }); + const application = await prisma.applications.create({ + data, + }); + await request(app.getHttpServer()) + .put(`/user/deleteInactiveUsersCronJob`) + .set({ passkey: process.env.API_PASS_KEY || '' }) + .set('Cookie', cookies) + .expect(200); + + const deletedUser = await prisma.userAccounts.findFirst({ + where: { id: publicUserInactiveWarned.id }, + }); + expect(deletedUser).toBeNull(); + + const nonDeletedUsers = await prisma.userAccounts.findMany({ + where: { + id: { + in: [ + publicUserInactiveNotWarned.id, + publicUserStillActive.id, + partnerUserInactive.id, + ], + }, + }, + }); + expect(nonDeletedUsers).toHaveLength(3); + expect(logger.warn).toBeCalledWith( + `Unable to delete user ${publicUserInactiveNotWarned.id} because they have not been warned by email`, + ); + // Validate PII was removed from applications + const updatedApplication = await prisma.applications.findFirst({ + include: { applicant: true, applicationsMailingAddress: true }, + where: { id: application.id }, + }); + expect(updatedApplication.additionalPhoneNumber).toBeNull(); + expect(updatedApplication.applicant.birthDay).toBeNull(); + expect(updatedApplication.applicant.birthMonth).toBeNull(); + expect(updatedApplication.applicant.birthYear).toBeNull(); + expect(updatedApplication.applicant.firstName).toBeNull(); + expect(updatedApplication.applicant.lastName).toBeNull(); + expect(updatedApplication.applicationsMailingAddress.street).toBeNull(); + expect(updatedApplication.applicationsMailingAddress.city).not.toBeNull(); + }); + }); + + describe('warnUserOfDeletionCronJob endpoint', () => { + let userA; + let userB; + let userC; + let userD; + let userE; + beforeAll(async () => { + process.env.USERS_DAYS_TILL_EXPIRY = '1095'; + // Public User that should be warned + userA = await prisma.userAccounts.create({ + data: await userFactory({ + firstName: 'A', + confirmedAt: new Date(), + lastLoginAt: dayjs(new Date()).subtract(4, 'years').toDate(), + }), + }); + // User that has logged in recently + userB = await prisma.userAccounts.create({ + data: await userFactory({ + firstName: 'B', + confirmedAt: new Date(), + lastLoginAt: dayjs(new Date()).subtract(4, 'days').toDate(), + }), + }); + // Partner user + userC = await prisma.userAccounts.create({ + data: await userFactory({ + firstName: 'C', + confirmedAt: new Date(), + roles: { isAdmin: true }, + lastLoginAt: dayjs(new Date()).subtract(1200, 'days').toDate(), + }), + }); + // User that has already been warned + userD = await prisma.userAccounts.create({ + data: await userFactory({ + firstName: 'D', + confirmedAt: new Date(), + lastLoginAt: dayjs(new Date()).subtract(4, 'years').toDate(), + wasWarnedOfDeletion: true, + }), + }); + // Public User that should be warned in spanish + userE = await prisma.userAccounts.create({ + data: await userFactory({ + firstName: 'E', + confirmedAt: new Date(), + lastLoginAt: dayjs(new Date()).subtract(4, 'years').toDate(), + language: LanguagesEnum.es, + }), + }); + }); + it('should send warning email to only public users over the date', async () => { + const mockWarnOfAccountRemoval = jest.spyOn( + testEmailService, + 'warnOfAccountRemoval', + ); + const res = await request(app.getHttpServer()) + .put(`/user/userWarnCronJob`) + .set({ passkey: process.env.API_PASS_KEY || '' }) + .set('Cookie', cookies) + .expect(200); + expect(res.text).toBe('{"success":true}'); + const updatedUserA = await prisma.userAccounts.findFirst({ + where: { id: userA.id }, + }); + expect(updatedUserA.wasWarnedOfDeletion).toBe(true); + const updatedUserB = await prisma.userAccounts.findFirst({ + where: { id: userB.id }, + }); + expect(updatedUserB.wasWarnedOfDeletion).toBe(false); + const updatedUserC = await prisma.userAccounts.findFirst({ + where: { id: userC.id }, + }); + expect(updatedUserC.wasWarnedOfDeletion).toBe(false); + const updatedUserD = await prisma.userAccounts.findFirst({ + where: { id: userD.id }, + }); + expect(updatedUserD.wasWarnedOfDeletion).toBe(true); + expect(mockWarnOfAccountRemoval.mock.calls.length).toBeGreaterThanOrEqual( + 2, + ); + expect(mockWarnOfAccountRemoval).toBeCalledWith( + expect.objectContaining({ email: userA.email, id: userA.id }), + ); + expect(mockWarnOfAccountRemoval).toBeCalledWith( + expect.objectContaining({ + email: userE.email, + id: userE.id, + language: LanguagesEnum.es, + }), + ); + }); + }); }); diff --git a/api/test/unit/passports/jwt.strategy.spec.ts b/api/test/unit/passports/jwt.strategy.spec.ts index a20fd4dc34..c4b244d383 100644 --- a/api/test/unit/passports/jwt.strategy.spec.ts +++ b/api/test/unit/passports/jwt.strategy.spec.ts @@ -61,7 +61,7 @@ describe('Testing jwt strategy', () => { }); }); - it('should fail because user password is outdated', async () => { + it('should not fail because user password is outdated', async () => { const id = randomUUID(); const token = sign( { @@ -73,6 +73,11 @@ describe('Testing jwt strategy', () => { id, passwordValidForDays: 100, passwordUpdatedAt: new Date(0), + activeAccessToken: token, + }); + + prisma.userAccounts.update = jest.fn().mockResolvedValue({ + id, }); const request = { @@ -81,12 +86,7 @@ describe('Testing jwt strategy', () => { }, }; - await expect( - async () => - await strategy.validate(request as unknown as Request, { sub: id }), - ).rejects.toThrowError( - `user ${id} attempted to log in, but password is outdated`, - ); + await strategy.validate(request as unknown as Request, { sub: id }); expect(prisma.userAccounts.findFirst).toHaveBeenCalledWith({ include: { @@ -107,6 +107,8 @@ describe('Testing jwt strategy', () => { id, }, }); + + expect(prisma.userAccounts.update).not.toHaveBeenCalled(); }); it('should fail because stored token does not match incoming token', async () => { diff --git a/api/test/unit/passports/mfa.strategy.spec.ts b/api/test/unit/passports/mfa.strategy.spec.ts index 80e770ef65..19a05519e5 100644 --- a/api/test/unit/passports/mfa.strategy.spec.ts +++ b/api/test/unit/passports/mfa.strategy.spec.ts @@ -91,6 +91,8 @@ describe('Testing mfa strategy', () => { failedLoginAttemptsCount: 0, confirmedAt: null, passwordHash: await passwordToHash('Abcdef12345!'), + passwordUpdatedAt: new Date(), + passwordValidFor: 180, }); const request = { @@ -203,6 +205,7 @@ describe('Testing mfa strategy', () => { data: { failedLoginAttemptsCount: 1, lastLoginAt: expect.anything(), + wasWarnedOfDeletion: false, }, where: { id, @@ -308,6 +311,7 @@ describe('Testing mfa strategy', () => { expect(prisma.userAccounts.update).toHaveBeenCalledWith({ data: { failedLoginAttemptsCount: 0, + wasWarnedOfDeletion: false, }, where: { id, @@ -360,6 +364,7 @@ describe('Testing mfa strategy', () => { expect(prisma.userAccounts.update).toHaveBeenCalledWith({ data: { failedLoginAttemptsCount: 0, + wasWarnedOfDeletion: false, }, where: { id, @@ -412,6 +417,7 @@ describe('Testing mfa strategy', () => { expect(prisma.userAccounts.update).toHaveBeenCalledWith({ data: { failedLoginAttemptsCount: 0, + wasWarnedOfDeletion: false, }, where: { id, diff --git a/api/test/unit/passports/single-use-code.strategy.spec.ts b/api/test/unit/passports/single-use-code.strategy.spec.ts index f38803b130..4cc410951e 100644 --- a/api/test/unit/passports/single-use-code.strategy.spec.ts +++ b/api/test/unit/passports/single-use-code.strategy.spec.ts @@ -163,6 +163,7 @@ describe('Testing single-use-code strategy', () => { expect(prisma.userAccounts.update).toHaveBeenCalledWith({ data: { failedLoginAttemptsCount: 0, + wasWarnedOfDeletion: false, }, where: { id, @@ -232,6 +233,7 @@ describe('Testing single-use-code strategy', () => { expect(prisma.userAccounts.update).toHaveBeenCalledWith({ data: { failedLoginAttemptsCount: 0, + wasWarnedOfDeletion: false, }, where: { id, @@ -301,6 +303,7 @@ describe('Testing single-use-code strategy', () => { expect(prisma.userAccounts.update).toHaveBeenCalledWith({ data: { failedLoginAttemptsCount: 0, + wasWarnedOfDeletion: false, }, where: { id, diff --git a/api/test/unit/services/app.service.spec.ts b/api/test/unit/services/app.service.spec.ts index bea848f872..1374d0e759 100644 --- a/api/test/unit/services/app.service.spec.ts +++ b/api/test/unit/services/app.service.spec.ts @@ -1,16 +1,22 @@ -import { randomUUID } from 'crypto'; import { Test, TestingModule } from '@nestjs/testing'; import { Logger } from '@nestjs/common'; import { SchedulerRegistry } from '@nestjs/schedule'; import { AppService } from '../../../src/services/app.service'; import { PrismaService } from '../../../src/services/prisma.service'; +import { CronJobService } from '../../../src/services/cron-job.service'; describe('Testing app service', () => { let service: AppService; let prisma: PrismaService; beforeAll(async () => { const module: TestingModule = await Test.createTestingModule({ - providers: [AppService, PrismaService, Logger, SchedulerRegistry], + providers: [ + AppService, + PrismaService, + Logger, + SchedulerRegistry, + CronJobService, + ], }).compile(); service = module.get(AppService); @@ -24,46 +30,4 @@ describe('Testing app service', () => { }); expect(prisma.$queryRaw).toHaveBeenCalled(); }); - - it('should create new cronjob entry if none is present', async () => { - prisma.cronJob.findFirst = jest.fn().mockResolvedValue(null); - prisma.cronJob.create = jest.fn().mockResolvedValue(true); - - await service.markCronJobAsStarted(); - - expect(prisma.cronJob.findFirst).toHaveBeenCalledWith({ - where: { - name: 'TEMP_FILE_CLEAR_CRON_JOB', - }, - }); - expect(prisma.cronJob.create).toHaveBeenCalledWith({ - data: { - lastRunDate: expect.anything(), - name: 'TEMP_FILE_CLEAR_CRON_JOB', - }, - }); - }); - - it('should update cronjob entry if one is present', async () => { - prisma.cronJob.findFirst = jest - .fn() - .mockResolvedValue({ id: randomUUID() }); - prisma.cronJob.update = jest.fn().mockResolvedValue(true); - - await service.markCronJobAsStarted(); - - expect(prisma.cronJob.findFirst).toHaveBeenCalledWith({ - where: { - name: 'TEMP_FILE_CLEAR_CRON_JOB', - }, - }); - expect(prisma.cronJob.update).toHaveBeenCalledWith({ - data: { - lastRunDate: expect.anything(), - }, - where: { - id: expect.anything(), - }, - }); - }); }); diff --git a/api/test/unit/services/application-exporter.service.spec.ts b/api/test/unit/services/application-exporter.service.spec.ts index 56e231a9e8..c1af5f3470 100644 --- a/api/test/unit/services/application-exporter.service.spec.ts +++ b/api/test/unit/services/application-exporter.service.spec.ts @@ -29,6 +29,7 @@ import { unitTypeToReadable, } from '../../../src/utilities/application-export-helpers'; import { FeatureFlagEnum } from '../../../src/enums/feature-flags/feature-flags-enum'; +import { CronJobService } from '../../../src/services/cron-job.service'; describe('Testing application export service', () => { let service: ApplicationExporterService; @@ -56,6 +57,7 @@ describe('Testing application export service', () => { ConfigService, Logger, SchedulerRegistry, + CronJobService, GoogleTranslateService, ], imports: [HttpModule], diff --git a/api/test/unit/services/application-flagged-set.service.spec.ts b/api/test/unit/services/application-flagged-set.service.spec.ts index 8f8ece1a51..fbf220ef10 100644 --- a/api/test/unit/services/application-flagged-set.service.spec.ts +++ b/api/test/unit/services/application-flagged-set.service.spec.ts @@ -8,13 +8,14 @@ import { ListingsStatusEnum, RuleEnum, } from '@prisma/client'; +import { randomUUID } from 'node:crypto'; import { ApplicationFlaggedSetService } from '../../../src/services/application-flagged-set.service'; import { PrismaService } from '../../../src/services/prisma.service'; import { View } from '../../../src/enums/application-flagged-sets/view'; import { Application } from '../../../src/dtos/applications/application.dto'; import { OrderByEnum } from '../../../src/enums/shared/order-by-enum'; import { User } from '../../../src/dtos/users/user.dto'; -import { randomUUID } from 'node:crypto'; +import { CronJobService } from '../../../src/services/cron-job.service'; describe('Testing application flagged set service', () => { let service: ApplicationFlaggedSetService; @@ -26,6 +27,7 @@ describe('Testing application flagged set service', () => { PrismaService, Logger, SchedulerRegistry, + CronJobService, ], }).compile(); @@ -708,58 +710,6 @@ describe('Testing application flagged set service', () => { }); }); - describe('Test markCronJobAsStarted', () => { - it('should mark existing job as begun', async () => { - prisma.cronJob.findFirst = jest.fn().mockResolvedValue({ - id: 'example id', - }); - - prisma.cronJob.update = jest.fn().mockResolvedValue({ - id: 'example id', - }); - - await service.markCronJobAsStarted('AFS_CRON_JOB'); - - expect(prisma.cronJob.findFirst).toHaveBeenCalledWith({ - where: { - name: 'AFS_CRON_JOB', - }, - }); - - expect(prisma.cronJob.update).toHaveBeenCalledWith({ - data: { - lastRunDate: expect.anything(), - }, - where: { - id: 'example id', - }, - }); - }); - - it('should create cronjob as begun', async () => { - prisma.cronJob.findFirst = jest.fn().mockResolvedValue(null); - - prisma.cronJob.create = jest.fn().mockResolvedValue({ - id: 'example id', - }); - - await service.markCronJobAsStarted('AFS_CRON_JOB'); - - expect(prisma.cronJob.findFirst).toHaveBeenCalledWith({ - where: { - name: 'AFS_CRON_JOB', - }, - }); - - expect(prisma.cronJob.create).toHaveBeenCalledWith({ - data: { - lastRunDate: expect.anything(), - name: 'AFS_CRON_JOB', - }, - }); - }); - }); - describe('Test checkAgainstEmail', () => { it('should get matching applications based on email', async () => { prisma.applications.findMany = jest diff --git a/api/test/unit/services/application.service.spec.ts b/api/test/unit/services/application.service.spec.ts index a7c6f6d677..4b32e4df65 100644 --- a/api/test/unit/services/application.service.spec.ts +++ b/api/test/unit/services/application.service.spec.ts @@ -10,6 +10,8 @@ import { ListingsStatusEnum, LotteryStatusEnum, } from '@prisma/client'; +import { Logger } from '@nestjs/common'; +import { SchedulerRegistry } from '@nestjs/schedule'; import { randomUUID } from 'crypto'; import dayjs from 'dayjs'; import { Request as ExpressRequest } from 'express'; @@ -34,6 +36,7 @@ import { HouseholdMemberRelationship } from '../../../src/enums/applications/hou import { PublicAppsViewQueryParams } from '../../../src/dtos/applications/public-apps-view-params.dto'; import { ApplicationsFilterEnum } from '../../../src/enums/applications/filter-enum'; import { FeatureFlagEnum } from '../../../src/enums/feature-flags/feature-flags-enum'; +import { CronJobService } from '../../../src/services/cron-job.service'; export const mockApplication = (options: { date: Date; @@ -182,52 +185,19 @@ export const mockApplicationSet = ( export const mockCreateApplicationData = ( exampleAddress: AddressCreate, submissionDate: Date, + multiselectQuestionId?: string, ): ApplicationCreate => { return { - contactPreferences: ['example contact preference'], - preferences: [ - { - key: 'example key', - claimed: true, - options: [ - { - key: 'example key', - checked: true, - extraData: [ - { - type: InputType.boolean, - key: 'example key', - value: true, - }, - ], - }, - ], - }, - ], - status: ApplicationStatusEnum.submitted, - submissionType: ApplicationSubmissionTypeEnum.electronical, - applicant: { - firstName: 'applicant first name', - middleName: 'applicant middle name', - lastName: 'applicant last name', - birthMonth: '12', - birthDay: '17', - birthYear: '1993', - emailAddress: 'example@email.com', - noEmail: false, - phoneNumber: '111-111-1111', - phoneNumberType: 'Cell', - noPhone: false, - workInRegion: YesNoEnum.yes, - applicantWorkAddress: exampleAddress, - applicantAddress: exampleAddress, - }, + acceptedTerms: true, accessibility: { mobility: false, vision: false, hearing: false, other: false, }, + additionalPhone: true, + additionalPhoneNumber: '111-111-1111', + additionalPhoneNumberType: 'example additional phone number type', alternateContact: { type: AlternateContactRelationship.other, otherType: 'example other type', @@ -238,11 +208,39 @@ export const mockCreateApplicationData = ( emailAddress: 'example@email.com', address: exampleAddress, }, + applicant: { + firstName: 'applicant first name', + middleName: 'applicant middle name', + lastName: 'applicant last name', + birthMonth: '12', + birthDay: '17', + birthYear: '1993', + emailAddress: 'example@email.com', + noEmail: false, + phoneNumber: '111-111-1111', + phoneNumberType: 'Cell', + noPhone: false, + workInRegion: YesNoEnum.yes, + applicantWorkAddress: exampleAddress, + applicantAddress: exampleAddress, + }, + applicationSelections: multiselectQuestionId + ? [ + { + multiselectQuestion: { id: multiselectQuestionId }, + selections: [ + { + addressHolderAddress: exampleAddress, + multiselectOption: { id: randomUUID() }, + }, + ], + }, + ] + : [], applicationsAlternateAddress: exampleAddress, applicationsMailingAddress: exampleAddress, - listings: { - id: randomUUID(), - }, + appUrl: 'http://www.example.com', + contactPreferences: ['example contact preference'], demographics: { ethnicity: 'example ethnicity', gender: 'example gender', @@ -250,11 +248,7 @@ export const mockCreateApplicationData = ( howDidYouHear: ['example how did you hear'], race: ['example race'], }, - preferredUnitTypes: [ - { - id: randomUUID(), - }, - ], + householdExpectingChanges: false, householdMember: [ { orderId: 0, @@ -271,22 +265,40 @@ export const mockCreateApplicationData = ( householdMemberAddress: exampleAddress, }, ], - appUrl: 'http://www.example.com', - additionalPhone: true, - additionalPhoneNumber: '111-111-1111', - additionalPhoneNumberType: 'example additional phone number type', householdSize: 2, - housingStatus: 'example housing status', - sendMailToMailingAddress: true, - householdExpectingChanges: false, householdStudent: false, + housingStatus: 'example housing status', incomeVouchers: false, income: '36000', incomePeriod: IncomePeriodEnum.perYear, language: LanguagesEnum.en, - acceptedTerms: true, - submissionDate: submissionDate, - reviewStatus: ApplicationReviewStatusEnum.valid, + listings: { + id: randomUUID(), + }, + preferences: [ + { + key: 'example key', + claimed: true, + options: [ + { + key: 'example key', + checked: true, + extraData: [ + { + type: InputType.boolean, + key: 'example key', + value: true, + }, + ], + }, + ], + }, + ], + preferredUnitTypes: [ + { + id: randomUUID(), + }, + ], programs: [ { key: 'example key', @@ -306,6 +318,11 @@ export const mockCreateApplicationData = ( ], }, ], + reviewStatus: ApplicationReviewStatusEnum.valid, + sendMailToMailingAddress: true, + status: ApplicationStatusEnum.submitted, + submissionDate: submissionDate, + submissionType: ApplicationSubmissionTypeEnum.electronical, } as ApplicationCreate; }; @@ -365,6 +382,30 @@ const detailView = { other: true, }, }, + applicationSelections: { + include: { + multiselectQuestion: true, + selections: { + include: { + addressHolderAddress: { + select: { + id: true, + placeName: true, + city: true, + county: true, + state: true, + street: true, + street2: true, + zipCode: true, + latitude: true, + longitude: true, + }, + }, + multiselectOption: true, + }, + }, + }, + }, applicationsMailingAddress: { select: { id: true, @@ -448,6 +489,7 @@ const detailView = { name: true, }, }, + listingMultiselectQuestions: true, }, }, householdMember: { @@ -560,6 +602,30 @@ const baseView = { other: true, }, }, + applicationSelections: { + include: { + multiselectQuestion: true, + selections: { + include: { + addressHolderAddress: { + select: { + id: true, + placeName: true, + city: true, + county: true, + state: true, + street: true, + street2: true, + zipCode: true, + latitude: true, + longitude: true, + }, + }, + multiselectOption: true, + }, + }, + }, + }, applicationsMailingAddress: { select: { id: true, @@ -643,6 +709,7 @@ const baseView = { name: true, }, }, + listingMultiselectQuestions: true, }, }, householdMember: { @@ -703,6 +770,9 @@ describe('Testing application service', () => { ApplicationService, PrismaService, GeocodingService, + Logger, + CronJobService, + SchedulerRegistry, { provide: EmailService, useValue: { @@ -812,397 +882,405 @@ describe('Testing application service', () => { }, }; - it('should get applications from list() when applications are available', async () => { - const mockedValue = mockApplicationSet(3, date); - prisma.applications.findMany = jest.fn().mockResolvedValue(mockedValue); - prisma.applications.count = jest.fn().mockResolvedValue(3); - prisma.applications.findFirst = jest.fn().mockResolvedValue({ - id: 'example id', - }); - prisma.jurisdictions.findFirst = jest.fn().mockResolvedValue(null); - - const params: ApplicationQueryParams = { - orderBy: ApplicationOrderByKeys.createdAt, - order: OrderByEnum.ASC, - listingId: 'example listing id', - limit: 3, - page: 1, - }; - - expect( - await service.list(params, { - user: requestingUser, - } as unknown as ExpressRequest), - ).toEqual({ - items: mockedValue.map((mock) => ({ ...mock, flagged: true })), - meta: { - currentPage: 1, - itemCount: 3, - itemsPerPage: 3, - totalItems: 3, - totalPages: 1, - }, - }); + describe('listing endpoint', () => { + it('should get applications from list() when applications are available', async () => { + const mockedValue = mockApplicationSet(3, date); + prisma.applications.findMany = jest.fn().mockResolvedValue(mockedValue); + prisma.applications.count = jest.fn().mockResolvedValue(3); + prisma.applications.findFirst = jest.fn().mockResolvedValue({ + id: 'example id', + }); + prisma.jurisdictions.findFirst = jest.fn().mockResolvedValue(null); + + const params: ApplicationQueryParams = { + orderBy: ApplicationOrderByKeys.createdAt, + order: OrderByEnum.ASC, + listingId: 'example listing id', + limit: 3, + page: 1, + }; - expect(prisma.applications.count).toHaveBeenCalledWith({ - where: { - AND: [ - { - listingId: 'example listing id', + expect( + await service.list(params, { + user: requestingUser, + } as unknown as ExpressRequest), + ).toEqual({ + items: mockedValue.map((mock) => ({ ...mock, flagged: true })), + meta: { + currentPage: 1, + itemCount: 3, + itemsPerPage: 3, + totalItems: 3, + totalPages: 1, + }, + }); + + expect(prisma.applications.count).toHaveBeenCalledWith({ + where: { + AND: [ + { + listingId: 'example listing id', + }, + { + deletedAt: null, + }, + ], + }, + }); + + expect(prisma.applications.findFirst).toHaveBeenNthCalledWith(1, { + select: { + id: true, + }, + where: { + id: mockedValue[0].id, + applicationFlaggedSet: { + some: {}, }, - { - deletedAt: null, + }, + }); + expect(prisma.applications.findFirst).toHaveBeenNthCalledWith(2, { + select: { + id: true, + }, + where: { + id: mockedValue[1].id, + applicationFlaggedSet: { + some: {}, }, - ], - }, - }); - - expect(prisma.applications.findFirst).toHaveBeenNthCalledWith(1, { - select: { - id: true, - }, - where: { - id: mockedValue[0].id, - applicationFlaggedSet: { - some: {}, }, - }, - }); - expect(prisma.applications.findFirst).toHaveBeenNthCalledWith(2, { - select: { - id: true, - }, - where: { - id: mockedValue[1].id, - applicationFlaggedSet: { - some: {}, + }); + expect(prisma.applications.findFirst).toHaveBeenNthCalledWith(3, { + select: { + id: true, }, - }, - }); - expect(prisma.applications.findFirst).toHaveBeenNthCalledWith(3, { - select: { - id: true, - }, - where: { - id: mockedValue[2].id, - applicationFlaggedSet: { - some: {}, + where: { + id: mockedValue[2].id, + applicationFlaggedSet: { + some: {}, + }, }, - }, + }); }); }); - it('should get publicAppsView() info when applications are available and filterType is all', async () => { - const mockedValues = mockApplicationSet(3, date); - const listingStatuses = [ - { status: ListingsStatusEnum.active }, - { status: ListingsStatusEnum.closed }, - { - status: ListingsStatusEnum.closed, - lotteryStatus: LotteryStatusEnum.publishedToPublic, - }, - ]; - const mockedValuesWithListing = mockedValues.map((mockedValue, idx) => { - return { - ...mockedValue, - listings: listingStatuses[idx], + describe('publicAppsView endpoint', () => { + it('should get publicAppsView() info when applications are available and filterType is all', async () => { + const mockedValues = mockApplicationSet(3, date); + const listingStatuses = [ + { status: ListingsStatusEnum.active }, + { status: ListingsStatusEnum.closed }, + { + status: ListingsStatusEnum.closed, + lotteryStatus: LotteryStatusEnum.publishedToPublic, + }, + ]; + const mockedValuesWithListing = mockedValues.map((mockedValue, idx) => { + return { + ...mockedValue, + listings: listingStatuses[idx], + }; + }); + prisma.applications.findMany = jest + .fn() + .mockResolvedValue(getPublicAppsFindManyMock(mockedValuesWithListing)); + + const params: PublicAppsViewQueryParams = { + userId: requestingUser.id, + filterType: ApplicationsFilterEnum.all, + includeLotteryApps: true, }; - }); - prisma.applications.findMany = jest - .fn() - .mockResolvedValue(getPublicAppsFindManyMock(mockedValuesWithListing)); - const params: PublicAppsViewQueryParams = { - userId: requestingUser.id, - filterType: ApplicationsFilterEnum.all, - includeLotteryApps: true, - }; - - const res = await service.publicAppsView(params, { - user: requestingUser, - } as unknown as ExpressRequest); - - expect(res.displayApplications.length).toEqual(3); - expect(res.applicationsCount).toEqual({ - total: 3, - open: 1, - closed: 1, - lottery: 1, - }); + const res = await service.publicAppsView(params, { + user: requestingUser, + } as unknown as ExpressRequest); + + expect(res.displayApplications.length).toEqual(3); + expect(res.applicationsCount).toEqual({ + total: 3, + open: 1, + closed: 1, + lottery: 1, + }); - expect(prisma.applications.findMany).toHaveBeenCalledWith( - publicAppsFindManyCalledWith, - ); - }); + expect(prisma.applications.findMany).toHaveBeenCalledWith( + publicAppsFindManyCalledWith, + ); + }); - it('should get publicAppsView() info when there are lottery listings but includeLottery is false and filter type is all', async () => { - const mockedValues = mockApplicationSet(3, date); - const listingStatuses = [ - { status: ListingsStatusEnum.active }, - { status: ListingsStatusEnum.closed }, - { - status: ListingsStatusEnum.closed, - lotteryStatus: LotteryStatusEnum.publishedToPublic, - }, - ]; - const mockedValuesWithListing = mockedValues.map((mockedValue, idx) => { - return { - ...mockedValue, - listings: listingStatuses[idx], + it('should get publicAppsView() info when there are lottery listings but includeLottery is false and filter type is all', async () => { + const mockedValues = mockApplicationSet(3, date); + const listingStatuses = [ + { status: ListingsStatusEnum.active }, + { status: ListingsStatusEnum.closed }, + { + status: ListingsStatusEnum.closed, + lotteryStatus: LotteryStatusEnum.publishedToPublic, + }, + ]; + const mockedValuesWithListing = mockedValues.map((mockedValue, idx) => { + return { + ...mockedValue, + listings: listingStatuses[idx], + }; + }); + prisma.applications.findMany = jest + .fn() + .mockResolvedValue(getPublicAppsFindManyMock(mockedValuesWithListing)); + + const params: PublicAppsViewQueryParams = { + userId: requestingUser.id, + filterType: ApplicationsFilterEnum.all, + includeLotteryApps: false, }; - }); - prisma.applications.findMany = jest - .fn() - .mockResolvedValue(getPublicAppsFindManyMock(mockedValuesWithListing)); - const params: PublicAppsViewQueryParams = { - userId: requestingUser.id, - filterType: ApplicationsFilterEnum.all, - includeLotteryApps: false, - }; - - const res = await service.publicAppsView(params, { - user: requestingUser, - } as unknown as ExpressRequest); - - expect(res.displayApplications.length).toEqual(3); - expect(res.applicationsCount).toEqual({ - total: 3, - open: 1, - closed: 2, - lottery: 0, - }); + const res = await service.publicAppsView(params, { + user: requestingUser, + } as unknown as ExpressRequest); + + expect(res.displayApplications.length).toEqual(3); + expect(res.applicationsCount).toEqual({ + total: 3, + open: 1, + closed: 2, + lottery: 0, + }); - expect(prisma.applications.findMany).toHaveBeenCalledWith( - publicAppsFindManyCalledWith, - ); - }); + expect(prisma.applications.findMany).toHaveBeenCalledWith( + publicAppsFindManyCalledWith, + ); + }); - it('should get publicAppsView() info when applications are available and filterType is open', async () => { - const mockedValues = mockApplicationSet(3, date); - const listingStatuses = [ - { status: ListingsStatusEnum.active }, - { status: ListingsStatusEnum.active }, - { - status: ListingsStatusEnum.closed, - lotteryStatus: LotteryStatusEnum.publishedToPublic, - }, - ]; - const mockedValuesWithListing = mockedValues.map((mockedValue, idx) => { - return { - ...mockedValue, - listings: listingStatuses[idx], + it('should get publicAppsView() info when applications are available and filterType is open', async () => { + const mockedValues = mockApplicationSet(3, date); + const listingStatuses = [ + { status: ListingsStatusEnum.active }, + { status: ListingsStatusEnum.active }, + { + status: ListingsStatusEnum.closed, + lotteryStatus: LotteryStatusEnum.publishedToPublic, + }, + ]; + const mockedValuesWithListing = mockedValues.map((mockedValue, idx) => { + return { + ...mockedValue, + listings: listingStatuses[idx], + }; + }); + prisma.applications.findMany = jest + .fn() + .mockResolvedValue(getPublicAppsFindManyMock(mockedValuesWithListing)); + + const params: PublicAppsViewQueryParams = { + userId: requestingUser.id, + filterType: ApplicationsFilterEnum.open, + includeLotteryApps: true, }; - }); - prisma.applications.findMany = jest - .fn() - .mockResolvedValue(getPublicAppsFindManyMock(mockedValuesWithListing)); - const params: PublicAppsViewQueryParams = { - userId: requestingUser.id, - filterType: ApplicationsFilterEnum.open, - includeLotteryApps: true, - }; - - const res = await service.publicAppsView(params, { - user: requestingUser, - } as unknown as ExpressRequest); - - expect(res.displayApplications.length).toEqual(2); - expect(res.applicationsCount).toEqual({ - total: 3, - open: 2, - closed: 0, - lottery: 1, - }); + const res = await service.publicAppsView(params, { + user: requestingUser, + } as unknown as ExpressRequest); + + expect(res.displayApplications.length).toEqual(2); + expect(res.applicationsCount).toEqual({ + total: 3, + open: 2, + closed: 0, + lottery: 1, + }); - expect(prisma.applications.findMany).toHaveBeenCalledWith( - publicAppsFindManyCalledWith, - ); - }); + expect(prisma.applications.findMany).toHaveBeenCalledWith( + publicAppsFindManyCalledWith, + ); + }); - it('should get publicAppsView() info when applications are available and filterType is closed', async () => { - const mockedValues = mockApplicationSet(3, date); - const listingStatuses = [ - { status: ListingsStatusEnum.closed }, - { status: ListingsStatusEnum.closed }, - { - status: ListingsStatusEnum.closed, - lotteryStatus: LotteryStatusEnum.publishedToPublic, - }, - ]; - const mockedValuesWithListing = mockedValues.map((mockedValue, idx) => { - return { - ...mockedValue, - listings: listingStatuses[idx], + it('should get publicAppsView() info when applications are available and filterType is closed', async () => { + const mockedValues = mockApplicationSet(3, date); + const listingStatuses = [ + { status: ListingsStatusEnum.closed }, + { status: ListingsStatusEnum.closed }, + { + status: ListingsStatusEnum.closed, + lotteryStatus: LotteryStatusEnum.publishedToPublic, + }, + ]; + const mockedValuesWithListing = mockedValues.map((mockedValue, idx) => { + return { + ...mockedValue, + listings: listingStatuses[idx], + }; + }); + prisma.applications.findMany = jest + .fn() + .mockResolvedValue(getPublicAppsFindManyMock(mockedValuesWithListing)); + + const params: PublicAppsViewQueryParams = { + userId: requestingUser.id, + filterType: ApplicationsFilterEnum.closed, + includeLotteryApps: true, }; + + const res = await service.publicAppsView(params, { + user: requestingUser, + } as unknown as ExpressRequest); + + expect(res.displayApplications.length).toEqual(2); + expect(res.applicationsCount).toEqual({ + total: 3, + open: 0, + closed: 2, + lottery: 1, + }); + + expect(prisma.applications.findMany).toHaveBeenCalledWith( + publicAppsFindManyCalledWith, + ); }); - prisma.applications.findMany = jest - .fn() - .mockResolvedValue(getPublicAppsFindManyMock(mockedValuesWithListing)); - const params: PublicAppsViewQueryParams = { - userId: requestingUser.id, - filterType: ApplicationsFilterEnum.closed, - includeLotteryApps: true, - }; - - const res = await service.publicAppsView(params, { - user: requestingUser, - } as unknown as ExpressRequest); - - expect(res.displayApplications.length).toEqual(2); - expect(res.applicationsCount).toEqual({ - total: 3, - open: 0, - closed: 2, - lottery: 1, + it('should get publicAppsView() info when applications are available and filterType is lottery', async () => { + const mockedValues = mockApplicationSet(3, date); + const listingStatuses = [ + { status: ListingsStatusEnum.active }, + { + status: ListingsStatusEnum.closed, + lotteryStatus: LotteryStatusEnum.publishedToPublic, + }, + { + status: ListingsStatusEnum.closed, + lotteryStatus: LotteryStatusEnum.publishedToPublic, + }, + ]; + const mockedValuesWithListing = mockedValues.map((mockedValue, idx) => { + return { + ...mockedValue, + listings: listingStatuses[idx], + }; + }); + prisma.applications.findMany = jest + .fn() + .mockResolvedValue(getPublicAppsFindManyMock(mockedValuesWithListing)); + + const params: PublicAppsViewQueryParams = { + userId: requestingUser.id, + filterType: ApplicationsFilterEnum.lottery, + includeLotteryApps: true, + }; + + const res = await service.publicAppsView(params, { + user: requestingUser, + } as unknown as ExpressRequest); + + expect(res.displayApplications.length).toEqual(2); + expect(res.applicationsCount).toEqual({ + total: 3, + open: 1, + closed: 0, + lottery: 2, + }); + + expect(prisma.applications.findMany).toHaveBeenCalledWith( + publicAppsFindManyCalledWith, + ); }); - expect(prisma.applications.findMany).toHaveBeenCalledWith( - publicAppsFindManyCalledWith, - ); - }); + it('should not error when publicAppsView() is called when applications are unavailable', async () => { + prisma.applications.findMany = jest + .fn() + .mockResolvedValue(getPublicAppsFindManyMock([])); - it('should get publicAppsView() info when applications are available and filterType is lottery', async () => { - const mockedValues = mockApplicationSet(3, date); - const listingStatuses = [ - { status: ListingsStatusEnum.active }, - { - status: ListingsStatusEnum.closed, - lotteryStatus: LotteryStatusEnum.publishedToPublic, - }, - { - status: ListingsStatusEnum.closed, - lotteryStatus: LotteryStatusEnum.publishedToPublic, - }, - ]; - const mockedValuesWithListing = mockedValues.map((mockedValue, idx) => { - return { - ...mockedValue, - listings: listingStatuses[idx], + const params: PublicAppsViewQueryParams = { + userId: requestingUser.id, + filterType: ApplicationsFilterEnum.all, + includeLotteryApps: true, }; - }); - prisma.applications.findMany = jest - .fn() - .mockResolvedValue(getPublicAppsFindManyMock(mockedValuesWithListing)); - const params: PublicAppsViewQueryParams = { - userId: requestingUser.id, - filterType: ApplicationsFilterEnum.lottery, - includeLotteryApps: true, - }; - - const res = await service.publicAppsView(params, { - user: requestingUser, - } as unknown as ExpressRequest); - - expect(res.displayApplications.length).toEqual(2); - expect(res.applicationsCount).toEqual({ - total: 3, - open: 1, - closed: 0, - lottery: 2, - }); + const res = await service.publicAppsView(params, { + user: requestingUser, + } as unknown as ExpressRequest); + + expect(res.displayApplications.length).toEqual(0); + expect(res.applicationsCount).toEqual({ + total: 0, + open: 0, + closed: 0, + lottery: 0, + }); - expect(prisma.applications.findMany).toHaveBeenCalledWith( - publicAppsFindManyCalledWith, - ); + expect(prisma.applications.findMany).toHaveBeenCalledWith( + publicAppsFindManyCalledWith, + ); + }); }); - it('should not error when publicAppsView() is called when applications are unavailable', async () => { - prisma.applications.findMany = jest - .fn() - .mockResolvedValue(getPublicAppsFindManyMock([])); + describe('findOne endpoint', () => { + it('should get an application when findOne() is called and Id exists', async () => { + const mockedValue = mockApplication({ date: date, position: 3 }); + prisma.applications.findUnique = jest.fn().mockResolvedValue(mockedValue); + prisma.jurisdictions.findFirst = jest + .fn() + .mockResolvedValue({ id: randomUUID() }); - const params: PublicAppsViewQueryParams = { - userId: requestingUser.id, - filterType: ApplicationsFilterEnum.all, - includeLotteryApps: true, - }; - - const res = await service.publicAppsView(params, { - user: requestingUser, - } as unknown as ExpressRequest); - - expect(res.displayApplications.length).toEqual(0); - expect(res.applicationsCount).toEqual({ - total: 0, - open: 0, - closed: 0, - lottery: 0, - }); - - expect(prisma.applications.findMany).toHaveBeenCalledWith( - publicAppsFindManyCalledWith, - ); - }); - - it('should get an application when findOne() is called and Id exists', async () => { - const mockedValue = mockApplication({ date: date, position: 3 }); - prisma.applications.findUnique = jest.fn().mockResolvedValue(mockedValue); - prisma.jurisdictions.findFirst = jest - .fn() - .mockResolvedValue({ id: randomUUID() }); - - expect( - await service.findOne('example Id', { - user: requestingUser, - } as unknown as ExpressRequest), - ).toEqual(mockedValue); + expect( + await service.findOne('example Id', { + user: requestingUser, + } as unknown as ExpressRequest), + ).toEqual(mockedValue); - expect(prisma.applications.findUnique).toHaveBeenCalledWith({ - where: { - id: 'example Id', - }, - include: { - ...detailView, - }, + expect(prisma.applications.findUnique).toHaveBeenCalledWith({ + where: { + id: 'example Id', + }, + include: { + ...detailView, + }, + }); }); - }); - it("should throw error when findOne() is called and Id doens't exists", async () => { - prisma.applications.findUnique = jest.fn().mockResolvedValue(null); + it("should throw error when findOne() is called and Id doens't exists", async () => { + prisma.applications.findUnique = jest.fn().mockResolvedValue(null); - await expect( - async () => - await service.findOne('example Id', { - user: requestingUser, - } as unknown as ExpressRequest), - ).rejects.toThrowError( - 'applicationId example Id was requested but not found', - ); + await expect( + async () => + await service.findOne('example Id', { + user: requestingUser, + } as unknown as ExpressRequest), + ).rejects.toThrowError( + 'applicationId example Id was requested but not found', + ); - expect(prisma.applications.findUnique).toHaveBeenCalledWith({ - where: { - id: 'example Id', - }, - include: { - ...detailView, - }, + expect(prisma.applications.findUnique).toHaveBeenCalledWith({ + where: { + id: 'example Id', + }, + include: { + ...detailView, + }, + }); }); }); - it('should get record from getDuplicateFlagsForApplication()', async () => { - prisma.applications.findFirst = jest - .fn() - .mockResolvedValue({ id: 'example id' }); + describe('getDuplicateFlagsForApplication endpoint', () => { + it('should get record from getDuplicateFlagsForApplication()', async () => { + prisma.applications.findFirst = jest + .fn() + .mockResolvedValue({ id: 'example id' }); - const res = await service.getDuplicateFlagsForApplication('example id'); + const res = await service.getDuplicateFlagsForApplication('example id'); - expect(prisma.applications.findFirst).toHaveBeenCalledWith({ - select: { - id: true, - }, - where: { - id: 'example id', - applicationFlaggedSet: { - some: {}, + expect(prisma.applications.findFirst).toHaveBeenCalledWith({ + select: { + id: true, }, - }, - }); + where: { + id: 'example id', + applicationFlaggedSet: { + some: {}, + }, + }, + }); - expect(res).toEqual({ id: 'example id' }); + expect(res).toEqual({ id: 'example id' }); + }); }); it('should return no filters when no params passed to buildWhereClause()', () => { @@ -1216,1346 +1294,2790 @@ describe('Testing application service', () => { }); }); - it('should return userId filter when userId param passed to buildWhereClause()', () => { - const res = service.buildWhereClause({ - userId: 'example user id', - }); - expect(res).toEqual({ - AND: [ - { - userAccounts: { - id: 'example user id', + describe('buildWhereClause helper function', () => { + it('should return userId filter when userId param passed to buildWhereClause()', () => { + const res = service.buildWhereClause({ + userId: 'example user id', + }); + expect(res).toEqual({ + AND: [ + { + userAccounts: { + id: 'example user id', + }, }, - }, - { - deletedAt: null, - }, - ], + { + deletedAt: null, + }, + ], + }); }); - }); - it('should return listingId filter when listingId param passed to buildWhereClause()', () => { - const res = service.buildWhereClause({ - listingId: 'example listing id', - }); - expect(res).toEqual({ - AND: [ - { - listingId: 'example listing id', - }, - { - deletedAt: null, - }, - ], + it('should return listingId filter when listingId param passed to buildWhereClause()', () => { + const res = service.buildWhereClause({ + listingId: 'example listing id', + }); + expect(res).toEqual({ + AND: [ + { + listingId: 'example listing id', + }, + { + deletedAt: null, + }, + ], + }); }); - }); - it('should return markedAsDuplicate filter when markedAsDuplicate param passed to buildWhereClause()', () => { - const res = service.buildWhereClause({ - markedAsDuplicate: false, - }); - expect(res).toEqual({ - AND: [ - { - markedAsDuplicate: false, - }, - { - deletedAt: null, - }, - ], + it('should return markedAsDuplicate filter when markedAsDuplicate param passed to buildWhereClause()', () => { + const res = service.buildWhereClause({ + markedAsDuplicate: false, + }); + expect(res).toEqual({ + AND: [ + { + markedAsDuplicate: false, + }, + { + deletedAt: null, + }, + ], + }); }); - }); - it('should return mixed filters when several params passed to buildWhereClause()', () => { - const res = service.buildWhereClause({ - userId: 'example user id', - listingId: 'example listing id', - markedAsDuplicate: false, - }); - expect(res).toEqual({ - AND: [ - { - userAccounts: { - id: 'example user id', + it('should return mixed filters when several params passed to buildWhereClause()', () => { + const res = service.buildWhereClause({ + userId: 'example user id', + listingId: 'example listing id', + markedAsDuplicate: false, + }); + expect(res).toEqual({ + AND: [ + { + userAccounts: { + id: 'example user id', + }, }, - }, - { - listingId: 'example listing id', - }, - { - markedAsDuplicate: false, - }, - { - deletedAt: null, - }, - ], + { + listingId: 'example listing id', + }, + { + markedAsDuplicate: false, + }, + { + deletedAt: null, + }, + ], + }); }); - }); - it('should return search filter when search param passed to buildWhereClause()', () => { - const res = service.buildWhereClause({ - search: 'test', - }); - const searchFilter = { contains: 'test', mode: 'insensitive' }; - expect(res).toEqual({ - AND: [ - { - OR: [ - { - confirmationCode: searchFilter, - }, - { - applicant: { - firstName: searchFilter, + it('should return search filter when search param passed to buildWhereClause()', () => { + const res = service.buildWhereClause({ + search: 'test', + }); + const searchFilter = { contains: 'test', mode: 'insensitive' }; + expect(res).toEqual({ + AND: [ + { + OR: [ + { + confirmationCode: searchFilter, }, - }, - { - applicant: { - lastName: searchFilter, + { + applicant: { + firstName: searchFilter, + }, }, - }, - { - applicant: { - emailAddress: searchFilter, + { + applicant: { + lastName: searchFilter, + }, }, - }, - { - applicant: { - phoneNumber: searchFilter, + { + applicant: { + emailAddress: searchFilter, + }, }, - }, - { - alternateContact: { - firstName: searchFilter, + { + applicant: { + phoneNumber: searchFilter, + }, }, - }, - { - alternateContact: { - lastName: searchFilter, + { + alternateContact: { + firstName: searchFilter, + }, }, - }, - { - alternateContact: { - emailAddress: searchFilter, + { + alternateContact: { + lastName: searchFilter, + }, }, - }, - { - alternateContact: { - phoneNumber: searchFilter, + { + alternateContact: { + emailAddress: searchFilter, + }, }, - }, - ], - }, - { - deletedAt: null, - }, - ], + { + alternateContact: { + phoneNumber: searchFilter, + }, + }, + ], + }, + { + deletedAt: null, + }, + ], + }); }); }); - it('should return application with no view when one exists', async () => { - prisma.applications.findUnique = jest - .fn() - .mockResolvedValue({ id: randomUUID() }); + describe('findOrThrow endpoint', () => { + it('should return application with no view when one exists', async () => { + prisma.applications.findUnique = jest + .fn() + .mockResolvedValue({ id: randomUUID() }); - await service.findOrThrow('example Id'); - expect(prisma.applications.findUnique).toHaveBeenCalledWith({ - where: { - id: 'example Id', - }, + await service.findOrThrow('example Id'); + expect(prisma.applications.findUnique).toHaveBeenCalledWith({ + where: { + id: 'example Id', + }, + }); }); - }); - it('should return application with base view when one exists', async () => { - prisma.applications.findUnique = jest - .fn() - .mockResolvedValue({ id: randomUUID() }); + it('should return application with base view when one exists', async () => { + prisma.applications.findUnique = jest + .fn() + .mockResolvedValue({ id: randomUUID() }); - await service.findOrThrow('example Id', ApplicationViews.base); - expect(prisma.applications.findUnique).toHaveBeenCalledWith({ - where: { - id: 'example Id', - }, - include: { - ...baseView, - }, + await service.findOrThrow('example Id', ApplicationViews.base); + expect(prisma.applications.findUnique).toHaveBeenCalledWith({ + where: { + id: 'example Id', + }, + include: { + ...baseView, + }, + }); }); - }); - it("should throw error when asking for application that doesen't exist", async () => { - prisma.applications.findUnique = jest.fn().mockResolvedValue(null); + it("should throw error when asking for application that doesen't exist", async () => { + prisma.applications.findUnique = jest.fn().mockResolvedValue(null); - await expect( - async () => await service.findOrThrow('example Id'), - ).rejects.toThrowError( - 'applicationId example Id was requested but not found', - ); + await expect( + async () => await service.findOrThrow('example Id'), + ).rejects.toThrowError( + 'applicationId example Id was requested but not found', + ); - expect(prisma.applications.findUnique).toHaveBeenCalledWith({ - where: { - id: 'example Id', - }, + expect(prisma.applications.findUnique).toHaveBeenCalledWith({ + where: { + id: 'example Id', + }, + }); }); }); - it('should update listing application edit timestamp', async () => { - prisma.listings.update = jest.fn().mockResolvedValue({ id: randomUUID() }); - await service.updateListingApplicationEditTimestamp(randomUUID()); + describe('updateListingApplicationEditTimestamp endpoint', () => { + it('should update listing application edit timestamp', async () => { + prisma.listings.update = jest + .fn() + .mockResolvedValue({ id: randomUUID() }); + await service.updateListingApplicationEditTimestamp(randomUUID()); - expect(prisma.listings.update).toHaveBeenCalledWith({ - where: { - id: expect.anything(), - }, - data: { - lastApplicationUpdateAt: expect.anything(), - }, + expect(prisma.listings.update).toHaveBeenCalledWith({ + where: { + id: expect.anything(), + }, + data: { + lastApplicationUpdateAt: expect.anything(), + }, + }); }); }); - it('should generate random confirmation code', () => { - const res = service.generateConfirmationCode(); - expect(res.length).toEqual(8); - }); - - it('should delete application when one exists', async () => { - prisma.applications.findUnique = jest - .fn() - .mockResolvedValue({ id: randomUUID(), listingId: randomUUID() }); - prisma.listings.update = jest.fn().mockResolvedValue({ id: randomUUID() }); - prisma.applications.update = jest - .fn() - .mockResolvedValue({ id: randomUUID() }); - - prisma.jurisdictions.findFirst = jest - .fn() - .mockResolvedValue({ id: randomUUID() }); - - await service.delete('example Id', { - id: 'requestingUser id', - userRoles: { isAdmin: true }, - } as unknown as User); - - expect(prisma.applications.findUnique).toHaveBeenCalledWith({ - where: { - id: 'example Id', - }, - }); - expect(prisma.listings.update).toHaveBeenCalledWith({ - where: { - id: expect.anything(), - }, - data: { - lastApplicationUpdateAt: expect.anything(), - }, - }); - expect(prisma.applications.update).toHaveBeenCalledWith({ - where: { - id: 'example Id', - }, - data: { - deletedAt: expect.anything(), - }, + describe('generateConfirmationCode helper function', () => { + it('should generate random confirmation code', () => { + const res = service.generateConfirmationCode(); + expect(res.length).toEqual(8); }); + }); - expect(canOrThrowMock).toHaveBeenCalledWith( - { + describe('delete endpoint', () => { + it('should delete application when one exists', async () => { + prisma.applications.findUnique = jest + .fn() + .mockResolvedValue({ id: randomUUID(), listingId: randomUUID() }); + prisma.listings.update = jest + .fn() + .mockResolvedValue({ id: randomUUID() }); + prisma.applications.update = jest + .fn() + .mockResolvedValue({ id: randomUUID() }); + + prisma.jurisdictions.findFirst = jest + .fn() + .mockResolvedValue({ id: randomUUID() }); + + await service.delete('example Id', { id: 'requestingUser id', userRoles: { isAdmin: true }, - } as unknown as User, - 'application', - permissionActions.delete, - expect.anything(), - ); - }); + } as unknown as User); - it("should throw error when trying to delete application that doesen't exist", async () => { - prisma.applications.findUnique = jest.fn().mockResolvedValue(null); + expect(prisma.applications.findUnique).toHaveBeenCalledWith({ + where: { + id: 'example Id', + }, + }); + expect(prisma.listings.update).toHaveBeenCalledWith({ + where: { + id: expect.anything(), + }, + data: { + lastApplicationUpdateAt: expect.anything(), + }, + }); + expect(prisma.applications.update).toHaveBeenCalledWith({ + where: { + id: 'example Id', + }, + data: { + deletedAt: expect.anything(), + }, + }); - await expect( - async () => - await service.delete('example Id', { + expect(canOrThrowMock).toHaveBeenCalledWith( + { id: 'requestingUser id', userRoles: { isAdmin: true }, - } as unknown as User), - ).rejects.toThrowError( - 'applicationId example Id was requested but not found', - ); - - expect(prisma.applications.findUnique).toHaveBeenCalledWith({ - where: { - id: 'example Id', - }, + } as unknown as User, + 'application', + permissionActions.delete, + expect.anything(), + ); }); - expect(canOrThrowMock).not.toHaveBeenCalled(); - }); + it("should throw error when trying to delete application that doesen't exist", async () => { + prisma.applications.findUnique = jest.fn().mockResolvedValue(null); - it('should create an application from public site', async () => { - prisma.listings.findUnique = jest.fn().mockResolvedValue({ - id: randomUUID(), - applicationDueDate: dayjs(new Date()).add(5, 'days').toDate(), - digitalApplication: true, - commonDigitalApplication: true, - }); + await expect( + async () => + await service.delete('example Id', { + id: 'requestingUser id', + userRoles: { isAdmin: true }, + } as unknown as User), + ).rejects.toThrowError( + 'applicationId example Id was requested but not found', + ); - prisma.applications.updateMany = jest.fn().mockResolvedValue({}); - prisma.applications.create = jest.fn().mockResolvedValue({ - id: randomUUID(), + expect(prisma.applications.findUnique).toHaveBeenCalledWith({ + where: { + id: 'example Id', + }, + }); + + expect(canOrThrowMock).not.toHaveBeenCalled(); }); - prisma.$transaction = jest - .fn() - .mockResolvedValue([ - prisma.applications.updateMany, - prisma.applications.create, - ]); + }); + + describe('create endpoint', () => { + it('should create an application from public site with user account', async () => { + prisma.listings.findUnique = jest.fn().mockResolvedValue({ + id: randomUUID(), + applicationDueDate: dayjs(new Date()).add(5, 'days').toDate(), + digitalApplication: true, + commonDigitalApplication: true, + jurisdictions: { + id: randomUUID(), + }, + }); + prisma.listings.update = jest.fn().mockResolvedValue({ + id: randomUUID(), + }); - const exampleAddress = addressFactory() as AddressCreate; - const dto = mockCreateApplicationData(exampleAddress, new Date()); + prisma.applications.updateMany = jest.fn().mockResolvedValue({}); + prisma.applications.create = jest.fn().mockResolvedValue({ + id: randomUUID(), + }); + prisma.$transaction = jest + .fn() + .mockResolvedValue([ + prisma.applications.updateMany, + prisma.applications.create, + ]); - prisma.jurisdictions.findFirst = jest - .fn() - .mockResolvedValue({ id: randomUUID() }); + const exampleAddress = addressFactory() as AddressCreate; + const dto = mockCreateApplicationData(exampleAddress, new Date()); - await service.create(dto, true, { - id: 'requestingUser id', - userRoles: { isAdmin: true }, - } as unknown as User); + prisma.jurisdictions.findFirst = jest + .fn() + .mockResolvedValue({ id: randomUUID() }); - expect(prisma.listings.findUnique).toHaveBeenCalledWith({ - where: { - id: expect.anything(), - }, - include: { - jurisdictions: true, - unitGroups: true, - listingsBuildingAddress: true, - listingMultiselectQuestions: { - include: { - multiselectQuestions: true, - }, - }, - }, - }); + await service.create(dto, true, { + id: 'requestingUser id', + } as unknown as User); - expect(prisma.applications.updateMany).toHaveBeenCalledWith({ - data: { - isNewest: false, - }, - where: { - userId: 'requestingUser id', - isNewest: true, - }, - }); - expect(prisma.applications.create).toHaveBeenCalledWith({ - include: { ...detailView }, - data: { - isNewest: true, - contactPreferences: ['example contact preference'], - status: ApplicationStatusEnum.submitted, - submissionType: ApplicationSubmissionTypeEnum.electronical, - appUrl: 'http://www.example.com', - additionalPhone: true, - additionalPhoneNumber: '111-111-1111', - additionalPhoneNumberType: 'example additional phone number type', - householdSize: 2, - housingStatus: 'example housing status', - sendMailToMailingAddress: true, - householdExpectingChanges: false, - householdStudent: false, - incomeVouchers: false, - income: '36000', - incomePeriod: IncomePeriodEnum.perYear, - language: LanguagesEnum.en, - acceptedTerms: true, - // Submission date is the moment it was created - submissionDate: expect.any(Date), - reviewStatus: ApplicationReviewStatusEnum.valid, - confirmationCode: expect.anything(), - applicant: { - create: { - firstName: 'applicant first name', - middleName: 'applicant middle name', - lastName: 'applicant last name', - birthMonth: 12, - birthDay: 17, - birthYear: 1993, - emailAddress: 'example@email.com', - noEmail: false, - phoneNumber: '111-111-1111', - phoneNumberType: 'Cell', - noPhone: false, - workInRegion: YesNoEnum.yes, - applicantAddress: { - create: { - ...exampleAddress, - }, - }, - applicantWorkAddress: { - create: { - ...exampleAddress, - }, - }, - }, + expect(prisma.listings.findUnique).toHaveBeenCalledWith({ + where: { + id: expect.anything(), }, - accessibility: { - create: { - mobility: false, - vision: false, - hearing: false, - other: false, + include: { + jurisdictions: { include: { featureFlags: true } }, + listingsBuildingAddress: true, + listingMultiselectQuestions: { + include: { multiselectQuestions: true }, }, + unitGroups: true, }, - alternateContact: { - create: { - type: AlternateContactRelationship.other, - otherType: 'example other type', - firstName: 'example first name', - lastName: 'example last name', - agency: 'example agency', - phoneNumber: '111-111-1111', - emailAddress: 'example@email.com', - address: { - create: { - ...exampleAddress, - }, - }, - }, - }, - applicationsAlternateAddress: { - create: { - ...exampleAddress, - }, - }, - applicationsMailingAddress: { - create: { - ...exampleAddress, - }, - }, - listings: { - connect: { - id: dto.listings.id, - }, - }, - demographics: { - create: { - ethnicity: 'example ethnicity', - gender: 'example gender', - sexualOrientation: 'example sexual orientation', - howDidYouHear: ['example how did you hear'], - race: ['example race'], - }, + }); + + expect(prisma.applications.updateMany).toHaveBeenCalledWith({ + data: { + isNewest: false, }, - preferredUnitTypes: { - connect: [ - { - id: expect.anything(), - }, - ], + where: { + userId: 'requestingUser id', + isNewest: true, }, - householdMember: { - create: [ - { - orderId: 0, - firstName: 'example first name', - middleName: 'example middle name', - lastName: 'example last name', + }); + expect(prisma.applications.create).toHaveBeenCalledWith({ + include: { ...detailView }, + data: { + isNewest: true, + contactPreferences: ['example contact preference'], + status: ApplicationStatusEnum.submitted, + submissionType: ApplicationSubmissionTypeEnum.electronical, + appUrl: 'http://www.example.com', + additionalPhone: true, + additionalPhoneNumber: '111-111-1111', + additionalPhoneNumberType: 'example additional phone number type', + householdSize: 2, + housingStatus: 'example housing status', + sendMailToMailingAddress: true, + householdExpectingChanges: false, + householdStudent: false, + incomeVouchers: false, + income: '36000', + incomePeriod: IncomePeriodEnum.perYear, + language: LanguagesEnum.en, + acceptedTerms: true, + // Submission date is the moment it was created + submissionDate: expect.any(Date), + reviewStatus: ApplicationReviewStatusEnum.valid, + confirmationCode: expect.anything(), + applicant: { + create: { + firstName: 'applicant first name', + middleName: 'applicant middle name', + lastName: 'applicant last name', birthMonth: 12, birthDay: 17, birthYear: 1993, - sameAddress: YesNoEnum.yes, - relationship: HouseholdMemberRelationship.other, + emailAddress: 'example@email.com', + noEmail: false, + phoneNumber: '111-111-1111', + phoneNumberType: 'Cell', + noPhone: false, workInRegion: YesNoEnum.yes, - householdMemberAddress: { + applicantAddress: { create: { ...exampleAddress, }, }, - householdMemberWorkAddress: { + applicantWorkAddress: { create: { ...exampleAddress, }, }, }, - ], - }, - programs: [ - { - key: 'example key', - claimed: true, - options: [ + }, + accessibility: { + create: { + mobility: false, + vision: false, + hearing: false, + other: false, + }, + }, + alternateContact: { + create: { + type: AlternateContactRelationship.other, + otherType: 'example other type', + firstName: 'example first name', + lastName: 'example last name', + agency: 'example agency', + phoneNumber: '111-111-1111', + emailAddress: 'example@email.com', + address: { + create: { + ...exampleAddress, + }, + }, + }, + }, + applicationsAlternateAddress: { + create: { + ...exampleAddress, + }, + }, + applicationsMailingAddress: { + create: { + ...exampleAddress, + }, + }, + listings: { + connect: { + id: dto.listings.id, + }, + }, + demographics: { + create: { + ethnicity: 'example ethnicity', + gender: 'example gender', + sexualOrientation: 'example sexual orientation', + howDidYouHear: ['example how did you hear'], + race: ['example race'], + }, + }, + preferredUnitTypes: { + connect: [ { - key: 'example key', - checked: true, - extraData: [ - { - type: InputType.boolean, - key: 'example key', - value: true, - }, - ], + id: expect.anything(), }, ], }, - ], - preferences: [ - { - key: 'example key', - claimed: true, - options: [ + householdMember: { + create: [ { - key: 'example key', - checked: true, - extraData: [ - { - type: InputType.boolean, - key: 'example key', - value: true, + orderId: 0, + firstName: 'example first name', + middleName: 'example middle name', + lastName: 'example last name', + birthMonth: 12, + birthDay: 17, + birthYear: 1993, + sameAddress: YesNoEnum.yes, + relationship: HouseholdMemberRelationship.other, + workInRegion: YesNoEnum.yes, + householdMemberAddress: { + create: { + ...exampleAddress, + }, + }, + householdMemberWorkAddress: { + create: { + ...exampleAddress, }, - ], + }, }, ], }, - ], - userAccounts: { - connect: { - id: 'requestingUser id', - }, - }, - }, - }); - - expect(canOrThrowMock).not.toHaveBeenCalled(); - }); - - it('should error while creating an application from public site because submissions are closed', async () => { - prisma.listings.findUnique = jest.fn().mockResolvedValue({ - id: randomUUID(), - applicationDueDate: new Date(0), - digitalApplication: true, - commonDigitalApplication: true, - }); - - prisma.applications.create = jest.fn().mockResolvedValue({ - id: randomUUID(), - }); - - const exampleAddress = addressFactory() as AddressCreate; - const dto = mockCreateApplicationData(exampleAddress, new Date()); - - prisma.jurisdictions.findFirst = jest - .fn() - .mockResolvedValue({ id: randomUUID() }); - - await expect( - async () => - await service.create(dto, true, { - id: 'requestingUser id', - userRoles: { isAdmin: true }, - } as unknown as User), - ).rejects.toThrowError('Listing is not open for application submission'); - - expect(prisma.listings.findUnique).toHaveBeenCalledWith({ - where: { - id: expect.anything(), - }, - include: { - jurisdictions: true, - unitGroups: true, - listingsBuildingAddress: true, - listingMultiselectQuestions: { - include: { - multiselectQuestions: true, - }, - }, - }, - }); - - expect(prisma.applications.create).not.toHaveBeenCalled(); - - expect(canOrThrowMock).not.toHaveBeenCalled(); - }); - - it('should error while creating an application from public site on a listing without common app', async () => { - prisma.listings.findUnique = jest.fn().mockResolvedValue({ - id: randomUUID(), - applicationDueDate: new Date(0), - digitalApplication: false, - commonDigitalApplication: false, - }); - - prisma.applications.create = jest.fn().mockResolvedValue({ - id: randomUUID(), - }); - - const exampleAddress = addressFactory() as AddressCreate; - const dto = mockCreateApplicationData(exampleAddress, new Date()); - - prisma.jurisdictions.findFirst = jest - .fn() - .mockResolvedValue({ id: randomUUID() }); - - await expect( - async () => - await service.create(dto, true, { - id: 'requestingUser id', - userRoles: { isAdmin: true }, - } as unknown as User), - ).rejects.toThrowError('Listing is not open for application submission'); - - expect(prisma.listings.findUnique).toHaveBeenCalledWith({ - where: { - id: expect.anything(), - }, - include: { - jurisdictions: true, - unitGroups: true, - listingsBuildingAddress: true, - listingMultiselectQuestions: { - include: { - multiselectQuestions: true, + programs: [ + { + key: 'example key', + claimed: true, + options: [ + { + key: 'example key', + checked: true, + extraData: [ + { + type: InputType.boolean, + key: 'example key', + value: true, + }, + ], + }, + ], + }, + ], + preferences: [ + { + key: 'example key', + claimed: true, + options: [ + { + key: 'example key', + checked: true, + extraData: [ + { + type: InputType.boolean, + key: 'example key', + value: true, + }, + ], + }, + ], + }, + ], + userAccounts: { + connect: { + id: 'requestingUser id', + }, }, }, - }, - }); - - expect(prisma.applications.create).not.toHaveBeenCalled(); - - expect(canOrThrowMock).not.toHaveBeenCalled(); - }); + }); - it('should create an application from partner site', async () => { - prisma.listings.findUnique = jest.fn().mockResolvedValue({ - id: randomUUID(), + expect(canOrThrowMock).not.toHaveBeenCalled(); }); - prisma.$transaction = jest.fn().mockResolvedValue([ - // update previous applications - jest.fn().mockResolvedValue({ - id: randomUUID(), - }), - // application create mock - jest.fn().mockResolvedValue({ + it('should create an application from public site not logged in', async () => { + prisma.listings.findUnique = jest.fn().mockResolvedValue({ id: randomUUID(), - }), - ]); - - prisma.jurisdictions.findFirst = jest - .fn() - .mockResolvedValue({ id: randomUUID() }); - - const exampleAddress = addressFactory() as AddressCreate; - const dto = mockCreateApplicationData(exampleAddress, new Date()); - - await service.create(dto, false, { - id: 'requestingUser id', - userRoles: { isAdmin: true }, - } as unknown as User); + applicationDueDate: dayjs(new Date()).add(5, 'days').toDate(), + digitalApplication: true, + commonDigitalApplication: true, + }); - expect(prisma.listings.findUnique).toHaveBeenCalledWith({ - where: { - id: expect.anything(), - }, - include: { - jurisdictions: true, - unitGroups: true, - listingsBuildingAddress: true, - listingMultiselectQuestions: { - include: { - multiselectQuestions: true, + prisma.applications.updateMany = jest.fn().mockResolvedValue({}); + prisma.applications.create = jest.fn().mockResolvedValue({ + id: randomUUID(), + }); + prisma.$transaction = jest + .fn() + .mockResolvedValue([ + prisma.applications.updateMany, + prisma.applications.create, + ]); + + const exampleAddress = addressFactory() as AddressCreate; + const dto = mockCreateApplicationData(exampleAddress, new Date()); + + prisma.jurisdictions.findFirst = jest + .fn() + .mockResolvedValue({ id: randomUUID() }); + + await service.create(dto, true); + + expect(prisma.listings.findUnique).toHaveBeenCalledWith({ + where: { + id: expect.anything(), + }, + include: { + jurisdictions: { include: { featureFlags: true } }, + listingsBuildingAddress: true, + listingMultiselectQuestions: { + include: { multiselectQuestions: true }, }, + unitGroups: true, }, - }, - }); + }); - expect(prisma.applications.create).toHaveBeenCalledWith({ - include: { - ...detailView, - }, - data: { - isNewest: true, - contactPreferences: ['example contact preference'], - status: ApplicationStatusEnum.submitted, - submissionType: ApplicationSubmissionTypeEnum.electronical, - appUrl: 'http://www.example.com', - additionalPhone: true, - additionalPhoneNumber: '111-111-1111', - additionalPhoneNumberType: 'example additional phone number type', - householdSize: 2, - housingStatus: 'example housing status', - sendMailToMailingAddress: true, - householdExpectingChanges: false, - householdStudent: false, - incomeVouchers: false, - income: '36000', - incomePeriod: IncomePeriodEnum.perYear, - language: LanguagesEnum.en, - acceptedTerms: true, - submissionDate: expect.anything(), - reviewStatus: ApplicationReviewStatusEnum.valid, - confirmationCode: expect.anything(), - applicant: { - create: { - firstName: 'applicant first name', - middleName: 'applicant middle name', - lastName: 'applicant last name', - birthMonth: 12, - birthDay: 17, - birthYear: 1993, - emailAddress: 'example@email.com', - noEmail: false, - phoneNumber: '111-111-1111', - phoneNumberType: 'Cell', - noPhone: false, - workInRegion: YesNoEnum.yes, - applicantAddress: { - create: { - ...exampleAddress, - }, - }, - applicantWorkAddress: { - create: { - ...exampleAddress, - }, - }, - }, - }, - accessibility: { - create: { - mobility: false, - vision: false, - hearing: false, - other: false, - }, - }, - alternateContact: { - create: { - type: AlternateContactRelationship.other, - otherType: 'example other type', - firstName: 'example first name', - lastName: 'example last name', - agency: 'example agency', - phoneNumber: '111-111-1111', - emailAddress: 'example@email.com', - address: { - create: { - ...exampleAddress, - }, - }, - }, - }, - applicationsAlternateAddress: { - create: { - ...exampleAddress, - }, - }, - applicationsMailingAddress: { - create: { - ...exampleAddress, - }, - }, - listings: { - connect: { - id: dto.listings.id, - }, - }, - demographics: { - create: { - ethnicity: 'example ethnicity', - gender: 'example gender', - sexualOrientation: 'example sexual orientation', - howDidYouHear: ['example how did you hear'], - race: ['example race'], - }, - }, - preferredUnitTypes: { - connect: [ - { - id: expect.anything(), - }, - ], - }, - householdMember: { - create: [ - { - orderId: 0, - firstName: 'example first name', - middleName: 'example middle name', - lastName: 'example last name', + expect(prisma.applications.updateMany).toHaveBeenCalledTimes(0); + expect(prisma.applications.create).toHaveBeenCalledWith({ + include: { ...detailView }, + data: { + isNewest: false, + contactPreferences: ['example contact preference'], + status: ApplicationStatusEnum.submitted, + submissionType: ApplicationSubmissionTypeEnum.electronical, + appUrl: 'http://www.example.com', + additionalPhone: true, + additionalPhoneNumber: '111-111-1111', + additionalPhoneNumberType: 'example additional phone number type', + householdSize: 2, + housingStatus: 'example housing status', + sendMailToMailingAddress: true, + householdExpectingChanges: false, + householdStudent: false, + incomeVouchers: false, + income: '36000', + incomePeriod: IncomePeriodEnum.perYear, + language: LanguagesEnum.en, + acceptedTerms: true, + // Submission date is the moment it was created + submissionDate: expect.any(Date), + reviewStatus: ApplicationReviewStatusEnum.valid, + confirmationCode: expect.anything(), + applicant: { + create: { + firstName: 'applicant first name', + middleName: 'applicant middle name', + lastName: 'applicant last name', birthMonth: 12, birthDay: 17, birthYear: 1993, - sameAddress: YesNoEnum.yes, - relationship: HouseholdMemberRelationship.other, + emailAddress: 'example@email.com', + noEmail: false, + phoneNumber: '111-111-1111', + phoneNumberType: 'Cell', + noPhone: false, workInRegion: YesNoEnum.yes, - householdMemberAddress: { + applicantAddress: { create: { ...exampleAddress, }, }, - householdMemberWorkAddress: { + applicantWorkAddress: { create: { ...exampleAddress, }, }, }, - ], - }, - programs: [ - { - key: 'example key', - claimed: true, - options: [ + }, + accessibility: { + create: { + mobility: false, + vision: false, + hearing: false, + other: false, + }, + }, + alternateContact: { + create: { + type: AlternateContactRelationship.other, + otherType: 'example other type', + firstName: 'example first name', + lastName: 'example last name', + agency: 'example agency', + phoneNumber: '111-111-1111', + emailAddress: 'example@email.com', + address: { + create: { + ...exampleAddress, + }, + }, + }, + }, + applicationsAlternateAddress: { + create: { + ...exampleAddress, + }, + }, + applicationsMailingAddress: { + create: { + ...exampleAddress, + }, + }, + listings: { + connect: { + id: dto.listings.id, + }, + }, + demographics: { + create: { + ethnicity: 'example ethnicity', + gender: 'example gender', + sexualOrientation: 'example sexual orientation', + howDidYouHear: ['example how did you hear'], + race: ['example race'], + }, + }, + preferredUnitTypes: { + connect: [ { - key: 'example key', - checked: true, - extraData: [ - { - type: InputType.boolean, - key: 'example key', - value: true, - }, - ], + id: expect.anything(), }, ], }, - ], - preferences: [ - { - key: 'example key', - claimed: true, - options: [ + householdMember: { + create: [ { - key: 'example key', - checked: true, - extraData: [ - { - type: InputType.boolean, - key: 'example key', - value: true, + orderId: 0, + firstName: 'example first name', + middleName: 'example middle name', + lastName: 'example last name', + birthMonth: 12, + birthDay: 17, + birthYear: 1993, + sameAddress: YesNoEnum.yes, + relationship: HouseholdMemberRelationship.other, + workInRegion: YesNoEnum.yes, + householdMemberAddress: { + create: { + ...exampleAddress, }, - ], + }, + householdMemberWorkAddress: { + create: { + ...exampleAddress, + }, + }, }, ], }, - ], - userAccounts: { - connect: { - id: 'requestingUser id', - }, + programs: [ + { + key: 'example key', + claimed: true, + options: [ + { + key: 'example key', + checked: true, + extraData: [ + { + type: InputType.boolean, + key: 'example key', + value: true, + }, + ], + }, + ], + }, + ], + preferences: [ + { + key: 'example key', + claimed: true, + options: [ + { + key: 'example key', + checked: true, + extraData: [ + { + type: InputType.boolean, + key: 'example key', + value: true, + }, + ], + }, + ], + }, + ], + userAccounts: undefined, }, - }, + }); + + expect(canOrThrowMock).not.toHaveBeenCalled(); }); - expect(canOrThrowMock).toHaveBeenCalledWith( - { - id: 'requestingUser id', - userRoles: { isAdmin: true }, - } as unknown as User, - 'application', - permissionActions.create, - { - listingId: dto.listings.id, - jurisdictionId: expect.anything(), - }, - ); - }); + it('should create an application from public site with MSQV2 enabled', async () => { + const multiselectQuestionId = randomUUID(); + prisma.listings.findUnique = jest.fn().mockResolvedValue({ + id: randomUUID(), + applicationDueDate: dayjs(new Date()).add(5, 'days').toDate(), + commonDigitalApplication: true, + digitalApplication: true, + jurisdictions: { + id: randomUUID(), + featureFlags: [{ name: FeatureFlagEnum.enableV2MSQ, active: true }], + }, - it('should update an application when one exists', async () => { - prisma.applications.findUnique = jest - .fn() - .mockResolvedValue({ id: randomUUID() }); + listingMultiselectQuestions: [ + { multiselectQuestionId: multiselectQuestionId }, + ], + }); + prisma.listings.update = jest.fn().mockResolvedValue({ + id: randomUUID(), + }); - prisma.listings.update = jest.fn().mockResolvedValue({ - id: randomUUID(), - }); + prisma.applications.updateMany = jest.fn().mockResolvedValue({}); + prisma.applications.create = jest.fn().mockResolvedValue({ + id: randomUUID(), + }); + prisma.$transaction = jest + .fn() + .mockResolvedValue([ + prisma.applications.updateMany, + { id: randomUUID() }, + ]); + + prisma.address.create = jest.fn().mockResolvedValue({ + id: randomUUID(), + }); + prisma.applicationSelections.create = jest.fn().mockResolvedValue({ + id: randomUUID(), + }); - prisma.applications.update = jest.fn().mockResolvedValue({ - id: randomUUID(), - listingId: randomUUID(), - }); + const exampleAddress = addressFactory() as AddressCreate; + const dto = mockCreateApplicationData( + exampleAddress, + new Date(), + multiselectQuestionId, + ); - const exampleAddress = addressFactory() as AddressCreate; - const submissionDate = new Date(); + prisma.jurisdictions.findFirst = jest + .fn() + .mockResolvedValue({ id: randomUUID() }); - const dto: ApplicationUpdate = { - ...mockCreateApplicationData(exampleAddress, submissionDate), - id: randomUUID(), - }; - - prisma.jurisdictions.findFirst = jest - .fn() - .mockResolvedValue({ id: randomUUID() }); - prisma.listings.findFirst = jest - .fn() - .mockResolvedValue({ id: randomUUID() }); - prisma.householdMember.deleteMany = jest.fn().mockResolvedValue(null); - - await service.update(dto, { - id: 'requestingUser id', - userRoles: { isAdmin: true }, - } as unknown as User); - - expect(prisma.applications.findUnique).toHaveBeenCalledWith({ - where: { - id: expect.anything(), - }, - }); + await service.create(dto, true, requestingUser); - expect(prisma.applications.update).toHaveBeenCalledWith({ - include: { - ...detailView, - }, - data: { - contactPreferences: ['example contact preference'], - status: ApplicationStatusEnum.submitted, - submissionType: ApplicationSubmissionTypeEnum.electronical, - appUrl: 'http://www.example.com', - additionalPhone: true, - additionalPhoneNumber: '111-111-1111', - additionalPhoneNumberType: 'example additional phone number type', - householdSize: 2, - housingStatus: 'example housing status', - sendMailToMailingAddress: true, - householdExpectingChanges: false, - householdStudent: false, - incomeVouchers: false, - income: '36000', - incomePeriod: IncomePeriodEnum.perYear, - language: LanguagesEnum.en, - acceptedTerms: true, - submissionDate: submissionDate, - reviewStatus: ApplicationReviewStatusEnum.valid, - applicant: { - create: { - firstName: 'applicant first name', - middleName: 'applicant middle name', - lastName: 'applicant last name', - birthMonth: 12, - birthDay: 17, - birthYear: 1993, - emailAddress: 'example@email.com', - noEmail: false, - phoneNumber: '111-111-1111', - phoneNumberType: 'Cell', - noPhone: false, - workInRegion: YesNoEnum.yes, - applicantAddress: { - create: { - ...exampleAddress, - }, - }, - applicantWorkAddress: { - create: { - ...exampleAddress, - }, - }, - }, - }, - accessibility: { - create: { - mobility: false, - vision: false, - hearing: false, - other: false, - }, - }, - alternateContact: { - create: { - type: AlternateContactRelationship.other, - otherType: 'example other type', - firstName: 'example first name', - lastName: 'example last name', - agency: 'example agency', - phoneNumber: '111-111-1111', - emailAddress: 'example@email.com', - address: { - create: { - ...exampleAddress, - }, - }, - }, - }, - applicationsAlternateAddress: { - create: { - ...exampleAddress, - }, - }, - applicationsMailingAddress: { - create: { - ...exampleAddress, - }, + expect(prisma.listings.findUnique).toHaveBeenCalledWith({ + where: { + id: expect.anything(), }, - listings: { - connect: { - id: dto.listings.id, + include: { + jurisdictions: { include: { featureFlags: true } }, + listingsBuildingAddress: true, + listingMultiselectQuestions: { + include: { multiselectQuestions: true }, }, + unitGroups: true, }, - demographics: { - create: { - ethnicity: 'example ethnicity', - gender: 'example gender', - sexualOrientation: 'example sexual orientation', - howDidYouHear: ['example how did you hear'], - race: ['example race'], - }, + }); + + expect(prisma.applications.updateMany).toHaveBeenCalledWith({ + data: { + isNewest: false, }, - preferredUnitTypes: { - set: [{ id: expect.anything() }], + where: { + userId: requestingUser.id, + isNewest: true, }, - householdMember: { - create: [ - { - orderId: 0, - firstName: 'example first name', - middleName: 'example middle name', - lastName: 'example last name', + }); + expect(prisma.applications.create).toHaveBeenCalledWith({ + include: { ...detailView }, + data: { + isNewest: true, + contactPreferences: ['example contact preference'], + status: ApplicationStatusEnum.submitted, + submissionType: ApplicationSubmissionTypeEnum.electronical, + appUrl: 'http://www.example.com', + additionalPhone: true, + additionalPhoneNumber: '111-111-1111', + additionalPhoneNumberType: 'example additional phone number type', + householdSize: 2, + housingStatus: 'example housing status', + sendMailToMailingAddress: true, + householdExpectingChanges: false, + householdStudent: false, + incomeVouchers: false, + income: '36000', + incomePeriod: IncomePeriodEnum.perYear, + language: LanguagesEnum.en, + acceptedTerms: true, + // Submission date is the moment it was created + submissionDate: expect.any(Date), + reviewStatus: ApplicationReviewStatusEnum.valid, + confirmationCode: expect.anything(), + applicant: { + create: { + firstName: 'applicant first name', + middleName: 'applicant middle name', + lastName: 'applicant last name', birthMonth: 12, birthDay: 17, birthYear: 1993, - sameAddress: YesNoEnum.yes, - relationship: HouseholdMemberRelationship.other, + emailAddress: 'example@email.com', + noEmail: false, + phoneNumber: '111-111-1111', + phoneNumberType: 'Cell', + noPhone: false, workInRegion: YesNoEnum.yes, - householdMemberAddress: { + applicantAddress: { create: { ...exampleAddress, }, }, - householdMemberWorkAddress: { + applicantWorkAddress: { create: { ...exampleAddress, }, }, }, - ], - }, - programs: [ - { - key: 'example key', - claimed: true, - options: [ - { - key: 'example key', - checked: true, - extraData: [ - { - type: InputType.boolean, - key: 'example key', - value: true, - }, - ], - }, - ], }, - ], - preferences: [ - { - key: 'example key', - claimed: true, - options: [ + accessibility: { + create: { + mobility: false, + vision: false, + hearing: false, + other: false, + }, + }, + alternateContact: { + create: { + type: AlternateContactRelationship.other, + otherType: 'example other type', + firstName: 'example first name', + lastName: 'example last name', + agency: 'example agency', + phoneNumber: '111-111-1111', + emailAddress: 'example@email.com', + address: { + create: { + ...exampleAddress, + }, + }, + }, + }, + applicationsAlternateAddress: { + create: { + ...exampleAddress, + }, + }, + applicationsMailingAddress: { + create: { + ...exampleAddress, + }, + }, + listings: { + connect: { + id: dto.listings.id, + }, + }, + demographics: { + create: { + ethnicity: 'example ethnicity', + gender: 'example gender', + sexualOrientation: 'example sexual orientation', + howDidYouHear: ['example how did you hear'], + race: ['example race'], + }, + }, + preferredUnitTypes: { + connect: [ { - key: 'example key', - checked: true, - extraData: [ - { - type: InputType.boolean, - key: 'example key', - value: true, + id: expect.anything(), + }, + ], + }, + householdMember: { + create: [ + { + orderId: 0, + firstName: 'example first name', + middleName: 'example middle name', + lastName: 'example last name', + birthMonth: 12, + birthDay: 17, + birthYear: 1993, + sameAddress: YesNoEnum.yes, + relationship: HouseholdMemberRelationship.other, + workInRegion: YesNoEnum.yes, + householdMemberAddress: { + create: { + ...exampleAddress, + }, + }, + householdMemberWorkAddress: { + create: { + ...exampleAddress, }, - ], + }, }, ], }, - ], - }, - where: { - id: expect.anything(), - }, - }); + programs: [ + { + key: 'example key', + claimed: true, + options: [ + { + key: 'example key', + checked: true, + extraData: [ + { + type: InputType.boolean, + key: 'example key', + value: true, + }, + ], + }, + ], + }, + ], + preferences: [ + { + key: 'example key', + claimed: true, + options: [ + { + key: 'example key', + checked: true, + extraData: [ + { + type: InputType.boolean, + key: 'example key', + value: true, + }, + ], + }, + ], + }, + ], + userAccounts: { + connect: { + id: requestingUser.id, + }, + }, + }, + }); - expect(prisma.listings.update).toHaveBeenCalledWith({ - where: { - id: expect.anything(), - }, - data: { - lastApplicationUpdateAt: expect.anything(), - }, + expect(prisma.applicationSelections.create).toHaveBeenCalledWith({ + data: { + applicationId: expect.anything(), + hasOptedOut: false, + multiselectQuestionId: multiselectQuestionId, + selections: { + createMany: { + data: [ + { + addressHolderAddressId: expect.anything(), + multiselectOptionId: expect.anything(), + }, + ], + }, + }, + }, + include: { + multiselectQuestion: true, + selections: { + include: { + addressHolderAddress: { + select: { + id: true, + placeName: true, + city: true, + county: true, + state: true, + street: true, + street2: true, + zipCode: true, + latitude: true, + longitude: true, + }, + }, + multiselectOption: true, + }, + }, + }, + }); + + expect(canOrThrowMock).not.toHaveBeenCalled(); }); - expect(canOrThrowMock).toHaveBeenCalledWith( - { - id: 'requestingUser id', - userRoles: { isAdmin: true }, - } as unknown as User, - 'application', - permissionActions.update, - expect.anything(), - ); - }); + it('should error while creating an application from public site because submissions are closed', async () => { + prisma.listings.findUnique = jest.fn().mockResolvedValue({ + id: randomUUID(), + applicationDueDate: new Date(0), + digitalApplication: true, + commonDigitalApplication: true, + jurisdictions: { + id: randomUUID(), + }, + }); + + prisma.applications.create = jest.fn().mockResolvedValue({ + id: randomUUID(), + }); - it("should error trying to update an application when one doesn't exists", async () => { - prisma.applications.findUnique = jest.fn().mockResolvedValue(null); + const exampleAddress = addressFactory() as AddressCreate; + const dto = mockCreateApplicationData(exampleAddress, new Date()); - prisma.listings.update = jest.fn().mockResolvedValue({ - id: randomUUID(), - }); + prisma.jurisdictions.findFirst = jest + .fn() + .mockResolvedValue({ id: randomUUID() }); - prisma.applications.update = jest.fn().mockResolvedValue({ - id: randomUUID(), - listingId: randomUUID(), + await expect( + async () => + await service.create(dto, true, { + id: 'requestingUser id', + userRoles: { isAdmin: true }, + } as unknown as User), + ).rejects.toThrowError('Listing is not open for application submission'); + + expect(prisma.listings.findUnique).toHaveBeenCalledWith({ + where: { + id: expect.anything(), + }, + include: { + jurisdictions: { include: { featureFlags: true } }, + listingsBuildingAddress: true, + listingMultiselectQuestions: { + include: { multiselectQuestions: true }, + }, + unitGroups: true, + }, + }); + + expect(prisma.applications.create).not.toHaveBeenCalled(); + + expect(canOrThrowMock).not.toHaveBeenCalled(); }); - const exampleAddress = addressFactory() as AddressCreate; - const submissionDate = new Date(); + it('should error while creating an application from public site on a listing without common app', async () => { + prisma.listings.findUnique = jest.fn().mockResolvedValue({ + id: randomUUID(), + applicationDueDate: new Date(0), + digitalApplication: false, + commonDigitalApplication: false, + jurisdictions: { + id: randomUUID(), + }, + }); + + prisma.applications.create = jest.fn().mockResolvedValue({ + id: randomUUID(), + }); - const dto: ApplicationUpdate = { - ...mockCreateApplicationData(exampleAddress, submissionDate), - id: randomUUID(), - }; + const exampleAddress = addressFactory() as AddressCreate; + const dto = mockCreateApplicationData(exampleAddress, new Date()); - prisma.jurisdictions.findFirst = jest - .fn() - .mockResolvedValue({ id: randomUUID() }); + prisma.jurisdictions.findFirst = jest + .fn() + .mockResolvedValue({ id: randomUUID() }); - await expect( - async () => - await service.update(dto, { - id: 'requestingUser id', - userRoles: { isAdmin: true }, - } as unknown as User), - ).rejects.toThrowError(expect.anything()); + await expect( + async () => + await service.create(dto, true, { + id: 'requestingUser id', + userRoles: { isAdmin: true }, + } as unknown as User), + ).rejects.toThrowError('Listing is not open for application submission'); + + expect(prisma.listings.findUnique).toHaveBeenCalledWith({ + where: { + id: expect.anything(), + }, + include: { + jurisdictions: { include: { featureFlags: true } }, + listingsBuildingAddress: true, + listingMultiselectQuestions: { + include: { multiselectQuestions: true }, + }, + unitGroups: true, + }, + }); - expect(prisma.applications.findUnique).toHaveBeenCalledWith({ - where: { - id: expect.anything(), - }, + expect(prisma.applications.create).not.toHaveBeenCalled(); + + expect(canOrThrowMock).not.toHaveBeenCalled(); }); - expect(prisma.applications.update).not.toHaveBeenCalled(); + it('should create an application from partner site', async () => { + prisma.listings.findUnique = jest.fn().mockResolvedValue({ + id: randomUUID(), + jurisdictions: { + id: randomUUID(), + }, + }); + prisma.applications.create = jest.fn().mockResolvedValue({ + id: randomUUID(), + }); + prisma.listings.update = jest.fn().mockResolvedValue({ + id: randomUUID(), + }); + prisma.$transaction = jest.fn().mockResolvedValue([ + // update previous applications + jest.fn().mockResolvedValue({ + id: randomUUID(), + }), + // application create mock + jest.fn().mockResolvedValue({ + id: randomUUID(), + }), + ]); + + prisma.jurisdictions.findFirst = jest + .fn() + .mockResolvedValue({ id: randomUUID() }); - expect(prisma.listings.update).not.toHaveBeenCalled(); + const exampleAddress = addressFactory() as AddressCreate; + const dto = mockCreateApplicationData(exampleAddress, new Date()); - expect(canOrThrowMock).not.toHaveBeenCalled(); - }); + await service.create(dto, false, { + id: 'requestingUser id', + userRoles: { isAdmin: true }, + } as unknown as User); - it('should get most recent application for a user', async () => { - const mockedValue = mockApplication({ position: 3, date }); - prisma.applications.findUnique = jest.fn().mockResolvedValue(mockedValue); - prisma.applications.findFirst = jest - .fn() - .mockResolvedValue({ id: mockedValue.id }); + expect(prisma.listings.findUnique).toHaveBeenCalledWith({ + where: { + id: expect.anything(), + }, + include: { + jurisdictions: { include: { featureFlags: true } }, + listingsBuildingAddress: true, + listingMultiselectQuestions: { + include: { multiselectQuestions: true }, + }, + unitGroups: true, + }, + }); - expect( - await service.mostRecentlyCreated({ userId: 'example Id' }, { - user: requestingUser, - } as unknown as ExpressRequest), - ).toEqual(mockedValue); - expect(prisma.applications.findFirst).toHaveBeenCalledWith({ - select: { - id: true, - }, - orderBy: { createdAt: 'desc' }, - where: { - userId: 'example Id', - }, - }); - expect(prisma.applications.findUnique).toHaveBeenCalledWith({ - where: { - id: mockedValue.id, - }, - include: { - applicant: { - select: { - id: true, - firstName: true, - middleName: true, - lastName: true, - birthMonth: true, - birthDay: true, - birthYear: true, - emailAddress: true, - noEmail: true, - phoneNumber: true, - phoneNumberType: true, - noPhone: true, - workInRegion: true, - fullTimeStudent: true, - applicantAddress: { - select: { - id: true, - placeName: true, - city: true, - county: true, - state: true, - street: true, - street2: true, - zipCode: true, - latitude: true, - longitude: true, + expect(prisma.applications.create).toHaveBeenCalledWith({ + include: { + ...detailView, + }, + data: { + isNewest: false, + contactPreferences: ['example contact preference'], + status: ApplicationStatusEnum.submitted, + submissionType: ApplicationSubmissionTypeEnum.electronical, + appUrl: 'http://www.example.com', + additionalPhone: true, + additionalPhoneNumber: '111-111-1111', + additionalPhoneNumberType: 'example additional phone number type', + householdSize: 2, + housingStatus: 'example housing status', + sendMailToMailingAddress: true, + householdExpectingChanges: false, + householdStudent: false, + incomeVouchers: false, + income: '36000', + incomePeriod: IncomePeriodEnum.perYear, + language: LanguagesEnum.en, + acceptedTerms: true, + submissionDate: expect.anything(), + reviewStatus: ApplicationReviewStatusEnum.valid, + confirmationCode: expect.anything(), + applicant: { + create: { + firstName: 'applicant first name', + middleName: 'applicant middle name', + lastName: 'applicant last name', + birthMonth: 12, + birthDay: 17, + birthYear: 1993, + emailAddress: 'example@email.com', + noEmail: false, + phoneNumber: '111-111-1111', + phoneNumberType: 'Cell', + noPhone: false, + workInRegion: YesNoEnum.yes, + applicantAddress: { + create: { + ...exampleAddress, + }, + }, + applicantWorkAddress: { + create: { + ...exampleAddress, + }, }, }, - applicantWorkAddress: { - select: { - id: true, - placeName: true, - city: true, - county: true, - state: true, - street: true, - street2: true, - zipCode: true, - latitude: true, - longitude: true, + }, + accessibility: { + create: { + mobility: false, + vision: false, + hearing: false, + other: false, + }, + }, + alternateContact: { + create: { + type: AlternateContactRelationship.other, + otherType: 'example other type', + firstName: 'example first name', + lastName: 'example last name', + agency: 'example agency', + phoneNumber: '111-111-1111', + emailAddress: 'example@email.com', + address: { + create: { + ...exampleAddress, + }, }, }, }, - }, - accessibility: { - select: { - id: true, - mobility: true, - vision: true, - hearing: true, - other: true, - }, - }, - applicationsMailingAddress: { - select: { - id: true, - placeName: true, - city: true, - county: true, - state: true, - street: true, - street2: true, - zipCode: true, - latitude: true, - longitude: true, - }, - }, - applicationsAlternateAddress: { - select: { - id: true, - placeName: true, - city: true, - county: true, - state: true, - street: true, - street2: true, - zipCode: true, - latitude: true, - longitude: true, - }, - }, - alternateContact: { - select: { - id: true, - type: true, - otherType: true, - firstName: true, - lastName: true, - agency: true, - phoneNumber: true, - emailAddress: true, - address: { - select: { - id: true, - placeName: true, - city: true, - county: true, - state: true, - street: true, - street2: true, - zipCode: true, - latitude: true, - longitude: true, + applicationsAlternateAddress: { + create: { + ...exampleAddress, + }, + }, + applicationsMailingAddress: { + create: { + ...exampleAddress, + }, + }, + listings: { + connect: { + id: dto.listings.id, + }, + }, + demographics: { + create: { + ethnicity: 'example ethnicity', + gender: 'example gender', + sexualOrientation: 'example sexual orientation', + howDidYouHear: ['example how did you hear'], + race: ['example race'], + }, + }, + preferredUnitTypes: { + connect: [ + { + id: expect.anything(), + }, + ], + }, + householdMember: { + create: [ + { + orderId: 0, + firstName: 'example first name', + middleName: 'example middle name', + lastName: 'example last name', + birthMonth: 12, + birthDay: 17, + birthYear: 1993, + sameAddress: YesNoEnum.yes, + relationship: HouseholdMemberRelationship.other, + workInRegion: YesNoEnum.yes, + householdMemberAddress: { + create: { + ...exampleAddress, + }, + }, + householdMemberWorkAddress: { + create: { + ...exampleAddress, + }, + }, }, + ], + }, + programs: [ + { + key: 'example key', + claimed: true, + options: [ + { + key: 'example key', + checked: true, + extraData: [ + { + type: InputType.boolean, + key: 'example key', + value: true, + }, + ], + }, + ], + }, + ], + preferences: [ + { + key: 'example key', + claimed: true, + options: [ + { + key: 'example key', + checked: true, + extraData: [ + { + type: InputType.boolean, + key: 'example key', + value: true, + }, + ], + }, + ], + }, + ], + userAccounts: { + connect: { + id: 'requestingUser id', }, }, }, - demographics: { - select: { - id: true, - createdAt: true, - updatedAt: true, - ethnicity: true, - gender: true, - sexualOrientation: true, - howDidYouHear: true, - race: true, - }, + }); + + expect(canOrThrowMock).toHaveBeenCalledWith( + { + id: 'requestingUser id', + userRoles: { isAdmin: true }, + } as unknown as User, + 'application', + permissionActions.create, + { + listingId: dto.listings.id, + jurisdictionId: expect.anything(), }, - preferredUnitTypes: { - select: { - id: true, - name: true, - numBedrooms: true, - }, + ); + }); + + it('should create an application from partner site when listing is closed', async () => { + process.env.APPLICATION_DAYS_TILL_EXPIRY = '60'; + prisma.listings.findUnique = jest.fn().mockResolvedValue({ + id: randomUUID(), + status: ListingsStatusEnum.closed, + closedAt: new Date('2024-04-28 00:00 -08:00'), + jurisdictions: { + id: randomUUID(), }, - listings: { - select: { - id: true, - name: true, - jurisdictions: { - select: { - id: true, - name: true, + }); + prisma.applications.create = jest.fn().mockResolvedValue({ + id: randomUUID(), + }); + prisma.listings.update = jest.fn().mockResolvedValue({ + id: randomUUID(), + }); + prisma.$transaction = jest.fn().mockResolvedValue([ + // update previous applications + jest.fn().mockResolvedValue({ + id: randomUUID(), + }), + // application create mock + jest.fn().mockResolvedValue({ + id: randomUUID(), + }), + ]); + + prisma.jurisdictions.findFirst = jest + .fn() + .mockResolvedValue({ id: randomUUID() }); + + const exampleAddress = addressFactory() as AddressCreate; + const dto = mockCreateApplicationData(exampleAddress, new Date()); + + await service.create(dto, false, { + id: 'requestingUser id', + userRoles: { isAdmin: true }, + } as unknown as User); + + expect(prisma.applications.create).toHaveBeenCalledWith({ + include: { + ...detailView, + }, + data: { + isNewest: false, + expireAfter: new Date('2024-06-27T08:00:00.000Z'), + contactPreferences: ['example contact preference'], + status: ApplicationStatusEnum.submitted, + submissionType: ApplicationSubmissionTypeEnum.electronical, + appUrl: 'http://www.example.com', + additionalPhone: true, + additionalPhoneNumber: '111-111-1111', + additionalPhoneNumberType: 'example additional phone number type', + householdSize: 2, + housingStatus: 'example housing status', + sendMailToMailingAddress: true, + householdExpectingChanges: false, + householdStudent: false, + incomeVouchers: false, + income: '36000', + incomePeriod: IncomePeriodEnum.perYear, + language: LanguagesEnum.en, + acceptedTerms: true, + submissionDate: expect.anything(), + reviewStatus: ApplicationReviewStatusEnum.valid, + confirmationCode: expect.anything(), + applicant: { + create: { + firstName: 'applicant first name', + middleName: 'applicant middle name', + lastName: 'applicant last name', + birthMonth: 12, + birthDay: 17, + birthYear: 1993, + emailAddress: 'example@email.com', + noEmail: false, + phoneNumber: '111-111-1111', + phoneNumberType: 'Cell', + noPhone: false, + workInRegion: YesNoEnum.yes, + applicantAddress: { + create: { + ...exampleAddress, + }, + }, + applicantWorkAddress: { + create: { + ...exampleAddress, + }, }, }, }, - }, - householdMember: { - select: { - id: true, - orderId: true, - firstName: true, - middleName: true, - lastName: true, - birthMonth: true, - birthDay: true, - birthYear: true, - sameAddress: true, - relationship: true, - workInRegion: true, - fullTimeStudent: true, - householdMemberAddress: { - select: { - id: true, - placeName: true, - city: true, - county: true, - state: true, - street: true, - street2: true, - zipCode: true, - latitude: true, - longitude: true, + accessibility: { + create: { + mobility: false, + vision: false, + hearing: false, + other: false, + }, + }, + alternateContact: { + create: { + type: AlternateContactRelationship.other, + otherType: 'example other type', + firstName: 'example first name', + lastName: 'example last name', + agency: 'example agency', + phoneNumber: '111-111-1111', + emailAddress: 'example@email.com', + address: { + create: { + ...exampleAddress, + }, }, }, - householdMemberWorkAddress: { - select: { - id: true, - placeName: true, - city: true, - county: true, - state: true, - street: true, - street2: true, - zipCode: true, - latitude: true, - longitude: true, + }, + applicationsAlternateAddress: { + create: { + ...exampleAddress, + }, + }, + applicationsMailingAddress: { + create: { + ...exampleAddress, + }, + }, + listings: { + connect: { + id: dto.listings.id, + }, + }, + demographics: { + create: { + ethnicity: 'example ethnicity', + gender: 'example gender', + sexualOrientation: 'example sexual orientation', + howDidYouHear: ['example how did you hear'], + race: ['example race'], + }, + }, + preferredUnitTypes: { + connect: [ + { + id: expect.anything(), + }, + ], + }, + householdMember: { + create: [ + { + orderId: 0, + firstName: 'example first name', + middleName: 'example middle name', + lastName: 'example last name', + birthMonth: 12, + birthDay: 17, + birthYear: 1993, + sameAddress: YesNoEnum.yes, + relationship: HouseholdMemberRelationship.other, + workInRegion: YesNoEnum.yes, + householdMemberAddress: { + create: { + ...exampleAddress, + }, + }, + householdMemberWorkAddress: { + create: { + ...exampleAddress, + }, + }, }, + ], + }, + programs: [ + { + key: 'example key', + claimed: true, + options: [ + { + key: 'example key', + checked: true, + extraData: [ + { + type: InputType.boolean, + key: 'example key', + value: true, + }, + ], + }, + ], + }, + ], + preferences: [ + { + key: 'example key', + claimed: true, + options: [ + { + key: 'example key', + checked: true, + extraData: [ + { + type: InputType.boolean, + key: 'example key', + value: true, + }, + ], + }, + ], + }, + ], + userAccounts: { + connect: { + id: 'requestingUser id', }, }, }, - userAccounts: { - select: { - id: true, - firstName: true, - lastName: true, - email: true, - }, + }); + + expect(canOrThrowMock).toHaveBeenCalledWith( + { + id: 'requestingUser id', + userRoles: { isAdmin: true }, + } as unknown as User, + 'application', + permissionActions.create, + { + listingId: dto.listings.id, + jurisdictionId: expect.anything(), }, - }, + ); + process.env.APPLICATION_DAYS_TILL_EXPIRY = null; + }); + }); + + describe('update endpoint', () => { + it('should update an application when one exists', async () => { + prisma.applications.findUnique = jest.fn().mockResolvedValue({ + id: randomUUID(), + listingId: randomUUID(), + }); + prisma.applications.update = jest.fn().mockResolvedValue({ + id: randomUUID(), + listingId: randomUUID(), + }); + + prisma.householdMember.deleteMany = jest.fn().mockResolvedValue(null); + + prisma.listings.findUnique = jest.fn().mockResolvedValue({ + id: randomUUID(), + jurisdictions: { + id: randomUUID(), + }, + }); + prisma.listings.update = jest.fn().mockResolvedValue({ + id: randomUUID(), + }); + + prisma.$transaction = jest.fn().mockResolvedValue([ + prisma.householdMember.deleteMany, + { + id: randomUUID(), + listingId: randomUUID(), + }, + ]); + + const exampleAddress = addressFactory() as AddressCreate; + const submissionDate = new Date(); + + const dto: ApplicationUpdate = { + ...mockCreateApplicationData(exampleAddress, submissionDate), + id: randomUUID(), + applicationSelections: [], + }; + + await service.update(dto, { + id: 'requestingUser id', + userRoles: { isAdmin: true }, + } as unknown as User); + + expect(prisma.applications.findUnique).toHaveBeenCalledWith({ + include: baseView, + where: { + id: expect.anything(), + }, + }); + + expect(prisma.applications.update).toHaveBeenCalledWith({ + include: { + ...detailView, + }, + data: { + contactPreferences: ['example contact preference'], + status: ApplicationStatusEnum.submitted, + submissionType: ApplicationSubmissionTypeEnum.electronical, + appUrl: 'http://www.example.com', + additionalPhone: true, + additionalPhoneNumber: '111-111-1111', + additionalPhoneNumberType: 'example additional phone number type', + householdSize: 2, + housingStatus: 'example housing status', + sendMailToMailingAddress: true, + householdExpectingChanges: false, + householdStudent: false, + incomeVouchers: false, + income: '36000', + incomePeriod: IncomePeriodEnum.perYear, + language: LanguagesEnum.en, + acceptedTerms: true, + submissionDate: submissionDate, + reviewStatus: ApplicationReviewStatusEnum.valid, + applicant: { + create: { + firstName: 'applicant first name', + middleName: 'applicant middle name', + lastName: 'applicant last name', + birthMonth: 12, + birthDay: 17, + birthYear: 1993, + emailAddress: 'example@email.com', + noEmail: false, + phoneNumber: '111-111-1111', + phoneNumberType: 'Cell', + noPhone: false, + workInRegion: YesNoEnum.yes, + applicantAddress: { + create: { + ...exampleAddress, + }, + }, + applicantWorkAddress: { + create: { + ...exampleAddress, + }, + }, + }, + }, + accessibility: { + create: { + mobility: false, + vision: false, + hearing: false, + other: false, + }, + }, + alternateContact: { + create: { + type: AlternateContactRelationship.other, + otherType: 'example other type', + firstName: 'example first name', + lastName: 'example last name', + agency: 'example agency', + phoneNumber: '111-111-1111', + emailAddress: 'example@email.com', + address: { + create: { + ...exampleAddress, + }, + }, + }, + }, + applicationSelections: {}, + applicationsAlternateAddress: { + create: { + ...exampleAddress, + }, + }, + applicationsMailingAddress: { + create: { + ...exampleAddress, + }, + }, + listings: { + connect: { + id: dto.listings.id, + }, + }, + demographics: { + create: { + ethnicity: 'example ethnicity', + gender: 'example gender', + sexualOrientation: 'example sexual orientation', + howDidYouHear: ['example how did you hear'], + race: ['example race'], + }, + }, + preferredUnitTypes: { + set: [{ id: expect.anything() }], + }, + householdMember: { + create: [ + { + orderId: 0, + firstName: 'example first name', + middleName: 'example middle name', + lastName: 'example last name', + birthMonth: 12, + birthDay: 17, + birthYear: 1993, + sameAddress: YesNoEnum.yes, + relationship: HouseholdMemberRelationship.other, + workInRegion: YesNoEnum.yes, + householdMemberAddress: { + create: { + ...exampleAddress, + }, + }, + householdMemberWorkAddress: { + create: { + ...exampleAddress, + }, + }, + }, + ], + }, + programs: [ + { + key: 'example key', + claimed: true, + options: [ + { + key: 'example key', + checked: true, + extraData: [ + { + type: InputType.boolean, + key: 'example key', + value: true, + }, + ], + }, + ], + }, + ], + preferences: [ + { + key: 'example key', + claimed: true, + options: [ + { + key: 'example key', + checked: true, + extraData: [ + { + type: InputType.boolean, + key: 'example key', + value: true, + }, + ], + }, + ], + }, + ], + }, + where: { + id: expect.anything(), + }, + }); + + expect(prisma.listings.update).toHaveBeenCalledWith({ + where: { + id: expect.anything(), + }, + data: { + lastApplicationUpdateAt: expect.anything(), + }, + }); + + expect(canOrThrowMock).toHaveBeenCalledWith( + { + id: 'requestingUser id', + userRoles: { isAdmin: true }, + } as unknown as User, + 'application', + permissionActions.update, + expect.anything(), + ); + }); + + it.skip('should add new applicationSelection to an application with MSQV2 enabled', async () => { + const applicationId = randomUUID(); + const multiselectQuestionId = randomUUID(); + const multiselectOptionId = randomUUID(); + + prisma.address.create = jest.fn().mockResolvedValue({ + id: randomUUID(), + }); + prisma.applicationSelections.create = jest.fn().mockResolvedValue({ + id: randomUUID(), + }); + + prisma.applications.findUnique = jest.fn().mockResolvedValue({ + id: applicationId, + applicationSelections: [], + listingId: randomUUID(), + }); + prisma.applications.update = jest.fn().mockResolvedValue({ + id: applicationId, + listingId: randomUUID(), + }); + + prisma.householdMember.deleteMany = jest.fn().mockResolvedValue(null); + + prisma.listings.findUnique = jest.fn().mockResolvedValue({ + id: randomUUID(), + jurisdictions: { + id: randomUUID(), + featureFlags: [{ name: FeatureFlagEnum.enableV2MSQ, active: true }], + }, + listingMultiselectQuestions: [ + { multiselectQuestionId: multiselectQuestionId }, + ], + }); + prisma.listings.update = jest.fn().mockResolvedValue({ + id: randomUUID(), + }); + + prisma.$transaction = jest.fn().mockResolvedValue([ + prisma.householdMember.deleteMany, + { + id: randomUUID(), + listingId: randomUUID(), + }, + ]); + + const exampleAddress = addressFactory() as AddressCreate; + const submissionDate = new Date(); + + const dto: ApplicationUpdate = { + ...mockCreateApplicationData(exampleAddress, submissionDate), + id: applicationId, + applicationSelections: [ + { + application: { id: applicationId }, + hasOptedOut: false, + multiselectQuestion: { id: multiselectQuestionId }, + selections: [ + { + addressHolderAddress: exampleAddress, + multiselectOption: { id: multiselectOptionId }, + }, + ], + }, + ], + }; + + await service.update(dto, { + id: 'requestingUser id', + userRoles: { isAdmin: true }, + } as unknown as User); + + expect(prisma.applications.findUnique).toHaveBeenCalledWith({ + include: baseView, + where: { + id: applicationId, + }, + }); + + expect(prisma.applicationSelections.create).toHaveBeenCalledWith({ + data: { + applicationId: applicationId, + hasOptedOut: false, + multiselectQuestionId: multiselectQuestionId, + selections: { + createMany: { + data: [ + { + addressHolderAddressId: expect.anything(), + multiselectOptionId: multiselectOptionId, + }, + ], + }, + }, + }, + }); + + expect(prisma.applications.update).toHaveBeenCalledWith({ + include: { + ...detailView, + }, + data: { + contactPreferences: ['example contact preference'], + status: ApplicationStatusEnum.submitted, + submissionType: ApplicationSubmissionTypeEnum.electronical, + appUrl: 'http://www.example.com', + additionalPhone: true, + additionalPhoneNumber: '111-111-1111', + additionalPhoneNumberType: 'example additional phone number type', + householdSize: 2, + housingStatus: 'example housing status', + sendMailToMailingAddress: true, + householdExpectingChanges: false, + householdStudent: false, + incomeVouchers: false, + income: '36000', + incomePeriod: IncomePeriodEnum.perYear, + language: LanguagesEnum.en, + acceptedTerms: true, + submissionDate: submissionDate, + reviewStatus: ApplicationReviewStatusEnum.valid, + applicant: { + create: { + firstName: 'applicant first name', + middleName: 'applicant middle name', + lastName: 'applicant last name', + birthMonth: 12, + birthDay: 17, + birthYear: 1993, + emailAddress: 'example@email.com', + noEmail: false, + phoneNumber: '111-111-1111', + phoneNumberType: 'Cell', + noPhone: false, + workInRegion: YesNoEnum.yes, + applicantAddress: { + create: { + ...exampleAddress, + }, + }, + applicantWorkAddress: { + create: { + ...exampleAddress, + }, + }, + }, + }, + accessibility: { + create: { + mobility: false, + vision: false, + hearing: false, + other: false, + }, + }, + alternateContact: { + create: { + type: AlternateContactRelationship.other, + otherType: 'example other type', + firstName: 'example first name', + lastName: 'example last name', + agency: 'example agency', + phoneNumber: '111-111-1111', + emailAddress: 'example@email.com', + address: { + create: { + ...exampleAddress, + }, + }, + }, + }, + applicationSelections: {}, + applicationsAlternateAddress: { + create: { + ...exampleAddress, + }, + }, + applicationsMailingAddress: { + create: { + ...exampleAddress, + }, + }, + listings: { + connect: { + id: dto.listings.id, + }, + }, + demographics: { + create: { + ethnicity: 'example ethnicity', + gender: 'example gender', + sexualOrientation: 'example sexual orientation', + howDidYouHear: ['example how did you hear'], + race: ['example race'], + }, + }, + preferredUnitTypes: { + set: [{ id: expect.anything() }], + }, + householdMember: { + create: [ + { + orderId: 0, + firstName: 'example first name', + middleName: 'example middle name', + lastName: 'example last name', + birthMonth: 12, + birthDay: 17, + birthYear: 1993, + sameAddress: YesNoEnum.yes, + relationship: HouseholdMemberRelationship.other, + workInRegion: YesNoEnum.yes, + householdMemberAddress: { + create: { + ...exampleAddress, + }, + }, + householdMemberWorkAddress: { + create: { + ...exampleAddress, + }, + }, + }, + ], + }, + programs: [ + { + key: 'example key', + claimed: true, + options: [ + { + key: 'example key', + checked: true, + extraData: [ + { + type: InputType.boolean, + key: 'example key', + value: true, + }, + ], + }, + ], + }, + ], + preferences: [ + { + key: 'example key', + claimed: true, + options: [ + { + key: 'example key', + checked: true, + extraData: [ + { + type: InputType.boolean, + key: 'example key', + value: true, + }, + ], + }, + ], + }, + ], + }, + where: { + id: applicationId, + }, + }); + + expect(prisma.listings.update).toHaveBeenCalledWith({ + where: { + id: expect.anything(), + }, + data: { + lastApplicationUpdateAt: expect.anything(), + }, + }); + + expect(canOrThrowMock).toHaveBeenCalledWith( + { + id: 'requestingUser id', + userRoles: { isAdmin: true }, + } as unknown as User, + 'application', + permissionActions.update, + expect.anything(), + ); + }); + + it.skip('should remove an applicationSelection to an application with MSQV2 enabled', async () => { + const applicationId = randomUUID(); + const multiselectQuestionId = randomUUID(); + + prisma.address.create = jest.fn().mockResolvedValue({ + id: randomUUID(), + }); + + prisma.applicationSelections.deleteMany = jest + .fn() + .mockResolvedValue(null); + + prisma.applications.findUnique = jest.fn().mockResolvedValue({ + id: applicationId, + applicationSelections: [ + { + id: randomUUID(), + application: { id: applicationId }, + hasOptedOut: false, + multiselectQuestion: { id: multiselectQuestionId }, + selections: [ + { + id: randomUUID(), + multiselectOption: { id: randomUUID() }, + }, + ], + }, + ], + listingId: randomUUID(), + }); + prisma.applications.update = jest.fn().mockResolvedValue({ + id: applicationId, + listingId: randomUUID(), + }); + + prisma.householdMember.deleteMany = jest.fn().mockResolvedValue(null); + + prisma.listings.findUnique = jest.fn().mockResolvedValue({ + id: randomUUID(), + jurisdictions: { + id: randomUUID(), + featureFlags: [{ name: FeatureFlagEnum.enableV2MSQ, active: true }], + }, + listingMultiselectQuestions: [ + { multiselectQuestionId: multiselectQuestionId }, + ], + }); + prisma.listings.update = jest.fn().mockResolvedValue({ + id: randomUUID(), + }); + + prisma.$transaction = jest.fn().mockResolvedValue([ + prisma.applicationSelections.deleteMany, + prisma.householdMember.deleteMany, + { + id: randomUUID(), + listingId: randomUUID(), + }, + ]); + + const exampleAddress = addressFactory() as AddressCreate; + const submissionDate = new Date(); + + const dto: ApplicationUpdate = { + ...mockCreateApplicationData(exampleAddress, submissionDate), + id: applicationId, + applicationSelections: [], + }; + + await service.update(dto, { + id: 'requestingUser id', + userRoles: { isAdmin: true }, + } as unknown as User); + + expect(prisma.applications.findUnique).toHaveBeenCalledWith({ + include: baseView, + where: { + id: applicationId, + }, + }); + + expect(prisma.applicationSelections.deleteMany).toHaveBeenCalledWith({ + where: { id: { in: [expect.anything()] } }, + }); + + expect(prisma.applications.update).toHaveBeenCalledWith({ + include: { + ...detailView, + }, + data: { + contactPreferences: ['example contact preference'], + status: ApplicationStatusEnum.submitted, + submissionType: ApplicationSubmissionTypeEnum.electronical, + appUrl: 'http://www.example.com', + additionalPhone: true, + additionalPhoneNumber: '111-111-1111', + additionalPhoneNumberType: 'example additional phone number type', + householdSize: 2, + housingStatus: 'example housing status', + sendMailToMailingAddress: true, + householdExpectingChanges: false, + householdStudent: false, + incomeVouchers: false, + income: '36000', + incomePeriod: IncomePeriodEnum.perYear, + language: LanguagesEnum.en, + acceptedTerms: true, + submissionDate: submissionDate, + reviewStatus: ApplicationReviewStatusEnum.valid, + applicant: { + create: { + firstName: 'applicant first name', + middleName: 'applicant middle name', + lastName: 'applicant last name', + birthMonth: 12, + birthDay: 17, + birthYear: 1993, + emailAddress: 'example@email.com', + noEmail: false, + phoneNumber: '111-111-1111', + phoneNumberType: 'Cell', + noPhone: false, + workInRegion: YesNoEnum.yes, + applicantAddress: { + create: { + ...exampleAddress, + }, + }, + applicantWorkAddress: { + create: { + ...exampleAddress, + }, + }, + }, + }, + accessibility: { + create: { + mobility: false, + vision: false, + hearing: false, + other: false, + }, + }, + alternateContact: { + create: { + type: AlternateContactRelationship.other, + otherType: 'example other type', + firstName: 'example first name', + lastName: 'example last name', + agency: 'example agency', + phoneNumber: '111-111-1111', + emailAddress: 'example@email.com', + address: { + create: { + ...exampleAddress, + }, + }, + }, + }, + applicationSelections: {}, + applicationsAlternateAddress: { + create: { + ...exampleAddress, + }, + }, + applicationsMailingAddress: { + create: { + ...exampleAddress, + }, + }, + listings: { + connect: { + id: dto.listings.id, + }, + }, + demographics: { + create: { + ethnicity: 'example ethnicity', + gender: 'example gender', + sexualOrientation: 'example sexual orientation', + howDidYouHear: ['example how did you hear'], + race: ['example race'], + }, + }, + preferredUnitTypes: { + set: [{ id: expect.anything() }], + }, + householdMember: { + create: [ + { + orderId: 0, + firstName: 'example first name', + middleName: 'example middle name', + lastName: 'example last name', + birthMonth: 12, + birthDay: 17, + birthYear: 1993, + sameAddress: YesNoEnum.yes, + relationship: HouseholdMemberRelationship.other, + workInRegion: YesNoEnum.yes, + householdMemberAddress: { + create: { + ...exampleAddress, + }, + }, + householdMemberWorkAddress: { + create: { + ...exampleAddress, + }, + }, + }, + ], + }, + programs: [ + { + key: 'example key', + claimed: true, + options: [ + { + key: 'example key', + checked: true, + extraData: [ + { + type: InputType.boolean, + key: 'example key', + value: true, + }, + ], + }, + ], + }, + ], + preferences: [ + { + key: 'example key', + claimed: true, + options: [ + { + key: 'example key', + checked: true, + extraData: [ + { + type: InputType.boolean, + key: 'example key', + value: true, + }, + ], + }, + ], + }, + ], + }, + where: { + id: applicationId, + }, + }); + + expect(prisma.listings.update).toHaveBeenCalledWith({ + where: { + id: expect.anything(), + }, + data: { + lastApplicationUpdateAt: expect.anything(), + }, + }); + + expect(canOrThrowMock).toHaveBeenCalledWith( + { + id: 'requestingUser id', + userRoles: { isAdmin: true }, + } as unknown as User, + 'application', + permissionActions.update, + expect.anything(), + ); + }); + + it.skip('should update an applicationSelection to an application with MSQV2 enabled', async () => { + const applicationId = randomUUID(); + const applicationSelectionId = randomUUID(); + const multiselectQuestionId = randomUUID(); + + prisma.address.create = jest.fn().mockResolvedValue({ + id: randomUUID(), + }); + + prisma.applications.findUnique = jest.fn().mockResolvedValue({ + id: applicationId, + applicationSelections: [ + { + id: applicationSelectionId, + application: { id: applicationId }, + hasOptedOut: false, + multiselectQuestion: { id: multiselectQuestionId }, + selections: [ + { + id: randomUUID(), + multiselectOption: { id: randomUUID() }, + }, + ], + }, + ], + listingId: randomUUID(), + }); + prisma.applications.update = jest.fn().mockResolvedValue({ + id: applicationId, + listingId: randomUUID(), + }); + + prisma.householdMember.deleteMany = jest.fn().mockResolvedValue(null); + + prisma.listings.findUnique = jest.fn().mockResolvedValue({ + id: randomUUID(), + jurisdictions: { + id: randomUUID(), + featureFlags: [{ name: FeatureFlagEnum.enableV2MSQ, active: true }], + }, + listingMultiselectQuestions: [ + { multiselectQuestionId: multiselectQuestionId }, + ], + }); + prisma.listings.update = jest.fn().mockResolvedValue({ + id: randomUUID(), + }); + + prisma.$transaction = jest.fn().mockResolvedValue([ + prisma.applicationSelections.deleteMany, + prisma.householdMember.deleteMany, + { + id: randomUUID(), + listingId: randomUUID(), + }, + ]); + + const exampleAddress = addressFactory() as AddressCreate; + const submissionDate = new Date(); + + const dto: ApplicationUpdate = { + ...mockCreateApplicationData(exampleAddress, submissionDate), + id: applicationId, + applicationSelections: [ + { + id: applicationSelectionId, + application: { id: applicationId }, + hasOptedOut: true, + multiselectQuestion: { id: multiselectQuestionId }, + selections: [ + { + id: randomUUID(), + multiselectOption: { id: randomUUID() }, + }, + ], + }, + ], + }; + + await service.update(dto, { + id: 'requestingUser id', + userRoles: { isAdmin: true }, + } as unknown as User); + + expect(prisma.applications.findUnique).toHaveBeenCalledWith({ + include: baseView, + where: { + id: applicationId, + }, + }); + + expect(prisma.applicationSelections.deleteMany).toHaveBeenCalledWith({ + where: { id: { in: [expect.anything()] } }, + }); + + expect(prisma.applications.update).toHaveBeenCalledWith({ + include: { + ...detailView, + }, + data: { + contactPreferences: ['example contact preference'], + status: ApplicationStatusEnum.submitted, + submissionType: ApplicationSubmissionTypeEnum.electronical, + appUrl: 'http://www.example.com', + additionalPhone: true, + additionalPhoneNumber: '111-111-1111', + additionalPhoneNumberType: 'example additional phone number type', + householdSize: 2, + housingStatus: 'example housing status', + sendMailToMailingAddress: true, + householdExpectingChanges: false, + householdStudent: false, + incomeVouchers: false, + income: '36000', + incomePeriod: IncomePeriodEnum.perYear, + language: LanguagesEnum.en, + acceptedTerms: true, + submissionDate: submissionDate, + reviewStatus: ApplicationReviewStatusEnum.valid, + applicant: { + create: { + firstName: 'applicant first name', + middleName: 'applicant middle name', + lastName: 'applicant last name', + birthMonth: 12, + birthDay: 17, + birthYear: 1993, + emailAddress: 'example@email.com', + noEmail: false, + phoneNumber: '111-111-1111', + phoneNumberType: 'Cell', + noPhone: false, + workInRegion: YesNoEnum.yes, + applicantAddress: { + create: { + ...exampleAddress, + }, + }, + applicantWorkAddress: { + create: { + ...exampleAddress, + }, + }, + }, + }, + accessibility: { + create: { + mobility: false, + vision: false, + hearing: false, + other: false, + }, + }, + alternateContact: { + create: { + type: AlternateContactRelationship.other, + otherType: 'example other type', + firstName: 'example first name', + lastName: 'example last name', + agency: 'example agency', + phoneNumber: '111-111-1111', + emailAddress: 'example@email.com', + address: { + create: { + ...exampleAddress, + }, + }, + }, + }, + applicationSelections: {}, + applicationsAlternateAddress: { + create: { + ...exampleAddress, + }, + }, + applicationsMailingAddress: { + create: { + ...exampleAddress, + }, + }, + listings: { + connect: { + id: dto.listings.id, + }, + }, + demographics: { + create: { + ethnicity: 'example ethnicity', + gender: 'example gender', + sexualOrientation: 'example sexual orientation', + howDidYouHear: ['example how did you hear'], + race: ['example race'], + }, + }, + preferredUnitTypes: { + set: [{ id: expect.anything() }], + }, + householdMember: { + create: [ + { + orderId: 0, + firstName: 'example first name', + middleName: 'example middle name', + lastName: 'example last name', + birthMonth: 12, + birthDay: 17, + birthYear: 1993, + sameAddress: YesNoEnum.yes, + relationship: HouseholdMemberRelationship.other, + workInRegion: YesNoEnum.yes, + householdMemberAddress: { + create: { + ...exampleAddress, + }, + }, + householdMemberWorkAddress: { + create: { + ...exampleAddress, + }, + }, + }, + ], + }, + programs: [ + { + key: 'example key', + claimed: true, + options: [ + { + key: 'example key', + checked: true, + extraData: [ + { + type: InputType.boolean, + key: 'example key', + value: true, + }, + ], + }, + ], + }, + ], + preferences: [ + { + key: 'example key', + claimed: true, + options: [ + { + key: 'example key', + checked: true, + extraData: [ + { + type: InputType.boolean, + key: 'example key', + value: true, + }, + ], + }, + ], + }, + ], + }, + where: { + id: applicationId, + }, + }); + + expect(prisma.listings.update).toHaveBeenCalledWith({ + where: { + id: expect.anything(), + }, + data: { + lastApplicationUpdateAt: expect.anything(), + }, + }); + + expect(canOrThrowMock).toHaveBeenCalledWith( + { + id: 'requestingUser id', + userRoles: { isAdmin: true }, + } as unknown as User, + 'application', + permissionActions.update, + expect.anything(), + ); + }); + + it("should error trying to update an application when one doesn't exists", async () => { + prisma.applications.findUnique = jest.fn().mockResolvedValue(null); + prisma.applications.update = jest.fn().mockResolvedValue(null); + + prisma.listings.findUnique = jest.fn().mockResolvedValue(null); + prisma.listings.update = jest.fn().mockResolvedValue(null); + + const exampleAddress = addressFactory() as AddressCreate; + const submissionDate = new Date(); + + const dto: ApplicationUpdate = { + ...mockCreateApplicationData(exampleAddress, submissionDate), + id: randomUUID(), + applicationSelections: [], + }; + + await expect( + async () => + await service.update(dto, { + id: 'requestingUser id', + userRoles: { isAdmin: true }, + } as unknown as User), + ).rejects.toThrowError(expect.anything()); + + expect(prisma.applications.findUnique).toHaveBeenCalledWith({ + include: baseView, + where: { + id: expect.anything(), + }, + }); + + expect(prisma.applications.update).not.toHaveBeenCalled(); + + expect(prisma.listings.update).not.toHaveBeenCalled(); + + expect(canOrThrowMock).not.toHaveBeenCalled(); + }); + }); + + describe('mostRecentlyCreated endpoint', () => { + it('should get most recent application for a user', async () => { + const mockedValue = mockApplication({ position: 3, date }); + prisma.applications.findUnique = jest.fn().mockResolvedValue(mockedValue); + prisma.applications.findFirst = jest + .fn() + .mockResolvedValue({ id: mockedValue.id }); + + expect( + await service.mostRecentlyCreated({ userId: 'example Id' }, { + user: requestingUser, + } as unknown as ExpressRequest), + ).toEqual(mockedValue); + expect(prisma.applications.findFirst).toHaveBeenCalledWith({ + select: { + id: true, + }, + orderBy: { createdAt: 'desc' }, + where: { + userId: 'example Id', + }, + }); + expect(prisma.applications.findUnique).toHaveBeenCalledWith({ + where: { + id: mockedValue.id, + }, + include: detailView, + }); }); }); }); diff --git a/api/test/unit/services/auth.service.spec.ts b/api/test/unit/services/auth.service.spec.ts index c76a3232c8..cb0c3acb95 100644 --- a/api/test/unit/services/auth.service.spec.ts +++ b/api/test/unit/services/auth.service.spec.ts @@ -1,35 +1,40 @@ +import { RecaptchaEnterpriseServiceClient } from '@google-cloud/recaptcha-enterprise'; +import { Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { SchedulerRegistry } from '@nestjs/schedule'; import { Test, TestingModule } from '@nestjs/testing'; +import { MailService } from '@sendgrid/mail'; import { randomUUID } from 'crypto'; -import { sign } from 'jsonwebtoken'; import { Response } from 'express'; -import { ConfigService } from '@nestjs/config'; -import { MailService } from '@sendgrid/mail'; -import { RecaptchaEnterpriseServiceClient } from '@google-cloud/recaptcha-enterprise'; +import { sign } from 'jsonwebtoken'; +import { Jurisdiction } from '../../../src/dtos/jurisdictions/jurisdiction.dto'; +import { MfaType } from '../../../src/enums/mfa/mfa-type-enum'; +import { ApplicationService } from '../../../src/services/application.service'; import { ACCESS_TOKEN_AVAILABLE_NAME, ACCESS_TOKEN_AVAILABLE_OPTIONS, - AuthService, AUTH_COOKIE_OPTIONS, + AuthService, REFRESH_COOKIE_NAME, REFRESH_COOKIE_OPTIONS, TOKEN_COOKIE_NAME, } from '../../../src/services/auth.service'; -import { UserService } from '../../../src/services/user.service'; +import { CronJobService } from '../../../src/services/cron-job.service'; +import { EmailService } from '../../../src/services/email.service'; +import { GeocodingService } from '../../../src/services/geocoding.service'; +import { GoogleTranslateService } from '../../../src/services/google-translate.service'; +import { JurisdictionService } from '../../../src/services/jurisdiction.service'; +import { PermissionService } from '../../../src/services/permission.service'; import { PrismaService } from '../../../src/services/prisma.service'; +import { SendGridService } from '../../../src/services/sendgrid.service'; import { SmsService } from '../../../src/services/sms.service'; +import { TranslationService } from '../../../src/services/translation.service'; +import { UserService } from '../../../src/services/user.service'; import { generateSalt, hashPassword, passwordToHash, } from '../../../src/utilities/password-helpers'; -import { MfaType } from '../../../src/enums/mfa/mfa-type-enum'; -import { EmailService } from '../../../src/services/email.service'; -import { SendGridService } from '../../../src/services/sendgrid.service'; -import { TranslationService } from '../../../src/services/translation.service'; -import { JurisdictionService } from '../../../src/services/jurisdiction.service'; -import { GoogleTranslateService } from '../../../src/services/google-translate.service'; -import { PermissionService } from '../../../src/services/permission.service'; -import { Jurisdiction } from '../../../src/dtos/jurisdictions/jurisdiction.dto'; jest.mock('@google-cloud/recaptcha-enterprise'); const mockedRecaptcha = @@ -53,6 +58,11 @@ describe('Testing auth service', () => { const module: TestingModule = await Test.createTestingModule({ providers: [ AuthService, + ApplicationService, + GeocodingService, + CronJobService, + SchedulerRegistry, + Logger, UserService, { provide: EmailService, diff --git a/api/test/unit/services/cron-job.service.spec.ts b/api/test/unit/services/cron-job.service.spec.ts new file mode 100644 index 0000000000..ce205ae981 --- /dev/null +++ b/api/test/unit/services/cron-job.service.spec.ts @@ -0,0 +1,62 @@ +import { PrismaService } from '../../../src/services/prisma.service'; +import { CronJobService } from '../../../src/services/cron-job.service'; +import { Test, TestingModule } from '@nestjs/testing'; +import { Logger } from '@nestjs/common'; +import { SchedulerRegistry } from '@nestjs/schedule'; +import { randomUUID } from 'crypto'; + +describe('Testing app service', () => { + let service: CronJobService; + let prisma: PrismaService; + beforeAll(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [CronJobService, PrismaService, Logger, SchedulerRegistry], + }).compile(); + + service = module.get(CronJobService); + prisma = module.get(PrismaService); + }); + + const sampleName = 'SAMPLE_NAME'; + it('should create new cronjob entry if none is present', async () => { + prisma.cronJob.findFirst = jest.fn().mockResolvedValue(null); + prisma.cronJob.create = jest.fn().mockResolvedValue(true); + + await service.markCronJobAsStarted(sampleName); + + expect(prisma.cronJob.findFirst).toHaveBeenCalledWith({ + where: { + name: sampleName, + }, + }); + expect(prisma.cronJob.create).toHaveBeenCalledWith({ + data: { + lastRunDate: expect.anything(), + name: sampleName, + }, + }); + }); + + it('should update cronjob entry if one is present', async () => { + prisma.cronJob.findFirst = jest + .fn() + .mockResolvedValue({ id: randomUUID() }); + prisma.cronJob.update = jest.fn().mockResolvedValue(true); + + await service.markCronJobAsStarted(sampleName); + + expect(prisma.cronJob.findFirst).toHaveBeenCalledWith({ + where: { + name: sampleName, + }, + }); + expect(prisma.cronJob.update).toHaveBeenCalledWith({ + data: { + lastRunDate: expect.anything(), + }, + where: { + id: expect.anything(), + }, + }); + }); +}); diff --git a/api/test/unit/services/email.service.spec.ts b/api/test/unit/services/email.service.spec.ts index 5a0aeb5938..dc00ac6590 100644 --- a/api/test/unit/services/email.service.spec.ts +++ b/api/test/unit/services/email.service.spec.ts @@ -27,7 +27,11 @@ const translationServiceMock = { const jurisdictionServiceMock = { findOne: () => { - return { name: 'Jurisdiction 1', publicUrl: 'https://example.com' }; + return { + id: 'jurisdictionId', + name: 'Jurisdiction 1', + publicUrl: 'https://example.com', + }; }, }; @@ -335,6 +339,40 @@ describe('Testing email service', () => { 'You may be contacted while on the waitlist to confirm that you wish to remain on the waitlist', ); }); + it('Test waitlistLottery', async () => { + await service.applicationConfirmation( + { ...listing, reviewOrderType: ReviewOrderTypeEnum.waitlistLottery }, + application as ApplicationCreate, + 'http://localhost:3001', + ); + expect(sendMock).toHaveBeenCalled(); + expect(sendMock.mock.calls[0][0].to).toEqual( + 'applicant.email@example.com', + ); + expect(sendMock.mock.calls[0][0].subject).toEqual( + 'Your Application Confirmation', + ); + expect(sendMock.mock.calls[0][0].html).toContain( + 'indication of step completed', + ); + expect(sendMock.mock.calls[0][0].html).toContain( + 'Application
received
', + ); + expect(sendMock.mock.calls[0][0].html).toContain('What happens next?'); + expect(sendMock.mock.calls[0][0].html).toContain( + 'Eligible applicants will be placed on the waitlist based on lottery rank order.', + ); + // removed reference to preferences in Detroit + // expect(sendMock.mock.calls[0][0].html).toContain( + // 'Housing preferences, if applicable, will affect waitlist order.', + // ); + expect(sendMock.mock.calls[0][0].html).toContain( + 'If you are contacted for an interview, you will be asked to fill out a more detailed application and provide supporting documents', + ); + expect(sendMock.mock.calls[0][0].html).toContain( + 'You may be contacted while on the waitlist to confirm that you wish to remain on the waitlist', + ); + }); it('Test leasing agent section with all fields present', async () => { const listingWithLeasingAgent = { @@ -545,5 +583,31 @@ describe('Testing email service', () => { ); // expect(emailMock.html).toMatch(/please visit https:\/\/example\.com/); }); + + it('should load translations with jurisdiction for each language', async () => { + const emailArr = ['testOne@xample.com', 'testTwo@example.com']; + const getMergedTranslationsSpy = jest.spyOn( + translationServiceMock, + 'getMergedTranslations', + ); + const service = await module.resolve(EmailService); + + await service.lotteryPublishedApplicant( + { name: 'listing name', id: 'listingId', juris: 'jurisdictionId' }, + { en: emailArr, es: ['spanish@example.com'] }, + ); + + expect(getMergedTranslationsSpy).toHaveBeenCalledTimes(2); + expect(getMergedTranslationsSpy).toHaveBeenNthCalledWith( + 1, + 'jurisdictionId', + 'en', + ); + expect(getMergedTranslationsSpy).toHaveBeenNthCalledWith( + 2, + 'jurisdictionId', + 'es', + ); + }); }); }); diff --git a/api/test/unit/services/listing-csv-export.service.spec.ts b/api/test/unit/services/listing-csv-export.service.spec.ts index 95600a6304..26d96c40ba 100644 --- a/api/test/unit/services/listing-csv-export.service.spec.ts +++ b/api/test/unit/services/listing-csv-export.service.spec.ts @@ -1,6 +1,12 @@ import { Test, TestingModule } from '@nestjs/testing'; import { Logger } from '@nestjs/common'; -import { ListingEventsTypeEnum, ListingsStatusEnum } from '@prisma/client'; +import { + ListingEventsTypeEnum, + ListingsStatusEnum, + MarketingSeasonEnum, + MarketingTypeEnum, + MonthEnum, +} from '@prisma/client'; import dayjs from 'dayjs'; import { randomUUID } from 'crypto'; import fs from 'fs'; @@ -8,6 +14,10 @@ import { ListingCsvExporterService } from '../../../src/services/listing-csv-exp import { PrismaService } from '../../../src/services/prisma.service'; import Listing from '../../../src/dtos/listings/listing.dto'; import { User } from '../../../src/dtos/users/user.dto'; +import { Unit } from '../../../src/dtos/units/unit.dto'; +import { FeatureFlagEnum } from '../../../src/enums/feature-flags/feature-flags-enum'; +import { Jurisdiction } from '../../../src/dtos/jurisdictions/jurisdiction.dto'; +import { FeatureFlag } from '../../../src/dtos/feature-flags/feature-flag.dto'; describe('Testing listing csv export service', () => { let service: ListingCsvExporterService; @@ -33,109 +43,266 @@ describe('Testing listing csv export service', () => { }); jest.restoreAllMocks(); }); + const timestamp = new Date(1759430299657); - describe('crateCsv', () => { - it('should create the listing csv', async () => { - const timestamp = new Date(1759430299657); - const unit = { - number: 1, - numBathrooms: 2, - floor: 3, - sqFeet: 1200, - minOccupancy: 1, - maxOccupancy: 8, - amiPercentage: 80, - monthlyRentAsPercentOfIncome: null, - monthlyRent: 4000, - unitTypes: { id: randomUUID(), name: 'studio' }, - amiChart: { id: randomUUID(), name: 'Ami Chart Name' }, - }; - const mockListing = { - id: 'listing1-ID', - name: `listing1-Name`, + const mockBaseJurisdiction: Jurisdiction = { + id: 'jurisdiction-ID', + name: 'jurisdiction-Name', + createdAt: timestamp, + updatedAt: timestamp, + featureFlags: [], + languages: ['en', 'es'], + multiselectQuestions: [], + publicUrl: '', + emailFromAddress: '', + rentalAssistanceDefault: '', + whatToExpect: '', + whatToExpectAdditionalText: '', + whatToExpectUnderConstruction: '', + allowSingleUseCodeLogin: false, + listingApprovalPermissions: [], + duplicateListingPermissions: [], + requiredListingFields: [], + visibleNeighborhoodAmenities: [], + }; + + const mockBaseUnit: Unit = { + number: '1', + numBathrooms: 2, + floor: 3, + sqFeet: '1200', + minOccupancy: 1, + maxOccupancy: 8, + amiPercentage: '80', + monthlyRentAsPercentOfIncome: null, + monthlyRent: '4000', + unitTypes: { + id: randomUUID(), + name: 'studio', + createdAt: timestamp, + updatedAt: timestamp, + numBedrooms: 1, + }, + amiChart: { + id: randomUUID(), + name: 'Ami Chart Name', + createdAt: timestamp, + updatedAt: timestamp, + items: [], + jurisdictions: null, + }, + id: 'unit1-ID', + createdAt: timestamp, + updatedAt: timestamp, + }; + type MockListing = Listing & { + userAccounts: User[]; + }; + const mockBaseListing: MockListing = { + id: 'listing1-ID', + name: `listing1-Name`, + createdAt: timestamp, + jurisdictions: mockBaseJurisdiction, + status: ListingsStatusEnum.active, + publishedAt: timestamp, + contentUpdatedAt: timestamp, + developer: 'developer', + listingsBuildingAddress: { + street: '123 main st', + city: 'Bloomington', + state: 'BL', + zipCode: '01234', + latitude: 100.5, + longitude: 200.5, + id: 'listingbuildingaddress1-ID', + createdAt: timestamp, + updatedAt: timestamp, + }, + neighborhood: 'neighborhood', + yearBuilt: 2025, + listingEvents: [ + { + type: ListingEventsTypeEnum.publicLottery, + startTime: timestamp, + endTime: dayjs(timestamp).add(2, 'hours').toDate(), + note: 'lottery note', + id: 'listingevent1-ID', createdAt: timestamp, - jurisdictions: { id: 'jurisdiction-ID', name: 'jurisdiction-Name' }, - status: ListingsStatusEnum.active, - publishedAt: timestamp, - contentUpdatedAt: timestamp, - developer: 'developer', - listingsBuildingAddress: { - street: '123 main st', - city: 'Bloomington', - state: 'BL', - zipCode: '01234', - latitude: 'latitude', - longitude: 'longitude', - }, - neighborhood: 'neighborhood', - yearBuilt: '2025', - listingEvents: [ + updatedAt: timestamp, + }, + ], + applicationFee: '45', + depositHelperText: 'sample deposit helper text', + depositMin: '12', + depositMax: '120', + costsNotIncluded: 'sample costs not included', + amenities: 'sample amenities', + accessibility: 'sample accessibility', + unitAmenities: 'sample unit amenities', + smokingPolicy: 'sample smoking policy', + petPolicy: 'sample pet policy', + servicesOffered: 'sample services offered', + leasingAgentName: 'Name of leasing agent', + leasingAgentEmail: 'Email of leasing agent', + leasingAgentTitle: 'Title of leasing agent', + leasingAgentOfficeHours: 'office hours', + listingsLeasingAgentAddress: { + street: '321 main st', + city: 'Bloomington', + state: 'BL', + zipCode: '01234', + latitude: 100.5, + longitude: 200.5, + id: 'listingleasingagentaddress1-ID', + createdAt: timestamp, + updatedAt: timestamp, + }, + listingsApplicationMailingAddress: { + street: '456 main st', + city: 'Bloomington', + state: 'BL', + zipCode: '01234', + latitude: 100.5, + longitude: 200.5, + id: 'listingmailingaddress1-ID', + createdAt: timestamp, + updatedAt: timestamp, + }, + listingsApplicationPickUpAddress: { + street: '789 main st', + city: 'Bloomington', + state: 'BL', + zipCode: '01234', + latitude: 100.5, + longitude: 200.5, + id: 'listingpickupaddress1-ID', + createdAt: timestamp, + updatedAt: timestamp, + }, + applicationDueDate: timestamp, + listingMultiselectQuestions: [], + applicationMethods: [], + units: [mockBaseUnit], + displayWaitlistSize: false, + showWaitlist: false, + referralApplication: null, + assets: [], + applicationLotteryTotals: null, + updatedAt: timestamp, + marketingType: MarketingTypeEnum.comingSoon, + marketingSeason: MarketingSeasonEnum.summer, + marketingMonth: MonthEnum.july, + marketingYear: 2025, + userAccounts: [ + { + firstName: 'userFirst', + lastName: 'userLast', + } as User, + ], + listingsBuildingSelectionCriteriaFile: { + id: 'asset1-ID', + fileId: 'buildingSelectionCriteriaFileId', + createdAt: timestamp, + updatedAt: timestamp, + label: 'Building Selection Criteria Label', + }, + }; + + describe('createCsv', () => { + it('should create the listing csv with no feature flags', async () => { + await service.createCsv('sampleFile.csv', undefined, { + listings: [mockBaseListing], + user: { jurisdictions: [mockBaseJurisdiction] } as unknown as User, + }); + + expect(writeStream.bytesWritten).toBeGreaterThan(0); + const content = fs.readFileSync('sampleFile.csv', 'utf8'); + // Validate headers + expect(content).toContain( + 'Listing Id,Created At Date,Jurisdiction,Listing Name,Listing Status,Publish Date,Last Updated,Copy or Original,Copied From,Housing Provider,Building Street Address,Building City,Building State,Building Zip,Building Neighborhood,Building Year Built,Reserved Community Types,Latitude,Longitude,Number of Units,Listing Availability,Review Order,Lottery Date,Lottery Start,Lottery End,Lottery Notes,Housing Preferences,Housing Programs,Application Fee,Deposit Helper Text,Deposit Type,Deposit Value,Deposit Min,Deposit Max,Costs Not Included,Property Amenities,Additional Accessibility,Unit Amenities,Pets Policy,Services Offered,Smoking Policy,Eligibility Rules - Credit History,Eligibility Rules - Rental History,Eligibility Rules - Criminal Background,Eligibility Rules - Rental Assistance,Building Selection Criteria,Important Program Rules,Required Documents,Special Notes,Waitlist,Leasing Agent Name,Leasing Agent Email,Leasing Agent Phone,Leasing Agent Title,Leasing Agent Office Hours,Leasing Agent Street Address,Leasing Agent Apt/Unit #,Leasing Agent City,Leasing Agent State,Leasing Agent Zip,Leasing Agency Mailing Address,Leasing Agency Mailing Address Street 2,Leasing Agency Mailing Address City,Leasing Agency Mailing Address State,Leasing Agency Mailing Address Zip,Leasing Agency Pickup Address,Leasing Agency Pickup Address Street 2,Leasing Agency Pickup Address City,Leasing Agency Pickup Address State,Leasing Agency Pickup Address Zip,Leasing Pick Up Office Hours,Digital Application,Digital Application URL,Paper Application,Paper Application URL,Referral Opportunity,Can applications be mailed in?,Can applications be picked up?,Can applications be dropped off?,Postmark,Additional Application Submission Notes,Application Due Date,Application Due Time,Open House,Partners Who Have Access', + ); + // Validate first row + expect(content).toContain( + '"listing1-ID","10-02-2025 11:38:19AM PDT","jurisdiction-Name","listing1-Name","Public","10-02-2025 11:38:19AM PDT","10-02-2025 11:38:19AM PDT","Original",,"developer","123 main st","Bloomington","BL","01234","neighborhood","2025",,"100.5","200.5","1","Available Units",,"10-02-2025","11:38AM PDT","01:38PM PDT","lottery note",,,"$45","sample deposit helper text",,,"$12","$120","sample costs not included","sample amenities","sample accessibility","sample unit amenities","sample pet policy","sample services offered","sample smoking policy",,,,,"https://res.cloudinary.com/exygy/image/upload/buildingSelectionCriteriaFileId.pdf",,,,"No","Name of leasing agent","Email of leasing agent",,"Title of leasing agent","office hours","321 main st",,"Bloomington","BL","01234","456 main st",,"Bloomington","BL","01234","789 main st",,"Bloomington","BL","01234",,"No",,"No",,"No","No","No","No",,,"10-02-2025","11:38AM PDT",,"userFirst userLast"', + ); + }); + it('should create the listing csv with marketing type seasons', async () => { + await service.createCsv('sampleFile.csv', undefined, { + listings: [ { - type: ListingEventsTypeEnum.publicLottery, - startTime: timestamp, - endTime: dayjs(timestamp).add(2, 'hours').toDate(), - note: 'lottery note', + ...mockBaseListing, + marketingMonth: null, + showWaitlist: false, + referralApplication: null, + listingsBuildingSelectionCriteriaFile: null, + buildingSelectionCriteria: + 'https://www.example.com/building-criteria.pdf', }, ], - applicationFee: 45, - depositHelperText: 'sample deposit helper text', - depositMin: 12, - depositMax: 120, - costsNotIncluded: 'sample costs not included', - amenities: 'sample amenities', - accessibility: 'sample accessibility', - unitAmenities: 'sample unit amenities', - smokingPolicy: 'sample smoking policy', - petPolicy: 'sample pet policy', - servicesOffered: 'sample services offered', - leasingAgentName: 'Name of leasing agent', - leasingAgentEmail: 'Email of leasing agent', - leasingAgentTitle: 'Title of leasing agent', - leasingAgentOfficeHours: 'office hours', - listingsLeasingAgentAddress: { - street: '321 main st', - city: 'Bloomington', - state: 'BL', - zipCode: '01234', - latitude: 'latitude', - longitude: 'longitude', - }, - listingsApplicationMailingAddress: { - street: '456 main st', - city: 'Bloomington', - state: 'BL', - zipCode: '01234', - latitude: 'latitude', - longitude: 'longitude', - }, - listingsApplicationPickUpAddress: { - street: '789 main st', - city: 'Bloomington', - state: 'BL', - zipCode: '01234', - latitude: 'latitude', - longitude: 'longitude', - }, - applicationDueDate: timestamp, - listingMultiselectQuestions: [], - applicationMethods: [], - userAccounts: [{ firstName: 'userFirst', lastName: 'userLast' }], - units: [unit], - }; + user: { + jurisdictions: [ + { + ...mockBaseJurisdiction, + featureFlags: [ + { + name: FeatureFlagEnum.enableMarketingStatus, + active: true, + } as FeatureFlag, + ], + }, + ], + } as unknown as User, + }); + + expect(writeStream.bytesWritten).toBeGreaterThan(0); + const content = fs.readFileSync('sampleFile.csv', 'utf8'); + // Validate headers + expect(content).toContain( + 'Listing Id,Created At Date,Jurisdiction,Listing Name,Listing Status,Publish Date,Last Updated,Copy or Original,Copied From,Housing Provider,Building Street Address,Building City,Building State,Building Zip,Building Neighborhood,Building Year Built,Reserved Community Types,Latitude,Longitude,Number of Units,Listing Availability,Review Order,Lottery Date,Lottery Start,Lottery End,Lottery Notes,Housing Preferences,Housing Programs,Application Fee,Deposit Helper Text,Deposit Type,Deposit Value,Deposit Min,Deposit Max,Costs Not Included,Property Amenities,Additional Accessibility,Unit Amenities,Pets Policy,Services Offered,Smoking Policy,Eligibility Rules - Credit History,Eligibility Rules - Rental History,Eligibility Rules - Criminal Background,Eligibility Rules - Rental Assistance,Building Selection Criteria,Important Program Rules,Required Documents,Special Notes,Waitlist,Marketing Status,Marketing Season,Marketing Year,Leasing Agent Name,Leasing Agent Email,Leasing Agent Phone,Leasing Agent Title,Leasing Agent Office Hours,Leasing Agent Street Address,Leasing Agent Apt/Unit #,Leasing Agent City,Leasing Agent State,Leasing Agent Zip,Leasing Agency Mailing Address,Leasing Agency Mailing Address Street 2,Leasing Agency Mailing Address City,Leasing Agency Mailing Address State,Leasing Agency Mailing Address Zip,Leasing Agency Pickup Address,Leasing Agency Pickup Address Street 2,Leasing Agency Pickup Address City,Leasing Agency Pickup Address State,Leasing Agency Pickup Address Zip,Leasing Pick Up Office Hours,Digital Application,Digital Application URL,Paper Application,Paper Application URL,Referral Opportunity,Can applications be mailed in?,Can applications be picked up?,Can applications be dropped off?,Postmark,Additional Application Submission Notes,Application Due Date,Application Due Time,Open House,Partners Who Have Access', + ); + // Validate first row + expect(content).toContain( + '"listing1-ID","10-02-2025 11:38:19AM PDT","jurisdiction-Name","listing1-Name","Public","10-02-2025 11:38:19AM PDT","10-02-2025 11:38:19AM PDT","Original",,"developer","123 main st","Bloomington","BL","01234","neighborhood","2025",,"100.5","200.5","1","Available Units",,"10-02-2025","11:38AM PDT","01:38PM PDT","lottery note",,,"$45","sample deposit helper text",,,"$12","$120","sample costs not included","sample amenities","sample accessibility","sample unit amenities","sample pet policy","sample services offered","sample smoking policy",,,,,"https://www.example.com/building-criteria.pdf",,,,"No","Under Construction","Summer","2025","Name of leasing agent","Email of leasing agent",,"Title of leasing agent","office hours","321 main st",,"Bloomington","BL","01234","456 main st",,"Bloomington","BL","01234","789 main st",,"Bloomington","BL","01234",,"No",,"No",,"No","No","No","No",,,"10-02-2025","11:38AM PDT",,"userFirst userLast"', + ); + }); + it('should create the listing csv with marketing type months', async () => { await service.createCsv('sampleFile.csv', undefined, { - listings: [mockListing as unknown as Listing], - user: { jurisdictions: [] } as unknown as User, + listings: [ + { + ...mockBaseListing, + marketingSeason: null, + showWaitlist: false, + referralApplication: null, + }, + ], + user: { + jurisdictions: [ + { + ...mockBaseJurisdiction, + featureFlags: [ + { + name: FeatureFlagEnum.enableMarketingStatus, + active: true, + } as FeatureFlag, + { + name: FeatureFlagEnum.enableMarketingStatusMonths, + active: true, + } as FeatureFlag, + ], + }, + ], + } as unknown as User, }); expect(writeStream.bytesWritten).toBeGreaterThan(0); const content = fs.readFileSync('sampleFile.csv', 'utf8'); // Validate headers expect(content).toContain( - 'Listing Id,Created At Date,Jurisdiction,Listing Name,Listing Status,Publish Date,Last Updated,Copy or Original,Copied From,Developer,Building Street Address,Building City,Building State,Building Zip,Building Neighborhood,Building Year Built,Community Types,Latitude,Longitude,Listing Availability,Review Order,Lottery Date,Lottery Start,Lottery End,Lottery Notes,Housing Preferences,Application Fee,Deposit Helper Text,Deposit Min,Deposit Max,Deposit Type,Deposit Value,Deposit Range Min,Deposit Range Max,Costs Not Included,Property Amenities,Additional Accessibility,Unit Amenities,Smoking Policy,Pets Policy,Services Offered,Eligibility Rules - Credit History,Eligibility Rules - Rental History,Eligibility Rules - Criminal Background,Eligibility Rules - Rental Assistance,Building Selection Criteria,Important Program Rules,Required Documents,Special Notes,Waitlist,Leasing Agent Name,Leasing Agent Email,Leasing Agent Phone,Leasing Agent Title,Leasing Agent Office Hours,Leasing Agent Street Address,Leasing Agent Apt/Unit #,Leasing Agent City,Leasing Agent State,Leasing Agent Zip,Leasing Agency Mailing Address,Leasing Agency Mailing Address Street 2,Leasing Agency Mailing Address City,Leasing Agency Mailing Address State,Leasing Agency Mailing Address Zip,Leasing Agency Pickup Address,Leasing Agency Pickup Address Street 2,Leasing Agency Pickup Address City,Leasing Agency Pickup Address State,Leasing Agency Pickup Address Zip,Leasing Pick Up Office Hours,Digital Application,Digital Application URL,Paper Application,Paper Application URL,Referral Opportunity,Can applications be mailed in?,Can applications be picked up?,Can applications be dropped off?,Postmark,Additional Application Submission Notes,Application Due Date,Application Due Time,Open House,Partners Who Have Access', + 'Listing Id,Created At Date,Jurisdiction,Listing Name,Listing Status,Publish Date,Last Updated,Copy or Original,Copied From,Housing Provider,Building Street Address,Building City,Building State,Building Zip,Building Neighborhood,Building Year Built,Reserved Community Types,Latitude,Longitude,Number of Units,Listing Availability,Review Order,Lottery Date,Lottery Start,Lottery End,Lottery Notes,Housing Preferences,Housing Programs,Application Fee,Deposit Helper Text,Deposit Type,Deposit Value,Deposit Min,Deposit Max,Costs Not Included,Property Amenities,Additional Accessibility,Unit Amenities,Pets Policy,Services Offered,Smoking Policy,Eligibility Rules - Credit History,Eligibility Rules - Rental History,Eligibility Rules - Criminal Background,Eligibility Rules - Rental Assistance,Building Selection Criteria,Important Program Rules,Required Documents,Special Notes,Waitlist,Marketing Status,Marketing Month,Marketing Year,Leasing Agent Name,Leasing Agent Email,Leasing Agent Phone,Leasing Agent Title,Leasing Agent Office Hours,Leasing Agent Street Address,Leasing Agent Apt/Unit #,Leasing Agent City,Leasing Agent State,Leasing Agent Zip,Leasing Agency Mailing Address,Leasing Agency Mailing Address Street 2,Leasing Agency Mailing Address City,Leasing Agency Mailing Address State,Leasing Agency Mailing Address Zip,Leasing Agency Pickup Address,Leasing Agency Pickup Address Street 2,Leasing Agency Pickup Address City,Leasing Agency Pickup Address State,Leasing Agency Pickup Address Zip,Leasing Pick Up Office Hours,Digital Application,Digital Application URL,Paper Application,Paper Application URL,Referral Opportunity,Can applications be mailed in?,Can applications be picked up?,Can applications be dropped off?,Postmark,Additional Application Submission Notes,Application Due Date,Application Due Time,Open House,Partners Who Have Access', ); // Validate first row expect(content).toContain( - '"listing1-ID","10-02-2025 11:38:19AM PDT","jurisdiction-Name","listing1-Name","Public","10-02-2025 11:38:19AM PDT","10-02-2025 11:38:19AM PDT","Original",,"developer","123 main st","Bloomington","BL","01234","neighborhood","2025",,"latitude","longitude","Available Units",,"10-02-2025","11:38AM PDT","01:38PM PDT","lottery note",,"$45","sample deposit helper text","$12","$120",,,,,"sample costs not included","sample amenities","sample accessibility","sample unit amenities","sample smoking policy","sample pet policy","sample services offered",,,,,,,,,"No","Name of leasing agent","Email of leasing agent",,"Title of leasing agent","office hours","321 main st",,"Bloomington","BL","01234","456 main st",,"Bloomington","BL","01234","789 main st",,"Bloomington","BL","01234",,"No",,"No",,"No","No","No","No",,,"10-02-2025","11:38AM PDT",,"userFirst userLast"', + '"listing1-ID","10-02-2025 11:38:19AM PDT","jurisdiction-Name","listing1-Name","Public","10-02-2025 11:38:19AM PDT","10-02-2025 11:38:19AM PDT","Original",,"developer","123 main st","Bloomington","BL","01234","neighborhood","2025",,"100.5","200.5","1","Available Units",,"10-02-2025","11:38AM PDT","01:38PM PDT","lottery note",,,"$45","sample deposit helper text",,,"$12","$120","sample costs not included","sample amenities","sample accessibility","sample unit amenities","sample pet policy","sample services offered","sample smoking policy",,,,,"https://res.cloudinary.com/exygy/image/upload/buildingSelectionCriteriaFileId.pdf",,,,"No","Under Construction","July","2025","Name of leasing agent","Email of leasing agent",,"Title of leasing agent","office hours","321 main st",,"Bloomington","BL","01234","456 main st",,"Bloomington","BL","01234","789 main st",,"Bloomington","BL","01234",,"No",,"No",,"No","No","No","No",,,"10-02-2025","11:38AM PDT",,"userFirst userLast"', ); }); it.todo('should create the listing csv with feature flagged columns'); diff --git a/api/test/unit/services/listing.service.spec.ts b/api/test/unit/services/listing.service.spec.ts index d8c97bfcdb..3c5b44af32 100644 --- a/api/test/unit/services/listing.service.spec.ts +++ b/api/test/unit/services/listing.service.spec.ts @@ -48,6 +48,7 @@ import { FeatureFlagEnum } from '../../../src/enums/feature-flags/feature-flags- import { ApplicationService } from '../../../src/services/application.service'; import { GeocodingService } from '../../../src/services/geocoding.service'; import { FilterAvailabilityEnum } from '../../../src/enums/listings/filter-availability-enum'; +import { CronJobService } from '../../../src/services/cron-job.service'; /* generates a super simple mock listing for us to test logic with @@ -241,6 +242,7 @@ describe('Testing listing service', () => { const afsMock = { process: jest.fn().mockResolvedValue(true), + processDuplicates: jest.fn().mockResolvedValue(true), }; beforeAll(async () => { @@ -283,6 +285,7 @@ describe('Testing listing service', () => { ConfigService, Logger, SchedulerRegistry, + CronJobService, ], imports: [HttpModule], }).compile(); @@ -290,6 +293,7 @@ describe('Testing listing service', () => { service = module.get(ListingService); prisma = module.get(PrismaService); config = module.get(ConfigService); + process.env.APPLICATION_DAYS_TILL_EXPIRY = null; }); afterAll(() => { @@ -467,6 +471,10 @@ describe('Testing listing service', () => { listingsLeasingAgentAddress: exampleAddress, listingsBuildingSelectionCriteriaFile: exampleAsset, listingsResult: exampleAsset, + marketingFlyer: 'https://example.com/marketing-flyer.pdf', + accessibleMarketingFlyer: undefined, + listingsMarketingFlyerFile: null, + listingsAccessibleMarketingFlyerFile: exampleAsset, listingEvents: [ { type: ListingEventsTypeEnum.openHouse, @@ -573,6 +581,12 @@ describe('Testing listing service', () => { parksAndCommunityCenters: 'parks', schools: 'schools', publicTransportation: 'public transportation', + busStops: 'bus stops', + hospitals: 'hospitals', + playgrounds: 'playgrounds', + recreationalFacilities: 'recreational facilities', + seniorCenters: 'senior centers', + shoppingVenues: 'shopping venues', }, marketingType: undefined, }; @@ -602,7 +616,7 @@ describe('Testing listing service', () => { listingsBuildingAddress: true, requestedChangesUser: true, reservedCommunityTypes: true, - + requiredDocumentsList: true, listingImages: { include: { assets: true, @@ -627,6 +641,8 @@ describe('Testing listing service', () => { }, }, listingsBuildingSelectionCriteriaFile: true, + listingsMarketingFlyerFile: true, + listingsAccessibleMarketingFlyerFile: true, listingEvents: { include: { assets: true, @@ -2367,6 +2383,7 @@ describe('Testing listing service', () => { listingsBuildingAddress: true, requestedChangesUser: true, reservedCommunityTypes: true, + requiredDocumentsList: true, lastUpdatedByUser: true, listingImages: { include: { @@ -2391,6 +2408,8 @@ describe('Testing listing service', () => { }, }, listingsBuildingSelectionCriteriaFile: true, + listingsMarketingFlyerFile: true, + listingsAccessibleMarketingFlyerFile: true, listingEvents: { include: { assets: true, @@ -2884,6 +2903,7 @@ describe('Testing listing service', () => { requestedChangesUser: true, lastUpdatedByUser: true, reservedCommunityTypes: true, + requiredDocumentsList: true, listingImages: { include: { assets: true, @@ -2907,6 +2927,8 @@ describe('Testing listing service', () => { }, }, listingsBuildingSelectionCriteriaFile: true, + listingsMarketingFlyerFile: true, + listingsAccessibleMarketingFlyerFile: true, listingEvents: { include: { assets: true, @@ -3183,10 +3205,13 @@ describe('Testing listing service', () => { listingsApplicationMailingAddress: true, listingsBuildingAddress: true, listingsBuildingSelectionCriteriaFile: true, + listingsMarketingFlyerFile: true, + listingsAccessibleMarketingFlyerFile: true, listingsLeasingAgentAddress: true, listingsResult: true, requestedChangesUser: true, reservedCommunityTypes: true, + requiredDocumentsList: true, units: { include: { amiChart: { @@ -3308,10 +3333,13 @@ describe('Testing listing service', () => { listingsApplicationMailingAddress: true, listingsBuildingAddress: true, listingsBuildingSelectionCriteriaFile: true, + listingsMarketingFlyerFile: true, + listingsAccessibleMarketingFlyerFile: true, listingsLeasingAgentAddress: true, listingsResult: true, requestedChangesUser: true, reservedCommunityTypes: true, + requiredDocumentsList: true, units: { include: { amiChart: { @@ -3434,6 +3462,12 @@ describe('Testing listing service', () => { ...exampleAsset, }, }, + listingsMarketingFlyerFile: undefined, + listingsAccessibleMarketingFlyerFile: { + create: { + ...exampleAsset, + }, + }, listingUtilities: { create: { water: false, @@ -3488,6 +3522,12 @@ describe('Testing listing service', () => { parksAndCommunityCenters: 'parks', schools: 'schools', publicTransportation: 'public transportation', + busStops: 'bus stops', + hospitals: 'hospitals', + playgrounds: 'playgrounds', + recreationalFacilities: 'recreational facilities', + seniorCenters: 'senior centers', + shoppingVenues: 'shopping venues', }, }, jurisdictions: { @@ -3673,10 +3713,13 @@ describe('Testing listing service', () => { listingsApplicationMailingAddress: true, listingsBuildingAddress: true, listingsBuildingSelectionCriteriaFile: true, + listingsMarketingFlyerFile: true, + listingsAccessibleMarketingFlyerFile: true, listingsLeasingAgentAddress: true, listingsResult: true, requestedChangesUser: true, reservedCommunityTypes: true, + requiredDocumentsList: true, units: { include: { amiChart: { @@ -3812,10 +3855,13 @@ describe('Testing listing service', () => { listingsApplicationMailingAddress: true, listingsBuildingAddress: true, listingsBuildingSelectionCriteriaFile: true, + listingsMarketingFlyerFile: true, + listingsAccessibleMarketingFlyerFile: true, listingsLeasingAgentAddress: true, listingsResult: true, requestedChangesUser: true, reservedCommunityTypes: true, + requiredDocumentsList: true, units: { include: { amiChart: { @@ -3936,6 +3982,12 @@ describe('Testing listing service', () => { ...exampleAsset, }, }, + listingsMarketingFlyerFile: undefined, + listingsAccessibleMarketingFlyerFile: { + create: { + ...exampleAsset, + }, + }, listingUtilities: { create: { water: false, @@ -3990,6 +4042,12 @@ describe('Testing listing service', () => { parksAndCommunityCenters: 'parks', schools: 'schools', publicTransportation: 'public transportation', + busStops: 'bus stops', + hospitals: 'hospitals', + playgrounds: 'playgrounds', + recreationalFacilities: 'recreational facilities', + seniorCenters: 'senior centers', + shoppingVenues: 'shopping venues', }, }, jurisdictions: { @@ -4143,11 +4201,14 @@ describe('Testing listing service', () => { listingsApplicationPickUpAddress: true, listingsBuildingAddress: true, listingsBuildingSelectionCriteriaFile: true, + listingsMarketingFlyerFile: true, + listingsAccessibleMarketingFlyerFile: true, listingsApplicationMailingAddress: true, listingsLeasingAgentAddress: true, listingsResult: true, requestedChangesUser: true, reservedCommunityTypes: true, + requiredDocumentsList: true, units: { include: { amiChart: { @@ -4251,11 +4312,14 @@ describe('Testing listing service', () => { listingsApplicationPickUpAddress: true, listingsBuildingAddress: true, listingsBuildingSelectionCriteriaFile: true, + listingsMarketingFlyerFile: true, + listingsAccessibleMarketingFlyerFile: true, listingsApplicationMailingAddress: true, listingsLeasingAgentAddress: true, listingsResult: true, requestedChangesUser: true, reservedCommunityTypes: true, + requiredDocumentsList: true, units: { include: { amiChart: { @@ -4350,11 +4414,14 @@ describe('Testing listing service', () => { listingsApplicationPickUpAddress: true, listingsBuildingAddress: true, listingsBuildingSelectionCriteriaFile: true, + listingsMarketingFlyerFile: true, + listingsAccessibleMarketingFlyerFile: true, listingsApplicationMailingAddress: true, listingsLeasingAgentAddress: true, listingsResult: true, requestedChangesUser: true, reservedCommunityTypes: true, + requiredDocumentsList: true, units: { include: { amiChart: { @@ -4469,6 +4536,12 @@ describe('Testing listing service', () => { parksAndCommunityCenters: 'parks', schools: 'schools', publicTransportation: 'public transportation', + busStops: 'bus stops', + hospitals: 'hospitals', + playgrounds: 'playgrounds', + recreationalFacilities: 'recreational facilities', + seniorCenters: 'senior centers', + shoppingVenues: 'shopping venues', }; const calculatedUnitsAvailable = service.calculateUnitsAvailable( @@ -4775,11 +4848,14 @@ describe('Testing listing service', () => { listingsApplicationPickUpAddress: true, listingsBuildingAddress: true, listingsBuildingSelectionCriteriaFile: true, + listingsMarketingFlyerFile: true, + listingsAccessibleMarketingFlyerFile: true, listingsApplicationMailingAddress: true, listingsLeasingAgentAddress: true, listingsResult: true, requestedChangesUser: true, reservedCommunityTypes: true, + requiredDocumentsList: true, units: { include: { amiChart: { @@ -4839,6 +4915,12 @@ describe('Testing listing service', () => { listingsBuildingSelectionCriteriaFile: { disconnect: true, }, + listingsMarketingFlyerFile: { + disconnect: true, + }, + listingsAccessibleMarketingFlyerFile: { + disconnect: true, + }, listingNeighborhoodAmenities: { upsert: { create: { @@ -4848,6 +4930,12 @@ describe('Testing listing service', () => { pharmacies: null, publicTransportation: null, schools: null, + busStops: null, + hospitals: null, + playgrounds: null, + recreationalFacilities: null, + seniorCenters: null, + shoppingVenues: null, }, update: { groceryStores: null, @@ -4856,6 +4944,12 @@ describe('Testing listing service', () => { pharmacies: null, publicTransportation: null, schools: null, + busStops: null, + hospitals: null, + playgrounds: null, + recreationalFacilities: null, + seniorCenters: null, + shoppingVenues: null, }, where: { id: undefined, @@ -4899,6 +4993,9 @@ describe('Testing listing service', () => { id: 'example id', name: 'example name', }); + prisma.applications.updateMany = jest + .fn() + .mockResolvedValue({ count: 10 }); prisma.$transaction = jest .fn() .mockResolvedValue([{ id: 'example id', name: 'example name' }]); @@ -4999,6 +5096,12 @@ describe('Testing listing service', () => { pharmacies: null, publicTransportation: null, schools: null, + busStops: null, + hospitals: null, + playgrounds: null, + recreationalFacilities: null, + seniorCenters: null, + shoppingVenues: null, }, update: { groceryStores: null, @@ -5007,6 +5110,12 @@ describe('Testing listing service', () => { pharmacies: null, publicTransportation: null, schools: null, + busStops: null, + hospitals: null, + playgrounds: null, + recreationalFacilities: null, + seniorCenters: null, + shoppingVenues: null, }, where: { id: undefined, @@ -5022,6 +5131,12 @@ describe('Testing listing service', () => { listingsBuildingSelectionCriteriaFile: { disconnect: true, }, + listingsMarketingFlyerFile: { + disconnect: true, + }, + listingsAccessibleMarketingFlyerFile: { + disconnect: true, + }, unitsAvailable: 5, unitGroups: { create: [ @@ -5065,6 +5180,8 @@ describe('Testing listing service', () => { }, }); + expect(prisma.applications.updateMany).toHaveBeenCalledTimes(0); + expect(canOrThrowMock).toHaveBeenCalledWith( user, 'listing', @@ -5074,6 +5191,73 @@ describe('Testing listing service', () => { }, ); }); + + it('should process duplicates and expire applications on listing close', async () => { + jest.useFakeTimers().setSystemTime(new Date('2025-11-22T12:25:00.000Z')); + process.env.APPLICATION_DAYS_TILL_EXPIRY = '90'; + process.env.DUPLICATES_CLOSE_DATE = '2024-06-28 00:00 -08:00'; + const listingId = randomUUID(); + prisma.listings.findUnique = jest.fn().mockResolvedValue({ + id: listingId, + name: 'example name', + status: ListingsStatusEnum.active, + }); + prisma.listings.update = jest.fn().mockResolvedValue({ + id: listingId, + name: 'example name', + }); + prisma.listingEvents.findMany = jest.fn().mockResolvedValue([]); + prisma.listingEvents.update = jest.fn().mockResolvedValue({ + id: 'example id', + name: 'example name', + }); + prisma.assets.delete = jest.fn().mockResolvedValue({ + id: 'example id', + name: 'example name', + }); + prisma.$transaction = jest + .fn() + .mockResolvedValue([{ id: listingId, name: 'example name' }]); + prisma.jurisdictions.findFirst = jest.fn().mockResolvedValue(null); + + prisma.applications.updateMany = jest + .fn() + .mockResolvedValue({ count: 10 }); + + await service.update( + { + id: listingId, + name: 'example listing name', + depositMin: '5', + assets: [ + { + fileId: randomUUID(), + label: 'example asset', + }, + ], + jurisdictions: { + id: randomUUID(), + }, + status: ListingsStatusEnum.closed, + displayWaitlistSize: false, + unitsSummary: null, + listingEvents: [], + lastUpdatedByUser: user, + } as ListingUpdate, + user, + ); + + expect(afsMock.processDuplicates).toHaveBeenCalledWith(listingId); + expect(prisma.applications.updateMany).toHaveBeenCalledWith({ + data: { + expireAfter: new Date('2026-02-20T12:25:00.000Z'), + }, + where: { + listingId: listingId, + }, + }); + process.env.APPLICATION_DAYS_TILL_EXPIRY = null; + }); }); describe('Test listingApprovalNotify endpoint', () => { @@ -5226,7 +5410,7 @@ describe('Testing listing service', () => { }); describe('Test closeListings endpoint', () => { - it('should call the purge if no listings needed to get processed', async () => { + it('should call the purge if listings needed to get processed', async () => { prisma.listings.findMany = jest.fn().mockResolvedValue([ { id: 'example id1', @@ -5323,49 +5507,48 @@ describe('Testing listing service', () => { expect(prisma.cronJob.update).toHaveBeenCalled(); process.env.PROXY_URL = undefined; }); - }); - - describe('Test markCronJobAsStarted endpoint', () => { - it('should create new cronjob entry if none is present', async () => { - prisma.cronJob.findFirst = jest.fn().mockResolvedValue(null); - prisma.cronJob.create = jest.fn().mockResolvedValue(true); - await service.markCronJobAsStarted('LISTING_CRON_JOB'); - - expect(prisma.cronJob.findFirst).toHaveBeenCalledWith({ - where: { - name: 'LISTING_CRON_JOB', + it('should set expire_after on applications if APPLICATION_DAYS_TILL_EXPIRY', async () => { + jest.useFakeTimers().setSystemTime(new Date('2025-11-21T12:25:00.000Z')); + prisma.listings.findMany = jest.fn().mockResolvedValue([ + { + id: 'example id1', }, - }); - expect(prisma.cronJob.create).toHaveBeenCalledWith({ - data: { - lastRunDate: expect.anything(), - name: 'LISTING_CRON_JOB', + { + id: 'example id2', }, - }); - }); - - it('should update cronjob entry if one is present', async () => { + ]); + prisma.listings.updateMany = jest.fn().mockResolvedValue({ count: 2 }); + prisma.activityLog.createMany = jest.fn().mockResolvedValue({ count: 2 }); prisma.cronJob.findFirst = jest .fn() .mockResolvedValue({ id: randomUUID() }); prisma.cronJob.update = jest.fn().mockResolvedValue(true); + prisma.applications.updateMany = jest + .fn() + .mockResolvedValue({ count: 2 }); - await service.markCronJobAsStarted('LISTING_CRON_JOB'); + process.env.APPLICATION_DAYS_TILL_EXPIRY = '90'; + await service.closeListings(); - expect(prisma.cronJob.findFirst).toHaveBeenCalledWith({ + expect(prisma.applications.updateMany).toBeCalledTimes(2); + expect(prisma.applications.updateMany).toBeCalledWith({ + data: { + expireAfter: new Date('2026-02-19T12:25:00.000Z'), + }, where: { - name: 'LISTING_CRON_JOB', + listingId: 'example id1', }, }); - expect(prisma.cronJob.update).toHaveBeenCalledWith({ + expect(prisma.applications.updateMany).toBeCalledWith({ data: { - lastRunDate: expect.anything(), + expireAfter: new Date('2026-02-19T12:25:00.000Z'), }, where: { - id: expect.anything(), + listingId: 'example id2', }, }); + process.env.APPLICATION_DAYS_TILL_EXPIRY = null; }); }); diff --git a/api/test/unit/services/lottery.service.spec.ts b/api/test/unit/services/lottery.service.spec.ts index 83d3dfcbd8..4f6f0ac267 100644 --- a/api/test/unit/services/lottery.service.spec.ts +++ b/api/test/unit/services/lottery.service.spec.ts @@ -30,6 +30,7 @@ import { OrderByEnum } from '../../../src/enums/shared/order-by-enum'; import { LotteryService } from '../../../src/services/lottery.service'; import { ListingLotteryStatus } from '../../../src/dtos/listings/listing-lottery-status.dto'; import { permissionActions } from '../../../src/enums/permissions/permission-actions-enum'; +import { CronJobService } from '../../../src/services/cron-job.service'; const canOrThrowMock = jest.fn(); const lotteryReleasedMock = jest.fn(); @@ -79,6 +80,7 @@ describe('Testing lottery service', () => { Logger, SchedulerRegistry, GoogleTranslateService, + CronJobService, ], imports: [HttpModule], }).compile(); diff --git a/api/test/unit/services/multiselect-question.service.spec.ts b/api/test/unit/services/multiselect-question.service.spec.ts index dadfe8d1c7..4e27fd8939 100644 --- a/api/test/unit/services/multiselect-question.service.spec.ts +++ b/api/test/unit/services/multiselect-question.service.spec.ts @@ -1,20 +1,37 @@ +import { randomUUID } from 'crypto'; +import { Logger } from '@nestjs/common'; +import { SchedulerRegistry } from '@nestjs/schedule'; import { Test, TestingModule } from '@nestjs/testing'; -import { PrismaService } from '../../../src/services/prisma.service'; -import { MultiselectQuestionService } from '../../../src/services/multiselect-question.service'; -import { MultiselectQuestionCreate } from '../../../src/dtos/multiselect-questions/multiselect-question-create.dto'; -import { MultiselectQuestionUpdate } from '../../../src/dtos/multiselect-questions/multiselect-question-update.dto'; import { + ListingsStatusEnum, MultiselectQuestionsApplicationSectionEnum, MultiselectQuestionsStatusEnum, } from '@prisma/client'; +import MultiselectQuestion from '../../../src/dtos/multiselect-questions/multiselect-question.dto'; +import { MultiselectQuestionCreate } from '../../../src/dtos/multiselect-questions/multiselect-question-create.dto'; +import { MultiselectQuestionFilterParams } from '../../../src/dtos/multiselect-questions/multiselect-question-filter-params.dto'; import { MultiselectQuestionQueryParams } from '../../../src/dtos/multiselect-questions/multiselect-question-query-params.dto'; +import { MultiselectQuestionUpdate } from '../../../src/dtos/multiselect-questions/multiselect-question-update.dto'; import { Compare } from '../../../src/dtos/shared/base-filter.dto'; -import { randomUUID } from 'crypto'; +import { User } from '../../../src/dtos/users/user.dto'; +import { FeatureFlagEnum } from '../../../src/enums/feature-flags/feature-flags-enum'; +import { MultiselectQuestionOrderByKeys } from '../../../src/enums/multiselect-questions/order-by-enum'; +import { MultiselectQuestionViews } from '../../../src/enums/multiselect-questions/view-enum'; +import { OrderByEnum } from '../../../src/enums/shared/order-by-enum'; +import { MultiselectQuestionService } from '../../../src/services/multiselect-question.service'; +import { PermissionService } from '../../../src/services/permission.service'; +import { PrismaService } from '../../../src/services/prisma.service'; +import { CronJobService } from '../../../src/services/cron-job.service'; + +const user = new User(); +const canOrThrowMock = jest.fn(); export const mockMultiselectQuestion = ( position: number, date: Date, section?: MultiselectQuestionsApplicationSectionEnum, + enableV2MSQ = false, + status: MultiselectQuestionsStatusEnum = MultiselectQuestionsStatusEnum.visible, ) => { return { id: randomUUID(), @@ -29,7 +46,17 @@ export const mockMultiselectQuestion = ( hideFromListing: false, applicationSection: section ?? MultiselectQuestionsApplicationSectionEnum.programs, - jurisdiction: { name: `jurisdiction${position}`, id: randomUUID() }, + jurisdiction: { + name: `jurisdiction${position}`, + id: randomUUID(), + featureFlags: [ + { name: FeatureFlagEnum.enableV2MSQ, active: enableV2MSQ }, + ], + }, + isExclusive: enableV2MSQ ? true : false, + name: enableV2MSQ ? `name ${position}` : `text ${position}`, + status: enableV2MSQ ? status : MultiselectQuestionsStatusEnum.draft, + multiselectOptions: [], }; }; @@ -47,7 +74,19 @@ describe('Testing multiselect question service', () => { beforeAll(async () => { const module: TestingModule = await Test.createTestingModule({ - providers: [MultiselectQuestionService, PrismaService], + providers: [ + Logger, + CronJobService, + MultiselectQuestionService, + { + provide: PermissionService, + useValue: { + canOrThrow: canOrThrowMock, + }, + }, + PrismaService, + SchedulerRegistry, + ], }).compile(); service = module.get( @@ -60,6 +99,7 @@ describe('Testing multiselect question service', () => { it('should get records with empty param call to list()', async () => { const date = new Date(); const mockedValue = mockMultiselectQuestionSet(3, date); + prisma.multiselectQuestions.count = jest.fn().mockResolvedValue(3); prisma.multiselectQuestions.findMany = jest .fn() .mockResolvedValue(mockedValue); @@ -78,6 +118,11 @@ describe('Testing multiselect question service', () => { hideFromListing: false, applicationSection: MultiselectQuestionsApplicationSectionEnum.programs, + jurisdiction: { + id: mockedValue[0].jurisdiction.id, + name: 'jurisdiction0', + ordinal: undefined, + }, jurisdictions: [ { id: mockedValue[0].jurisdiction.id, @@ -85,6 +130,11 @@ describe('Testing multiselect question service', () => { ordinal: undefined, }, ], + multiselectOptions: [], + // Because enableMSQV2 is off + isExclusive: false, + name: 'text 0', + status: MultiselectQuestionsStatusEnum.draft, }, { id: mockedValue[1].id, @@ -99,6 +149,11 @@ describe('Testing multiselect question service', () => { hideFromListing: false, applicationSection: MultiselectQuestionsApplicationSectionEnum.programs, + jurisdiction: { + id: mockedValue[1].jurisdiction.id, + name: 'jurisdiction1', + ordinal: undefined, + }, jurisdictions: [ { id: mockedValue[1].jurisdiction.id, @@ -106,6 +161,11 @@ describe('Testing multiselect question service', () => { ordinal: undefined, }, ], + multiselectOptions: [], + // Because enableMSQV2 is off + isExclusive: false, + name: 'text 1', + status: MultiselectQuestionsStatusEnum.draft, }, { id: mockedValue[2].id, @@ -120,6 +180,11 @@ describe('Testing multiselect question service', () => { hideFromListing: false, applicationSection: MultiselectQuestionsApplicationSectionEnum.programs, + jurisdiction: { + id: mockedValue[2].jurisdiction.id, + name: 'jurisdiction2', + ordinal: undefined, + }, jurisdictions: [ { id: mockedValue[2].jurisdiction.id, @@ -127,27 +192,42 @@ describe('Testing multiselect question service', () => { ordinal: undefined, }, ], + multiselectOptions: [], + // Because enableMSQV2 is off + isExclusive: false, + name: 'text 2', + status: MultiselectQuestionsStatusEnum.draft, }, ]); expect(prisma.multiselectQuestions.findMany).toHaveBeenCalledWith({ include: { jurisdiction: true, + multiselectOptions: true, }, + skip: 0, where: { AND: [], }, }); }); - it('should get records with paramaterized call to list()', async () => { + it('should get records with parameterized call to list()', async () => { const date = new Date(); const mockedValue = mockMultiselectQuestionSet(3, date); + prisma.multiselectQuestions.count = jest.fn().mockResolvedValue(3); prisma.multiselectQuestions.findMany = jest .fn() .mockResolvedValue(mockedValue); const params: MultiselectQuestionQueryParams = { + view: MultiselectQuestionViews.base, + page: 1, + limit: 10, + orderBy: [MultiselectQuestionOrderByKeys.name], + orderDir: [OrderByEnum.ASC], + // Because enableMSQV2 is off + search: 'text', filter: [ { $comparison: Compare['='], @@ -171,6 +251,11 @@ describe('Testing multiselect question service', () => { hideFromListing: false, applicationSection: MultiselectQuestionsApplicationSectionEnum.programs, + jurisdiction: { + id: mockedValue[0].jurisdiction.id, + name: 'jurisdiction0', + ordinal: undefined, + }, jurisdictions: [ { id: mockedValue[0].jurisdiction.id, @@ -178,6 +263,11 @@ describe('Testing multiselect question service', () => { ordinal: undefined, }, ], + multiselectOptions: [], + // Because enableMSQV2 is off + isExclusive: false, + name: 'text 0', + status: MultiselectQuestionsStatusEnum.draft, }, { id: mockedValue[1].id, @@ -192,6 +282,11 @@ describe('Testing multiselect question service', () => { hideFromListing: false, applicationSection: MultiselectQuestionsApplicationSectionEnum.programs, + jurisdiction: { + id: mockedValue[1].jurisdiction.id, + name: 'jurisdiction1', + ordinal: undefined, + }, jurisdictions: [ { id: mockedValue[1].jurisdiction.id, @@ -199,6 +294,11 @@ describe('Testing multiselect question service', () => { ordinal: undefined, }, ], + multiselectOptions: [], + // Because enableMSQV2 is off + isExclusive: false, + name: 'text 1', + status: MultiselectQuestionsStatusEnum.draft, }, { id: mockedValue[2].id, @@ -213,6 +313,11 @@ describe('Testing multiselect question service', () => { hideFromListing: false, applicationSection: MultiselectQuestionsApplicationSectionEnum.programs, + jurisdiction: { + id: mockedValue[2].jurisdiction.id, + name: 'jurisdiction2', + ordinal: undefined, + }, jurisdictions: [ { id: mockedValue[2].jurisdiction.id, @@ -220,13 +325,30 @@ describe('Testing multiselect question service', () => { ordinal: undefined, }, ], + multiselectOptions: [], + // Because enableMSQV2 is off + isExclusive: false, + name: 'text 2', + status: MultiselectQuestionsStatusEnum.draft, }, ]); expect(prisma.multiselectQuestions.findMany).toHaveBeenCalledWith({ include: { jurisdiction: true, + listings: true, + multiselectOptions: true, }, + orderBy: [ + { + name: 'asc', + }, + { + name: 'asc', + }, + ], + skip: 0, + take: 10, where: { AND: [ { @@ -238,68 +360,222 @@ describe('Testing multiselect question service', () => { }, ], }, + { + name: { + contains: 'text', + mode: 'insensitive', + }, + }, ], }, }); }); + + it('should return first page if params are more than count', async () => { + const date = new Date(); + const mockedValue = mockMultiselectQuestionSet(5, date); + prisma.multiselectQuestions.count = jest.fn().mockResolvedValue(5); + prisma.multiselectQuestions.findMany = jest + .fn() + .mockResolvedValue(mockedValue); + + const params: MultiselectQuestionQueryParams = { + view: MultiselectQuestionViews.base, + page: 1, + limit: 3, + orderBy: [MultiselectQuestionOrderByKeys.name], + orderDir: [OrderByEnum.ASC], + }; + + await service.list(params); + + expect(prisma.multiselectQuestions.findMany).toHaveBeenCalledWith({ + include: { + jurisdiction: true, + listings: true, + multiselectOptions: true, + }, + orderBy: [ + { + name: 'asc', + }, + { + name: 'asc', + }, + ], + skip: 0, + take: 3, + where: { + AND: [], + }, + }); + + expect(prisma.multiselectQuestions.count).toHaveBeenCalledWith({ + where: { + AND: [], + }, + }); + }); + }); + + describe('buildWhere', () => { + it('should return a where clause for filter jurisdiction', () => { + const jurisdictionId = randomUUID(); + const filter = [ + { + $comparison: '=', + jurisdiction: jurisdictionId, + } as MultiselectQuestionFilterParams, + ]; + const whereClause = service.buildWhere({ filter: filter }); + + expect(whereClause).toStrictEqual({ + AND: [ + { + OR: [ + { + jurisdiction: { + id: { + equals: jurisdictionId, + }, + }, + }, + ], + }, + ], + }); + }); + + it('should return a where clause for filter status', () => { + const status = ListingsStatusEnum.active; + const filter = [ + { $comparison: '=', status: status } as MultiselectQuestionFilterParams, + ]; + const whereClause = service.buildWhere({ filter: filter }); + + expect(whereClause).toStrictEqual({ + AND: [ + { + OR: [ + { + status: { + equals: status, + }, + }, + ], + }, + ], + }); + }); + + it('should return a where clause for search', () => { + const search = 'searchName'; + + const whereClause = service.buildWhere({ search: search }); + + expect(whereClause).toStrictEqual({ + AND: [ + { + name: { + contains: search, + mode: 'insensitive', + }, + }, + ], + }); + }); }); describe('findOne', () => { it('should get record with call to findOne()', async () => { const date = new Date(); - const mockedValue = mockMultiselectQuestion(3, date); - prisma.multiselectQuestions.findFirst = jest + const mockedMultiselectQuestion = mockMultiselectQuestion(3, date); + prisma.multiselectQuestions.findUnique = jest .fn() - .mockResolvedValue(mockedValue); + .mockResolvedValue(mockedMultiselectQuestion); expect(await service.findOne('example Id')).toEqual({ - id: mockedValue.id, - createdAt: date, - updatedAt: date, - text: 'text 3', - subText: 'subText 3', - description: 'description 3', - links: [], - options: [], - optOutText: 'optOutText 3', - hideFromListing: false, - applicationSection: MultiselectQuestionsApplicationSectionEnum.programs, + ...mockedMultiselectQuestion, + jurisdiction: { + id: mockedMultiselectQuestion.jurisdiction.id, + name: 'jurisdiction3', + ordinal: undefined, + }, jurisdictions: [ { - id: mockedValue.jurisdiction.id, + id: mockedMultiselectQuestion.jurisdiction.id, name: 'jurisdiction3', ordinal: undefined, }, ], }); - expect(prisma.multiselectQuestions.findFirst).toHaveBeenCalledWith({ + expect(prisma.multiselectQuestions.findUnique).toHaveBeenCalledWith({ where: { - id: { - equals: 'example Id', + id: 'example Id', + }, + include: { + jurisdiction: true, + multiselectOptions: true, + }, + }); + }); + + it('should get record with call to findOne() with v2 enabled', async () => { + const date = new Date(); + const mockedMultiselectQuestion = mockMultiselectQuestion( + 3, + date, + MultiselectQuestionsApplicationSectionEnum.preferences, + true, + ); + prisma.multiselectQuestions.findUnique = jest + .fn() + .mockResolvedValue(mockedMultiselectQuestion); + + expect(await service.findOne('example Id')).toEqual({ + ...mockedMultiselectQuestion, + jurisdiction: { + id: mockedMultiselectQuestion.jurisdiction.id, + name: 'jurisdiction3', + ordinal: undefined, + }, + jurisdictions: [ + { + id: mockedMultiselectQuestion.jurisdiction.id, + name: 'jurisdiction3', + ordinal: undefined, }, + ], + }); + + expect(prisma.multiselectQuestions.findUnique).toHaveBeenCalledWith({ + where: { + id: 'example Id', }, include: { jurisdiction: true, + multiselectOptions: true, }, }); }); it('should error when nonexistent id is passed to findOne()', async () => { - prisma.multiselectQuestions.findFirst = jest.fn().mockResolvedValue(null); + prisma.multiselectQuestions.findUnique = jest + .fn() + .mockResolvedValue(null); await expect( async () => await service.findOne('example Id'), ).rejects.toThrowError(); - expect(prisma.multiselectQuestions.findFirst).toHaveBeenCalledWith({ + expect(prisma.multiselectQuestions.findUnique).toHaveBeenCalledWith({ where: { - id: { - equals: 'example Id', - }, + id: 'example Id', }, include: { jurisdiction: true, + multiselectOptions: true, }, }); }); @@ -308,106 +584,225 @@ describe('Testing multiselect question service', () => { describe('create', () => { it('should create with call to create()', async () => { const date = new Date(); - const mockedValue = mockMultiselectQuestion(3, date); + const mockedValue = mockMultiselectQuestion(1, date); prisma.multiselectQuestions.create = jest .fn() .mockResolvedValue(mockedValue); + prisma.jurisdictions.findFirstOrThrow = jest + .fn() + .mockResolvedValue(mockedValue.jurisdiction); const params: MultiselectQuestionCreate = { - text: 'text 4', - subText: 'subText 4', - description: 'description 4', + applicationSection: MultiselectQuestionsApplicationSectionEnum.programs, + description: 'description 1', + hideFromListing: false, + jurisdictions: [{ id: mockedValue.jurisdiction.id }], links: [], options: [], - optOutText: 'optOutText 4', - hideFromListing: false, - applicationSection: MultiselectQuestionsApplicationSectionEnum.programs, - jurisdictions: [{ id: 'jurisdiction id' }], + optOutText: 'optOutText 1', + status: MultiselectQuestionsStatusEnum.draft, + subText: 'subText 1', + text: 'text 1', }; - expect(await service.create(params)).toEqual({ + expect(await service.create(params, user)).toEqual({ + ...params, id: mockedValue.id, createdAt: date, updatedAt: date, - text: 'text 3', - subText: 'subText 3', - description: 'description 3', - links: [], - options: [], - optOutText: 'optOutText 3', - hideFromListing: false, - applicationSection: MultiselectQuestionsApplicationSectionEnum.programs, + jurisdiction: { + id: mockedValue.jurisdiction.id, + name: 'jurisdiction1', + ordinal: undefined, + }, jurisdictions: [ { id: mockedValue.jurisdiction.id, - name: 'jurisdiction3', + name: 'jurisdiction1', ordinal: undefined, }, ], + multiselectOptions: [], + // Because enableMSQV2 is off + isExclusive: false, + name: 'text 1', + status: MultiselectQuestionsStatusEnum.draft, }); + delete params['jurisdictions']; expect(prisma.multiselectQuestions.create).toHaveBeenCalledWith({ data: { - applicationSection: - MultiselectQuestionsApplicationSectionEnum.programs, - description: 'description 4', - isExclusive: false, - hideFromListing: false, - jurisdiction: { connect: { id: 'jurisdiction id' } }, - links: [], + ...params, + jurisdiction: { connect: { id: mockedValue.jurisdiction.id } }, multiselectOptions: undefined, - name: 'text 4', - options: [], - optOutText: 'optOutText 4', + // Because enableMSQV2 is off + isExclusive: false, + name: 'text 1', status: MultiselectQuestionsStatusEnum.draft, - subText: 'subText 4', - text: 'text 4', }, include: { jurisdiction: true, + multiselectOptions: true, + }, + }); + }); + + it('should create with call to create() with v2 enabled', async () => { + const date = new Date(); + const mockedMultiselectQuestion = mockMultiselectQuestion( + 2, + date, + MultiselectQuestionsApplicationSectionEnum.programs, + true, + ); + prisma.multiselectQuestions.create = jest + .fn() + .mockResolvedValue(mockedMultiselectQuestion); + + prisma.jurisdictions.findFirstOrThrow = jest + .fn() + .mockResolvedValue(mockedMultiselectQuestion.jurisdiction); + + const params: MultiselectQuestionCreate = { + applicationSection: MultiselectQuestionsApplicationSectionEnum.programs, + description: 'description 2', + isExclusive: true, + hideFromListing: false, + jurisdiction: { id: mockedMultiselectQuestion.jurisdiction.id }, + jurisdictions: undefined, + links: [], + multiselectOptions: [], + name: 'name 2', + options: [], + optOutText: 'optOutText 2', + status: MultiselectQuestionsStatusEnum.visible, + subText: 'subText 2', + text: 'text 2', + }; + + expect(await service.create(params, user)).toEqual({ + ...params, + id: mockedMultiselectQuestion.id, + createdAt: date, + updatedAt: date, + jurisdiction: { + id: mockedMultiselectQuestion.jurisdiction.id, + name: 'jurisdiction2', + ordinal: undefined, + }, + jurisdictions: [ + { + id: mockedMultiselectQuestion.jurisdiction.id, + name: 'jurisdiction2', + ordinal: undefined, + }, + ], + }); + + delete params['jurisdictions']; + expect(prisma.multiselectQuestions.create).toHaveBeenCalledWith({ + data: { + ...params, + jurisdiction: { + connect: { id: mockedMultiselectQuestion.jurisdiction.id }, + }, + multiselectOptions: { + createMany: { + data: [], + }, + }, + }, + include: { + jurisdiction: true, + multiselectOptions: true, }, }); }); + + it('should error invalid status is passed to create()', async () => { + const date = new Date(); + const mockedMultiselectQuestion = mockMultiselectQuestion( + 2, + date, + MultiselectQuestionsApplicationSectionEnum.programs, + true, + ); + prisma.multiselectQuestions.create = jest.fn().mockResolvedValue(null); + + prisma.jurisdictions.findFirstOrThrow = jest + .fn() + .mockResolvedValue(mockedMultiselectQuestion.jurisdiction); + + const params: MultiselectQuestionCreate = { + applicationSection: MultiselectQuestionsApplicationSectionEnum.programs, + description: 'description 2', + isExclusive: true, + hideFromListing: false, + jurisdiction: { id: mockedMultiselectQuestion.jurisdiction.id }, + jurisdictions: undefined, + links: [], + multiselectOptions: [], + name: 'name 2', + options: [], + optOutText: 'optOutText 2', + status: MultiselectQuestionsStatusEnum.active, + subText: 'subText 2', + text: 'text 2', + }; + + await expect( + async () => await service.create(params, user), + ).rejects.toThrowError("status must be 'draft' or 'visible' on create"); + }); }); describe('update', () => { it('should update with call to update()', async () => { const date = new Date(); + const mockedMultiselectQuestion = mockMultiselectQuestion(3, date); - const mockedMultiselectQuestions = mockMultiselectQuestion(3, date); - - prisma.multiselectQuestions.findFirst = jest + prisma.multiselectQuestions.findUnique = jest .fn() - .mockResolvedValue(mockedMultiselectQuestions); + .mockResolvedValue(mockedMultiselectQuestion); prisma.multiselectQuestions.update = jest.fn().mockResolvedValue({ - ...mockedMultiselectQuestions, + ...mockedMultiselectQuestion, text: '', applicationSection: MultiselectQuestionsApplicationSectionEnum.preferences, jurisdiction: { name: 'jurisdiction1', id: 'jurisdictionId' }, }); + prisma.$transaction = jest.fn().mockResolvedValue([ + { + ...mockedMultiselectQuestion, + text: '', + applicationSection: + MultiselectQuestionsApplicationSectionEnum.preferences, + jurisdiction: { name: 'jurisdiction1', id: 'jurisdictionId' }, + }, + ]); + + prisma.jurisdictions.findFirstOrThrow = jest.fn().mockResolvedValue({ + name: 'jurisdiction1', + id: 'jurisdictionId', + }); const params: MultiselectQuestionUpdate = { - id: mockedMultiselectQuestions.id, - jurisdictions: [{ name: 'jurisdiction1', id: 'jurisdictionId' }], - text: '', + id: mockedMultiselectQuestion.id, applicationSection: MultiselectQuestionsApplicationSectionEnum.preferences, + jurisdictions: [{ name: 'jurisdiction1', id: 'jurisdictionId' }], + status: MultiselectQuestionsStatusEnum.draft, + text: '', }; - expect(await service.update(params)).toEqual({ - id: mockedMultiselectQuestions.id, - createdAt: date, - updatedAt: date, - text: '', - subText: 'subText 3', - description: 'description 3', - links: [], - options: [], - optOutText: 'optOutText 3', - hideFromListing: false, - applicationSection: - MultiselectQuestionsApplicationSectionEnum.preferences, + expect(await service.update(params, user)).toEqual({ + ...mockedMultiselectQuestion, + ...params, + jurisdiction: { + id: 'jurisdictionId', + name: 'jurisdiction1', + ordinal: undefined, + }, jurisdictions: [ { id: 'jurisdictionId', @@ -417,89 +812,789 @@ describe('Testing multiselect question service', () => { ], }); - expect(prisma.multiselectQuestions.findFirst).toHaveBeenCalledWith({ + expect(prisma.multiselectQuestions.findUnique).toHaveBeenCalledWith({ include: { jurisdiction: true, + multiselectOptions: true, }, where: { - id: mockedMultiselectQuestions.id, + id: mockedMultiselectQuestion.id, }, }); + delete params['id']; + delete params['jurisdictions']; expect(prisma.multiselectQuestions.update).toHaveBeenCalledWith({ data: { - applicationSection: - MultiselectQuestionsApplicationSectionEnum.preferences, + ...params, isExclusive: false, links: undefined, jurisdiction: { connect: { id: 'jurisdictionId' } }, multiselectOptions: undefined, name: '', options: undefined, - text: '', }, where: { - id: mockedMultiselectQuestions.id, + id: mockedMultiselectQuestion.id, }, include: { jurisdiction: true, + multiselectOptions: true, }, }); }); - it('should error when nonexistent id is passed to update()', async () => { - prisma.multiselectQuestions.findFirst = jest.fn().mockResolvedValue(null); - prisma.multiselectQuestions.update = jest.fn().mockResolvedValue(null); + it('should update with call to update() with v2 enabled', async () => { + const date = new Date(); + const mockedMultiselectQuestion = mockMultiselectQuestion( + 4, + date, + MultiselectQuestionsApplicationSectionEnum.programs, + true, + ); - const params: MultiselectQuestionUpdate = { - id: 'example id', + prisma.multiselectQuestions.findUnique = jest + .fn() + .mockResolvedValue(mockedMultiselectQuestion); + prisma.multiselectQuestions.update = jest.fn().mockResolvedValue({ + ...mockedMultiselectQuestion, + applicationSection: + MultiselectQuestionsApplicationSectionEnum.preferences, + isExclusive: false, + jurisdiction: { name: 'jurisdiction1', id: 'jurisdictionId' }, + jurisdictions: undefined, + name: 'name change', + status: MultiselectQuestionsStatusEnum.draft, text: '', - jurisdictions: [], - applicationSection: MultiselectQuestionsApplicationSectionEnum.programs, - }; - - await expect( - async () => await service.update(params), - ).rejects.toThrowError(); - - expect(prisma.multiselectQuestions.findFirst).toHaveBeenCalledWith({ - include: { - jurisdiction: true, - }, - where: { - id: 'example id', - }, }); - }); + prisma.$transaction = jest.fn().mockResolvedValue([ + { + ...mockedMultiselectQuestion, + applicationSection: + MultiselectQuestionsApplicationSectionEnum.preferences, + isExclusive: false, + jurisdiction: { name: 'jurisdiction1', id: 'jurisdictionId' }, + jurisdictions: undefined, + name: 'name change', + status: MultiselectQuestionsStatusEnum.draft, + text: '', + }, + ]); + + prisma.jurisdictions.findFirstOrThrow = jest.fn().mockResolvedValue({ + name: 'jurisdiction1', + id: 'jurisdictionId', + featureFlags: [{ name: FeatureFlagEnum.enableV2MSQ, active: true }], + }); + + const params: MultiselectQuestionUpdate = { + id: mockedMultiselectQuestion.id, + applicationSection: + MultiselectQuestionsApplicationSectionEnum.preferences, + isExclusive: false, + jurisdiction: { name: 'jurisdiction1', id: 'jurisdictionId' }, + jurisdictions: undefined, + name: 'name change', + status: MultiselectQuestionsStatusEnum.draft, + text: '', + }; + + expect(await service.update(params, user)).toEqual({ + ...mockedMultiselectQuestion, + ...params, + jurisdiction: { + id: 'jurisdictionId', + name: 'jurisdiction1', + ordinal: undefined, + }, + jurisdictions: [ + { + id: 'jurisdictionId', + name: 'jurisdiction1', + ordinal: undefined, + }, + ], + }); + + expect(prisma.multiselectQuestions.findUnique).toHaveBeenCalledWith({ + include: { + jurisdiction: true, + multiselectOptions: true, + }, + where: { + id: mockedMultiselectQuestion.id, + }, + }); + + delete params['id']; + delete params['jurisdictions']; + expect(prisma.multiselectQuestions.update).toHaveBeenCalledWith({ + data: { + ...params, + links: undefined, + jurisdiction: { connect: { id: 'jurisdictionId' } }, + multiselectOptions: { + createMany: { + data: undefined, + }, + }, + options: undefined, + }, + where: { + id: mockedMultiselectQuestion.id, + }, + include: { + jurisdiction: true, + multiselectOptions: true, + }, + }); + }); + + it('should error when nonexistent id is passed to update()', async () => { + prisma.multiselectQuestions.findUnique = jest + .fn() + .mockResolvedValue(null); + prisma.multiselectQuestions.update = jest.fn().mockResolvedValue(null); + prisma.$transaction = jest.fn().mockResolvedValue([]); + + const params: MultiselectQuestionUpdate = { + id: 'example id', + applicationSection: MultiselectQuestionsApplicationSectionEnum.programs, + jurisdictions: [], + status: MultiselectQuestionsStatusEnum.draft, + text: '', + }; + + await expect( + async () => await service.update(params, user), + ).rejects.toThrowError(); + + expect(prisma.multiselectQuestions.findUnique).toHaveBeenCalledWith({ + include: { + jurisdiction: true, + multiselectOptions: true, + }, + where: { + id: 'example id', + }, + }); + }); }); - describe('update', () => { + describe('delete', () => { it('should delete with call to delete()', async () => { const date = new Date(); - const mockedValue = mockMultiselectQuestion(3, date); - prisma.multiselectQuestions.findFirst = jest + const mockedMultiselectQuestion = mockMultiselectQuestion(5, date); + prisma.multiselectQuestions.findUnique = jest .fn() - .mockResolvedValue(mockedValue); + .mockResolvedValue(mockedMultiselectQuestion); prisma.multiselectQuestions.delete = jest .fn() - .mockResolvedValue(mockedValue); + .mockResolvedValue(mockedMultiselectQuestion); + const id = mockedMultiselectQuestion.id; - expect(await service.delete('example Id')).toEqual({ + expect(await service.delete(id, user)).toEqual({ success: true, }); + expect(prisma.multiselectQuestions.findUnique).toHaveBeenCalledWith({ + include: { + jurisdiction: true, + multiselectOptions: true, + }, + where: { + id: id, + }, + }); + expect(prisma.multiselectQuestions.delete).toHaveBeenCalledWith({ where: { - id: 'example Id', + id: id, }, }); + }); + + it('should delete with call to delete() with v2 enabled', async () => { + const date = new Date(); + const mockedMultiselectQuestion = mockMultiselectQuestion( + 6, + date, + MultiselectQuestionsApplicationSectionEnum.preferences, + true, + ); + prisma.multiselectQuestions.findUnique = jest + .fn() + .mockResolvedValue(mockedMultiselectQuestion); + prisma.multiselectQuestions.delete = jest + .fn() + .mockResolvedValue(mockedMultiselectQuestion); + + const id = mockedMultiselectQuestion.id; + + expect(await service.delete(id, user)).toEqual({ + success: true, + }); - expect(prisma.multiselectQuestions.findFirst).toHaveBeenCalledWith({ + expect(prisma.multiselectQuestions.findUnique).toHaveBeenCalledWith({ include: { jurisdiction: true, + multiselectOptions: true, }, where: { - id: 'example Id', + id: id, + }, + }); + + expect(prisma.multiselectQuestions.delete).toHaveBeenCalledWith({ + where: { + id: id, + }, + }); + }); + }); + + describe('validateStatusStateTransition', () => { + describe('draft transitions', () => { + it('should allow draft to draft', () => { + expect( + service.validateStatusStateTransition( + MultiselectQuestionsStatusEnum.draft, + MultiselectQuestionsStatusEnum.draft, + ), + ).toBeNull; + }); + it('should allow draft to visible', () => { + expect( + service.validateStatusStateTransition( + MultiselectQuestionsStatusEnum.draft, + MultiselectQuestionsStatusEnum.visible, + ), + ).toBeNull; + }); + it('should error when moving draft to a state other than visible', () => { + expect(async () => + service.validateStatusStateTransition( + MultiselectQuestionsStatusEnum.draft, + MultiselectQuestionsStatusEnum.active, + ), + ).rejects.toThrowError(); + + expect(async () => + service.validateStatusStateTransition( + MultiselectQuestionsStatusEnum.retired, + MultiselectQuestionsStatusEnum.toRetire, + ), + ).rejects.toThrowError(); + + expect(async () => + service.validateStatusStateTransition( + MultiselectQuestionsStatusEnum.retired, + MultiselectQuestionsStatusEnum.retired, + ), + ).rejects.toThrowError(); + }); + }); + + describe('visible transitions', () => { + it('should allow visible to visible', () => { + expect( + service.validateStatusStateTransition( + MultiselectQuestionsStatusEnum.visible, + MultiselectQuestionsStatusEnum.visible, + ), + ).toBeNull; + }); + it('should allow visible to draft', () => { + expect( + service.validateStatusStateTransition( + MultiselectQuestionsStatusEnum.visible, + MultiselectQuestionsStatusEnum.draft, + ), + ).toBeNull; + }); + it('should allow visible to active', () => { + expect( + service.validateStatusStateTransition( + MultiselectQuestionsStatusEnum.visible, + MultiselectQuestionsStatusEnum.active, + ), + ).toBeNull; + }); + it('should error when moving visible to a state other than draft or active', () => { + expect(async () => + service.validateStatusStateTransition( + MultiselectQuestionsStatusEnum.visible, + MultiselectQuestionsStatusEnum.toRetire, + ), + ).rejects.toThrowError(); + + expect(async () => + service.validateStatusStateTransition( + MultiselectQuestionsStatusEnum.retired, + MultiselectQuestionsStatusEnum.retired, + ), + ).rejects.toThrowError(); + }); + }); + + describe('active transitions', () => { + it('should allow active to toRetire', () => { + expect( + service.validateStatusStateTransition( + MultiselectQuestionsStatusEnum.active, + MultiselectQuestionsStatusEnum.toRetire, + ), + ).toBeNull; + }); + it('should allow active to retired', () => { + expect( + service.validateStatusStateTransition( + MultiselectQuestionsStatusEnum.active, + MultiselectQuestionsStatusEnum.retired, + ), + ).toBeNull; + }); + it('should not allow active to active', () => { + expect(async () => + service.validateStatusStateTransition( + MultiselectQuestionsStatusEnum.active, + MultiselectQuestionsStatusEnum.active, + ), + ).rejects.toThrowError(); + }); + it('should error when moving active to a state other than toRetire or retired', () => { + expect(async () => + service.validateStatusStateTransition( + MultiselectQuestionsStatusEnum.active, + MultiselectQuestionsStatusEnum.draft, + ), + ).rejects.toThrowError(); + + expect(async () => + service.validateStatusStateTransition( + MultiselectQuestionsStatusEnum.active, + MultiselectQuestionsStatusEnum.visible, + ), + ).rejects.toThrowError(); + }); + }); + + describe('toRetire transitions', () => { + it('should allow toRetire to active', () => { + expect( + service.validateStatusStateTransition( + MultiselectQuestionsStatusEnum.toRetire, + MultiselectQuestionsStatusEnum.active, + ), + ).toBeNull; + }); + it('should allow toRetire to retired', () => { + expect( + service.validateStatusStateTransition( + MultiselectQuestionsStatusEnum.toRetire, + MultiselectQuestionsStatusEnum.retired, + ), + ).toBeNull; + }); + it('should not allow toRetire to toRetire', () => { + expect(async () => + service.validateStatusStateTransition( + MultiselectQuestionsStatusEnum.toRetire, + MultiselectQuestionsStatusEnum.toRetire, + ), + ).rejects.toThrowError(); + }); + it('should error when moving toRetire to a state other than active or retired', () => { + expect(async () => + service.validateStatusStateTransition( + MultiselectQuestionsStatusEnum.toRetire, + MultiselectQuestionsStatusEnum.draft, + ), + ).rejects.toThrowError(); + + expect(async () => + service.validateStatusStateTransition( + MultiselectQuestionsStatusEnum.toRetire, + MultiselectQuestionsStatusEnum.visible, + ), + ).rejects.toThrowError(); + }); + }); + + describe('retired transitions', () => { + it('should not allow retired to retired', () => { + expect(async () => + service.validateStatusStateTransition( + MultiselectQuestionsStatusEnum.retired, + MultiselectQuestionsStatusEnum.retired, + ), + ).rejects.toThrowError(); + }); + it('should error when moving retired to any state', () => { + expect(async () => + service.validateStatusStateTransition( + MultiselectQuestionsStatusEnum.retired, + MultiselectQuestionsStatusEnum.draft, + ), + ).rejects.toThrowError(); + + expect(async () => + service.validateStatusStateTransition( + MultiselectQuestionsStatusEnum.retired, + MultiselectQuestionsStatusEnum.visible, + ), + ).rejects.toThrowError(); + + expect(async () => + service.validateStatusStateTransition( + MultiselectQuestionsStatusEnum.retired, + MultiselectQuestionsStatusEnum.active, + ), + ).rejects.toThrowError(); + + expect(async () => + service.validateStatusStateTransition( + MultiselectQuestionsStatusEnum.retired, + MultiselectQuestionsStatusEnum.toRetire, + ), + ).rejects.toThrowError(); + }); + }); + }); + + describe('statusStateTransition', () => { + it('should update the status of a multiselectQuestion with a valid transition', async () => { + const date = new Date(); + const mockedMultiselectQuestion = mockMultiselectQuestion( + 1, + date, + MultiselectQuestionsApplicationSectionEnum.programs, + true, + ); + + prisma.multiselectQuestions.update = jest.fn().mockResolvedValue(null); + + expect( + await service.statusStateTransition( + mockedMultiselectQuestion as unknown as MultiselectQuestion, + MultiselectQuestionsStatusEnum.active, + ), + ).toBeNull; + + expect(prisma.multiselectQuestions.update).toHaveBeenCalledWith({ + data: { + status: MultiselectQuestionsStatusEnum.active, + }, + where: { + id: mockedMultiselectQuestion.id, + }, + }); + }); + + it('should error with an invalid transition', async () => { + const date = new Date(); + const mockedMultiselectQuestion = mockMultiselectQuestion( + 2, + date, + MultiselectQuestionsApplicationSectionEnum.programs, + true, + ); + + prisma.multiselectQuestions.update = jest.fn().mockResolvedValue(null); + + expect( + async () => + await service.statusStateTransition( + mockedMultiselectQuestion as unknown as MultiselectQuestion, + MultiselectQuestionsStatusEnum.retired, + ), + ).rejects.toThrowError(); + + expect(prisma.multiselectQuestions.update).not.toHaveBeenCalled(); + }); + }); + + describe('activateMany', () => { + it('should update status to active for multiselectQuestions in visible state', async () => { + const date = new Date(); + const mockedVisible = mockMultiselectQuestion( + 1, + date, + MultiselectQuestionsApplicationSectionEnum.programs, + true, + ); + const mockedActive = mockMultiselectQuestion( + 2, + date, + MultiselectQuestionsApplicationSectionEnum.programs, + true, + MultiselectQuestionsStatusEnum.active, + ); + + prisma.multiselectQuestions.update = jest.fn().mockResolvedValue(null); + + expect( + await service.activateMany([ + mockedVisible as unknown as MultiselectQuestion, + mockedActive as unknown as MultiselectQuestion, + ]), + ).toEqual({ + success: true, + }); + + expect(prisma.multiselectQuestions.update).toHaveBeenCalledTimes(1); + expect(prisma.multiselectQuestions.update).toHaveBeenCalledWith({ + data: { + status: MultiselectQuestionsStatusEnum.active, + }, + where: { + id: mockedVisible.id, + }, + }); + }); + }); + + describe('reActivate', () => { + it('should update status to active for a multiselectQuestion in toRetire state', async () => { + const date = new Date(); + const mockedMultiselectQuestion = mockMultiselectQuestion( + 1, + date, + MultiselectQuestionsApplicationSectionEnum.programs, + true, + MultiselectQuestionsStatusEnum.toRetire, + ); + + prisma.multiselectQuestions.findUnique = jest + .fn() + .mockResolvedValue(mockedMultiselectQuestion); + prisma.multiselectQuestions.update = jest.fn().mockResolvedValue(null); + + expect( + await service.reActivate(mockedMultiselectQuestion.id, user), + ).toEqual({ + success: true, + }); + + expect(prisma.multiselectQuestions.findUnique).toHaveBeenCalledWith({ + include: { + jurisdiction: true, + multiselectOptions: true, + }, + where: { + id: mockedMultiselectQuestion.id, + }, + }); + expect(prisma.multiselectQuestions.update).toHaveBeenCalledWith({ + data: { + status: MultiselectQuestionsStatusEnum.active, + }, + where: { + id: mockedMultiselectQuestion.id, + }, + }); + }); + + it('should error with an invalid transition', async () => { + const date = new Date(); + const mockedMultiselectQuestion = mockMultiselectQuestion( + 2, + date, + MultiselectQuestionsApplicationSectionEnum.programs, + true, + MultiselectQuestionsStatusEnum.active, + ); + + prisma.multiselectQuestions.findUnique = jest + .fn() + .mockResolvedValue(mockedMultiselectQuestion); + prisma.multiselectQuestions.update = jest.fn().mockResolvedValue(null); + + expect( + async () => + await service.reActivate(mockedMultiselectQuestion.id, user), + ).rejects.toThrowError(); + + expect(prisma.multiselectQuestions.findUnique).toHaveBeenCalledWith({ + include: { + jurisdiction: true, + multiselectOptions: true, + }, + where: { + id: mockedMultiselectQuestion.id, + }, + }); + expect(prisma.multiselectQuestions.update).not.toHaveBeenCalled(); + }); + }); + + describe('retire', () => { + it('should update status to retired for a multiselectQuestion with closed listings', async () => { + const date = new Date(); + const mockedMultiselectQuestion = mockMultiselectQuestion( + 1, + date, + MultiselectQuestionsApplicationSectionEnum.programs, + true, + MultiselectQuestionsStatusEnum.active, + ); + + prisma.multiselectQuestions.findUnique = jest.fn().mockResolvedValue({ + ...mockedMultiselectQuestion, + listings: [{ listings: { status: ListingsStatusEnum.closed } }], + }); + prisma.multiselectQuestions.update = jest.fn().mockResolvedValue(null); + + expect(await service.retire(mockedMultiselectQuestion.id, user)).toEqual({ + success: true, + }); + + expect(prisma.multiselectQuestions.findUnique).toHaveBeenCalledWith({ + include: { + jurisdiction: true, + listings: { + include: { + listings: { + select: { + status: true, + }, + }, + }, + }, + }, + where: { + id: mockedMultiselectQuestion.id, + }, + }); + expect(prisma.multiselectQuestions.update).toHaveBeenCalledWith({ + data: { + status: MultiselectQuestionsStatusEnum.retired, + }, + where: { + id: mockedMultiselectQuestion.id, + }, + }); + }); + + it('should update status to toRetire for a multiselectQuestion with active listings', async () => { + const date = new Date(); + const mockedMultiselectQuestion = mockMultiselectQuestion( + 1, + date, + MultiselectQuestionsApplicationSectionEnum.programs, + true, + MultiselectQuestionsStatusEnum.active, + ); + + prisma.multiselectQuestions.findUnique = jest.fn().mockResolvedValue({ + ...mockedMultiselectQuestion, + listings: [ + { listings: { status: ListingsStatusEnum.closed } }, + { listings: { status: ListingsStatusEnum.active } }, + ], + }); + prisma.multiselectQuestions.update = jest.fn().mockResolvedValue(null); + + expect(await service.retire(mockedMultiselectQuestion.id, user)).toEqual({ + success: true, + }); + + expect(prisma.multiselectQuestions.findUnique).toHaveBeenCalledWith({ + include: { + jurisdiction: true, + listings: { + include: { + listings: { + select: { + status: true, + }, + }, + }, + }, + }, + where: { + id: mockedMultiselectQuestion.id, + }, + }); + expect(prisma.multiselectQuestions.update).toHaveBeenCalledWith({ + data: { + status: MultiselectQuestionsStatusEnum.toRetire, + }, + where: { + id: mockedMultiselectQuestion.id, + }, + }); + }); + + it('should error with an invalid transition', async () => { + const date = new Date(); + const mockedMultiselectQuestion = mockMultiselectQuestion( + 2, + date, + MultiselectQuestionsApplicationSectionEnum.programs, + true, + ); + + prisma.multiselectQuestions.findUnique = jest.fn().mockResolvedValue({ + ...mockedMultiselectQuestion, + listings: [{ listings: { status: ListingsStatusEnum.closed } }], + }); + prisma.multiselectQuestions.update = jest.fn().mockResolvedValue(null); + + expect( + async () => await service.retire(mockedMultiselectQuestion.id, user), + ).rejects.toThrowError(); + + expect(prisma.multiselectQuestions.findUnique).toHaveBeenCalledWith({ + include: { + jurisdiction: true, + listings: { + include: { + listings: { + select: { + status: true, + }, + }, + }, + }, + }, + where: { + id: mockedMultiselectQuestion.id, + }, + }); + expect(prisma.multiselectQuestions.update).not.toHaveBeenCalled(); + }); + }); + + describe('retireMultiselectQuestions', () => { + it('should call updateMany', async () => { + prisma.cronJob.findFirst = jest + .fn() + .mockResolvedValue({ id: randomUUID() }); + prisma.cronJob.update = jest.fn().mockResolvedValue(true); + prisma.multiselectQuestions.updateMany = jest + .fn() + .mockResolvedValue({ count: 2 }); + + expect(await service.retireMultiselectQuestions()).toEqual({ + success: true, + }); + + expect(prisma.cronJob.findFirst).toHaveBeenCalled(); + expect(prisma.cronJob.update).toHaveBeenCalled(); + expect(prisma.multiselectQuestions.updateMany).toHaveBeenCalledWith({ + data: { + status: MultiselectQuestionsStatusEnum.retired, + }, + where: { + listings: { + every: { + listings: { + status: ListingsStatusEnum.closed, + }, + }, + }, + status: MultiselectQuestionsStatusEnum.toRetire, }, }); }); diff --git a/api/test/unit/services/translation.service.spec.ts b/api/test/unit/services/translation.service.spec.ts index 88ce00d6d4..9e3dfeb211 100644 --- a/api/test/unit/services/translation.service.spec.ts +++ b/api/test/unit/services/translation.service.spec.ts @@ -186,38 +186,6 @@ describe('Testing translations service', () => { mockConsoleWarn.mockRestore(); }); - it('Should fall back to english if language does not exist', async () => { - const jurisdictionId = randomUUID(); - const translations = { - id: 'translations id 1', - createdAt: new Date(), - updatedAt: new Date(), - language: LanguagesEnum.en, - jurisdictionId: jurisdictionId, - translations: { - translation1: 'translation 1', - translation2: 'translation 2', - }, - }; - // first call fails to find value so moves to the fallback - prisma.translations.findFirst = jest - .fn() - .mockResolvedValueOnce(null) - .mockResolvedValueOnce(translations); - - const result = - await service.getTranslationByLanguageAndJurisdictionOrDefaultEn( - LanguagesEnum.es, - jurisdictionId, - ); - - expect(prisma.translations.findFirst).toHaveBeenCalledTimes(2); - expect(result).toEqual(translations); - expect(mockConsoleWarn).toHaveBeenCalledWith( - `Fetching translations for es failed on jurisdiction ${jurisdictionId}, defaulting to english.`, - ); - }); - it('Should get unique translations by language and jurisdiction', async () => { const jurisdictionId = randomUUID(); const translations = { @@ -235,11 +203,10 @@ describe('Testing translations service', () => { .fn() .mockResolvedValueOnce(translations); - const result = - await service.getTranslationByLanguageAndJurisdictionOrDefaultEn( - LanguagesEnum.es, - jurisdictionId, - ); + const result = await service.getTranslationByLanguageAndJurisdiction( + LanguagesEnum.es, + jurisdictionId, + ); expect(result).toEqual(translations); expect(prisma.translations.findFirst).toHaveBeenCalledTimes(1); diff --git a/api/test/unit/services/user.service.spec.ts b/api/test/unit/services/user.service.spec.ts index f2fdf2a9f8..ce1cd28f1b 100644 --- a/api/test/unit/services/user.service.spec.ts +++ b/api/test/unit/services/user.service.spec.ts @@ -1,30 +1,35 @@ -import { Test, TestingModule } from '@nestjs/testing'; +import { Logger } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; -import { Request } from 'express'; -import { PrismaService } from '../../../src/services/prisma.service'; -import { UserService } from '../../../src/services/user.service'; -import { randomUUID } from 'crypto'; +import { SchedulerRegistry } from '@nestjs/schedule'; +import { Test, TestingModule } from '@nestjs/testing'; import { LanguagesEnum } from '@prisma/client'; +import { randomUUID } from 'crypto'; +import { Request } from 'express'; import { verify } from 'jsonwebtoken'; -import { passwordToHash } from '../../../src/utilities/password-helpers'; import { IdDTO } from '../../../src/dtos/shared/id.dto'; -import { EmailService } from '../../../src/services/email.service'; -import { TranslationService } from '../../../src/services/translation.service'; -import { JurisdictionService } from '../../../src/services/jurisdiction.service'; -import { GoogleTranslateService } from '../../../src/services/google-translate.service'; -import { SendGridService } from '../../../src/services/sendgrid.service'; import { User } from '../../../src/dtos/users/user.dto'; -import { PermissionService } from '../../../src/services/permission.service'; import { permissionActions } from '../../../src/enums/permissions/permission-actions-enum'; import { ModificationEnum } from '../../../src/enums/shared/modification-enum'; import { OrderByEnum } from '../../../src/enums/shared/order-by-enum'; import { UserViews } from '../../../src/enums/user/view-enum'; -import { Logger } from '@nestjs/common'; +import { ApplicationService } from '../../../src/services/application.service'; +import { CronJobService } from '../../../src/services/cron-job.service'; +import { EmailService } from '../../../src/services/email.service'; +import { GeocodingService } from '../../../src/services/geocoding.service'; +import { GoogleTranslateService } from '../../../src/services/google-translate.service'; +import { JurisdictionService } from '../../../src/services/jurisdiction.service'; +import { PermissionService } from '../../../src/services/permission.service'; +import { PrismaService } from '../../../src/services/prisma.service'; +import { SendGridService } from '../../../src/services/sendgrid.service'; +import { TranslationService } from '../../../src/services/translation.service'; +import { UserService } from '../../../src/services/user.service'; +import { passwordToHash } from '../../../src/utilities/password-helpers'; describe('Testing user service', () => { let service: UserService; let prisma: PrismaService; let emailService: EmailService; + let applicationService: ApplicationService; const mockUser = (position: number, date: Date) => { return { @@ -73,11 +78,16 @@ describe('Testing user service', () => { isConfigured: () => true, fetch: jest.fn(), }; + const LoggerServiceMock = { + warn: jest.fn(), + error: jest.fn(), + }; const canOrThrowMock = jest.fn(); beforeAll(async () => { const module: TestingModule = await Test.createTestingModule({ + imports: [], providers: [ UserService, PrismaService, @@ -86,7 +96,10 @@ describe('Testing user service', () => { SendGridService, TranslationService, JurisdictionService, - Logger, + ApplicationService, + GeocodingService, + SchedulerRegistry, + CronJobService, { provide: SendGridService, useValue: SendGridServiceMock, @@ -101,12 +114,23 @@ describe('Testing user service', () => { canOrThrow: canOrThrowMock, }, }, + { + provide: Logger, + useValue: LoggerServiceMock, + }, + { + provide: ApplicationService, + useValue: { + removePII: jest.fn(), + }, + }, ], }).compile(); service = module.get(UserService); prisma = module.get(PrismaService); emailService = module.get(EmailService); + applicationService = module.get(ApplicationService); }); afterEach(() => { @@ -1161,7 +1185,7 @@ describe('Testing user service', () => { }); describe('delete', () => { - it('should delete user', async () => { + it('should delete user without userRoles', async () => { const id = randomUUID(); prisma.userAccounts.findUnique = jest.fn().mockResolvedValue({ @@ -1174,6 +1198,53 @@ describe('Testing user service', () => { id, }); + await service.delete(id, { + id: 'requestingUser id', + userRoles: { isAdmin: true }, + } as unknown as User); + expect(prisma.userAccounts.findUnique).toHaveBeenCalledWith({ + where: { + id, + }, + include: { + jurisdictions: true, + userRoles: true, + }, + }); + expect(prisma.userAccounts.delete).toHaveBeenCalledWith({ + where: { + id, + }, + }); + expect(prisma.userRoles.delete).toHaveBeenCalledTimes(0); + + expect(canOrThrowMock).toHaveBeenCalledWith( + { + id: 'requestingUser id', + userRoles: { isAdmin: true }, + } as unknown as User, + 'user', + permissionActions.delete, + { + id, + }, + ); + }); + + it('should delete user with userRoles', async () => { + const id = randomUUID(); + + prisma.userAccounts.findUnique = jest.fn().mockResolvedValue({ + id, + userRoles: { isAdmin: true }, + }); + prisma.userAccounts.delete = jest.fn().mockResolvedValue({ + id, + }); + prisma.userRoles.delete = jest.fn().mockResolvedValue({ + id, + }); + await service.delete(id, { id: 'requestingUser id', userRoles: { isAdmin: true }, @@ -2278,11 +2349,56 @@ describe('Testing user service', () => { }); }); + it('should fail because user password is outdated', async () => { + const id = randomUUID(); + emailService.sendSingleUseCode = jest.fn(); + + prisma.userAccounts.findFirst = jest.fn().mockResolvedValue({ + id, + email: 'example@exygy.com', + passwordValidForDays: 100, + passwordUpdatedAt: new Date(0), + jurisdictions: [], + }); + prisma.jurisdictions.findFirst = jest.fn().mockResolvedValue({ + id, + allowSingleUseCodeLogin: true, + }); + prisma.userAccounts.update = jest.fn().mockResolvedValue({ + id, + }); + + await expect( + async () => + await service.requestSingleUseCode( + { + email: 'example@exygy.com', + }, + { headers: { jurisdictionname: 'juris 1' } } as unknown as Request, + ), + ).rejects.toThrowError( + `user ${id} attempted to login, but password is no longer valid`, + ); + + expect(prisma.userAccounts.findFirst).toHaveBeenCalledWith({ + where: { + email: 'example@exygy.com', + }, + include: { + jurisdictions: true, + }, + }); + expect(prisma.jurisdictions.findFirst).not.toHaveBeenCalled(); + expect(prisma.userAccounts.update).not.toHaveBeenCalled(); + expect(emailService.sendSingleUseCode).not.toHaveBeenCalled(); + }); + it('should request single use code but jurisdiction does not exist', async () => { const id = randomUUID(); emailService.sendSingleUseCode = jest.fn(); prisma.userAccounts.findFirst = jest.fn().mockResolvedValue({ id, + passwordUpdatedAt: new Date(), }); prisma.jurisdictions.findFirst = jest.fn().mockResolvedValue(null); prisma.userAccounts.update = jest.fn().mockResolvedValue({ @@ -2311,6 +2427,7 @@ describe('Testing user service', () => { select: { id: true, allowSingleUseCodeLogin: true, + name: true, }, where: { name: 'juris 1', @@ -2328,6 +2445,7 @@ describe('Testing user service', () => { emailService.sendSingleUseCode = jest.fn(); prisma.userAccounts.findFirst = jest.fn().mockResolvedValue({ id, + passwordUpdatedAt: new Date(), }); prisma.jurisdictions.findFirst = jest.fn().mockResolvedValue({ id, @@ -2370,10 +2488,12 @@ describe('Testing user service', () => { id, singleUseCode: '00000', singleUseCodeUpdatedAt: new Date(), + passwordUpdatedAt: new Date(), }); prisma.jurisdictions.findFirst = jest.fn().mockResolvedValue({ id, allowSingleUseCodeLogin: true, + name: 'juris 1', }); prisma.userAccounts.update = jest.fn().mockResolvedValue({ id, @@ -2398,6 +2518,7 @@ describe('Testing user service', () => { select: { id: true, allowSingleUseCodeLogin: true, + name: true, }, where: { name: 'juris 1', @@ -2424,6 +2545,7 @@ describe('Testing user service', () => { prisma.userAccounts.findFirst = jest.fn().mockResolvedValue({ id, singleUseCode: '00000', + passwordUpdatedAt: new Date(), singleUseCodeUpdatedAt: new Date( new Date().getTime() - Number(process.env.MFA_CODE_VALUE) * 2, ), @@ -2431,6 +2553,7 @@ describe('Testing user service', () => { prisma.jurisdictions.findFirst = jest.fn().mockResolvedValue({ id, allowSingleUseCodeLogin: true, + name: 'juris 1', }); prisma.userAccounts.update = jest.fn().mockResolvedValue({ id, @@ -2455,6 +2578,7 @@ describe('Testing user service', () => { select: { id: true, allowSingleUseCodeLogin: true, + name: true, }, where: { name: 'juris 1', @@ -2606,4 +2730,170 @@ describe('Testing user service', () => { expect(prisma.userAccounts.update).not.toHaveBeenCalledWith(); }); }); + + describe('deleteAfterInactivity', () => { + it('should not run if USERS_DAYS_TILL_EXPIRY does not exist', async () => { + process.env.USERS_DAYS_TILL_EXPIRY = null; + const response = await service.deleteAfterInactivity(); + expect(response).toEqual({ success: false }); + expect(LoggerServiceMock.warn).toBeCalledWith( + 'USERS_DAYS_TILL_EXPIRY variable is not set so deleteAfterInactivity will not run', + ); + }); + it('should not run if USERS_DAYS_TILL_EXPIRY is not a number', async () => { + process.env.USERS_DAYS_TILL_EXPIRY = 'not a number'; + const response = await service.deleteAfterInactivity(); + expect(response).toEqual({ success: false }); + expect(LoggerServiceMock.warn).toBeCalledWith( + 'USERS_DAYS_TILL_EXPIRY variable is not set so deleteAfterInactivity will not run', + ); + }); + it('should delete users for all inactive user', async () => { + // This test goes through all possible options + // has or has not been warned of deletion + // has or doesn't have applications + // has or doesn't have roles (public vs partner user) + prisma.userAccounts.findMany = jest.fn().mockResolvedValue([ + { id: 'userId1', wasWarnedOfDeletion: true }, + { id: 'userId2', wasWarnedOfDeletion: false }, + { id: 'userId3', wasWarnedOfDeletion: true }, + { + id: 'userId4', + wasWarnedOfDeletion: true, + userRoles: { isAdmin: true }, + }, + ]); + prisma.applications.findMany = jest + .fn() + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([{ id: 'application1' }, { id: 'application2' }]) + .mockResolvedValueOnce([ + { id: 'application3' }, + { id: 'application4' }, + ]); + + prisma.userRoles.delete = jest.fn().mockResolvedValue({}); + prisma.userAccounts.delete = jest.fn().mockResolvedValue({}); + process.env.USERS_DAYS_TILL_EXPIRY = '1095'; + const response = await service.deleteAfterInactivity(); + expect(response).toEqual({ success: true }); + expect(prisma.userAccounts.delete).toBeCalledWith({ + where: { id: 'userId1' }, + }); + expect(LoggerServiceMock.warn).toBeCalledWith( + 'Unable to delete user userId2 because they have not been warned by email', + ); + expect(prisma.userAccounts.delete).toBeCalledWith({ + where: { id: 'userId3' }, + }); + expect(applicationService.removePII).toBeCalledWith('application1'); + expect(applicationService.removePII).toBeCalledWith('application2'); + expect(prisma.userAccounts.delete).toBeCalledWith({ + where: { id: 'userId4' }, + }); + }); + }); + + describe('warnUserOfDeletionCronJob', () => { + it('should not run if USERS_DAYS_TILL_EXPIRY does not exist', async () => { + process.env.USERS_DAYS_TILL_EXPIRY = null; + const response = await service.warnUserOfDeletionCronJob(); + expect(response).toEqual({ success: false }); + expect(LoggerServiceMock.warn).toBeCalledWith( + 'USERS_DAYS_TILL_EXPIRY not set so warnUserOfDeletion cron job not run', + ); + }); + it('should not run if USERS_DAYS_TILL_EXPIRY is not a number', async () => { + process.env.USERS_DAYS_TILL_EXPIRY = 'not a number'; + const response = await service.warnUserOfDeletionCronJob(); + expect(response).toEqual({ success: false }); + expect(LoggerServiceMock.warn).toBeCalledWith( + 'USERS_DAYS_TILL_EXPIRY not set so warnUserOfDeletion cron job not run', + ); + }); + it('should send warn email for all expired public users', async () => { + process.env.USERS_DAYS_TILL_EXPIRY = '1095'; + jest.useFakeTimers().setSystemTime(new Date('2025-11-22T12:25:00.000Z')); + prisma.userAccounts.findMany = jest + .fn() + .mockResolvedValue([{ id: 'id1' }, { id: 'id2' }]); + prisma.userAccounts.update = jest.fn().mockResolvedValue({}); + prisma.cronJob.findFirst = jest.fn().mockResolvedValue({}); + prisma.cronJob.findFirst = jest.fn().mockResolvedValue({}); + prisma.cronJob.create = jest.fn().mockResolvedValue({}); + prisma.cronJob.update = jest.fn().mockResolvedValue({}); + emailService.warnOfAccountRemoval = jest.fn(); + const response = await service.warnUserOfDeletionCronJob(); + expect(prisma.userAccounts.findMany).toBeCalledWith({ + include: { + jurisdictions: true, + }, + where: { + lastLoginAt: { lte: new Date('2022-12-23T12:25:00.000Z') }, + userRoles: null, + wasWarnedOfDeletion: false, + }, + }); + expect(prisma.userAccounts.update).toBeCalledWith({ + data: { + wasWarnedOfDeletion: true, + }, + where: { + id: 'id1', + }, + }); + expect(prisma.userAccounts.update).toBeCalledWith({ + data: { + wasWarnedOfDeletion: true, + }, + where: { + id: 'id2', + }, + }); + expect(emailService.warnOfAccountRemoval).toBeCalledWith({ id: 'id1' }); + expect(emailService.warnOfAccountRemoval).toBeCalledWith({ id: 'id2' }); + expect(response).toEqual({ success: true }); + }); + it('should not update user if email failed', async () => { + process.env.USERS_DAYS_TILL_EXPIRY = '1095'; + jest.useFakeTimers().setSystemTime(new Date('2025-11-22T12:25:00.000Z')); + prisma.userAccounts.findMany = jest + .fn() + .mockResolvedValue([{ id: 'id1' }, { id: 'id2' }]); + prisma.userAccounts.update = jest.fn().mockResolvedValue({}); + prisma.cronJob.findFirst = jest.fn().mockResolvedValue({}); + prisma.cronJob.findFirst = jest.fn().mockResolvedValue({}); + prisma.cronJob.create = jest.fn().mockResolvedValue({}); + prisma.cronJob.update = jest.fn().mockResolvedValue({}); + emailService.warnOfAccountRemoval = jest + .fn() + .mockResolvedValueOnce({}) + .mockRejectedValue('error sending email'); + const response = await service.warnUserOfDeletionCronJob(); + expect(prisma.userAccounts.findMany).toBeCalledWith({ + include: { + jurisdictions: true, + }, + where: { + lastLoginAt: { lte: new Date('2022-12-23T12:25:00.000Z') }, + userRoles: null, + wasWarnedOfDeletion: false, + }, + }); + expect(emailService.warnOfAccountRemoval).toBeCalledTimes(2); + expect(prisma.userAccounts.update).toBeCalledTimes(1); + expect(prisma.userAccounts.update).toBeCalledWith({ + data: { + wasWarnedOfDeletion: true, + }, + where: { + id: 'id1', + }, + }); + expect(LoggerServiceMock.error).toBeCalledWith( + 'warnUserOfDeletion email failed for user id2', + ); + expect(response).toEqual({ success: true }); + }); + }); }); diff --git a/api/test/unit/utilities/build-order-by.spec.ts b/api/test/unit/utilities/build-order-by.spec.ts index 8904e97fb0..e6cdb191c5 100644 --- a/api/test/unit/utilities/build-order-by.spec.ts +++ b/api/test/unit/utilities/build-order-by.spec.ts @@ -1,115 +1,174 @@ import { ListingOrderByKeys } from '../../../src/enums/listings/order-by-enum'; +import { MultiselectQuestionOrderByKeys } from '../../../src/enums/multiselect-questions/order-by-enum'; import { OrderByEnum } from '../../../src/enums/shared/order-by-enum'; import { buildOrderBy, buildOrderByForListings, + buildOrderByForMultiselectQuestions, } from '../../../src/utilities/build-order-by'; -describe('Testing buildOrderByForListings', () => { - it('should return applicationDueDate in array when orderBy contains applicationDueDate', () => { +describe('Testing buildOrderBy', () => { + it('should return correctly mapped array when both arrays have the same length', () => { expect( - buildOrderByForListings( - [ListingOrderByKeys.applicationDates], - [OrderByEnum.ASC], - ), - ).toEqual([{ applicationDueDate: 'asc' }, { name: 'asc' }]); + buildOrderBy(['order1', 'order2'], [OrderByEnum.ASC, OrderByEnum.DESC]), + ).toEqual([{ order1: 'asc' }, { order2: 'desc' }]); }); - it('should return marketingType in array when orderBy contains marketingType', () => { - expect( - buildOrderByForListings( - [ListingOrderByKeys.marketingType], - [OrderByEnum.ASC], - ), - ).toEqual([{ marketingType: 'asc' }, { name: 'asc' }]); + it('should return empty array when both arrays are empty', () => { + expect(buildOrderBy([], [])).toEqual(undefined); }); - it('should return closedAt in array when orderBy contains mostRecentlyClosed', () => { - expect( - buildOrderByForListings( - [ListingOrderByKeys.mostRecentlyClosed], - [OrderByEnum.ASC], - ), - ).toEqual([{ closedAt: 'asc' }, { name: 'asc' }]); - }); + describe('Testing buildOrderByForListings', () => { + it('should return applicationDueDate in array when orderBy contains applicationDueDate', () => { + expect( + buildOrderByForListings( + [ListingOrderByKeys.applicationDates], + [OrderByEnum.ASC], + ), + ).toEqual([{ applicationDueDate: 'asc' }, { name: 'asc' }]); + }); - it('should return publishedAt in array when orderBy contains mostRecentlyPublished', () => { - expect( - buildOrderByForListings( - [ListingOrderByKeys.mostRecentlyPublished], - [OrderByEnum.ASC], - ), - ).toEqual([{ publishedAt: 'asc' }, { name: 'asc' }]); - }); + it('should return marketingType in array when orderBy contains marketingType', () => { + expect( + buildOrderByForListings( + [ListingOrderByKeys.marketingType], + [OrderByEnum.ASC], + ), + ).toEqual([{ marketingType: 'asc' }, { name: 'asc' }]); + }); - it('should return updatedAt in array when orderBy contains mostRecentlyUpdated', () => { - expect( - buildOrderByForListings( - [ListingOrderByKeys.mostRecentlyUpdated], - [OrderByEnum.ASC], - ), - ).toEqual([{ updatedAt: 'asc' }, { name: 'asc' }]); - }); + it('should return closedAt in array when orderBy contains mostRecentlyClosed', () => { + expect( + buildOrderByForListings( + [ListingOrderByKeys.mostRecentlyClosed], + [OrderByEnum.ASC], + ), + ).toEqual([{ closedAt: 'asc' }, { name: 'asc' }]); + }); - it('should return name in array when orderBy contains name', () => { - expect( - buildOrderByForListings([ListingOrderByKeys.name], [OrderByEnum.ASC]), - ).toEqual([{ name: 'asc' }, { name: 'asc' }]); - }); + it('should return publishedAt in array when orderBy contains mostRecentlyPublished', () => { + expect( + buildOrderByForListings( + [ListingOrderByKeys.mostRecentlyPublished], + [OrderByEnum.ASC], + ), + ).toEqual([{ publishedAt: 'asc' }, { name: 'asc' }]); + }); - it('should return marketingType in array when orderBy contains status', () => { - expect( - buildOrderByForListings([ListingOrderByKeys.status], [OrderByEnum.ASC]), - ).toEqual([{ status: 'asc' }, { name: 'asc' }]); - }); + it('should return updatedAt in array when orderBy contains mostRecentlyUpdated', () => { + expect( + buildOrderByForListings( + [ListingOrderByKeys.mostRecentlyUpdated], + [OrderByEnum.ASC], + ), + ).toEqual([{ updatedAt: 'asc' }, { name: 'asc' }]); + }); - it('should return unitsAvailable in array when orderBy contains unitsAvailable', () => { - expect( - buildOrderByForListings( - [ListingOrderByKeys.unitsAvailable], - [OrderByEnum.ASC], - ), - ).toEqual([{ unitsAvailable: 'asc' }, { name: 'asc' }]); - }); + it('should return name in array when orderBy contains name', () => { + expect( + buildOrderByForListings([ListingOrderByKeys.name], [OrderByEnum.ASC]), + ).toEqual([{ name: 'asc' }, { name: 'asc' }]); + }); - it('should return isWaitlistOpen in array when orderBy contains waitlistOpen', () => { - expect( - buildOrderByForListings( - [ListingOrderByKeys.waitlistOpen], - [OrderByEnum.ASC], - ), - ).toEqual([{ isWaitlistOpen: 'asc' }, { name: 'asc' }]); - }); + it('should return marketingType in array when orderBy contains status', () => { + expect( + buildOrderByForListings([ListingOrderByKeys.status], [OrderByEnum.ASC]), + ).toEqual([{ status: 'asc' }, { name: 'asc' }]); + }); - it('should return undefined when orderBy contains a value that is not an enum', () => { - expect(buildOrderByForListings(['order1'], [OrderByEnum.ASC])).toEqual([ - undefined, - { name: 'asc' }, - ]); - }); + it('should return unitsAvailable in array when orderBy contains unitsAvailable', () => { + expect( + buildOrderByForListings( + [ListingOrderByKeys.unitsAvailable], + [OrderByEnum.ASC], + ), + ).toEqual([{ unitsAvailable: 'asc' }, { name: 'asc' }]); + }); - it('should return undefined when arrays are of unequal length', () => { - expect( - buildOrderByForListings( - [ListingOrderByKeys.name], - [OrderByEnum.ASC, OrderByEnum.ASC], - ), - ).toEqual(undefined); - }); + it('should return isWaitlistOpen in array when orderBy contains waitlistOpen', () => { + expect( + buildOrderByForListings( + [ListingOrderByKeys.waitlistOpen], + [OrderByEnum.ASC], + ), + ).toEqual([{ isWaitlistOpen: 'asc' }, { name: 'asc' }]); + }); - it('should return undefined when both arrays are empty', () => { - expect(buildOrderByForListings([], [])).toEqual(undefined); - }); -}); + it('should return undefined when orderBy contains a value that is not an enum', () => { + expect(buildOrderByForListings(['order1'], [OrderByEnum.ASC])).toEqual([ + undefined, + { name: 'asc' }, + ]); + }); -describe('Testing buildOrderBy', () => { - it('should return correctly mapped array when both arrays have the same length', () => { - expect( - buildOrderBy(['order1', 'order2'], [OrderByEnum.ASC, OrderByEnum.DESC]), - ).toEqual([{ order1: 'asc' }, { order2: 'desc' }]); + it('should return undefined when arrays are of unequal length', () => { + expect( + buildOrderByForListings( + [ListingOrderByKeys.name], + [OrderByEnum.ASC, OrderByEnum.ASC], + ), + ).toEqual(undefined); + }); + + it('should return undefined when both arrays are empty', () => { + expect(buildOrderByForListings([], [])).toEqual(undefined); + }); }); - it('should return empty array when both arrays are empty', () => { - expect(buildOrderBy([], [])).toEqual(undefined); + describe('Testing buildOrderByForMultiselectQuestions', () => { + it('should return name in array when orderBy contains name', () => { + expect( + buildOrderByForMultiselectQuestions( + [MultiselectQuestionOrderByKeys.name], + [OrderByEnum.ASC], + ), + ).toEqual([{ name: 'asc' }, { name: 'asc' }]); + }); + + it('should return status in array when orderBy contains status', () => { + expect( + buildOrderByForMultiselectQuestions( + [MultiselectQuestionOrderByKeys.status], + [OrderByEnum.ASC], + ), + ).toEqual([{ status: 'asc' }, { name: 'asc' }]); + }); + + it('should return jurisdiction in array when orderBy contains jurisdiction', () => { + expect( + buildOrderByForMultiselectQuestions( + [MultiselectQuestionOrderByKeys.jurisdiction], + [OrderByEnum.ASC], + ), + ).toEqual([{ jurisdiction: { name: 'asc' } }, { name: 'asc' }]); + }); + + it('should return updatedAt in array when orderBy contains updatedAt', () => { + expect( + buildOrderByForMultiselectQuestions( + [MultiselectQuestionOrderByKeys.updatedAt], + [OrderByEnum.ASC], + ), + ).toEqual([{ updatedAt: 'asc' }, { name: 'asc' }]); + }); + + it('should return undefined when orderBy contains a value that is not an enum', () => { + expect( + buildOrderByForMultiselectQuestions(['order1'], [OrderByEnum.ASC]), + ).toEqual([undefined, { name: 'asc' }]); + }); + + it('should return undefined when arrays are of unequal length', () => { + expect( + buildOrderByForMultiselectQuestions( + [ListingOrderByKeys.name], + [OrderByEnum.ASC, OrderByEnum.ASC], + ), + ).toEqual(undefined); + }); + + it('should return undefined when both arrays are empty', () => { + expect(buildOrderByForMultiselectQuestions([], [])).toEqual(undefined); + }); }); }); diff --git a/api/test/unit/utilities/unit-group-transformations.spec.ts b/api/test/unit/utilities/unit-group-transformations.spec.ts index 89b7e6bc96..f39ce48abd 100644 --- a/api/test/unit/utilities/unit-group-transformations.spec.ts +++ b/api/test/unit/utilities/unit-group-transformations.spec.ts @@ -1114,5 +1114,149 @@ describe('Unit Group Transformations', () => { }); expect(result.householdMaxIncomeSummary.rows).toHaveLength(2); }); + + it('should currently summarize unit groups for non-regulated listing', () => { + const unitGroups: UnitGroup[] = [ + { + id: 'e8ebae55-103d-4a38-ab57-a4d974a11075', + createdAt: new Date(), + updatedAt: new Date(), + maxOccupancy: 2, + minOccupancy: 1, + flatRentValueFrom: null, + flatRentValueTo: null, + monthlyRent: 3000, + floorMin: null, + floorMax: null, + totalCount: 4, + totalAvailable: 3, + bathroomMin: 1, + bathroomMax: 1, + openWaitlist: false, + sqFeetMin: null, + sqFeetMax: null, + rentType: 'fixedRent', + unitGroupAmiLevels: [], + unitTypes: [ + { + id: 'f70e3cfe-80d3-4e9c-88e0-0b8e4c587b17', + createdAt: new Date(), + updatedAt: new Date(), + name: 'studio', + numBedrooms: 0, + }, + ], + }, + { + id: '2d758f88-eadb-4cc6-ab01-912955455b22', + createdAt: new Date(), + updatedAt: new Date(), + maxOccupancy: 4, + minOccupancy: 2, + flatRentValueFrom: 1500, + flatRentValueTo: 2200, + monthlyRent: null, + floorMin: null, + floorMax: null, + totalCount: 3, + totalAvailable: 2, + bathroomMin: 1, + bathroomMax: 2, + openWaitlist: false, + sqFeetMin: null, + sqFeetMax: null, + rentType: 'rentRange', + unitGroupAmiLevels: [], + unitTypes: [ + { + id: 'ccc0dc7c-2e2b-4ed7-b7ec-a74a7d48d373', + createdAt: new Date(), + updatedAt: new Date(), + name: 'twoBdrm', + numBedrooms: 2, + }, + { + id: '1e371562-572a-4de2-9734-065608be1073', + createdAt: new Date(), + updatedAt: new Date(), + name: 'fourBdrm', + numBedrooms: 4, + }, + ], + }, + ]; + + const result = summarizeUnitGroups(unitGroups, [], true); + + expect(result).toHaveProperty('unitGroupSummary'); + expect(result).toHaveProperty('householdMaxIncomeSummary'); + + expect(result.unitGroupSummary).toHaveLength(2); + const testUnitType = result.unitGroupSummary; + expect(testUnitType[0]).toEqual( + expect.objectContaining({ + unitTypes: expect.arrayContaining([ + expect.objectContaining({ + name: 'studio', + numBedrooms: 0, + }), + ]), + rentRange: { + min: '$3000', + max: '$3000', + }, + openWaitlist: false, + unitVacancies: 3, + bathroomRange: { + min: 1, + max: 1, + }, + floorRange: { + min: null, + max: null, + }, + sqFeetRange: { + min: null, + max: null, + }, + }), + ); + + expect(testUnitType[1]).toEqual( + expect.objectContaining({ + unitTypes: expect.arrayContaining([ + expect.objectContaining({ + name: 'twoBdrm', + numBedrooms: 2, + }), + expect.objectContaining({ + name: 'fourBdrm', + numBedrooms: 4, + }), + ]), + openWaitlist: false, + unitVacancies: 2, + bathroomRange: { + min: 1, + max: 2, + }, + floorRange: { + min: null, + max: null, + }, + sqFeetRange: { + min: null, + max: null, + }, + }), + ); + + expect(result.householdMaxIncomeSummary).toMatchObject({ + columns: { + householdSize: 'householdSize', + }, + rows: [], + }); + }); }); }); diff --git a/api/test/unit/validation-pipes/listing-create-update-pipe.spec.ts b/api/test/unit/validation-pipes/listing-create-update-pipe.spec.ts index da3918aece..4c9d9c381f 100644 --- a/api/test/unit/validation-pipes/listing-create-update-pipe.spec.ts +++ b/api/test/unit/validation-pipes/listing-create-update-pipe.spec.ts @@ -132,6 +132,7 @@ describe('ListingCreateUpdateValidationPipe', () => { const expectedTransformedValue = { ...value, + minimumImagesRequired: 0, units: [], unitGroups: [], requiredFields: ['name', 'listingsBuildingAddress'], @@ -142,7 +143,10 @@ describe('ListingCreateUpdateValidationPipe', () => { expect(mockPrisma.jurisdictions.findFirst).toHaveBeenCalledWith({ where: { id: jurisdictionId }, - select: { requiredListingFields: true }, + select: { + requiredListingFields: true, + minimumListingPublishImagesRequired: true, + }, }); expect(mockSuperTransform).toHaveBeenCalledWith( @@ -180,6 +184,7 @@ describe('ListingCreateUpdateValidationPipe', () => { const expectedTransformedValue = { ...value, + minimumImagesRequired: 0, units: [], unitGroups: [], requiredFields: expectedDefaultFields, @@ -223,6 +228,7 @@ describe('ListingCreateUpdateValidationPipe', () => { const expectedTransformedValue = { ...value, + minimumImagesRequired: 0, units: [], unitGroups: [], requiredFields: expectedDefaultFields, @@ -264,6 +270,7 @@ describe('ListingCreateUpdateValidationPipe', () => { const expectedTransformedValue = { ...value, + minimumImagesRequired: 0, units: [], unitGroups: [], requiredFields: expectedDefaultFields, @@ -301,6 +308,7 @@ describe('ListingCreateUpdateValidationPipe', () => { const expectedTransformedValue = { ...value, + minimumImagesRequired: 0, units: [], unitGroups: [], requiredFields: ['name', 'leasingAgentEmail', 'digitalApplication'], @@ -368,6 +376,7 @@ describe('ListingCreateUpdateValidationPipe', () => { const expectedTransformedValue = { ...value, + minimumImagesRequired: 0, units: [{ id: 'id1' }], unitGroups: [], requiredFields: ['name'], @@ -404,6 +413,7 @@ describe('ListingCreateUpdateValidationPipe', () => { ...value, units: [], unitGroups: [{ id: 'id1' }], + minimumImagesRequired: 0, requiredFields: ['name'], }; mockSuperTransform.mockResolvedValue(expectedTransformedValue); diff --git a/api/yarn.lock b/api/yarn.lock index 1b686b7c1d..aa06a1e0ee 100644 --- a/api/yarn.lock +++ b/api/yarn.lock @@ -5328,12 +5328,14 @@ form-data@^2.5.5: safe-buffer "^5.2.1" form-data@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/form-data/-/form-data-4.0.0.tgz#93919daeaf361ee529584b9b31664dc12c9fa452" - integrity sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww== + version "4.0.5" + resolved "https://registry.yarnpkg.com/form-data/-/form-data-4.0.5.tgz#b49e48858045ff4cbf6b03e1805cebcad3679053" + integrity sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w== dependencies: asynckit "^0.4.0" combined-stream "^1.0.8" + es-set-tostringtag "^2.1.0" + hasown "^2.0.2" mime-types "^2.1.12" formdata-polyfill@^4.0.10: diff --git a/docker-compose.yml b/docker-compose.yml index 7ffdd693ac..5c5c33ebfa 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -69,7 +69,7 @@ services: POSTGRES_PASSWORD: "example" POSTGRES_DB: "bloom_prisma" healthcheck: - test: ["CMD-SHELL", "PGPASSWORD=example", "psql", "--host=127.0.0.1", "--port=5432", "--username=postgres", "--command='SELECT 1;'"] + test: ["CMD-SHELL", "PGPASSWORD=example psql --host=127.0.0.1 --port=5432 --username=postgres --command='SELECT 1;'"] interval: "2s" timeout: "1s" retries: 20 @@ -107,10 +107,10 @@ services: DATABASE_URL: "postgres://postgres:example@db:5432/bloom_prisma" healthcheck: test: ["CMD", "curl", "--fail", "http://127.0.0.1:3100/"] - interval: "2s" - timeout: "1s" - retries: 20 - start_period: "1s" + interval: "5s" + timeout: "2s" + retries: 10 + start_period: "5s" depends_on: db: condition: service_healthy @@ -163,7 +163,7 @@ services: LANGUAGES: "en,es,zh,vi,tl" RTL_LANGUAGES: "ar" - IDLE_TIEMOUT: 5 # seconds + IDLE_TIMEMOUT: 5 # seconds CACHE_REVALIDATE: 30 # seconds SHOW_PUBLIC_LOTTERY: "TRUE" diff --git a/docker.md b/docker.md index e7ef4a0f1a..9d7522e13d 100644 --- a/docker.md +++ b/docker.md @@ -30,7 +30,7 @@ The following containers are defined in the [docker-compose.yml](./docker-compos - `dbseed`: runs a [db seed script](./api/Dockerfile.dbseed.dev). - `api`: the [api](./api). - `partners`: the [partners site](./sites/partners). -- `public`: the [public site](./sites/partners). +- `public`: the [public site](./sites/public). Build, start, and tear down containers with the following commands. Each command takes an optional list of containers to operate on. By default the command operates on all containers. diff --git a/infra/.dockerignore b/infra/.dockerignore new file mode 100644 index 0000000000..3256e5ddb4 --- /dev/null +++ b/infra/.dockerignore @@ -0,0 +1 @@ +**/.terraform diff --git a/infra/DEVELOPMENT.md b/infra/DEVELOPMENT.md new file mode 100644 index 0000000000..9ef51cff99 --- /dev/null +++ b/infra/DEVELOPMENT.md @@ -0,0 +1,129 @@ +# Infrastructure Development + +## Required environment + +1. bash: https://www.gnu.org/software/bash/bash.html +2. openssl: https://github.com/openssl/openssl +3. tr: https://man7.org/linux/man-pages/man1/tr.1.html +4. OpenTofu: https://opentofu.org/docs/intro/install/ +5. AWS CLI: https://docs.aws.amazon.com/cli/latest/userguide/getting-started-install.html + +### Infra-dev docker container + +[./Dockerfile.dev](./Dockerfile.dev) builds a container image with the required binaries. Run the +container with `docker container run` or `podman container run`. The infra-dev container does not +include the infra source code in its root filesystem - clone the Bloom repo on your host and give +the container access through volume mounts: + +1. `-v ./infra:/infra:z` makes the infra directory available to the container. +2. `-v "${HOME}/.aws/cli":/home/.aws/cli:z` makes the AWS cli directory available to the + container. This is required for successful use of aws CLI calls in local-exec resource + provisioners. +3. `-v "${HOME}/.aws/sso/cache":/home/.aws/sso/cache:z` makes the AWS SSO cache available to the + container. This volume mount is not required for the container to run, however you will need to + go through the SSO flow on every run if not provided. + +Because the source code on the host filesystem has file ownership to the user running the docker +container, specifying the user the container process will run as is also necessary: + +1. If using docker: `--user "$(id -u):$(id -g)"` +2. If using podman: `--userns=keep-id` + +The container runs [./docker-entrypoint.py](./docker-entrypoint.py). The first argument is a root +module name in [./tofu_root_modules](./tofu_root_modules). The rest of the arguments are passed +directly to the OpenTofu binary. Because OpenTofu requires user input to approve apply operations, +use the `-it` flag when running the infra-dev container. + +Before running OpenTofu with the passed in args, the script automatically: + +1. Runs `aws sso login` on the correct profile for the selected root module. + + `aws sso login` does not need to run every time because the credentials are cached in + `$HOME/.aws/sso/cache/`. Once the SSO credentials expire, you will need to go through the SSO + flow again. To skip the auto sso step, pass `--skip-sso` or `-ss`. + +2. Runs `tofu init` on the selected root module. + + `tofu init` only needs to run when initially downloading the OpenTofu providers to your host's + filesystem or when changing providers and/or their versions. To skip the auto init step, pass + `--skip-init` or `-si`. + +All together: + +```bash +docker container run --rm -it \ +--user "$(id -u):$(id -g)" \ +-v ./infra:/infra:z \ +-v "${HOME}/.aws/cli":/home/.aws/cli:z \ +-v "${HOME}/.aws/sso/cache":/home/.aws/sso/cache:z \ +ghcr.io/bloom-housing/bloom/infra-dev:gitbranch-main-latest \ +[[--skip-sso] | [--skip-init]] +``` + +You may find it convenient to add an alias. From the root of the Bloom repo: + +```bash +alias bloomtofu="docker container run --rm -it --user $(id -u):$(id -g) -v ${PWD}/infra:/infra:z -v ${HOME}/.aws/cli:/home/.aws/cli:z -v ${HOME}/.aws/sso/cache:/home/.aws/sso/cache:z ghcr.io/bloom-housing/bloom/infra-dev:gitbranch-main-latest" + +bloomtofu -ss -si bloom_dev apply +``` + +## Testing changes in the bloom-dev account: + +1. Edit the `.tf` files. +2. Run `tofu apply` on the `bloom_dev` root module and review the planned changes. If there are + unexpected planned changes, go back to step 1. If all the changes are expected, approve the + apply. +3. If the apply fails because of permission issues, update the permissions in the [deployer + importable module](./tofu_importable_modules/bloom_deployer_permission_set_policy/main.tf), then + update the `bloom_dev_deployer_permission_set_policy` root module, then retry step 2. +4. Inspect the relevant AWS resources via the CLI or the AWS web console (Log in via + https://d-9067ac8222.awsapps.com/start). If there are unexpected results, go back to step 1. In + some cases you may have to manually modify or delete resources directly to 'unstick' OpenTofu. +5. Test the deletion path for resources provisioned by the deployer importable module: + + ```bash + tofu destroy -target=module.bloom_deployment + ``` + +## Working with root modules + +### Downloading provider dependencies + +OpenTofu relies on provider dependencies being present locally in the +`.terraform` directory in the root module directory. To download the dependencies, run: + +```bash +tofu init +``` + +Once the provider dependences have been downloaded, you will not have to run `tofu init` again +unless you add a provider. + +To update a required version for a provider, change the version then run: + +```bash +tofu init -upgrade +``` + +Both of these commands will download the new dependencies and update the `.terraform.lock.hcl` file +in the root module directory. Whenever updating the `.terraform.lock.hcl` file in a PR, lock the hashes for +all platforms: + +```bash +tofu providers lock -platform=linux_amd64 -platform=linux_arm64 -platform=darwin_amd64 -platform=darwin_arm64 +``` + +This ensures that developers on other platforms will not get `.terraform.lock.hcl` diffs after +downloading the updated providers from your PR. + +### Forcing resource recreation + +To force Tofu to replace a resource, run `tofu apply -replace=ADDRESS`. For example: + +``` +tofu apply -replace=module.bloom_deployment.aws_secretsmanager_secret.api_jwt_signing_key +``` + +This is helpful when testing the local-exec provisioner because the provisioner only runs on +resource creation. diff --git a/infra/Dockerfile b/infra/Dockerfile new file mode 100644 index 0000000000..54551a5dd9 --- /dev/null +++ b/infra/Dockerfile @@ -0,0 +1,78 @@ +# This file creates a docker image that contains the required binaries and source code to deploy +# Bloom root modules. It is meant to be used in deployment pipelines. + +# Get OpenTofu binary following https://opentofu.org/docs/intro/install/docker/#method-1-using-a-multi-stage-build +# +# To update, find the latest stable version from: +# https://github.com/opentofu/opentofu/pkgs/container/opentofu/versions?tag=latest +# +# Use the `-minimal` image. Explicitly pin the version tag and image sha. +# +# IMPORTANT: keep this in sync with ./Dockerfile.dev +FROM ghcr.io/opentofu/opentofu:1.10.7-minimal@sha256:e8b80de6a840eb0e19d82aecbdaa2e2718cb107d15dbe849e6b550955aceacd2 AS opentofu + +# Use aws-cli image as the base image. It is built on amazonlinux base image. +# +# https://github.com/aws/aws-cli/blob/v2/docker/Dockerfile +# https://docs.aws.amazon.com/linux/al2023/ug/what-is-amazon-linux.html +# +# To update, find the latest stable version from: +# https://hub.docker.com/r/amazon/aws-cli/tags +# +# Explicitly pin the version tag and image sha. +# +# IMPORTANT: keep this in sync with ./Dockerfile.dev +FROM docker.io/amazon/aws-cli:2.32.9@sha256:734684f3fc98bbac0e7796c34f44c375c01f05febac52a10649a55eef1e96c24 + +# Copy over the tofu binary. +COPY --from=opentofu /usr/local/bin/tofu /usr/local/bin/tofu + +# Install other required binaries. +RUN < /bloom/.tofurc +EOF + +# Copy in the tofu modules and initialize the provider dependencies for the root modules. +COPY --chown=2002:2002 tofu_importable_modules /bloom/tofu_importable_modules +COPY --chown=2002:2002 tofu_root_modules /bloom/tofu_root_modules + +# Initialize all the root modules with -backend=false. This only downloads the tofu provider +# dependencies. Tofu init will still need to be called from within the container, at which point the +# state file in the S3 bucket will be read. +WORKDIR /bloom/tofu_root_modules +RUN < ...` +# +# If using podman: +# `podman container run --rm -it --userns=keep-id -v ./infra:/infra:z -v "${HOME}/.aws/sso/cache":/home/.aws/sso/cache:z ...` + +# Get OpenTofu binary following https://opentofu.org/docs/intro/install/docker/#method-1-using-a-multi-stage-build +# +# To update, find the latest stable version from: +# https://github.com/opentofu/opentofu/pkgs/container/opentofu/versions?tag=latest +# +# Use the `-minimal` image. Explicitly pin the version tag and image sha. +# +# IMPORTANT: keep this in sync with ./Dockerfile +FROM ghcr.io/opentofu/opentofu:1.10.7-minimal@sha256:e8b80de6a840eb0e19d82aecbdaa2e2718cb107d15dbe849e6b550955aceacd2 AS opentofu + +# Use aws-cli image as the base image. It is built on amazonlinux base image. +# +# https://github.com/aws/aws-cli/blob/v2/docker/Dockerfile +# https://docs.aws.amazon.com/linux/al2023/ug/what-is-amazon-linux.html +# +# To update, find the latest stable version from: +# https://hub.docker.com/r/amazon/aws-cli/tags +# +# Explicitly pin the version tag and image sha. +# +# IMPORTANT: keep this in sync with ./Dockerfile +FROM docker.io/amazon/aws-cli:2.32.9@sha256:734684f3fc98bbac0e7796c34f44c375c01f05febac52a10649a55eef1e96c24 + +# Copy over the tofu binary. +COPY --from=opentofu /usr/local/bin/tofu /usr/local/bin/tofu + +# Install other required binaries. +RUN <Permission Set] + PS_PROD[bloom-prod-deployer
Permission Set] + PS_DEV_IAM[bloom-dev-iam-admin
Permission Set] + PS_PROD_IAM[bloom-prod-iam-admin
Permission Set] + + HUMANS -->|if developer, member of| GRP_DEVELOPERS + HUMANS -->|if prod deployer, member of| GRP_PROD_DEPLOYERS + HUMANS -->|if prod IAM admin, member of| GRP_PROD_IAM + GRP_DEVELOPERS -->|assigned| PS_DEV + GRP_DEVELOPERS -->|assigned| PS_DEV_IAM + GRP_PROD_DEPLOYERS -->|assigned| PS_PROD + GRP_PROD_IAM -->|assigned| PS_PROD_IAM + PS_DEV_IAM -.->|can edit| PS_DEV + PS_PROD_IAM -.->|can edit| PS_PROD + end + subgraph IAM[AWS IAM] + direction TB + + SSO_ROLE_DEV_IAM[AWSReservedSSO_bloom-dev-iam-admin...
Role] + SSO_ROLE_PROD_IAM[AWSReservedSSO_bloom-prod-iam-admin...
Role] + end + end + subgraph DEV[bloom-dev Account] + SSO_ROLE_DEV[AWSReservedSSO_bloom-dev-deployer_...
Role] + end + subgraph PROD[bloom-prod Account] + SSO_ROLE_PROD[AWSReservedSSO_bloom-prod-deployer_...
Role] + end + + PS_DEV -.-|AWS automatically creates| SSO_ROLE_DEV + PS_PROD -.-|AWS automatically creates| SSO_ROLE_PROD + + end + + subgraph LEGEND + PREREQ[Pre-requisite] + CREATED[Created] + GENERATED[AWS Generated] + + PREREQ ~~~ CREATED ~~~ GENERATED + end + + %% Invisible link to position legend at top + LEGEND ~~~ ORG + + %% Color coding styles + %% Grey for AWS-generated roles + classDef awsGenerated fill:#f0f0f0,stroke:#999,stroke-width:2px + %% Blue for manually created + classDef manual fill:#e3f2fd,stroke:#1976d2,stroke-width:2px + %% Dashed border for prerequisites + classDef prerequisite fill:#fff,stroke:#666,stroke-width:2px,stroke-dasharray: 5 5 + %% Legend container style + classDef legendStyle fill:#fff,stroke:#333,stroke-width:1px + + %% Apply prerequisite style to org structure + class ORG,MA,IC,PREREQ,DEV,PROD,IAM prerequisite + + %% Apply legend style + class LEGEND legendStyle + + %% Apply AWS-generated style (grey) + class SSO_ROLE_DEV,SSO_ROLE_PROD,GENERATED,SSO_ROLE_DEV_IAM,SSO_ROLE_PROD_IAM awsGenerated + + %% Apply manual style (blue) + class HUMANS,GRP_DEVELOPERS,GRP_PROD_DEPLOYERS,GRP_PROD_IAM,PS_DEV_IAM,PS_PROD_IAM,PS_DEV,PS_PROD,CREATED manual +``` + +## Required permissions + +1. Create Users in the organization's IAM Identity Center instance. +2. Create Groups in the organization's IAM Identity Center instance and add users to the groups. +3. Create Permission sets in the organization's IAM Identity Center instance and assign the + Permission sets to the organization management account and the dev and prod Bloom accounts. +4. List and read roles in the organization management account and the dev and prod Bloom accounts. + +## Before these steps + +1. Complete the steps in the [Create AWS Accounts](./1_create_aws_accounts.md). The AWS account + numbers for the dev and prod Bloom accounts will be needed. +2. Open the organization management account and go to the 'IAM Identity Center > Settings' + page. **Note the IAM Identity Center Instance ARN, Region, and the AWS access portal URL**. + +## Steps + +### 1. Create IAM Identity Center Users and Groups + +1. Create an IAM Identity Center user for every person who will be interacting with the Bloom + deployments, if they do not already have a user. +2. Create a `bloom-dev-deployers` IAM Identity Center group. Add the users who should have access to + manage the dev Bloom deployment. +3. Create a `bloom-prod-iam-admins` IAM Identity Center group. Add the users who should have access + to manage the `bloom-prod-deployers` permission set policy. +4. Create a `bloom-prod-deployers` IAM Identity Center group. Add the users who should have access + to manage the prod Bloom deployment. + +Optionally, create a `bloom-dev-iam-admins` if the group of people who should have access to manage +the `bloom-dev-deployer` permission set policy is different from the group of people should should +have the `bloom-dev-deployer` permissions. + +### 2. Create IAM Identity Center Permission Sets + +1. Create a `bloom-dev-deployer` IAM Identity Center permission set: + 1. In the 'Select permission set type' screen, select the 'Custom permission set' option. + 2. In the 'Specify policies and permission boundary' screen, do not set any polices and click the + 'Next' button. + 3. In the 'Specify permission set details' screen: + 1. Enter 'bloom-dev-deployer' in the 'Permission set name' field. + 2. Enter 'Permissions to manage the bloom-dev deployment' in the 'Description' field. + 4. Follow the rest of the screens and create the permission set. **Note the ARN of the created + `bloom-dev-deployer` permission set**. + +2. Create a `bloom-dev-iam-admin` IAM Identity Center permission set: + 1. In the 'Select permission set type' screen, select the 'Custom permission set' option. + 2. In the 'Specify policies and permission boundary' screen, expand the 'Inline policy' + section. Paste in the following policy and change: + + - `CHANGEME_IAM_IDENTITY_CENTER_INSTANCE_ARN` to the IAM Identity Center instance ARN in your + notes. + - `CHANGEME_BLOOM_DEV_ACCOUNT_NUMBER` to the Bloom dev account number in your notes. + - `CHANGEME_DEV_DEPLOYER_PERMISSIONSET_ARN` to the ARN in your notes from step 2.1.4. + + ``` + { + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "PermissionSet", + "Effect": "Allow", + "Action": [ + "sso:DeleteInlinePolicyFromPermissionSet", + "sso:DescribePermissionSetProvisioningStatus", + "sso:GetInlinePolicyForPermissionSet", + "sso:ProvisionPermissionSet", + "sso:PutInlinePolicyToPermissionSet" + ], + "Resource": [ + "CHANGEME_IAM_IDENTITY_CENTER_INSTANCE_ARN", + "arn:aws:sso:::account/CHANGEME_BLOOM_DEV_ACCOUNT_NUMBER", + "CHANGEME_DEV_DEPLOYER_PERMISSIONSET_ARN" + ] + } + ] + } + ``` + 3. In the 'Specify permission set details' screen: + 1. Enter 'bloom-dev-iam-admin' in the 'Permission set name' field. + 2. Enter 'Permissions to manage the bloom-dev-deployer permission set policy' in the + 'Description' field. + 4. Follow the rest of the screens and create the permission set. + +3. Create a `bloom-prod-deployer` IAM Identity Center permission set. + 1. In the 'Select permission set type' screen, select the 'Custom permission set' option. + 2. In the 'Specify policies and permission boundary' screen, do not set any polices and click the + 'Next' button. + 3. In the 'Specify permission set details' screen: + 1. Enter 'bloom-prod-deployer' in the 'Permission set name' field. + 2. Enter 'Permissions to manage the bloom-prod deployment' in the 'Description' field. + 4. Follow the rest of the screens and create the permission set. **Note the ARN of the created + `bloom-prod-deployer` permission set**. + +4. Create a `bloom-prod-iam-admin` IAM Identity Center permission set. + 1. In the 'Select permission set type' screen, select the 'Custom permission set' option. + 2. In the 'Specify policies and permission boundary' screen, expand the 'Inline policy' + section. Paste in the following policy then modify it with the following specifics: + + - `CHANGEME_IAM_IDENTITY_CENTER_INSTANCE_ARN` to the IAM Identity Center instance ARN in your + notes. + - `CHANGEME_BLOOM_PROD_ACCOUNT_NUMBER` to the Bloom prod account number in your notes. + - `CHANGEME_PROD_DEPLOYER_PERMISSIONSET_ARN` to the ARN in your notes from step 2.3.4. + + ``` + { + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "PermissionSet", + "Effect": "Allow", + "Action": [ + "sso:DeleteInlinePolicyFromPermissionSet", + "sso:DescribePermissionSetProvisioningStatus", + "sso:GetInlinePolicyForPermissionSet", + "sso:ProvisionPermissionSet", + "sso:PutInlinePolicyToPermissionSet" + ], + "Resource": [ + "CHANGEME_IAM_IDENTITY_CENTER_INSTANCE_ARN", + "arn:aws:sso:::account/CHANGEME_BLOOM_PROD_ACCOUNT_NUMBER", + "CHANGEME_PROD_DEPLOYER_PERMISSIONSET_ARN" + ] + } + ] + } + ``` + 3. In the 'Specify permission set details' screen: + 1. Enter 'bloom-prod-iam-admin' in the 'Permission set name' field. + 2. Enter 'Permissions to manage the bloom-prod-deployer permission set policy' in the + 'Description' field. + 4. Follow the rest of the screens and create the permission set. + +### 3. Assign the Permission Sets + +On the IAM Identity Center 'AWS Organizations: AWS accounts' page: + +1. Assign the `bloom-dev-iam-admin` permission set to the organization management account: + 1. Select the checkbox for the organization management account then click the 'Assign users or + groups' button. + 2. Select the `bloom-dev-deployers` group checkbox then click the 'Next' button. **If a separate + `bloom-dev-iam-admins` group was created in step 1, use that group instead of the + `bloom-dev-deployers` group**. + 3. Select the `bloom-dev-iam-admin` permission set checkbox then click the 'Next' button. + +2. Assign the `bloom-prod-iam-admin` permission set to the organization management account: + 1. Select the checkbox for the organization management account then click the 'Assign users or + groups' button. + 2. Select the `bloom-prod-iam-admins` group checkbox then click the 'Next' button. + 3. Select the `bloom-prod-iam-admin` permission set checkbox then click the 'Next' button. + +3. Assign the `bloom-dev-deployer` permission set to the dev account: + 1. Select the checkbox for the dev account in your notes then click the 'Assign users or groups' + button. + 2. Select the `bloom-dev-deployers` group checkbox then click the 'Next' button. + 3. Select the `bloom-dev-deployer` permission set checkbox then click the 'Next' button. + +4. Assign the `bloom-prod-deployer` permission set to the prod account: + 1. Select the checkbox for the prod account in your notes then click the 'Assign users or groups' + button. + 2. Select the `bloom-prod-deployers` group checkbox then click the 'Next' button. + 3. Select the `bloom-prod-deployer` permission set checkbox then click the 'Next' button. + +### 4. Find the ARNs of the AWS-generated Roles + +Each permission set assignment in step 3 caused AWS to generate a Role in the account the permission +set was assigned to. These roles need access to a S3 bucket that will be created in the next steps. + +1. In the organization management account, go to the 'IAM > Roles' page. Search for + 'AWSReservedSSO_bloom'. Two roles, 'AWSReservedSSO_bloom-dev-iam-admin_..' and + 'AWSReservedSSO_bloom-prod-iam-admin_..' should be returned. Go into each role detail page and + **note the role ARN for each role**. +2. In the dev Bloom account, go to the 'IAM > Roles' page. Search for 'AWSReservedSSO_bloom'. One + role 'AWSReservedSSO_bloom-dev-deployer_..' should be returned. Go into each role detail page and + **note the role ARN**. +3. In the prod Bloom account, go to the 'IAM > Roles' page. Search for 'AWSReservedSSO_bloom'. One + role 'AWSReservedSSO_bloom-prod-deployer_..' should be returned. Go into each role detail page + and **note the role ARN**. + +## After these steps + +1. The organization management account should have assignments for the bloom-dev-iam-admin and + bloom-prod-iam-admin permission sets. +2. The dev Bloom account should have the bloom-dev-deployers permission set assigned. +3. The prod Bloom account should have the bloom-prod-deployers permission set assigned. +4. Your notes should have the ARNs for 4 AWS-generated roles. diff --git a/infra/aws_deployment_guide/3_create_tofu_state_s3_bucket.md b/infra/aws_deployment_guide/3_create_tofu_state_s3_bucket.md new file mode 100644 index 0000000000..3a511719b5 --- /dev/null +++ b/infra/aws_deployment_guide/3_create_tofu_state_s3_bucket.md @@ -0,0 +1,219 @@ +# Create OpenTofu State S3 Bucket + +This directory contains instructions for deploying Bloom dev and prod environments to an AWS +organization. The guide is broken down into a series of files that should be followed in order: + +1. [Create AWS Accounts](./1_create_aws_accounts.md) +2. [IAM Identity Center Configuration](./2_iam_identity_center_configuration.md) +3. [Create Tofu State S3 Bucket](./3_create_tofu_state_s3_bucket.md) (you are here) +4. [Fork the Bloom Repo](./4_fork_bloom_repo.md) +5. [Apply Deployer Permission Set Tofu Modules](./5_apply_deployer_permission_set_tofu_modules.md) +6. [Apply Bloom Deployment Tofu Modules](./6_apply_bloom_deployment_tofu_modules.md) + +The steps in this file create the following resources: + +```mermaid +--- +config: + theme: 'base' +--- +%% Diagram created by prompting Claude Opus 4.1 and manually edited. + +graph TB + subgraph ORG[AWS Organization] + direction TB + + subgraph MA[AWS Management Account] + direction TB + + S3[Bloom Tofu State Files
AWS S3 Bucket] + end + end + + subgraph LEGEND + direction LR + + PREREQ[Pre-requisite] + CREATED[Created] + + PREREQ ~~~ CREATED + end + + %% Invisible link to position legend at top + LEGEND ~~~ ORG + + %% Blue for manually created + classDef manual fill:#e3f2fd,stroke:#1976d2,stroke-width:2px + %% Dashed border for prerequisites + classDef prerequisite fill:#fff,stroke:#666,stroke-width:2px,stroke-dasharray: 5 5 + %% Legend container style + classDef legendStyle fill:#fff,stroke:#333,stroke-width:1px + + %% Apply prerequisite style to org structure + class ORG,MA,PREREQ prerequisite + + %% Apply legend style + class LEGEND legendStyle + + %% Apply manual style (blue) + class CREATED,S3 manual +``` + +## Required permissions + +1. Create a S3 bucket in an AWS account and set its permission policy. + +## Before these steps + +1. Complete the steps in the [IAM Identity Center + Configuration](./2_iam_identity_center_configuration.md). The ARNs for the AWS-generated Roles in + the organization management account and the dev and prod Bloom accounts will be needed. + +## Steps + +### 1. Create a S3 Bucket to store the OpenTofu state files + +An AWS S3 bucket is required to store OpenTofu state files. State files record the results of each +apply command. The S3 bucket can be created in any suitable AWS account in your organization. For +example, the S3 bucket used by the Bloom Core deployments is in Exygy's organization management +account. + +Create a S3 bucket: + +1. In the 'General configuration' section: + 1. Select the 'General purpose' Bucket type. + 2. Enter a descriptive bucket name. S3 bucket names must be globally unique so different names + may have to be tried before finding a name that is unused. For example, the S3 bucket used by + the Bloom Core deployments is named 'bloom-core-tofu-state-files'. **Note the bucket name and + the AWS region it is created in**. +2. In the 'Object Ownership' section, select the 'ACLs disabled (recommended)' option. +3. In the 'Block Public Access settings for this bucket' section, select the 'Block all public + access' option. +4. In the 'Bucket Versioning' section, select the 'Enable' option. (this is highly recommended: + https://opentofu.org/docs/language/settings/backends/s3/). + +### 2. Set the S3 Bucket policy + +The AWS-generated roles need access to the S3 bucket. On the 'Permissions' tab of the bucket page: + +1. Click the 'Edit' button on the 'Bucket policy' section. +2. Paste in the following policy and change: + + - `CHANGEME_S3_BUCKET_NAME` to the S3 bucket name in your notes. + - `CHANGEME_DEV_IAM_ADMIN_GENERATED_ROLE_ARN` to the ARN of the + `AWSReservedSSO_bloom-dev-iam-admin_..` AWS-generated role in the organization management + account in your notes. + - `CHANGEME_PROD_IAM_ADMIN_GENERATED_ROLE_ARN` to the ARN of the + `AWSReservedSSO_bloom-prod-iam-admin_..` AWS-generated role in the organization management + account in your notes. + - `CHANGEME_DEV_DEPLOYER_GENERATED_ROLE_ARN` to the ARN of the + `AWSReservedSSO_bloom-dev-deployer_..` AWS-generated role in the dev Bloom account in your + notes. + - `CHANGEME_PROD_DEPLOYER_GENERATED_ROLE_ARN` to the ARN of the + `AWSReservedSSO_bloom-prod-deployer_..` AWS-generated role in the prod Bloom account in your + notes. + + ``` + { + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "StateBucket", + "Effect": "Allow", + "Action": [ + "s3:ListBucket" + ], + "Resource": [ + "arn:aws:s3:::CHANGEME_S3_BUCKET_NAME" + ], + "Principal": { + "AWS": [ + "CHANGEME_DEV_IAM_ADMIN_GENERATED_ROLE_ARN", + "CHANGEME_PROD_IAM_ADMIN_GENERATED_ROLE_ARN", + "CHANGEME_DEV_DEPLOYER_GENERATED_ROLE_ARN", + "CHANGEME_PROD_DEPLOYER_GENERATED_ROLE_ARN" + ] + } + }, + { + "Sid": "DevDeployerPermissionSetStateFiles", + "Effect": "Allow", + "Action": [ + "s3:DeleteObject", + "s3:GetObject", + "s3:PutObject" + ], + "Resource": [ + "arn:aws:s3:::CHANGEME_S3_BUCKET_NAME/bloom-dev-deployer-permissionset-policy/state", + "arn:aws:s3:::CHANGEME_S3_BUCKET_NAME/bloom-dev-deployer-permissionset-policy/state.tflock" + ], + "Principal": { + "AWS": [ + "CHANGEME_DEV_IAM_ADMIN_GENERATED_ROLE_ARN" + ] + } + }, + { + "Sid": "ProdDeployerPermissionSetStateFiles", + "Effect": "Allow", + "Action": [ + "s3:DeleteObject", + "s3:GetObject", + "s3:PutObject" + ], + "Resource": [ + "arn:aws:s3:::CHANGEME_S3_BUCKET_NAME/bloom-prod-deployer-permissionset-policy/state", + "arn:aws:s3:::CHANGEME_S3_BUCKET_NAME/bloom-prod-deployer-permissionset-policy/state.tflock" + ], + "Principal": { + "AWS": [ + "CHANGEME_PROD_IAM_ADMIN_GENERATED_ROLE_ARN" + ] + } + }, + { + "Sid": "DevStateFiles", + "Effect": "Allow", + "Action": [ + "s3:DeleteObject", + "s3:GetObject", + "s3:PutObject" + ], + "Resource": [ + "arn:aws:s3:::CHANGEME_S3_BUCKET_NAME/bloom-dev/state", + "arn:aws:s3:::CHANGEME_S3_BUCKET_NAME/bloom-dev/state.tflock" + ], + "Principal": { + "AWS": [ + "CHANGEME_DEV_DEPLOYER_GENERATED_ROLE_ARN" + ] + } + }, + { + "Sid": "ProdStateFiles", + "Effect": "Allow", + "Action": [ + "s3:DeleteObject", + "s3:GetObject", + "s3:PutObject" + ], + "Resource": [ + "arn:aws:s3:::CHANGEME_S3_BUCKET_NAME/bloom-prod/state", + "arn:aws:s3:::CHANGEME_S3_BUCKET_NAME/bloom-prod/state.tflock" + ], + "Principal": { + "AWS": [ + "CHANGEME_PROD_DEPLOYER_GENERATED_ROLE_ARN" + ] + } + } + ] + } + ``` + + +## After these steps + +Your notes should have: + +1. Name and region for the created S3 bucket. diff --git a/infra/aws_deployment_guide/4_fork_bloom_repo.md b/infra/aws_deployment_guide/4_fork_bloom_repo.md new file mode 100644 index 0000000000..415d3a6d32 --- /dev/null +++ b/infra/aws_deployment_guide/4_fork_bloom_repo.md @@ -0,0 +1,114 @@ +# Fork the Bloom Repo + +This directory contains instructions for deploying Bloom dev and prod environments to an AWS +organization. The guide is broken down into a series of files that should be followed in order: + +1. [Create AWS Accounts](./1_create_aws_accounts.md) +2. [IAM Identity Center Configuration](./2_iam_identity_center_configuration.md) +3. [Create Tofu State S3 Bucket](./3_create_tofu_state_s3_bucket.md) +4. [Fork the Bloom Repo](./4_fork_bloom_repo.md) (you are here) +5. [Apply Deployer Permission Set Tofu Modules](./5_apply_deployer_permission_set_tofu_modules.md) +6. [Apply Bloom Deployment Tofu Modules](./6_apply_bloom_deployment_tofu_modules.md) + +## Before these steps + +1. Complete the steps in [Create Tofu State S3 Bucket](./3_create_tofu_state_s3_bucket.md). The + following notes from previous steps will be needed: + 1. AWS account numbers for the dev and prod Bloom accounts. + 2. S3 bucket name and region for the OpenTofu state file bucket. + 3. IAM Identity Center Instance ARN, region, and AWS access portal URL. + 4. Permission set ARNs for `bloom-dev-deployer` and `bloom-prod-deployer`. +2. Decide where you would like to host your Bloom fork. GitHub will work out-of-the-box. If choosing + another provider, you will need to bring your own method for building docker images from the + repo. +3. Pick an AWS region to deploy dev and prod Bloom to. +4. Pick DNS domains for the dev and prod Bloom deployments. You must be able to add CNAME records + for the chosen domains. + +## Steps + +1. Fork https://github.com/bloom-housing/bloom. After forking, GitHub should + trigger the 'Docker Image Build' action: + https://github.com//bloom/actions/workflows/docker_image_build.yml. It will + build and push Bloom docker images to the GitHub container registry in your GitHub organization. +2. Get the docker images for your fork's Bloom services. Go to your GitHub Organization's Packages + page: https://github.com/orgs//packages. + + 1. Click on the 'api' container and **note the container name**. + 2. Click on the 'partners' container and **note the container name**. + 3. Click on the 'public' container and **note the container name**. + +3. Update your fork's infra/ directory: + 1. Update the details in `infra/aws_sso_config` for your organization's IAM Identity Center and + account numbers. + 2. Update `infra/tofu_root_modules/bloom_dev_deployer_permission_set_policy/main.tf` with details + for your dev deployment: + + In the `locals {` block, update variables: + 1. `tofu_state_bucket_region` + 2. `tofu_state_bucket_name` + 3. `iam_identity_center_region` + 4. `iam_identity_center_instance_arn`. + 5. `deployer_permission_set_arn`, ensure this is the bloom-dev-deployer permission set ARN. + 6. `bloom_deployment_aws_account_number`, ensure this is the dev account number + 7. `bloom_deployment_aws_region`. + + 3. Update `infra/tofu_root_modules/bloom_prod_deployer_permission_set_policy/main.tf` with + details for your prod deployment: + + In the `locals {` block, update variables: + 1. `tofu_state_bucket_region` + 2. `tofu_state_bucket_name` + 3. `iam_identity_center_region` + 4. `iam_identity_center_instance_arn` + 5. `deployer_permission_set_arn`, ensure this is the bloom-prod-deployer permission set ARN. + 6. `bloom_deployment_aws_account_number`, ensure this is the prod account number. + 7. `bloom_deployment_aws_region` + + 4. Update `infra/tofu_root_modules/bloom_dev/main.tf` with details for your dev deployment: + + In the `locals {` block, update variables: + 1. `tofu_state_bucket_region` + 2. `tofu_state_bucket_name` + 3. `bloom_aws_account_number`, ensure this is the dev account number. + 4. `bloom_aws_region` + 5. `domain_name`, ensure this is the dev domain. + + In the `module "bloom_deployment"` block, update variables: + 1. `bloom_api_image` + 2. `bloom_site_partners_image` + 3. `bloom_site_public_image` + 4. `bloom_site_public_env_vars` + + 5. Update `infra/tofu_root_modules/bloom_prod/main.tf` with details for your prod deployment: + + In the `locals {` block, update variables: + 1. `tofu_state_bucket_region` + 2. `tofu_state_bucket_name` + 3. `bloom_aws_account_number`, ensure this is the prod account number. + 4. `bloom_aws_region` + 5. `domain_name`, ensure this is the prod domain. + + In the `module "bloom_deployment"` block, update variables: + 1. `bloom_api_image` + 2. `bloom_site_partners_image` + 3. `bloom_site_public_image` + 4. `bloom_site_public_env_vars` + + 6. Push the changes to your fork's main branch. + +4. Get the docker image for your fork's infra container. After pushing the infra updates, GitHub + should trigger the 'Docker Image Build' action again. Go to your GitHub Organization's Packages + page: https://github.com/orgs//packages. + 1. Click on the 'infra' container and **note the container name that corresponds to the git + commit SHA of your infra updates commit**. It must be the infra container version that was + build on or after the commit updating your forks infra/ directory. If you use an infra + container version that was build from a commit before your fork was updated, it will attempt + to deploy the Bloom Core infra/ config. + +## After these steps + +1. The main branch of your Bloom fork should be updated to have the details for your AWS deployments + and not the Bloom Core AWS deployment. +2. Your notes should have the infra docker container image version that was build from the commit + updating your fork. diff --git a/infra/aws_deployment_guide/5_apply_deployer_permission_set_tofu_modules.md b/infra/aws_deployment_guide/5_apply_deployer_permission_set_tofu_modules.md new file mode 100644 index 0000000000..c8a43c1a72 --- /dev/null +++ b/infra/aws_deployment_guide/5_apply_deployer_permission_set_tofu_modules.md @@ -0,0 +1,107 @@ +# Apply Deployer Permission Set OpenTofu Modules + +This directory contains instructions for deploying Bloom dev and prod environments to an AWS +organization. The guide is broken down into a series of files that should be followed in order: + +1. [Create AWS Accounts](./1_create_aws_accounts.md) +2. [IAM Identity Center Configuration](./2_iam_identity_center_configuration.md) +3. [Create Tofu State S3 Bucket](./3_create_tofu_state_s3_bucket.md) +4. [Fork the Bloom Repo](./4_fork_bloom_repo.md) +5. [Apply Deployer Permission Set Tofu Modules](./5_apply_deployer_permission_set_tofu_modules.md) + (you are here) +6. [Apply Bloom Deployment Tofu Modules](./6_apply_bloom_deployment_tofu_modules.md) + +The steps in this file create the following resources: + +```mermaid +--- +config: + theme: 'base' +--- +%% Diagram created by prompting Claude Opus 4.1 and manually edited. + +graph TB + subgraph ORG[AWS Organization] + direction TB + + subgraph MA[AWS Management Account] + direction TB + + S3[Bloom Tofu State Files
AWS S3 Bucket] + + subgraph IC[AWS IAM Identity Center] + direction LR + + subgraph PS_DEV[bloom-dev-deployer
Permission Set] + DEV_POLICY[Inline Permission Policy] + end + subgraph PS_PROD[bloom-prod-deployer
Permission Set] + PROD_POLICY[Inline Permission Policy] + end + end + + DEV_POLICY-.->|OpenTofu State file stored in|S3 + PROD_POLICY-.->|OpenTofu State file stored in|S3 + end + end + + subgraph LEGEND + direction LR + + PREREQ[Pre-requisite] + CREATED[Created by OpenTofu] + + PREREQ ~~~ CREATED + end + + %% Invisible link to position legend at top + LEGEND ~~~ ORG + + %% Green for Tofu created + classDef tofu fill:#e8f5e9,stroke:#388e3c,stroke-width:2px + %% Dashed border for prerequisites + classDef prerequisite fill:#fff,stroke:#666,stroke-width:2px,stroke-dasharray: 5 5 + %% Legend container style + classDef legendStyle fill:#fff,stroke:#333,stroke-width:1px + + %% Apply prerequisite style to org structure + class ORG,MA,IC,PREREQ,S3,PS_DEV,PS_PROD prerequisite + + %% Apply legend style + class LEGEND legendStyle + + %% Apply terraform style (green) + class DEV_POLICY,PROD_POLICY,CREATED tofu +``` + +## Required permissions + +1. Be a member of the `bloom-dev-deployers` (or `bloom-dev-iam-admins` if a separate group was + created in [IAM Identity Center Configuration](./2_iam_identity_center_configuration.md) step 1). +2. Be a member of the `bloom-prod-iam-admins` group. + +## Before these steps + +1. Complete the steps in [Fork the Bloom Repo](./4_fork_bloom_repo.md). The infra container image + name build from your fork will be needed. + +## Steps + +1. Apply the dev deployer OpenTofu root module: + + ```bash + docker run --rm -it ghcr.io//bloom/infra:gitsha-SOMESHA bloom_dev_deployer_permission_set_policy apply + ``` + +2. Apply the prod deployer OpenTofu root module: + + ```bash + docker run --rm -it ghcr.io//bloom/infra:gitsha-SOMESHA bloom_prod_deployer_permission_set_policy apply + ``` + +## After these steps + +1. The `bloom-dev-deployer` permission set should have its Inline policy set. +2. The `bloom-prod-deployer` permission set should have its Inline policy set. +3. The Tofu State S3 bucket should have two folders, `bloom-dev-deployer-permissionset-policy` and + `bloom-prod-deployer-permissionset-policy`. diff --git a/infra/aws_deployment_guide/6_apply_bloom_deployment_tofu_modules.md b/infra/aws_deployment_guide/6_apply_bloom_deployment_tofu_modules.md new file mode 100644 index 0000000000..1ac5a879cf --- /dev/null +++ b/infra/aws_deployment_guide/6_apply_bloom_deployment_tofu_modules.md @@ -0,0 +1,243 @@ +# Apply Bloom Deployment OpenTofu Modules + +This directory contains instructions for deploying Bloom dev and prod environments to an AWS +organization. The guide is broken down into a series of files that should be followed in order: + +1. [Create AWS Accounts](./1_create_aws_accounts.md) +2. [IAM Identity Center Configuration](./2_iam_identity_center_configuration.md) +3. [Create Tofu State S3 Bucket](./3_create_tofu_state_s3_bucket.md) +4. [Fork the Bloom Repo](./4_fork_bloom_repo.md) +5. [Apply Deployer Permission Set Tofu Modules](./5_apply_deployer_permission_set_tofu_modules.md) +6. [Apply Bloom Deployment Tofu Modules](./6_apply_bloom_deployment_tofu_modules.md) (you are here) + +The steps in this file create the following resources (the OpenTofu files in +[bloom_deployment](../tofu_importable_modules/bloom_deployment) fully describe all resources that +are created): + +```mermaid +--- +config: + theme: 'base' +--- +%% Diagram created by prompting Claude Opus 4.1 and manually edited. + +graph TB + subgraph ORG[AWS Organization] + direction TB + + subgraph MA[AWS Management Account] + direction TB + + S3[Bloom Tofu State Files
AWS S3 Bucket] + end + + subgraph DEV[bloom-dev Account] + direction LR + + subgraph VPC_DEV[VPC] + direction TB + + RDS_DEV[RDS postgres DB] + ELB_DEV[Application Load Balancer] + ECS_DEV[ECS tasks] + end + end + subgraph PROD[bloom-prod Account] + direction LR + + subgraph VPC_PROD[VPC] + direction TB + + RDS_PROD[RDS postgres DB] + ELB_PROD[Application Load Balancer] + ECS_PROD[ECS tasks] + end + end + + + DEV-.->|OpenTofu State file stored in|S3 + PROD-.->|OpenTofu State file stored in|S3 + end + + subgraph LEGEND + direction LR + + PREREQ[Pre-requisite] + CREATED[Created by OpenTofu] + + PREREQ ~~~ CREATED + end + + %% Invisible link to position legend at top + LEGEND ~~~ ORG + + %% Green for Tofu created + classDef tofu fill:#e8f5e9,stroke:#388e3c,stroke-width:2px + %% Dashed border for prerequisites + classDef prerequisite fill:#fff,stroke:#666,stroke-width:2px,stroke-dasharray: 5 5 + %% Legend container style + classDef legendStyle fill:#fff,stroke:#333,stroke-width:1px + + %% Apply prerequisite style to org structure + class ORG,MA,PREREQ,S3,DEV,PROD prerequisite + + %% Apply legend style + class LEGEND legendStyle + + %% Apply terraform style (green) + class CREATED,DEV,VPC_DEV,RDS_DEV,ELB_DEV,ECS_DEV,PROD,VPC_PROD,RDS_PROD,ELB_PROD,ECS_PROD tofu +``` + +## Required permissions + +1. Be a member of the `bloom-dev-deployers` group. +2. Be a member of the `bloom-prod-deployers` group. + +## Before these steps + +1. Complete the steps in [Apply Deployer Permission Set Tofu + Modules](./5_apply_deployer_permission_set_tofu_modules.md). + +## Steps + +### 1. Deploy dev + +1. Create the AWS managed certificate for your domain: + + ```bash + docker run --rm -it ghcr.io//bloom/infra:gitsha-SOMESHA bloom_dev apply -exclude=module.bloom_deployment + ``` + +2. Validate the AWS managed certificate: + + The `tofu apply` command from step 1 will output the DNS records that need to be added for AWS to + issue the certificate. Add the two required CNAME records in your DNS provider. For example, the + following records need to be added for the following output: + + Type | Name | Content + ---|---|--- + CNAME | _4b8c99d969da11b1e35c36786a74b6fe.core-dev.bloomhousing.dev. | _003be6eab99411307156f79225503c77.jkddzztszm.acm-validations.aws. + CNAME | _aa08a6efc0ba7025472371c5b7b44120.partners.core-dev.bloomhousing.dev. | _fc0aa55094ea4ea64f60830d3a008225.jkddzztszm.acm-validations.aws. + + ``` + Outputs: + + certificate_details = { + "certificate_arn" = "" + "certificate_status" = "ISSUED" + "expires_at" = "2026-12-18T23:59:59Z" + "managed_renewal" = { + "eligible" = "ELIGIBLE" + "status" = tolist([]) + } + "validation_dns_recods" = toset([ + { + "domain_name" = "core-dev.bloomhousing.dev" + "resource_record_name" = "_4b8c99d969da11b1e35c36786a74b6fe.core-dev.bloomhousing.dev." + "resource_record_type" = "CNAME" + "resource_record_value" = "_003be6eab99411307156f79225503c77.jkddzztszm.acm-validations.aws." + }, + { + "domain_name" = "partners.core-dev.bloomhousing.dev" + "resource_record_name" = "_aa08a6efc0ba7025472371c5b7b44120.partners.core-dev.bloomhousing.dev." + "resource_record_type" = "CNAME" + "resource_record_value" = "_fc0aa55094ea4ea64f60830d3a008225.jkddzztszm.acm-validations.aws." + }, + ]) + } + ``` + +3. Deploy Bloom: + + ```bash + docker run --rm -it ghcr.io//bloom/infra:gitsha-SOMESHA bloom_dev apply + ``` + + The output will include a `aws_lb_dns_name`. DNS CNAME records + need to be added that point to the load balancer DNS name. For example, the following records + need to be added for the following output: + + Type | Name | Content + ---|---|--- + CNAME | core-dev | bloom-1787634238.us-west-2.elb.amazonaws.com + CNAME | partners.core-dev | bloom-1787634238.us-west-2.elb.amazonaws.com + + ``` + Outputs: + + aws_lb_dns_name = "bloom-1787634238.us-west-2.elb.amazonaws.com" + ``` + +### 2. Deploy prod + +1. Create the AWS managed certificate for your domain: + + ```bash + docker run --rm -it ghcr.io//bloom/infra:gitsha-SOMESHA bloom_prod apply -exclude=module.bloom_deployment + ``` + +2. Validate the AWS managed certificate: + + The `tofu apply` command from step 1 will output the DNS records that need to be added for AWS to + issue the certificate. Add the two required CNAME records in your DNS provider. For example, the + following records need to be added for the following output: + + Type | Name | Content + ---|---|--- + CNAME | _BLAH.core-prod.bloomhousing.dev. | _BLAH.jkddzztszm.acm-validations.aws. + CNAME | _BLAH.partners.core-prod.bloomhousing.dev. | _BLAH.jkddzztszm.acm-validations.aws. + + ``` + Outputs: + + certificate_details = { + "certificate_arn" = "" + "certificate_status" = "ISSUED" + "expires_at" = "2026-12-18T23:59:59Z" + "managed_renewal" = { + "eligible" = "ELIGIBLE" + "status" = tolist([]) + } + "validation_dns_recods" = toset([ + { + "domain_name" = "core-prod.bloomhousing.dev" + "resource_record_name" = "_BLAH.core-prod.bloomhousing.dev." + "resource_record_type" = "CNAME" + "resource_record_value" = "_BLAH.jkddzztszm.acm-validations.aws." + }, + { + "domain_name" = "partners.core-prod.bloomhousing.dev" + "resource_record_name" = "_BLAH.partners.core-prod.bloomhousing.dev." + "resource_record_type" = "CNAME" + "resource_record_value" = "_BLAH.jkddzztszm.acm-validations.aws." + }, + ]) + } + ``` + +3. Deploy the Bloom services: + + ```bash + docker run --rm -it ghcr.io//bloom/infra:gitsha-SOMESHA bloom_prod apply + ``` + + The output will include a `aws_lb_dns_name`. DNS CNAME records + need to be added that point to the load balancer DNS name. For example, the following records + need to be added for the following output: + + Type | Name | Content + ---|---|--- + CNAME | core-prod | bloom-1787634238.us-west-2.elb.amazonaws.com + CNAME | partners.core-prod | bloom-1787634238.us-west-2.elb.amazonaws.com + + ``` + Outputs: + + aws_lb_dns_name = "bloom-1787634238.us-west-2.elb.amazonaws.com" + ``` + +## After these steps + +1. Your dev Bloom deployment should be accessible: + - public site: `https:// + - partners site: `https://partners. diff --git a/infra/aws_sso_config b/infra/aws_sso_config new file mode 100644 index 0000000000..de58e985fc --- /dev/null +++ b/infra/aws_sso_config @@ -0,0 +1,24 @@ +[profile bloom-dev-deployer] +sso_session = sso +sso_role_name = bloom-dev-deployer +sso_account_id = 242477209009 + +[profile bloom-prod-deployer] +sso_session = sso +sso_role_name = bloom-prod-deployer +sso_account_id = 966936071156 + +[profile bloom-dev-iam-admin] +sso_session = sso +sso_role_name = bloom-dev-iam-admin +sso_account_id = 098472360576 + +[profile bloom-prod-iam-admin] +sso_session = sso +sso_role_name = bloom-prod-iam-admin +sso_account_id = 098472360576 + +[sso-session sso] +sso_start_url = https://d-9067ac8222.awsapps.com/start +sso_region = us-east-1 +sso_registration_scopes = sso:account:access diff --git a/infra/docker-entrypoint.py b/infra/docker-entrypoint.py new file mode 100644 index 0000000000..006a11cdaa --- /dev/null +++ b/infra/docker-entrypoint.py @@ -0,0 +1,84 @@ +# Must work on the Python version contained in the awslinux base image. +# +# At current time of writing: 3.9.24 +# +# To get the version for a particular image tag: +# `docker container run --rm -it --entrypoint python3 ghcr.io/bloom-housing/bloom/infra: --version` +# +# Formatted with yapf https://github.com/google/yapf with args: +# `--style='{based_on_style:google,SPLIT_BEFORE_FIRST_ARGUMENT:true,COLUMN_LIMIT=100}'` +import argparse +import subprocess +import sys + + +def run_subprocess(cmd, cwd=None, always_exit=False): + """Runs a subprocess in a directory. + + If always_exit is True, the calling python process will be exited with the same code as the + subprocess. + + If always_exit is False and the subprocess exited with a non-0 code, the calling python process + will exited with a FATAL debug message. + """ + cmd_formatted = " ".join(cmd) + print(f"== docker-entrypoint.py: DIRECTORY='{cwd}', CMD='{cmd_formatted}'", flush=True) + + try: + p = subprocess.Popen(cmd, cwd=cwd, stdin=sys.stdin, stdout=sys.stdout, stderr=sys.stderr) + p.wait() + except KeyboardInterrupt: + # The subprocess will get the Interrupt signal without us needing to do anything, so just + # wait on the subprocess to exit. + p.wait() + + if always_exit: + sys.exit(p.returncode) + + if p.returncode != 0: + sys.exit(f"FATAL: '{cmd_formatted}' returned non-0 exit code.") + + +def main(): + # Valid root modules and their AWS CLI profile names: + mod_to_aws_profile = { + "bloom_dev": "bloom-dev-deployer", + "bloom_dev_deployer_permission_set_policy": "bloom-dev-iam-admin", + "bloom_prod": "bloom-prod-deployer", + "bloom_prod_deployer_permission_set_policy": "bloom-prod-iam-admin", + } + + p = argparse.ArgumentParser( + prog="docker-entrypoint.py", + description="CLI entrypoint for working with Bloom OpenTofu root modules.", + allow_abbrev=True) + + p.add_argument( + "-si", "--skip-init", help="Do not automatically run 'tofu init'.", action='store_true') + p.add_argument( + "-ss", "--skip-sso", help="Do not automatically run 'aws sso login'.", action='store_true') + + p.add_argument( + "root_module_name", + help="The root module name to work on.", + choices=mod_to_aws_profile.keys()) + p.add_argument( + "open_tofu_args", + help="Arguments that are directly passed to the OpenTofu binary.", + nargs=argparse.REMAINDER) + + args = p.parse_args() + + aws_profile = mod_to_aws_profile[args.root_module_name] + mod_path = f"/infra/tofu_root_modules/{args.root_module_name}" + + if not args.skip_sso: + run_subprocess(["aws", "sso", "login", "--use-device-code", "--profile", aws_profile]) + if not args.skip_init: + run_subprocess(["tofu", "init"], cwd=mod_path) + + run_subprocess(["tofu"] + args.open_tofu_args, cwd=mod_path, always_exit=True) + + +if __name__ == '__main__': + main() diff --git a/infra/tofu_importable_modules/bloom_deployer_permission_set_policy/main.tf b/infra/tofu_importable_modules/bloom_deployer_permission_set_policy/main.tf new file mode 100644 index 0000000000..38ed83ba58 --- /dev/null +++ b/infra/tofu_importable_modules/bloom_deployer_permission_set_policy/main.tf @@ -0,0 +1,331 @@ +terraform { + required_providers { + aws = { + version = "6.21.0" + source = "hashicorp/aws" + } + } +} + +variable "iam_identity_center_instance_arn" { + type = string + description = "ARN of the IAM Identity Center Instance where the bloom deployer permission set exists." +} +variable "permission_set_arn" { + type = string + description = "ARN of the bloom deployer permission set to configure." +} + +variable "bloom_deployment_aws_account_number" { + type = string + description = "AWS account number of the bloom deployment account this permission set manages." +} +variable "bloom_deployment_aws_region" { + type = string + description = "AWS region the bloom deployment will be deployed to." +} +variable "bloom_deployment_tofu_state_bucket_name" { + type = string + description = "S3 bucket name that will store the OpenTofu state for the bloom deployment this permission set manages." +} +variable "bloom_deployment_tofu_state_file_prefix" { + type = string + description = "Object name prefix for the OpenTofu state files for the bloom deployment this permission set manages." +} + +locals { + region_account = "${var.bloom_deployment_aws_region}:${var.bloom_deployment_aws_account_number}" +} + +resource "aws_ssoadmin_permission_set_inline_policy" "deployer" { + instance_arn = var.iam_identity_center_instance_arn + permission_set_arn = var.permission_set_arn + inline_policy = data.aws_iam_policy_document.deployer.json +} +data "aws_iam_policy_document" "deployer" { + statement { + sid = "TofuStateBucket" + actions = [ + "s3:ListBucket", + ] + resources = [ + "arn:aws:s3:::${var.bloom_deployment_tofu_state_bucket_name}", + ] + } + statement { + sid = "TofuStateFiles" + actions = [ + "s3:DeleteObject", + "s3:GetObject", + "s3:PutObject", + "s3:PutObjectAcl", + ] + resources = [ + "arn:aws:s3:::${var.bloom_deployment_tofu_state_bucket_name}/${var.bloom_deployment_tofu_state_file_prefix}/state", + "arn:aws:s3:::${var.bloom_deployment_tofu_state_bucket_name}/${var.bloom_deployment_tofu_state_file_prefix}/state.tflock", + ] + } + statement { + sid = "CloudTrail" + actions = [ + "cloudtrail:CreateEventDataStore", + "cloudtrail:DeleteEventDataStore", + "cloudtrail:GetEventDataStore", + "cloudtrail:ListTags", + ] + resources = [ + "arn:aws:cloudtrail:${local.region_account}:eventdatastore/*", + ] + } + statement { + sid = "ServiceLinkedRole" + actions = [ + "iam:CreateServiceLinkedRole", + "iam:DeleteServiceLinkedRole", + "iam:GetRole", + "iam:GetServiceLinkedRoleDeletionStatus", + ] + resources = [ + "arn:aws:iam::${var.bloom_deployment_aws_account_number}:role/aws-service-role/ecs.amazonaws.com/AWSServiceRoleForECS", + "arn:aws:iam::${var.bloom_deployment_aws_account_number}:role/aws-service-role/elasticloadbalancing.amazonaws.com/AWSServiceRoleForElasticLoadBalancing", + ] + } + statement { + # These calls are not scoped to a specific resource. + sid = "DescribeVPC" + actions = [ + "acm:ListCertificates", + "ec2:DescribeAccountAttributes", + "ec2:DescribeAddresses", + "ec2:DescribeAddressesAttribute", + "ec2:DescribeAvailabilityZones", + "ec2:DescribeInternetGateways", + "ec2:DescribeNatGateways", + "ec2:DescribeNetworkAcls", + "ec2:DescribeNetworkInterfaces", + "ec2:DescribePrefixLists", + "ec2:DescribeRouteTables", + "ec2:DescribeSecurityGroupRules", + "ec2:DescribeSecurityGroups", + "ec2:DescribeSubnets", + "ec2:DescribeVpcEndpoints", + "ec2:DescribeVpcs", + "elasticloadbalancing:DescribeListenerAttributes", + "elasticloadbalancing:DescribeListeners", + "elasticloadbalancing:DescribeLoadBalancerAttributes", + "elasticloadbalancing:DescribeLoadBalancers", + "elasticloadbalancing:DescribeRules", + "elasticloadbalancing:DescribeTags", + "elasticloadbalancing:DescribeTargetGroupAttributes", + "elasticloadbalancing:DescribeTargetGroups", + ] + resources = ["*"] + } + statement { + sid = "ConfigureVPC" + actions = [ + "acm:DeleteCertificate", + "acm:DescribeCertificate", + "acm:GetCertificate", + "acm:ListTagsForCertificate", + "acm:RequestCertificate", + "ec2:AllocateAddress", + "ec2:AssociateRouteTable", + "ec2:AssociateVpcCidrBlock", + "ec2:AttachInternetGateway", + "ec2:AuthorizeSecurityGroupEgress", + "ec2:AuthorizeSecurityGroupIngress", + "ec2:CreateInternetGateway", + "ec2:CreateNATGateway", + "ec2:CreateNatGateway", + "ec2:CreateRoute", + "ec2:CreateRouteTable", + "ec2:CreateSecurityGroup", + "ec2:CreateSubnet", + "ec2:CreateTags", + "ec2:CreateTags", + "ec2:CreateVpc", + "ec2:CreateVpcEndpoint", + "ec2:DeleteInternetGateway", + "ec2:DeleteNatGateway", + "ec2:DeleteRouteTable", + "ec2:DeleteSecurityGroup", + "ec2:DeleteSubnet", + "ec2:DeleteVPC", + "ec2:DeleteVpcEndpoints", + "ec2:DescribeVpcAttribute", + "ec2:DetachInternetGateway", + "ec2:DisassociateRouteTable", + "ec2:DisassociateVpcCidrBlock", + "ec2:ModifySubnetAttribute", + "ec2:ModifyVpcAttribute", + "ec2:ModifyVpcEndpoint", + "ec2:ReleaseAddress", + "ec2:RevokeSecurityGroupEgress", + "ec2:RevokeSecurityGroupIngress", + "elasticloadbalancing:AddTags", + "elasticloadbalancing:CreateListener", + "elasticloadbalancing:CreateLoadBalancer", + "elasticloadbalancing:CreateRule", + "elasticloadbalancing:CreateTargetGroup", + "elasticloadbalancing:DeleteListener", + "elasticloadbalancing:DeleteLoadBalancer", + "elasticloadbalancing:DeleteRule", + "elasticloadbalancing:DeleteTargetGroup", + "elasticloadbalancing:ModifyListener", + "elasticloadbalancing:ModifyLoadBalancerAttributes", + "elasticloadbalancing:ModifyTargetGroup", + "elasticloadbalancing:ModifyTargetGroupAttributes", + ] + resources = [ + "arn:aws:acm:${local.region_account}:certificate/*", + "arn:aws:ec2:${local.region_account}:elastic-ip/*", + "arn:aws:ec2:${local.region_account}:internet-gateway/*", + "arn:aws:ec2:${local.region_account}:natgateway/*", + "arn:aws:ec2:${local.region_account}:route-table/*", + "arn:aws:ec2:${local.region_account}:security-group-rule/*", + "arn:aws:ec2:${local.region_account}:security-group/*", + "arn:aws:ec2:${local.region_account}:subnet/*", + "arn:aws:ec2:${local.region_account}:vpc-endpoint/*", + "arn:aws:ec2:${local.region_account}:vpc/*", + "arn:aws:elasticloadbalancing:${local.region_account}:listener-rule/app/bloom/*", + "arn:aws:elasticloadbalancing:${local.region_account}:listener/app/bloom/*", + "arn:aws:elasticloadbalancing:${local.region_account}:loadbalancer/app/bloom/*", + "arn:aws:elasticloadbalancing:${local.region_account}:targetgroup/bloom-site-partners/*", + "arn:aws:elasticloadbalancing:${local.region_account}:targetgroup/bloom-site-public/*", + ] + } + statement { + sid = "DisassociateAddress" + actions = ["ec2:DisassociateAddress"] + # For some reason the DisassociateAdress calls use this resource pattern. + resources = ["arn:aws:ec2:${local.region_account}:*/*"] + } + statement { + # https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/UsingWithRDS.IAM.ServiceLinkedRoles.html + sid = "DBServiceLinkedRole" + actions = [ + "iam:CreateServiceLinkedRole", + ] + resources = [ + "arn:aws:iam::${var.bloom_deployment_aws_account_number}:role/aws-service-role/rds.amazonaws.com/AWSServiceRoleForRDS", + ] + condition { + test = "StringLike" + variable = "iam:AWSServiceName" + values = ["rds.amazonaws.com"] + } + } + statement { + sid = "DBMasterUserPassword" + actions = [ + "kms:DescribeKey", + "secretsmanager:CreateSecret", + "secretsmanager:TagResource", + ] + resources = ["*"] + } + statement { + sid = "DescribeDB" + actions = ["rds:DescribeDBInstances"] + resources = ["arn:aws:rds:${local.region_account}:db:*"] + } + statement { + # https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/security_iam_id-based-policy-examples-create-and-modify-examples.html + sid = "ConfigureDB" + actions = [ + "rds:CreateDBInstance", + "rds:CreateDBSnapshot", + "rds:CreateDBSubnetGroup", + "rds:DeleteDBInstance", + "rds:DeleteDBSubnetGroup", + "rds:DescribeDBInstances", + "rds:DescribeDBSubnetGroups", + "rds:ListTagsForResource", + "rds:ModifyDBInstance", + ] + resources = [ + "arn:aws:rds:${local.region_account}:db:bloom", + "arn:aws:rds:${local.region_account}:og:bloom", + "arn:aws:rds:${local.region_account}:pg:bloom", + "arn:aws:rds:${local.region_account}:snapshot:bloom-db-finalsnapshot", + "arn:aws:rds:${local.region_account}:subgrp:bloom", + ] + } + statement { + # For some reason the task APIs operate on the * resouce and don't support targeting the + # resource directly in the IAM policy. + sid = "ECSTaskDefinition" + actions = [ + "ecs:DeregisterTaskDefinition", + "ecs:DescribeTaskDefinition", + ] + resources = ["*"] + } + statement { + sid = "ConfigureECS" + actions = [ + "ecs:CreateCluster", + "ecs:CreateService", + "ecs:DeleteCluster", + "ecs:DeleteService", + "ecs:DescribeClusters", + "ecs:DescribeServiceDeployments", + "ecs:DescribeServices", + "ecs:ListServiceDeployments", + "ecs:ListTagsForResource", + "ecs:RegisterTaskDefinition", + "ecs:UpdateCluster", + "ecs:UpdateService", + "iam:CreateRole", + "iam:DeleteRole", + "iam:DeleteRolePolicy", + "iam:GetRole", + "iam:GetRolePolicy", + "iam:ListAttachedRolePolicies", + "iam:ListInstanceProfilesForRole", + "iam:ListRolePolicies", + "iam:PassRole", + "iam:PutRolePolicy", + "logs:CreateLogGroup", + "logs:DeleteLogGroup", + "logs:DescribeLogGroups", + "logs:ListTagsForResource", + "logs:PutRetentionPolicy", + "servicediscovery:CreateHttpNamespace", + "servicediscovery:DeleteNamespace", + "servicediscovery:GetNamespace", + "servicediscovery:GetOperation", + "servicediscovery:ListTagsForResource", + ] + resources = concat( + [ + "arn:aws:ecs:${local.region_account}:cluster/bloom", + "arn:aws:logs:${local.region_account}:log-group::log-stream:", + "arn:aws:servicediscovery:${local.region_account}:*/*" + ], + flatten( + [for name in ["api", "site-partners", "site-public"] : [ + "arn:aws:ecs:${local.region_account}:service-deployment/bloom/bloom-${name}/*", + "arn:aws:ecs:${local.region_account}:service/bloom/bloom-${name}", + "arn:aws:ecs:${local.region_account}:task-definition/bloom-${name}:*", + "arn:aws:iam::${var.bloom_deployment_aws_account_number}:role/bloom-${name}-container", + "arn:aws:iam::${var.bloom_deployment_aws_account_number}:role/bloom-${name}-ecs", + "arn:aws:logs:${local.region_account}:log-group:bloom-${name}*", + ]] + )) + } + statement { + sid = "APIJWTKeySecret" + actions = [ + "secretsmanager:CreateSecret", + "secretsmanager:DeleteSecret", + "secretsmanager:DescribeSecret", + "secretsmanager:GetResourcePolicy", + "secretsmanager:PutResourcePolicy", + "secretsmanager:PutSecretValue", + "secretsmanager:TagResource", + ] + resources = ["arn:aws:secretsmanager:${local.region_account}:secret:bloom-api-jwt-signing-key*"] + } +} diff --git a/infra/tofu_importable_modules/bloom_deployment/db.tf b/infra/tofu_importable_modules/bloom_deployment/db.tf new file mode 100644 index 0000000000..26bbb3ae11 --- /dev/null +++ b/infra/tofu_importable_modules/bloom_deployment/db.tf @@ -0,0 +1,42 @@ +# Create a database. +resource "aws_db_subnet_group" "bloom" { + region = var.aws_region + name = "bloom" + subnet_ids = [for s in aws_subnet.private : s.id] +} +resource "aws_db_instance" "bloom" { + identifier = "bloom" + deletion_protection = local.is_prod + engine = "postgres" + engine_version = "17" + instance_class = local.database_config.instance_class + multi_az = var.high_availability + enabled_cloudwatch_logs_exports = ["postgresql", "upgrade", "iam-db-auth-error"] + username = "master" + manage_master_user_password = true + + # Monitoring + performance_insights_enabled = "true" + performance_insights_retention_period = 7 # minimum + database_insights_mode = "standard" + + # Networking + vpc_security_group_ids = [aws_security_group.db.id] + iam_database_authentication_enabled = true + db_subnet_group_name = aws_db_subnet_group.bloom.id + + # Updates + apply_immediately = true # If false, any changes are applied in the next maintenance window instead of when tofu apply runs. + engine_lifecycle_support = "open-source-rds-extended-support" + allow_major_version_upgrade = false + auto_minor_version_upgrade = true + + # Storage + storage_encrypted = true + storage_type = "gp2" + allocated_storage = local.database_config.starting_storage_gb + max_allocated_storage = local.database_config.max_storage_gb + backup_retention_period = local.database_config.backup_retention_days + final_snapshot_identifier = "bloom-db-finalsnapshot" + skip_final_snapshot = !local.is_prod +} diff --git a/infra/tofu_importable_modules/bloom_deployment/ecs.tf b/infra/tofu_importable_modules/bloom_deployment/ecs.tf new file mode 100644 index 0000000000..a15c3b7fa5 --- /dev/null +++ b/infra/tofu_importable_modules/bloom_deployment/ecs.tf @@ -0,0 +1,189 @@ +# Create an ECS cluster and services for each Bloom binary. +resource "aws_iam_service_linked_role" "ecs" { + aws_service_name = "ecs.amazonaws.com" +} +resource "aws_ecs_cluster" "bloom" { + region = var.aws_region + name = "bloom" + depends_on = [aws_iam_service_linked_role.ecs] + setting { + name = "containerInsights" + value = "enhanced" + } +} + +# Set up service discovery for the sites to talk to the api. +resource "aws_service_discovery_http_namespace" "bloom" { + region = var.aws_region + name = "bloom" + description = "Service namespace the bloom services use." +} + +# Create logs groups. +resource "aws_cloudwatch_log_group" "task_logs" { + for_each = toset([ + "bloom-api", + "bloom-site-partners", + "bloom-site-public" + ]) + region = var.aws_region + name = each.value + log_group_class = "STANDARD" + retention_in_days = local.ecs_logs_retention_days +} + +# Create a secret key used by the API to sign JWTs. +resource "aws_secretsmanager_secret" "api_jwt_signing_key" { + region = var.aws_region + description = "Key used by the Bloom API to sign JWTs" + name_prefix = "bloom-api-jwt-signing-key" # avoids 'you can't create this secret because a secret with this name is already scheduled for deletion' issue when re-deploying an account. + recovery_window_in_days = 7 # minimum + + # TODO: use an ephemeral resource instead of local-exec: + # https://github.com/bloom-housing/bloom/issues/5637. + # + # The provisioner block runs after the resource has been created, and never again. + provisioner "local-exec" { + interpreter = ["/usr/bin/env", "bash", "-c"] + # We need to be very careful that any errors result in a non-zero exit code. Otherwise tofu will + # think this block succeeded and not error. + command = <<-EOT + if ! type -P aws &>/dev/null; then + echo 'ERROR: aws required' + exit 1 + fi + if ! type -P openssl &>/dev/null; then + echo 'ERROR: openssl required' + exit 1 + fi + if ! type -P tr &>/dev/null; then + echo 'ERROR: tr required' + exit 1 + fi + + if ! s=$(openssl rand -base64 256 | tr -d '\n'); then + echo 'ERROR: failed to generate random value' + exit 1 + fi + + if ! aws secretsmanager put-secret-value \ + --profile ${var.aws_profile} \ + --region ${var.aws_region} \ + --secret-id ${self.id} \ + --secret-string "$s" + then + echo 'ERROR: failed to put secret value' + exit 1 + fi + EOT + } +} + +locals { + roles = { + "api" = { + task_execution_policy_extra_statements = [ + { + Action = "secretsmanager:GetSecretValue" + Effect = "Allow" + Resource = one([for s in aws_db_instance.bloom.master_user_secret : s.secret_arn if s.secret_status == "active"]) + }, + { + Action = "secretsmanager:GetSecretValue" + Effect = "Allow" + Resource = aws_secretsmanager_secret.api_jwt_signing_key.arn + } + ] + } + "site-partners" = { + task_execution_policy_extra_statements = [] + } + "site-public" = { + task_execution_policy_extra_statements = [] + } + } +} + +# Create roles for the ECS task executor and the tasks. +resource "aws_iam_role" "bloom_ecs" { + for_each = local.roles + name = "bloom-${each.key}-ecs" + description = "Role the ECS service uses when launching Bloom ${each.key} tasks." + # https://docs.aws.amazon.com/AmazonECS/latest/developerguide/task-iam-roles.html#create_task_iam_policy_and_role + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Action = "sts:AssumeRole" + Effect = "Allow" + Principal = { + Service = "ecs-tasks.amazonaws.com" + } + Condition = { + ArnLike = { + "aws:SourceArn" = "arn:aws:ecs:${var.aws_region}:${var.aws_account_number}:*" + } + StringEquals = { + "aws:SourceAccount" = var.aws_account_number + } + } + }] + }) +} +resource "aws_iam_role_policy" "bloom_ecs" { + for_each = local.roles + name = "bloom-${each.key}-ecs" + role = aws_iam_role.bloom_ecs[each.key].id + policy = jsonencode({ + Version = "2012-10-17" + Statement = concat( + [ + { + Action = [ + "logs:CreateLogStream", + "logs:PutLogEvents", + ] + Effect = "Allow" + Resource = "${aws_cloudwatch_log_group.task_logs["bloom-${each.key}"].arn}:log-stream:*" + }, + ], + each.value.task_execution_policy_extra_statements + ) + }) +} +resource "aws_iam_role" "bloom_container" { + for_each = local.roles + name = "bloom-${each.key}-container" + description = "Role the Bloom ${each.key} container runs as." + # https://docs.aws.amazon.com/AmazonECS/latest/developerguide/task-iam-roles.html#create_task_iam_policy_and_role + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Action = "sts:AssumeRole" + Effect = "Allow" + Principal = { + Service = "ecs-tasks.amazonaws.com" + } + Condition = { + ArnLike = { + "aws:SourceArn" = "arn:aws:ecs:${var.aws_region}:${var.aws_account_number}:*" + } + StringEquals = { + "aws:SourceAccount" = var.aws_account_number + } + } + }] + }) +} +resource "aws_iam_role_policy" "bloom_container" { + for_each = local.roles + name = "bloom-${each.key}-container" + role = aws_iam_role.bloom_container[each.key].id + policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Action = "*" + Effect = "Deny" + Resource = "*" + }] + }) +} diff --git a/infra/tofu_importable_modules/bloom_deployment/ecs_api_task.tf b/infra/tofu_importable_modules/bloom_deployment/ecs_api_task.tf new file mode 100644 index 0000000000..e893fabf7d --- /dev/null +++ b/infra/tofu_importable_modules/bloom_deployment/ecs_api_task.tf @@ -0,0 +1,142 @@ +locals { + api_default_env_vars = { + PORT = "3100" + NODE_ENV = "production" + DB_USER = aws_db_instance.bloom.username + DB_HOST = aws_db_instance.bloom.endpoint + } +} +resource "aws_ecs_task_definition" "bloom_api" { + region = var.aws_region + family = "bloom-api" + network_mode = "awsvpc" + requires_compatibilities = ["FARGATE"] + runtime_platform { + operating_system_family = "LINUX" + cpu_architecture = "X86_64" + } + + execution_role_arn = aws_iam_role.bloom_ecs["api"].arn + task_role_arn = aws_iam_role.bloom_container["api"].arn + + # https://docs.aws.amazon.com/AmazonECS/latest/developerguide/task_definition_parameters.html#task_size + cpu = 1024 # 1 vCPU + memory = 2 * 1024 # 2 GiB in MiB + + container_definitions = jsonencode([ + { + Name = "bloom-api" + image = var.bloom_api_image + command = [ + "/bin/bash", + "-c", + "export DATABASE_URL=postgres://$DB_USER:$(node -e 'console.log(encodeURIComponent(process.argv[1]))' $DB_PASSWORD)@$DB_HOST/bloomprisma && env && yarn db:migration:run && yarn start:prod", + ] + secrets = [ + { + # TODO: replace with IAM authentication https://github.com/bloom-housing/bloom/issues/5451 + name = "DB_PASSWORD" + # The master_user_secret tofu attribute is a block, so it is exposed as a list even though + # there is only one element. + # + # The RDS managed secret has a username key and a password key. Docs for how to read + # a specific key out of a secret: + # https://docs.aws.amazon.com/AmazonECS/latest/developerguide/secrets-envvar-secrets-manager.html#secrets-envvar-secrets-manager-update-container-definition + valueFrom = "${aws_db_instance.bloom.master_user_secret[0].secret_arn}:password::" + }, + { + name = "APP_SECRET", + valueFrom = aws_secretsmanager_secret.api_jwt_signing_key.arn + } + ] + environment = [for k, v in merge(local.api_default_env_vars, var.bloom_api_env_vars) : { name = k, value = v }] + portMappings = [ + { + name = "http" + appProtocol = "http" + containerPort = 3100 + } + ] + restartPolicy = { + enabled = false + } + healthCheck = { + command = [ + "curl", + "--fail", + "http://127.0.0.1:3100/" + ] + interval = 5 # seconds + timeout = 2 # seconds + retries = 10 + startPeriod = 5 # seconds + } + logConfiguration = { + logDriver = "awslogs" + options = { + "awslogs-region" = var.aws_region + "awslogs-group" = aws_cloudwatch_log_group.task_logs["bloom-api"].name + "awslogs-stream-prefix" = "bloom-api" + } + } + } + ]) +} +resource "aws_ecs_service" "bloom_api" { + depends_on = [ + aws_db_instance.bloom, + aws_vpc_endpoint.secrets_manager, + aws_route_table_association.private_subnet, + ] + wait_for_steady_state = true # if tofu waits for the triggered deployment to complete. + region = var.aws_region + cluster = aws_ecs_cluster.bloom.arn + name = "bloom-api" + force_delete = true # allow deletion of the service without scaling down tasks to 0 first. + task_definition = aws_ecs_task_definition.bloom_api.arn + launch_type = "FARGATE" + scheduling_strategy = "REPLICA" + availability_zone_rebalancing = "ENABLED" + desired_count = local.bloom_api_task_count + deployment_configuration { + strategy = "ROLLING" + } + deployment_circuit_breaker { + enable = true + rollback = true + } + deployment_controller { + type = "ECS" + } + deployment_maximum_percent = 200 # allow surge of up to twice desired task count. + + network_configuration { + security_groups = [aws_security_group.api.id] + subnets = [for s in aws_subnet.private : s.id] + assign_public_ip = false + } + service_connect_configuration { + enabled = true + namespace = aws_service_discovery_http_namespace.bloom.arn + service { + port_name = "http" + client_alias { + dns_name = "bloom-api" + port = 3100 + } + discovery_name = "bloom-api" + timeout { + idle_timeout_seconds = 0 # disable idleTimeout + per_request_timeout_seconds = 60 + } + } + log_configuration { + log_driver = "awslogs" + options = { + "awslogs-region" = var.aws_region + "awslogs-group" = aws_cloudwatch_log_group.task_logs["bloom-api"].name + "awslogs-stream-prefix" = "service-connect-proxy" + } + } + } +} diff --git a/infra/tofu_importable_modules/bloom_deployment/ecs_site_partners_task.tf b/infra/tofu_importable_modules/bloom_deployment/ecs_site_partners_task.tf new file mode 100644 index 0000000000..f0d0822bb4 --- /dev/null +++ b/infra/tofu_importable_modules/bloom_deployment/ecs_site_partners_task.tf @@ -0,0 +1,103 @@ +locals { + site_partners_default_env_vars = { + NODE_ENV = "production" + NEXTJS_PORT = "3001" + BACKEND_API_BASE = "http://bloom-api:3100" + LISTINGS_QUERY = "/listings" + } +} +resource "aws_ecs_task_definition" "bloom_site_partners" { + region = var.aws_region + family = "bloom-site-partners" + network_mode = "awsvpc" + requires_compatibilities = ["FARGATE"] + runtime_platform { + operating_system_family = "LINUX" + cpu_architecture = "X86_64" + } + + execution_role_arn = aws_iam_role.bloom_ecs["site-partners"].arn + task_role_arn = aws_iam_role.bloom_container["site-partners"].arn + + # https://docs.aws.amazon.com/AmazonECS/latest/developerguide/task_definition_parameters.html#task_size + cpu = 2048 # 2 vCPU + memory = 4 * 1024 # 4 GiB in MiB + + container_definitions = jsonencode([ + { + Name = "bloom-site-partners" + image = var.bloom_site_partners_image + environment = [for k, v in merge(local.site_partners_default_env_vars, var.bloom_site_partners_env_vars) : { name = k, value = v }] + portMappings = [ + { + containerPort = 3001 + appProtocol = "http" + } + ] + restartPolicy = { + enabled = false + } + # TODO healthCheck https://github.com/bloom-housing/bloom/issues/5583 + logConfiguration = { + logDriver = "awslogs" + options = { + "awslogs-region" = var.aws_region + "awslogs-group" = aws_cloudwatch_log_group.task_logs["bloom-site-partners"].name + "awslogs-stream-prefix" = "bloom-site-partners" + } + } + } + ]) +} +resource "aws_ecs_service" "bloom_site_partners" { + depends_on = [ + aws_ecs_service.bloom_api, + aws_route_table_association.private_subnet, + ] + region = var.aws_region + cluster = aws_ecs_cluster.bloom.arn + name = "bloom-site-partners" + force_delete = true # allow deletion of the service without scaling down tasks to 0 first. + task_definition = aws_ecs_task_definition.bloom_site_partners.arn + wait_for_steady_state = true + + network_configuration { + security_groups = [aws_security_group.site_partners.id] + subnets = [for s in aws_subnet.private : s.id] + assign_public_ip = false + } + service_connect_configuration { + enabled = true + namespace = aws_service_discovery_http_namespace.bloom.arn + log_configuration { + log_driver = "awslogs" + options = { + "awslogs-region" = var.aws_region + "awslogs-group" = aws_cloudwatch_log_group.task_logs["bloom-site-partners"].name + "awslogs-stream-prefix" = "service-connect-proxy" + } + } + } + load_balancer { + target_group_arn = aws_lb_target_group.site_partners.arn + container_name = "bloom-site-partners" + container_port = 3001 + } + health_check_grace_period_seconds = 500 # seconds, how long the LB will wait before considering a new task unhealthy. + + launch_type = "FARGATE" + scheduling_strategy = "REPLICA" + availability_zone_rebalancing = "ENABLED" + desired_count = local.bloom_site_partners_task_count + deployment_configuration { + strategy = "ROLLING" + } + deployment_circuit_breaker { + enable = true + rollback = true + } + deployment_controller { + type = "ECS" + } + deployment_maximum_percent = 200 # allow surge of up to twice desired task count. +} diff --git a/infra/tofu_importable_modules/bloom_deployment/ecs_site_public_task.tf b/infra/tofu_importable_modules/bloom_deployment/ecs_site_public_task.tf new file mode 100644 index 0000000000..62134e0bb9 --- /dev/null +++ b/infra/tofu_importable_modules/bloom_deployment/ecs_site_public_task.tf @@ -0,0 +1,112 @@ +locals { + site_public_default_env_vars = { + NODE_ENV = "production" + NEXTJS_PORT = "3000" + BACKEND_API_BASE = "http://bloom-api:3100" + BACKEND_API_BASE_NEW = "http://bloom-api:3100" + LISTINGS_QUERY = "/listings" + MAX_BROWSE_LISTINGS = "10" + HOUSING_COUNSELOR_SERVICE_URL = "/get-assistance" + IDLE_TIMEMOUT = "5" # seconds + CACHE_REVALIDATE = "30" # seconds + SHOW_PUBLIC_LOTTERY = "TRUE" + SHOW_MANDATED_ACCOUNTS = "FALSE" + SHOW_PWDLESS = "FALSE" + SHOW_NEW_SEEDS_DESIGNS = "FALSE" + } +} +resource "aws_ecs_task_definition" "bloom_site_public" { + region = var.aws_region + family = "bloom-site-public" + network_mode = "awsvpc" + requires_compatibilities = ["FARGATE"] + runtime_platform { + operating_system_family = "LINUX" + cpu_architecture = "X86_64" + } + + execution_role_arn = aws_iam_role.bloom_ecs["site-public"].arn + task_role_arn = aws_iam_role.bloom_container["site-public"].arn + + # https://docs.aws.amazon.com/AmazonECS/latest/developerguide/task_definition_parameters.html#task_size + cpu = 2048 # 2 vCPU + memory = 6 * 1024 # 6 GiB in MiB + + container_definitions = jsonencode([ + { + Name = "bloom-site-public" + image = var.bloom_site_public_image + environment = [for k, v in merge(local.site_public_default_env_vars, var.bloom_site_public_env_vars) : { name = k, value = v }] + portMappings = [ + { + containerPort = 3000 + appProtocol = "http" + } + ] + restartPolicy = { + enabled = false + } + # TODO healthCheck https://github.com/bloom-housing/bloom/issues/5583 + logConfiguration = { + logDriver = "awslogs" + options = { + "awslogs-region" = var.aws_region + "awslogs-group" = aws_cloudwatch_log_group.task_logs["bloom-site-public"].name + "awslogs-stream-prefix" = "bloom-site-public" + } + } + } + ]) +} +resource "aws_ecs_service" "bloom_site_public" { + depends_on = [ + aws_ecs_service.bloom_api, + aws_route_table_association.private_subnet, + ] + region = var.aws_region + cluster = aws_ecs_cluster.bloom.arn + name = "bloom-site-public" + force_delete = true # allow deletion of the service without scaling down tasks to 0 first. + task_definition = aws_ecs_task_definition.bloom_site_public.arn + wait_for_steady_state = true + + network_configuration { + security_groups = [aws_security_group.site_public.id] + subnets = [for s in aws_subnet.private : s.id] + assign_public_ip = false + } + service_connect_configuration { + enabled = true + namespace = aws_service_discovery_http_namespace.bloom.arn + log_configuration { + log_driver = "awslogs" + options = { + "awslogs-region" = var.aws_region + "awslogs-group" = aws_cloudwatch_log_group.task_logs["bloom-site-public"].name + "awslogs-stream-prefix" = "service-connect-proxy" + } + } + } + load_balancer { + target_group_arn = aws_lb_target_group.site_public.arn + container_name = "bloom-site-public" + container_port = 3000 + } + health_check_grace_period_seconds = 500 # seconds, how long the LB will wait before considering a new task unhealthy. + + launch_type = "FARGATE" + scheduling_strategy = "REPLICA" + availability_zone_rebalancing = "ENABLED" + desired_count = local.bloom_site_public_task_count + deployment_configuration { + strategy = "ROLLING" + } + deployment_circuit_breaker { + enable = true + rollback = true + } + deployment_controller { + type = "ECS" + } + deployment_maximum_percent = 200 # allow surge of up to twice desired task count. +} diff --git a/infra/tofu_importable_modules/bloom_deployment/lb.tf b/infra/tofu_importable_modules/bloom_deployment/lb.tf new file mode 100644 index 0000000000..4f67196eca --- /dev/null +++ b/infra/tofu_importable_modules/bloom_deployment/lb.tf @@ -0,0 +1,173 @@ +# Create an application load balancer that the partners and public sites can be accessed through. +resource "aws_lb" "bloom" { + region = var.aws_region + name = "bloom" + enable_deletion_protection = local.is_prod + + load_balancer_type = "application" + internal = false + subnets = [for s in aws_subnet.public : s.id] + + idle_timeout = 60 # seconds + security_groups = [aws_security_group.lb.id] + enable_zonal_shift = true + desync_mitigation_mode = "strictest" + drop_invalid_header_fields = true +} +output "lb_dns_name" { + value = aws_lb.bloom.dns_name + description = "DNS name of the load balancer." +} +data "aws_acm_certificate" "bloom" { + domain = var.domain_name + most_recent = true + + # TODO: validate this stops creation of all other resources + lifecycle { + postcondition { + condition = self.status == "ISSUED" + error_message = "The certificate must be ISSUED before bloom can deploy its load balancer." + } + } +} +resource "aws_lb_listener" "bloom" { + region = var.aws_region + load_balancer_arn = aws_lb.bloom.arn + certificate_arn = var.aws_certificate_arn + + port = 443 + protocol = "HTTPS" + + default_action { + type = "fixed-response" + fixed_response { + content_type = "text/plain" + message_body = "Error: routing misconfiguration" + status_code = 501 + } + } +} + +# Routing for partners site +resource "aws_lb_listener_rule" "site_partners" { + region = var.aws_region + listener_arn = aws_lb_listener.bloom.arn + condition { + host_header { + values = ["partners.${var.domain_name}"] + } + } + action { + type = "forward" + target_group_arn = aws_lb_target_group.site_partners.arn + } + tags = { + Name = "bloom-site-partners" + } + + lifecycle { + replace_triggered_by = [aws_lb_target_group.site_partners] + } +} +resource "aws_lb_target_group" "site_partners" { + region = var.aws_region + vpc_id = aws_vpc.bloom.id + name = "bloom-site-partners" + + target_type = "ip" + ip_address_type = "ipv4" + port = 3001 + protocol = "HTTP" + protocol_version = "HTTP1" + + load_balancing_algorithm_type = "round_robin" + stickiness { + enabled = true + type = "app_cookie" + # https://github.com/bloom-housing/bloom/blob/main/docs/Authentication.md + cookie_name = "access-token" + } + + deregistration_delay = 5 # seconds + health_check { + enabled = true + healthy_threshold = 2 # this is the minimum + unhealthy_threshold = 2 # this is the minimum + interval = 10 # seconds, this is the minimum + timeout = 5 # seconds + protocol = "HTTP" + # TODO: use healthcheck endpoint https://github.com/bloom-housing/bloom/issues/5583 + path = "/" + matcher = "200" + } + target_group_health { + dns_failover { + minimum_healthy_targets_count = 1 + } + unhealthy_state_routing { + minimum_healthy_targets_count = 1 + } + } +} + +# Routing for public site +resource "aws_lb_listener_rule" "site_public" { + region = var.aws_region + listener_arn = aws_lb_listener.bloom.arn + condition { + host_header { + values = [var.domain_name] + } + } + action { + type = "forward" + target_group_arn = aws_lb_target_group.site_public.arn + } + tags = { + Name = "bloom-site-public" + } + + lifecycle { + replace_triggered_by = [aws_lb_target_group.site_public] + } +} +resource "aws_lb_target_group" "site_public" { + region = var.aws_region + vpc_id = aws_vpc.bloom.id + name = "bloom-site-public" + + target_type = "ip" + ip_address_type = "ipv4" + port = 3000 + protocol = "HTTP" + protocol_version = "HTTP1" + + load_balancing_algorithm_type = "round_robin" + stickiness { + enabled = true + type = "app_cookie" + # https://github.com/bloom-housing/bloom/blob/main/docs/Authentication.md + cookie_name = "access-token" + } + + deregistration_delay = 5 # seconds + health_check { + enabled = true + healthy_threshold = 2 # this is the minimum + unhealthy_threshold = 2 # this is the minimum + interval = 10 # seconds, this is the minimum + timeout = 5 # seconds + protocol = "HTTP" + # TODO: use healthcheck endpoint https://github.com/bloom-housing/bloom/issues/5583 + path = "/" + matcher = "200" + } + target_group_health { + dns_failover { + minimum_healthy_targets_count = 1 + } + unhealthy_state_routing { + minimum_healthy_targets_count = 1 + } + } +} diff --git a/infra/tofu_importable_modules/bloom_deployment/main.tf b/infra/tofu_importable_modules/bloom_deployment/main.tf new file mode 100644 index 0000000000..4674d5e59f --- /dev/null +++ b/infra/tofu_importable_modules/bloom_deployment/main.tf @@ -0,0 +1,175 @@ +terraform { + required_providers { + aws = { + version = "6.21.0" + source = "hashicorp/aws" + } + } +} + +variable "aws_profile" { + type = string + description = "AWS CLI profile to use when running aws commands in local-exec provisioners." +} +variable "aws_account_number" { + type = number + description = "AWS account number that is being configured." +} +variable "aws_region" { + type = string + description = "Region to deploy AWS resources to" + validation { + condition = ( + var.aws_region == "us-east-1" || + var.aws_region == "us-east-2" || + var.aws_region == "us-west-1" || + var.aws_region == "us-west-2" + ) + error_message = "Must be 'us-east-1', 'us-east-2', 'us-west-1', or 'us-west-2'." + } +} +variable "vpc_cidr_range" { + type = string + description = "The CIDR range to use for the Bloom VPC. Must be a /22 block in the RFC 1918 private IP space." + default = "10.0.0.0/22" + validation { + condition = ( + cidrcontains("10.0.0.0/8", var.vpc_cidr_range) || + cidrcontains("172.16.0.0/12", var.vpc_cidr_range) || + cidrcontains("192.168.0.0/16", var.vpc_cidr_range) + ) + error_message = "Must be in the RFC 1918 private IP space." + } + validation { + condition = cidrnetmask(var.vpc_cidr_range) == cidrnetmask("10.0.0.0/22") + error_message = "Must be a /22." + } +} +variable "high_availability" { + type = bool + description = "Deploy the Bloom services in a highly-available manner. If true, a minimum of 2 instances will be running for each Bloom service." + default = true +} +variable "env_type" { + type = string + description = "Type of environment this deployment is going in." + validation { + condition = ( + var.env_type == "dev" || + var.env_type == "production" + ) + error_message = "Must be 'dev' or 'production'." + } +} +locals { + is_prod = var.env_type == "production" +} +variable "database_config" { + description = "Settings for the Bloom database. Defaults are provided based on the env_type setting." + type = object({ + # Pricing: https://aws.amazon.com/rds/postgresql/pricing/?pg=pr&loc=3 + # Machine specs: https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/Concepts.DBInstanceClass.Summary.html#hardware-specifications.burstable-inst-classes + instance_class = string + starting_storage_gb = number + max_storage_gb = number + backup_retention_days = number + }) + default = null +} +locals { + database_config = var.database_config != null ? var.database_config : (local.is_prod ? { + # Prod defaults + instance_class = "db.t4g.medium" + starting_storage_gb = 10 + max_storage_gb = 100 + backup_retention_days = 30 + } : { + # Non-prod defaults + instance_class = "db.t4g.micro" + starting_storage_gb = 5 # minimum + max_storage_gb = 10 + backup_retention_days = 7 + }) +} + +variable "domain_name" { + type = string + description = "Domain name the bloom deployment will be served on" +} +variable "aws_certificate_arn" { + type = string + description = "ARN of the validated certificate for the domain_name" +} + +variable "ecs_logs_retention_days" { + description = "How long ECS task logs are retained. Default is provided based on the env_type setting." + type = number + default = null +} +locals { + ecs_logs_retention_days = var.ecs_logs_retention_days != null ? var.ecs_logs_retention_days : (local.is_prod ? 30 : 3) +} + +variable "bloom_api_image" { + type = string + description = "Container image for the Bloom API." +} +variable "bloom_api_env_vars" { + type = map(any) + description = "Environment variables for the Bloom API tasks. This is merged with the default env vars set in the ecs_api_task.tf file, with these variables taking precedence." + default = {} +} +variable "bloom_api_task_count" { + description = "How many API tasks that should be running. Default is provided based on the high_availability setting." + type = number + default = null +} +locals { + bloom_api_task_count = var.bloom_api_task_count != null ? var.bloom_api_task_count : (var.high_availability ? 2 : 1) +} +variable "bloom_site_partners_image" { + type = string + description = "Container image for the Bloom partners site." +} +variable "bloom_site_partners_env_vars" { + type = map(any) + description = "Environment variables for the Bloom partners site tasks. This is merged with the default env vars set in the ecs_site_partners_task.tf file, with these variables taking precedence." + default = {} +} +variable "bloom_site_partners_task_count" { + description = "How many partners site tasks that should be running. Default is provided based on the high_availability setting." + type = number + default = null +} +locals { + bloom_site_partners_task_count = var.bloom_site_partners_task_count != null ? var.bloom_site_partners_task_count : (var.high_availability ? 2 : 1) +} +variable "bloom_site_public_image" { + type = string + description = "Container image for the Bloom public site." +} +variable "bloom_site_public_env_vars" { + type = map(any) + description = "Environment variables for the Bloom public site tasks. This is merged with the default env vars set in the ecs_site_public_task.tf file, with these variables taking precedence." + default = {} +} +variable "bloom_site_public_task_count" { + description = "How many public site tasks that should be running. Default is provided based on the high_availability setting." + type = number + default = null +} +locals { + bloom_site_public_task_count = var.bloom_site_public_task_count != null ? var.bloom_site_public_task_count : (var.high_availability ? 2 : 1) +} + +# Create a CloudTrail data store so that audit events are query-able in SQL. +resource "aws_cloudtrail_event_data_store" "audit" { + count = local.is_prod ? 1 : 0 + + region = var.aws_region + name = "audit" + multi_region_enabled = true + retention_period = 30 + termination_protection_enabled = true +} + diff --git a/infra/tofu_importable_modules/bloom_deployment/vpc.tf b/infra/tofu_importable_modules/bloom_deployment/vpc.tf new file mode 100644 index 0000000000..a85d13180f --- /dev/null +++ b/infra/tofu_importable_modules/bloom_deployment/vpc.tf @@ -0,0 +1,412 @@ +# Network design: +# +# Provision a private and public subnet for each availability zone in the region. Only load +# balancers and NAT gateways are provisioned in the public subnets, everything else is provisioned +# in the private subnets. The Bloom ECS tasks need access to the internet so they can download their +# docker image and access their dependent services. Access is provided via NAT gateways. Access to +# AWS services is configured through AWS PrivateLink endpoints so that traffic stays in the AWS +# internal network rather than going over the internet. +# +# We get a /22 range (1,024 addresses total) and provision it into /26 blocks (64 addresses total, +# 59 usable [1]) for each subnet. This provisioning strategy supports up to 8 subnets in the region +# being deployed to. Assuming a base IP of 10.0.0.0, the subnet ranges will therefore be: +# +# cidr | zone | type +# --------------|------|-------- +# 10.0.0.0/26 | a | private +# 10.0.0.64/26 | a | public +# 10.0.0.128/26 | b | private +# 10.0.0.192/26 | b | public +# 10.0.1.0/26 | c | private +# 10.0.1.64/26 | c | public +# ... +# +# [1]: +# From https://aws.amazon.com/vpc/faqs/: +# +# > Amazon reserves the first four (4) IP addresses and the last one (1) IP address of every subnet for IP networking purposes. +# > The minimum size of a subnet is a /28 (or 14 IP addresses.) +data "aws_availability_zones" "zones" { + region = var.aws_region + state = "available" + + lifecycle { + postcondition { + # At the time of writing, the largest region is us-east-1 with 6 availability zones. + condition = length(self.names) <= 8 + error_message = "The IP provisioning strategy does not support more than 8 availability zones." + } + } +} +locals { + zones = data.aws_availability_zones.zones.names +} +resource "aws_vpc" "bloom" { + region = var.aws_region + cidr_block = var.vpc_cidr_range + enable_dns_support = true + enable_dns_hostnames = true + tags = { + Name = "bloom" + } +} +locals { + # VPC range is a /22, each subnet gets a /26 + newbits = 26 - 22 + + # Take even index ranges. + private_subnets = zipmap( + local.zones, + [for i in range(0, (length(local.zones) * 2) - 1, 2) : cidrsubnet(var.vpc_cidr_range, local.newbits, i)] + ) + # Take odd index ranges. + public_subnets = zipmap( + local.zones, + [for i in range(1, length(local.zones) * 2, 2) : cidrsubnet(var.vpc_cidr_range, local.newbits, i)] + ) +} +# What makes a AWS subnet public is a direct route to the internet. This is controlled through the +# route tables attached to each subnet. By default each route table has a 'local' route for cidr +# ranges used by the VPC. Add 'catch-all' route for 0.0.0.0/0 that routes to the VPC internet +# gateway. +resource "aws_internet_gateway" "bloom" { + region = var.aws_region + vpc_id = aws_vpc.bloom.id +} +resource "aws_subnet" "public" { + for_each = local.public_subnets + vpc_id = aws_vpc.bloom.id + availability_zone = each.key + cidr_block = each.value + map_public_ip_on_launch = false + tags = { + Name = "bloom-public-${each.key}" + } +} +resource "aws_route_table" "internet_gateway" { + region = var.aws_region + vpc_id = aws_vpc.bloom.id + route { + cidr_block = "0.0.0.0/0" + gateway_id = aws_internet_gateway.bloom.id + } + tags = { + Name = "bloom-internet-gateway" + } +} +resource "aws_route_table_association" "public_subnet" { + for_each = aws_subnet.public + region = var.aws_region + subnet_id = each.value.id + route_table_id = aws_route_table.internet_gateway.id +} +# ECS tasks in the private subnets still need access to the internet. This is provided by a NAT +# gateway. A NAT gateway is a zonal resource so provision one in each availability zone. Each +# private subnet needs a route table with a similar 'catch-all' route, but instead of going to the +# VPC internet gateway, it goes to the NAT gateway in the zone. +resource "aws_eip" "nat" { + for_each = aws_subnet.public + region = var.aws_region + domain = "vpc" +} +resource "aws_nat_gateway" "bloom" { + for_each = aws_subnet.public + region = var.aws_region + connectivity_type = "public" + subnet_id = each.value.id + allocation_id = aws_eip.nat[each.key].id + tags = { + Name = "bloom-nat-${each.key}" + } +} + +# Create private subnets. +resource "aws_subnet" "private" { + for_each = local.private_subnets + vpc_id = aws_vpc.bloom.id + availability_zone = each.key + cidr_block = each.value + map_public_ip_on_launch = false + tags = { + Name = "bloom-private-${each.key}" + } +} +resource "aws_route_table" "nat_gateway" { + for_each = aws_subnet.private + region = var.aws_region + vpc_id = aws_vpc.bloom.id + route { + cidr_block = "0.0.0.0/0" + nat_gateway_id = aws_nat_gateway.bloom[each.key].id + } + tags = { + Name = "bloom-nat-gateway-${each.key}" + } +} +resource "aws_route_table_association" "private_subnet" { + for_each = aws_subnet.private + region = var.aws_region + subnet_id = each.value.id + route_table_id = aws_route_table.nat_gateway[each.key].id +} + +# Create PrivateLink endpoints for AWS services to be accessed via. This keeps the traffic internal +# to AWS's network and avoids going through the NAT gateway over the internet. +resource "aws_vpc_endpoint" "secrets_manager" { + region = var.aws_region + vpc_id = aws_vpc.bloom.id + vpc_endpoint_type = "Interface" + private_dns_enabled = true # required for the AWS SDKs to use this network path by default instead of through the public internet. + service_name = "com.amazonaws.${var.aws_region}.secretsmanager" + subnet_ids = [for s in aws_subnet.private : s.id] + security_group_ids = [aws_security_group.secrets_manager_endpoint.id] + tags = { + Name = "bloom-secretsmanager" + } +} +resource "aws_security_group" "secrets_manager_endpoint" { + region = var.aws_region + vpc_id = aws_vpc.bloom.id + name = "secrets-manager-endpoint" + description = "Rules for secrets manager vpc endpoint" +} +resource "aws_vpc_security_group_ingress_rule" "bloom_service_tasks" { + for_each = { + "api" = aws_security_group.api.id + + # If/when the sites need secrets: + # "site-partners" = aws_security_group.site_partners.id + # "site-public" = aws_security_group.site_public.id + } + region = var.aws_region + security_group_id = aws_security_group.secrets_manager_endpoint.id + referenced_security_group_id = each.value + ip_protocol = "tcp" + from_port = 443 + to_port = 443 + tags = { + Name = "${each.key}-allow" + } +} + +# Create security group for database. +resource "aws_security_group" "db" { + region = var.aws_region + vpc_id = aws_vpc.bloom.id + name = "bloom-db" + description = "Rules for Bloom database." +} +resource "aws_vpc_security_group_ingress_rule" "db" { + region = var.aws_region + security_group_id = aws_security_group.db.id + referenced_security_group_id = aws_security_group.api.id + ip_protocol = "tcp" + from_port = 5432 + to_port = 5432 + tags = { + Name = "api-allow" + } +} + +# Create security group for LB. +resource "aws_security_group" "lb" { + region = var.aws_region + vpc_id = aws_vpc.bloom.id + name = "bloom-lb" + description = "Rules for Bloom load balancer." +} +resource "aws_vpc_security_group_ingress_rule" "lb" { + region = var.aws_region + security_group_id = aws_security_group.lb.id + cidr_ipv4 = "0.0.0.0/0" + ip_protocol = "tcp" + from_port = 443 + to_port = 443 + tags = { + Name = "internet-allow" + } +} +resource "aws_vpc_security_group_egress_rule" "lb_to_partners_site" { + region = var.aws_region + security_group_id = aws_security_group.lb.id + referenced_security_group_id = aws_security_group.site_partners.id + ip_protocol = "tcp" + from_port = 3001 + to_port = 3001 + tags = { + Name = "site-partners-allow" + } +} +resource "aws_vpc_security_group_egress_rule" "lb_to_public_site" { + region = var.aws_region + security_group_id = aws_security_group.lb.id + referenced_security_group_id = aws_security_group.site_public.id + ip_protocol = "tcp" + from_port = 3000 + to_port = 3000 + tags = { + Name = "site-public-allow" + } +} + +# Create security group for api ECS tasks. +resource "aws_security_group" "api" { + region = var.aws_region + vpc_id = aws_vpc.bloom.id + name = "bloom-api" + description = "Rules for Bloom API tasks." +} +resource "aws_vpc_security_group_ingress_rule" "api" { + for_each = { + "site-partners" = aws_security_group.site_partners.id + "site-public" = aws_security_group.site_public.id + } + region = var.aws_region + security_group_id = aws_security_group.api.id + referenced_security_group_id = each.value + ip_protocol = "tcp" + from_port = 3100 + to_port = 3100 + tags = { + Name = "${each.key}-allow" + } +} +resource "aws_vpc_security_group_egress_rule" "api_to_db" { + region = var.aws_region + security_group_id = aws_security_group.api.id + referenced_security_group_id = aws_security_group.db.id + ip_protocol = "tcp" + from_port = 5432 + to_port = 5432 + tags = { + Name = "allow-db" + } +} +resource "aws_vpc_security_group_egress_rule" "api_to_secretsmanager" { + region = var.aws_region + security_group_id = aws_security_group.api.id + referenced_security_group_id = aws_security_group.secrets_manager_endpoint.id + ip_protocol = "tcp" + from_port = 443 + to_port = 443 + tags = { + Name = "allow-secretsmanager" + } +} +resource "aws_vpc_security_group_egress_rule" "api_to_nat" { + region = var.aws_region + security_group_id = aws_security_group.api.id + cidr_ipv4 = "0.0.0.0/0" + ip_protocol = "tcp" + from_port = 443 + to_port = 443 + tags = { + Name = "allow-nat-https" + } +} + +# Create security group for partners site ECS tasks. +resource "aws_security_group" "site_partners" { + region = var.aws_region + vpc_id = aws_vpc.bloom.id + name = "bloom-site-partners" + description = "Rules for Bloom partners site tasks." +} +resource "aws_vpc_security_group_ingress_rule" "lb_to_site_partners" { + region = var.aws_region + security_group_id = aws_security_group.site_partners.id + referenced_security_group_id = aws_security_group.lb.id + ip_protocol = "tcp" + from_port = 3001 + to_port = 3001 + tags = { + Name = "allow-lb" + } +} +resource "aws_vpc_security_group_egress_rule" "site_partners_to_api" { + region = var.aws_region + security_group_id = aws_security_group.site_partners.id + referenced_security_group_id = aws_security_group.api.id + ip_protocol = "tcp" + from_port = 3100 + to_port = 3100 + tags = { + Name = "allow-api" + } +} +# If/when access to secrets manager is needed: +#resource "aws_vpc_security_group_egress_rule" "site_partners_to_secretsmanager" { +# region = var.aws_region +# security_group_id = aws_security_group.site_partners.id +# referenced_security_group_id = aws_security_group.secrets_manager_endpoint.id +# ip_protocol = "tcp" +# from_port = 443 +# to_port = 443 +# tags = { +# Name = "allow-secretsmanager" +# } +#} +resource "aws_vpc_security_group_egress_rule" "site_partners_to_nat" { + region = var.aws_region + security_group_id = aws_security_group.site_partners.id + cidr_ipv4 = "0.0.0.0/0" + ip_protocol = "tcp" + from_port = 443 + to_port = 443 + tags = { + Name = "allow-nat-https" + } +} + +# Create security group for public site ECS tasks. +resource "aws_security_group" "site_public" { + region = var.aws_region + vpc_id = aws_vpc.bloom.id + name = "bloom-site-public" + description = "Rules for Bloom public site tasks." +} +resource "aws_vpc_security_group_ingress_rule" "lb_to_site_public" { + region = var.aws_region + security_group_id = aws_security_group.site_public.id + referenced_security_group_id = aws_security_group.lb.id + ip_protocol = "tcp" + from_port = 3000 + to_port = 3000 + tags = { + Name = "allow-lb" + } +} +resource "aws_vpc_security_group_egress_rule" "site_public_to_api" { + region = var.aws_region + security_group_id = aws_security_group.site_public.id + referenced_security_group_id = aws_security_group.api.id + ip_protocol = "tcp" + from_port = 3100 + to_port = 3100 + tags = { + Name = "allow-api" + } +} +# If/when access to secrets manager is needed: +#resource "aws_vpc_security_group_egress_rule" "site_public_to_secretsmanager" { +# region = var.aws_region +# security_group_id = aws_security_group.site_public.id +# referenced_security_group_id = aws_security_group.secrets_manager_endpoint.id +# ip_protocol = "tcp" +# from_port = 443 +# to_port = 443 +# tags = { +# Name = "allow-secretsmanager" +# } +#} +resource "aws_vpc_security_group_egress_rule" "site_public_to_nat" { + region = var.aws_region + security_group_id = aws_security_group.site_public.id + cidr_ipv4 = "0.0.0.0/0" + ip_protocol = "tcp" + from_port = 443 + to_port = 443 + tags = { + Name = "allow-nat-https" + } +} diff --git a/infra/tofu_root_modules/bloom_dev/.terraform.lock.hcl b/infra/tofu_root_modules/bloom_dev/.terraform.lock.hcl new file mode 100644 index 0000000000..df558ece54 --- /dev/null +++ b/infra/tofu_root_modules/bloom_dev/.terraform.lock.hcl @@ -0,0 +1,22 @@ +# This file is maintained automatically by "tofu init". +# Manual edits may be lost in future updates. + +provider "registry.opentofu.org/hashicorp/aws" { + version = "6.21.0" + constraints = "6.21.0" + hashes = [ + "h1:2LDRo3iQcO6j5CP4Ltfv3Thx+41J+sw5UR0I/j+dVys=", + "h1:648ZuBRLeHgJTxpmNWhljc4lnUH5nS0OWBtYLLVY1iA=", + "h1:NuXyWDMddEsWGdh6jRbH/+Ybo85eH0NYoYOdVSkbo8Q=", + "h1:SN6l++JBiWWyq8M6tQg7neJtQCd4BtJkVwWVMv6sXX0=", + "zh:16483145131ad93139c8c903fee4e45d25e38e602810fa35a85d50eea3f47fcd", + "zh:177b5054b7b565f8d48733f480ac9bcd9d7d8e713729c7b493ecaf9ebbcbf8de", + "zh:2ec199a3ef0afe7559a7085a30702e65308de5aeaa32b7e6951f8346acf6bf1c", + "zh:4f8455ed43f57eaee6ff811bd68465bd10c0316d2975eb5f36857005ac602893", + "zh:9111c39c8765e0217112206edf5e7a3323c5485dd25480dcf6ab5ec0745aa271", + "zh:ba1fb5f1cb15beb5541a6757de3cf5391b93783c6bb11f6f7d8795140290b384", + "zh:c5816a1d5b261c0302f4f04b123472add562cdbced73e19d51570910c4f76acf", + "zh:e3ae0b43b3e2b4f98a18457021869b28e8c4bb0d7a21c9e0b4b2e70ed01101ca", + "zh:ff897b3b30b7fc6fe1d2fe4ef1ced2776f4f51f29e45df5030d5b37e3fcba835", + ] +} diff --git a/infra/tofu_root_modules/bloom_dev/README.md b/infra/tofu_root_modules/bloom_dev/README.md new file mode 100644 index 0000000000..95db2a6257 --- /dev/null +++ b/infra/tofu_root_modules/bloom_dev/README.md @@ -0,0 +1,17 @@ +# bloom-dev AWS account + +This root module configures the bloom-dev AWS account. + +- Partners site: https://partners.core-dev.bloomhousing.dev +- Public site: https://core-dev.bloomhousing.dev + +## First apply + +The AWS certificate needs to be created and validated before the rest of the Bloom deployment can be +applied. So, when applying to a fresh account, run: + +1. `tofu apply -exclude=module.bloom_deployment` +2. `tofu apply` + +See the instructions in [Apply Bloom Deployment OpenTofu +Modules](../../aws_deployment_guide/6_apply_bloom_deployment_tofu_modules.md) for full instructions. diff --git a/infra/tofu_root_modules/bloom_dev/main.tf b/infra/tofu_root_modules/bloom_dev/main.tf new file mode 100644 index 0000000000..acbb4fb95a --- /dev/null +++ b/infra/tofu_root_modules/bloom_dev/main.tf @@ -0,0 +1,90 @@ +terraform { + required_providers { + aws = { + version = "6.21.0" + source = "hashicorp/aws" + } + } + backend "s3" { + profile = local.sso_profile_id + region = local.tofu_state_bucket_region + bucket = local.tofu_state_bucket_name + key = "${local.tofu_state_key_prefix}/state" + use_lockfile = true + } +} + +locals { + bloom_deployment = "bloom-dev" + sso_profile_id = "${local.bloom_deployment}-deployer" + + tofu_state_bucket_region = "us-east-1" + tofu_state_bucket_name = "bloom-core-tofu-state-files" + tofu_state_key_prefix = local.bloom_deployment + + bloom_aws_account_number = 242477209009 + bloom_aws_region = "us-west-2" + domain_name = "core-dev.bloomhousing.dev" +} + +provider "aws" { + profile = local.sso_profile_id + region = local.bloom_aws_region +} + +# We need to create and validate a certificate for bloom_deployment module to deploy +# successfully. See the README.md for more details for how to deploy and validate the certificate +# before deploying the bloom_deployment module. +resource "aws_acm_certificate" "bloom" { + region = local.bloom_aws_region + validation_method = "DNS" + domain_name = local.domain_name + subject_alternative_names = [ + "partners.${local.domain_name}" + ] + lifecycle { + create_before_destroy = true + } +} +output "certificate_details" { + value = { + certificate_arn = aws_acm_certificate.bloom.arn + certificate_status = aws_acm_certificate.bloom.status + expires_at = aws_acm_certificate.bloom.not_after + managed_renewal = { + eligible = aws_acm_certificate.bloom.renewal_eligibility + status = aws_acm_certificate.bloom.renewal_summary + } + validation_dns_recods = aws_acm_certificate.bloom.domain_validation_options + } + description = "DNS records required to be manually added for the LB TLS certificate to be issued." +} + +# Deploy bloom into the account. +module "bloom_deployment" { + source = "../../tofu_importable_modules/bloom_deployment" + + aws_profile = local.sso_profile_id + aws_account_number = local.bloom_aws_account_number + aws_region = local.bloom_aws_region + + domain_name = aws_acm_certificate.bloom.domain_name + aws_certificate_arn = aws_acm_certificate.bloom.arn + + env_type = "dev" + high_availability = false + + bloom_api_image = "ghcr.io/bloom-housing/bloom/api:gitsha-f642fc1f3f056b9fa53429c4fa81689c5e856e5a" + bloom_site_partners_image = "ghcr.io/bloom-housing/bloom/partners:gitsha-f642fc1f3f056b9fa53429c4fa81689c5e856e5a" + bloom_site_public_image = "ghcr.io/bloom-housing/bloom/public:gitsha-f642fc1f3f056b9fa53429c4fa81689c5e856e5a" + bloom_site_public_env_vars = { + JURISDICTION_NAME = "Bloomington" + CLOUDINARY_CLOUD_NAME = "exygy" + LANGUAGES = "en,es,zh,vi,tl" + RTL_LANGUAGES = "ar" + } +} +output "aws_lb_dns_name" { + value = module.bloom_deployment.lb_dns_name + description = "DNS name of the load balancer." +} diff --git a/infra/tofu_root_modules/bloom_dev_deployer_permission_set/.terraform.lock.hcl b/infra/tofu_root_modules/bloom_dev_deployer_permission_set/.terraform.lock.hcl deleted file mode 100644 index 37d7fcb396..0000000000 --- a/infra/tofu_root_modules/bloom_dev_deployer_permission_set/.terraform.lock.hcl +++ /dev/null @@ -1,19 +0,0 @@ -# This file is maintained automatically by "tofu init". -# Manual edits may be lost in future updates. - -provider "registry.opentofu.org/hashicorp/aws" { - version = "6.16.0" - constraints = "6.16.0" - hashes = [ - "h1:BjHgGqMnWWbtQE714zLcCFpfj1InWVZNUu+vSFbDLrQ=", - "zh:35b8863c15ac346f4927a3bfa103a0a262e0a3f6070e141b822e81a85a31fdd6", - "zh:4011d6f4671f48cdce37ec6b894fcb3852e024194c8eb7dae143135c5b3f0b74", - "zh:480697e5f582a7e3f77e5ce438ad7685679ad41aa88d801839a5206247359a1c", - "zh:60f862cd7d3427f7c1d9cb06e709f0933a802f03dd23af1d8a08585fb7d87c2b", - "zh:68c6ddcc8224e899b49e7c0c6b41a9785830877458a2d101a47bcd9ba28e7264", - "zh:8cf5189aeb66ce9d15def65b6ab8f1b113d0b946330aa6d622d3c301886cad92", - "zh:8d4a54f419ddea11f762ed98fff298cc54a385a9cd6ee7aa24bd08f758b9549f", - "zh:982c47fe3c12fcb36f4e8b22041b354a3cedfbfd1cca938d0c6ce10e326165c0", - "zh:9cf6f792719231a1ed369d98295e3404785cb6f374d9ba7b72c90bdc945bcec9", - ] -} diff --git a/infra/tofu_root_modules/bloom_dev_deployer_permission_set/README.md b/infra/tofu_root_modules/bloom_dev_deployer_permission_set/README.md deleted file mode 100644 index 4eab090913..0000000000 --- a/infra/tofu_root_modules/bloom_dev_deployer_permission_set/README.md +++ /dev/null @@ -1,8 +0,0 @@ -# `bloom-dev-deployer` IAM Identity Center Permission Set - -This root module configures the `bloom-dev-deployer` IAM Identity Center Permission Set in the Exygy -AWS management account. The permission set is assigned on the `bloom-dev` AWS account to the -`bloom-dev-deployers` IAM Identity Center Group. - -The state file for this module is stored in the manually-created `bloom-dev-deployer-tofu-state` -bucket in the Exygy AWS management account. diff --git a/infra/tofu_root_modules/bloom_dev_deployer_permission_set/main.tf b/infra/tofu_root_modules/bloom_dev_deployer_permission_set/main.tf deleted file mode 100644 index a7b07b5ce7..0000000000 --- a/infra/tofu_root_modules/bloom_dev_deployer_permission_set/main.tf +++ /dev/null @@ -1,74 +0,0 @@ -terraform { - required_providers { - aws = { - version = "6.16.0" - source = "hashicorp/aws" - } - } - backend "s3" { - region = local.region - profile = local.sso_profile_id - bucket = "bloom-dev-deployer-tofu-state" - key = "state" - use_lockfile = true - } -} - -locals { - region = "us-east-1" - sso_profile_id = "bloom-permission-set-editor" - iam_identity_center_instance_arn = "arn:aws:sso:::instance/ssoins-72233fa322bcade3" -} - -provider "aws" { - profile = local.sso_profile_id - region = local.region -} - -resource "aws_ssoadmin_permission_set" "bloom_dev_deployer" { - instance_arn = local.iam_identity_center_instance_arn - name = "bloom-dev-deployer" - description = "Permission to configure a Bloom deployment in the bloom-dev-deployer account." - session_duration = "PT1H" -} -resource "aws_ssoadmin_permission_set_inline_policy" "bloom_dev_deployer" { - instance_arn = local.iam_identity_center_instance_arn - permission_set_arn = aws_ssoadmin_permission_set.bloom_dev_deployer.arn - inline_policy = data.aws_iam_policy_document.bloom_dev_deployer.json -} -data "aws_iam_policy_document" "bloom_dev_deployer" { - statement { - sid = "StateBucket" - actions = [ - "s3:CreateBucket", - "s3:GetAccelerateConfiguration", - "s3:GetBucketAcl", - "s3:GetBucketCors", - "s3:GetBucketLogging", - "s3:GetBucketObjectLockConfiguration", - "s3:GetBucketPolicy", - "s3:GetBucketRequestPayment", - "s3:GetBucketVersioning", - "s3:GetBucketWebsite", - "s3:GetEncryptionConfiguration", - "s3:GetLifecycleConfiguration", - "s3:GetReplicationConfiguration", - "s3:ListBucket", - "s3:GetBucketTagging", - "s3:PutBucketVersioning", - ] - resources = ["arn:aws:s3:::bloom-dev-tofu-state"] - } - statement { - sid = "StateFiles" - actions = [ - "s3:GetObject", - "s3:PutObject", - "s3:DeleteObject" - ] - resources = [ - "arn:aws:s3:::bloom-dev-tofu-state/state", - "arn:aws:s3:::bloom-dev-tofu-state/state.tflock" - ] - } -} diff --git a/infra/tofu_root_modules/bloom_dev_deployer_permission_set_policy/.terraform.lock.hcl b/infra/tofu_root_modules/bloom_dev_deployer_permission_set_policy/.terraform.lock.hcl new file mode 100644 index 0000000000..df558ece54 --- /dev/null +++ b/infra/tofu_root_modules/bloom_dev_deployer_permission_set_policy/.terraform.lock.hcl @@ -0,0 +1,22 @@ +# This file is maintained automatically by "tofu init". +# Manual edits may be lost in future updates. + +provider "registry.opentofu.org/hashicorp/aws" { + version = "6.21.0" + constraints = "6.21.0" + hashes = [ + "h1:2LDRo3iQcO6j5CP4Ltfv3Thx+41J+sw5UR0I/j+dVys=", + "h1:648ZuBRLeHgJTxpmNWhljc4lnUH5nS0OWBtYLLVY1iA=", + "h1:NuXyWDMddEsWGdh6jRbH/+Ybo85eH0NYoYOdVSkbo8Q=", + "h1:SN6l++JBiWWyq8M6tQg7neJtQCd4BtJkVwWVMv6sXX0=", + "zh:16483145131ad93139c8c903fee4e45d25e38e602810fa35a85d50eea3f47fcd", + "zh:177b5054b7b565f8d48733f480ac9bcd9d7d8e713729c7b493ecaf9ebbcbf8de", + "zh:2ec199a3ef0afe7559a7085a30702e65308de5aeaa32b7e6951f8346acf6bf1c", + "zh:4f8455ed43f57eaee6ff811bd68465bd10c0316d2975eb5f36857005ac602893", + "zh:9111c39c8765e0217112206edf5e7a3323c5485dd25480dcf6ab5ec0745aa271", + "zh:ba1fb5f1cb15beb5541a6757de3cf5391b93783c6bb11f6f7d8795140290b384", + "zh:c5816a1d5b261c0302f4f04b123472add562cdbced73e19d51570910c4f76acf", + "zh:e3ae0b43b3e2b4f98a18457021869b28e8c4bb0d7a21c9e0b4b2e70ed01101ca", + "zh:ff897b3b30b7fc6fe1d2fe4ef1ced2776f4f51f29e45df5030d5b37e3fcba835", + ] +} diff --git a/infra/tofu_root_modules/bloom_dev_deployer_permission_set_policy/main.tf b/infra/tofu_root_modules/bloom_dev_deployer_permission_set_policy/main.tf new file mode 100644 index 0000000000..02459821d8 --- /dev/null +++ b/infra/tofu_root_modules/bloom_dev_deployer_permission_set_policy/main.tf @@ -0,0 +1,44 @@ +terraform { + backend "s3" { + profile = local.sso_profile_id + region = local.tofu_state_bucket_region + bucket = local.tofu_state_bucket_name + key = "${local.tofu_state_key_prefix}/state" + use_lockfile = true + } +} + +locals { + bloom_deployment = "bloom-dev" + sso_profile_id = "${local.bloom_deployment}-iam-admin" + + tofu_state_bucket_region = "us-east-1" + tofu_state_bucket_name = "bloom-core-tofu-state-files" + tofu_state_key_prefix = "${local.bloom_deployment}-deployer-permissionset-policy" + + iam_identity_center_region = "us-east-1" + iam_identity_center_instance_arn = "arn:aws:sso:::instance/ssoins-72233fa322bcade3" + deployer_permission_set_arn = "arn:aws:sso:::permissionSet/ssoins-72233fa322bcade3/ps-7223cad033e489c7" + + bloom_deployment_aws_account_number = "242477209009" + bloom_deployment_aws_region = "us-west-2" + bloom_deployment_tofu_state_bucket_name = local.tofu_state_bucket_name + bloom_deployment_tofu_state_file_prefix = local.bloom_deployment +} + +provider "aws" { + profile = local.sso_profile_id + region = local.iam_identity_center_region +} + +module "deployer_permission_set" { + source = "../../tofu_importable_modules/bloom_deployer_permission_set_policy" + + iam_identity_center_instance_arn = local.iam_identity_center_instance_arn + permission_set_arn = local.deployer_permission_set_arn + + bloom_deployment_aws_account_number = local.bloom_deployment_aws_account_number + bloom_deployment_aws_region = local.bloom_deployment_aws_region + bloom_deployment_tofu_state_bucket_name = local.bloom_deployment_tofu_state_bucket_name + bloom_deployment_tofu_state_file_prefix = local.bloom_deployment_tofu_state_file_prefix +} diff --git a/infra/tofu_root_modules/bloom_prod_deployer_permission_set_policy/.terraform.lock.hcl b/infra/tofu_root_modules/bloom_prod_deployer_permission_set_policy/.terraform.lock.hcl new file mode 100644 index 0000000000..df558ece54 --- /dev/null +++ b/infra/tofu_root_modules/bloom_prod_deployer_permission_set_policy/.terraform.lock.hcl @@ -0,0 +1,22 @@ +# This file is maintained automatically by "tofu init". +# Manual edits may be lost in future updates. + +provider "registry.opentofu.org/hashicorp/aws" { + version = "6.21.0" + constraints = "6.21.0" + hashes = [ + "h1:2LDRo3iQcO6j5CP4Ltfv3Thx+41J+sw5UR0I/j+dVys=", + "h1:648ZuBRLeHgJTxpmNWhljc4lnUH5nS0OWBtYLLVY1iA=", + "h1:NuXyWDMddEsWGdh6jRbH/+Ybo85eH0NYoYOdVSkbo8Q=", + "h1:SN6l++JBiWWyq8M6tQg7neJtQCd4BtJkVwWVMv6sXX0=", + "zh:16483145131ad93139c8c903fee4e45d25e38e602810fa35a85d50eea3f47fcd", + "zh:177b5054b7b565f8d48733f480ac9bcd9d7d8e713729c7b493ecaf9ebbcbf8de", + "zh:2ec199a3ef0afe7559a7085a30702e65308de5aeaa32b7e6951f8346acf6bf1c", + "zh:4f8455ed43f57eaee6ff811bd68465bd10c0316d2975eb5f36857005ac602893", + "zh:9111c39c8765e0217112206edf5e7a3323c5485dd25480dcf6ab5ec0745aa271", + "zh:ba1fb5f1cb15beb5541a6757de3cf5391b93783c6bb11f6f7d8795140290b384", + "zh:c5816a1d5b261c0302f4f04b123472add562cdbced73e19d51570910c4f76acf", + "zh:e3ae0b43b3e2b4f98a18457021869b28e8c4bb0d7a21c9e0b4b2e70ed01101ca", + "zh:ff897b3b30b7fc6fe1d2fe4ef1ced2776f4f51f29e45df5030d5b37e3fcba835", + ] +} diff --git a/infra/tofu_root_modules/bloom_prod_deployer_permission_set_policy/main.tf b/infra/tofu_root_modules/bloom_prod_deployer_permission_set_policy/main.tf new file mode 100644 index 0000000000..3a58950dfa --- /dev/null +++ b/infra/tofu_root_modules/bloom_prod_deployer_permission_set_policy/main.tf @@ -0,0 +1,44 @@ +terraform { + backend "s3" { + profile = local.sso_profile_id + region = local.tofu_state_bucket_region + bucket = local.tofu_state_bucket_name + key = "${local.tofu_state_key_prefix}/state" + use_lockfile = true + } +} + +locals { + bloom_deployment = "bloom-prod" + sso_profile_id = "${local.bloom_deployment}-iam-admin" + + tofu_state_bucket_region = "us-east-1" + tofu_state_bucket_name = "bloom-core-tofu-state-files" + tofu_state_key_prefix = "${local.bloom_deployment}-deployer-permissionset-policy" + + iam_identity_center_region = "us-east-1" + iam_identity_center_instance_arn = "arn:aws:sso:::instance/ssoins-72233fa322bcade3" + deployer_permission_set_arn = "arn:aws:sso:::permissionSet/ssoins-72233fa322bcade3/ps-7223d37ee367da27" + + bloom_deployment_aws_account_number = "966936071156" + bloom_deployment_aws_region = "us-west-1" + bloom_deployment_tofu_state_bucket_name = local.tofu_state_bucket_name + bloom_deployment_tofu_state_file_prefix = local.bloom_deployment +} + +provider "aws" { + profile = local.sso_profile_id + region = local.iam_identity_center_region +} + +module "deployer_permission_set" { + source = "../../tofu_importable_modules/bloom_deployer_permission_set_policy" + + iam_identity_center_instance_arn = local.iam_identity_center_instance_arn + permission_set_arn = local.deployer_permission_set_arn + + bloom_deployment_aws_account_number = local.bloom_deployment_aws_account_number + bloom_deployment_aws_region = local.bloom_deployment_aws_region + bloom_deployment_tofu_state_bucket_name = local.bloom_deployment_tofu_state_bucket_name + bloom_deployment_tofu_state_file_prefix = local.bloom_deployment_tofu_state_file_prefix +} diff --git a/package.json b/package.json index 52df6e58e5..14a59e1b51 100644 --- a/package.json +++ b/package.json @@ -60,7 +60,7 @@ "@commitlint/config-conventional": "^13.1.0", "@types/jest": "^26.0.14", "@typescript-eslint/eslint-plugin": "^5.12.1", - "@typescript-eslint/parser": "^5.12.1", + "@typescript-eslint/parser": "^7.18.0", "commitizen": "^4.3.1", "concurrently": "^5.3.0", "eslint": "^8.0.1", @@ -74,11 +74,11 @@ "jest": "^26.5.3", "lint-staged": "^10.4.0", "prettier": "^2.8.8", - "react": "18.2.0", - "react-test-renderer": "18.2.0", + "react": "19.2.3", + "react-test-renderer": "19.2.3", "ts-jest": "^26.4.1", "ts-node": "^10.0.0", - "typescript": "4.6.4", + "typescript": "4.9.5", "wait-on": "^5.2.0" }, "prettier": { @@ -95,5 +95,9 @@ "*.{js,ts,tsx}": "eslint" }, "dependencies": {}, - "packageManager": "yarn@1.22.22+sha1.ac34549e6aa8e7ead463a7407e1c7390f61a6610" + "resolutions": { + "react": "19.2.3", + "react-dom": "19.2.3", + "@fortawesome/react-fontawesome": "3.1.0" + } } diff --git a/shared-helpers/__tests__/testHelpers.ts b/shared-helpers/__tests__/testHelpers.ts index 2cb8c76961..6a6282672f 100644 --- a/shared-helpers/__tests__/testHelpers.ts +++ b/shared-helpers/__tests__/testHelpers.ts @@ -59,6 +59,33 @@ export const multiselectQuestionPreference: MultiselectQuestion = { applicationSection: MultiselectQuestionsApplicationSectionEnum.preferences, } +export const householdMember = { + id: "hh_member_id", + createdAt: new Date(), + updatedAt: new Date(), + firstName: "Household First", + lastName: "Household Last", + birthDay: "25", + birthMonth: "11", + birthYear: "1966", + relationship: HouseholdMemberRelationship.friend, + sameAddress: YesNoEnum.no, + workInRegion: YesNoEnum.yes, + fullTimeStudent: YesNoEnum.no, + householdMemberAddress: { + id: "member_address_id", + createdAt: new Date(), + updatedAt: new Date(), + placeName: "Arches National Park", + city: "Moab", + state: "UT", + street: "25 E Center St", + zipCode: "84532", + latitude: 38.6190099, + longitude: -109.6969108, + }, +} + export const user = { agreedToTermsOfService: false, confirmedAt: new Date(), @@ -238,34 +265,7 @@ export const application: Application = { longitude: -68.3173111, }, }, - householdMember: [ - { - id: "hh_member_id", - createdAt: new Date(), - updatedAt: new Date(), - firstName: "Household First", - lastName: "Household Last", - birthDay: "25", - birthMonth: "11", - birthYear: "1966", - relationship: HouseholdMemberRelationship.friend, - sameAddress: YesNoEnum.no, - workInRegion: YesNoEnum.yes, - fullTimeStudent: YesNoEnum.no, - householdMemberAddress: { - id: "member_address_id", - createdAt: new Date(), - updatedAt: new Date(), - placeName: "Arches National Park", - city: "Moab", - state: "UT", - street: "25 E Center St", - zipCode: "84532", - latitude: 38.6190099, - longitude: -109.6969108, - }, - }, - ], + householdMember: [householdMember], preferences: [ { multiselectQuestionId: "1cd5dbc1-3fed-4a4d-b710-ff77749265d5", @@ -716,7 +716,7 @@ export const listing: Listing = { applicationLotteryTotals: [], jurisdictions: { id: "id", - name: "San Jose", + name: "Bloomington", }, depositMax: "", disableUnitsAccordion: false, diff --git a/shared-helpers/__tests__/utilities/address.methods.test.tsx b/shared-helpers/__tests__/utilities/address.methods.test.tsx new file mode 100644 index 0000000000..2d0087f90d --- /dev/null +++ b/shared-helpers/__tests__/utilities/address.methods.test.tsx @@ -0,0 +1,180 @@ +import React from "react" +import { cleanup, render } from "@testing-library/react" +import { oneLineAddress, multiLineAddress } from "../../src/utilities/Address" +import { Address as AddressType } from "../../src/types/backend-swagger" + +afterEach(cleanup) + +const street = "Mile Drive" +const street2 = "The Lone Cypress" +const city = "Pebble Beach" +const zipCode = "93953" +const state = "CA" + +describe("oneLineAddress", () => { + it("should return empty string for null address", () => { + expect(oneLineAddress(null)).toBe("") + }) + + it("should format address without street2 property", () => { + const address = { + street, + city, + state, + zipCode, + } as AddressType + expect(oneLineAddress(address)).toBe("Mile Drive, Pebble Beach, CA 93953") + }) + + it("should format address with street2", () => { + const address = { + street, + street2, + city, + state, + zipCode, + } as AddressType + expect(oneLineAddress(address)).toBe("Mile Drive, The Lone Cypress, Pebble Beach, CA 93953") + }) + + it("should format address with empty street2", () => { + const address = { + street, + street2: "", + city, + state, + zipCode, + } as AddressType + expect(oneLineAddress(address)).toBe("Mile Drive, Pebble Beach, CA 93953") + }) + + it("should format address with all fields including placeName", () => { + const address = { + placeName: "Building A", + street, + street2, + city, + state, + zipCode, + } as AddressType + expect(oneLineAddress(address)).toBe("Mile Drive, The Lone Cypress, Pebble Beach, CA 93953") + }) +}) + +describe("multiLineAddress", () => { + it("should return empty fragment for null address", () => { + const { container: resultContainer } = render(<>{multiLineAddress(null)}) + const { container: expectedContainer } = render(<>) + expect(resultContainer.innerHTML).toBe(expectedContainer.innerHTML) + }) + + it("should format address without placeName or street2", () => { + const address = { + street, + city, + state, + zipCode, + } as AddressType + const { container: resultContainer } = render(<>{multiLineAddress(address)}) + const { container: expectedContainer } = render( + <> + Mile Drive + Pebble Beach, CA 93953 + + ) + expect(resultContainer.innerHTML).toBe(expectedContainer.innerHTML) + }) + + it("should format address with street2 but without placeName", () => { + const address = { + street, + street2, + city, + state, + zipCode, + } as AddressType + const { container: resultContainer } = render(<>{multiLineAddress(address)}) + const { container: expectedContainer } = render( + <> + Mile Drive, The Lone Cypress + Pebble Beach, CA 93953 + + ) + expect(resultContainer.innerHTML).toBe(expectedContainer.innerHTML) + }) + + it("should format address with placeName but without street2", () => { + const address = { + placeName: "Building A", + street, + city, + state, + zipCode, + } as AddressType + const { container: resultContainer } = render(<>{multiLineAddress(address)}) + const { container: expectedContainer } = render( + <> + Building A + Mile Drive + Pebble Beach, CA 93953 + + ) + expect(resultContainer.innerHTML).toBe(expectedContainer.innerHTML) + }) + + it("should format address with both placeName and street2", () => { + const address = { + placeName: "Building A", + street, + street2, + city, + state, + zipCode, + } as AddressType + const { container: resultContainer } = render(<>{multiLineAddress(address)}) + const { container: expectedContainer } = render( + <> + Building A + Mile Drive, The Lone Cypress + Pebble Beach, CA 93953 + + ) + expect(resultContainer.innerHTML).toBe(expectedContainer.innerHTML) + }) + + it("should format address with empty street2", () => { + const address = { + street, + street2: "", + city, + state, + zipCode, + } as AddressType + const { container: resultContainer } = render(<>{multiLineAddress(address)}) + const { container: expectedContainer } = render( + <> + Mile Drive + Pebble Beach, CA 93953 + + ) + expect(resultContainer.innerHTML).toBe(expectedContainer.innerHTML) + }) + + it("should format address with empty placeName", () => { + const address: AddressType = { + placeName: "", + street, + city, + state, + zipCode, + } as AddressType + const { container: resultContainer } = render(<>{multiLineAddress(address)}) + const { container: expectedContainer } = render( + <> + Mile Drive + Pebble Beach, CA 93953 + + ) + expect(resultContainer.innerHTML).toBe(expectedContainer.innerHTML) + }) +}) diff --git a/shared-helpers/__tests__/utilities/address.test.tsx b/shared-helpers/__tests__/utilities/address.test.tsx new file mode 100644 index 0000000000..fc00a90084 --- /dev/null +++ b/shared-helpers/__tests__/utilities/address.test.tsx @@ -0,0 +1,163 @@ +import React from "react" +import { render, screen } from "@testing-library/react" +import { Address } from "../../src/utilities/Address" +import { Address as AddressType } from "../../src/types/backend-swagger" + +describe("Address component integration tests", () => { + const baseAddress = { + street: "Mile Drive", + city: "Pebble Beach", + state: "CA", + zipCode: "93953", + } as AddressType + + describe("Rendering without getDirections property", () => { + it("should render address without placeName or street2", () => { + const { container } = render(
) + + expect(screen.getByText("Mile Drive")).toBeInTheDocument() + expect(screen.getByText("Pebble Beach, CA 93953")).toBeInTheDocument() + expect(container.querySelector("a")).not.toBeInTheDocument() + }) + + it("should render address with street2", () => { + const addressWithStreet2 = { + ...baseAddress, + street2: "The Lone Cypress", + } as AddressType + + render(
) + + expect(screen.getByText("Mile Drive, The Lone Cypress")).toBeInTheDocument() + expect(screen.getByText("Pebble Beach, CA 93953")).toBeInTheDocument() + }) + + it("should render address with placeName", () => { + const addressWithPlaceName = { + ...baseAddress, + placeName: "City Hall", + } as AddressType + + render(
) + + expect(screen.getByText("City Hall")).toBeInTheDocument() + expect(screen.getByText("Mile Drive")).toBeInTheDocument() + expect(screen.getByText("Pebble Beach, CA 93953")).toBeInTheDocument() + }) + + it("should render address with both placeName and street2", () => { + const fullAddress = { + placeName: "Municipal Building", + street: "456 Oak Ave", + street2: "Suite 200", + city: "Oakland", + state: "CA", + zipCode: "94612", + } as AddressType + + render(
) + + expect(screen.getByText("Municipal Building")).toBeInTheDocument() + expect(screen.getByText("456 Oak Ave, Suite 200")).toBeInTheDocument() + expect(screen.getByText("Oakland, CA 94612")).toBeInTheDocument() + }) + + it("should render nothing for null address", () => { + const { container } = render(
) + + expect(container.firstChild).toBeNull() + }) + + it("should render nothing for undefined address", () => { + const { container } = render(
) + + expect(container.firstChild).toBeNull() + }) + }) + + describe("Rendering with getDirections", () => { + it("should render get directions link for basic address", () => { + render(
) + + const link = screen.getByRole("link") + expect(link).toBeInTheDocument() + expect(link).toHaveAttribute( + "href", + "https://www.google.com/maps/place/Mile Drive, Pebble Beach, CA 93953" + ) + }) + + it("should render get directions link with street2", () => { + const addressWithStreet2: AddressType = { + ...baseAddress, + street2: "The Lone Cypress", + } + + render(
) + + const link = screen.getByRole("link") + expect(link).toHaveAttribute( + "href", + "https://www.google.com/maps/place/Mile Drive, The Lone Cypress, Pebble Beach, CA 93953" + ) + }) + + it("should render get directions link with placeName (placeName not included in URL)", () => { + const addressWithPlaceName: AddressType = { + ...baseAddress, + placeName: "City Hall", + } + + render(
) + + const link = screen.getByRole("link") + expect(link).toHaveAttribute( + "href", + "https://www.google.com/maps/place/Mile Drive, Pebble Beach, CA 93953" + ) + }) + + it("should render complete address with directions link", () => { + const fullAddress = { + placeName: "Municipal Building", + street: "789 Pine Rd", + street2: "Floor 3", + city: "Berkeley", + state: "CA", + zipCode: "94704", + } as AddressType + + render(
) + + expect(screen.getByText("Municipal Building")).toBeInTheDocument() + expect(screen.getByText("789 Pine Rd, Floor 3")).toBeInTheDocument() + expect(screen.getByText("Berkeley, CA 94704")).toBeInTheDocument() + + const link = screen.getByRole("link") + expect(link).toHaveAttribute( + "href", + "https://www.google.com/maps/place/789 Pine Rd, Floor 3, Berkeley, CA 94704" + ) + + expect(link.parentElement).toHaveClass("seeds-m-bs-text") + }) + + it("should not render directions link when getDirections is false", () => { + const { container } = render(
) + + expect(container.querySelector("a")).not.toBeInTheDocument() + }) + + it("should not render directions link when getDirections is undefined", () => { + const { container } = render(
) + + expect(container.querySelector("a")).not.toBeInTheDocument() + }) + + it("should not render directions link for null address even with getDirections true", () => { + const { container } = render(
) + + expect(container.querySelector("a")).not.toBeInTheDocument() + }) + }) +}) diff --git a/shared-helpers/__tests__/views/summaryTables.test.ts b/shared-helpers/__tests__/views/summaryTables.test.ts index d214248635..8879d08ff3 100644 --- a/shared-helpers/__tests__/views/summaryTables.test.ts +++ b/shared-helpers/__tests__/views/summaryTables.test.ts @@ -1,4 +1,5 @@ import { cleanup } from "@testing-library/react" +import { renderToString } from "react-dom/server" import { UnitSummary, UnitTypeEnum, UnitGroupSummary } from "../../src/types/backend-swagger" import { mergeSummaryRows, @@ -6,20 +7,27 @@ import { mergeGroupSummaryRows, stackedUnitGroupsSummariesTable, getAvailabilityText, + getStackedUnitTableData, } from "../../src/views/summaryTables" afterEach(cleanup) // The backend won't accept null in the type, even though that's the data that is actually being generated, so I'm casting in order to test against realistic data -const defaultUnit = { +const defaultUnitType = { id: "a", createdAt: new Date(), updatedAt: new Date(), numBedrooms: null as unknown, name: null as unknown, } -const defaultUnitSummary = { - unitTypes: defaultUnit, + +const defaultUnit = { + id: "a", + createdAt: new Date(), + updatedAt: new Date(), +} +const defaultUnitTypeSummary = { + unitTypes: defaultUnitType, minIncomeRange: { min: null as unknown, max: null as unknown }, occupancyRange: { min: null as unknown, max: null as unknown }, rentAsPercentIncomeRange: { min: null as unknown, max: null as unknown }, @@ -28,7 +36,7 @@ const defaultUnitSummary = { areaRange: { min: null as unknown, max: null as unknown }, } as UnitSummary -const defaultUnitGroupSummary = { +const defaultUnitTypeGroupSummary = { unitTypes: [], rentRange: { min: null as unknown, max: null as unknown }, rentAsPercentIncomeRange: { min: null as unknown, max: null as unknown }, @@ -39,9 +47,9 @@ const defaultUnitGroupSummary = { const rentNoRanges = [ { - ...defaultUnitSummary, + ...defaultUnitTypeSummary, unitTypes: { - ...defaultUnit, + ...defaultUnitType, numBedrooms: 1, name: UnitTypeEnum.oneBdrm, }, @@ -49,9 +57,9 @@ const rentNoRanges = [ rentRange: { min: "1200", max: "1200" }, }, { - ...defaultUnitSummary, + ...defaultUnitTypeSummary, unitTypes: { - ...defaultUnit, + ...defaultUnitType, numBedrooms: 1, name: UnitTypeEnum.oneBdrm, }, @@ -62,9 +70,9 @@ const rentNoRanges = [ const rentRanges = [ { - ...defaultUnitSummary, + ...defaultUnitTypeSummary, unitTypes: { - ...defaultUnit, + ...defaultUnitType, numBedrooms: 1, name: UnitTypeEnum.oneBdrm, }, @@ -72,9 +80,9 @@ const rentRanges = [ rentRange: { min: "1400", max: "1500" }, }, { - ...defaultUnitSummary, + ...defaultUnitTypeSummary, unitTypes: { - ...defaultUnit, + ...defaultUnitType, numBedrooms: 1, name: UnitTypeEnum.oneBdrm, }, @@ -85,9 +93,9 @@ const rentRanges = [ const percentageRentNoRanges = [ { - ...defaultUnitSummary, + ...defaultUnitTypeSummary, unitTypes: { - ...defaultUnit, + ...defaultUnitType, numBedrooms: 1, name: UnitTypeEnum.oneBdrm, }, @@ -95,9 +103,9 @@ const percentageRentNoRanges = [ rentAsPercentIncomeRange: { min: 30, max: 30 }, }, { - ...defaultUnitSummary, + ...defaultUnitTypeSummary, unitTypes: { - ...defaultUnit, + ...defaultUnitType, numBedrooms: 1, name: UnitTypeEnum.oneBdrm, }, @@ -108,9 +116,9 @@ const percentageRentNoRanges = [ const percentageRent = [ { - ...defaultUnitSummary, + ...defaultUnitTypeSummary, unitTypes: { - ...defaultUnit, + ...defaultUnitType, numBedrooms: 1, name: UnitTypeEnum.oneBdrm, }, @@ -118,9 +126,9 @@ const percentageRent = [ rentAsPercentIncomeRange: { min: 5, max: 10 }, }, { - ...defaultUnitSummary, + ...defaultUnitTypeSummary, unitTypes: { - ...defaultUnit, + ...defaultUnitType, numBedrooms: 1, name: UnitTypeEnum.oneBdrm, }, @@ -131,9 +139,9 @@ const percentageRent = [ const mixedRentUnits = [ { - ...defaultUnitSummary, + ...defaultUnitTypeSummary, unitTypes: { - ...defaultUnit, + ...defaultUnitType, numBedrooms: 1, name: UnitTypeEnum.oneBdrm, }, @@ -141,9 +149,9 @@ const mixedRentUnits = [ rentAsPercentIncomeRange: { min: 20, max: 30 }, }, { - ...defaultUnitSummary, + ...defaultUnitTypeSummary, unitTypes: { - ...defaultUnit, + ...defaultUnitType, numBedrooms: 3, name: UnitTypeEnum.threeBdrm, }, @@ -151,9 +159,9 @@ const mixedRentUnits = [ rentAsPercentIncomeRange: { min: 10, max: 15 }, }, { - ...defaultUnitSummary, + ...defaultUnitTypeSummary, unitTypes: { - ...defaultUnit, + ...defaultUnitType, numBedrooms: 1, name: UnitTypeEnum.oneBdrm, }, @@ -161,9 +169,9 @@ const mixedRentUnits = [ rentRange: { min: "450", max: "750" }, }, { - ...defaultUnitSummary, + ...defaultUnitTypeSummary, unitTypes: { - ...defaultUnit, + ...defaultUnitType, numBedrooms: 2, name: UnitTypeEnum.twoBdrm, }, @@ -174,17 +182,17 @@ const mixedRentUnits = [ const noRentData = [ { - ...defaultUnitSummary, + ...defaultUnitTypeSummary, unitTypes: { - ...defaultUnit, + ...defaultUnitType, numBedrooms: 1, name: UnitTypeEnum.oneBdrm, }, }, { - ...defaultUnitSummary, + ...defaultUnitTypeSummary, unitTypes: { - ...defaultUnit, + ...defaultUnitType, numBedrooms: 1, name: UnitTypeEnum.oneBdrm, }, @@ -194,10 +202,10 @@ const noRentData = [ // Test data for unit groups const groupRentNoRanges = [ { - ...defaultUnitGroupSummary, + ...defaultUnitTypeGroupSummary, unitTypes: [ { - ...defaultUnit, + ...defaultUnitType, numBedrooms: 1, name: UnitTypeEnum.oneBdrm, }, @@ -205,10 +213,10 @@ const groupRentNoRanges = [ rentRange: { min: "1200", max: "1200" }, }, { - ...defaultUnitGroupSummary, + ...defaultUnitTypeGroupSummary, unitTypes: [ { - ...defaultUnit, + ...defaultUnitType, numBedrooms: 1, name: UnitTypeEnum.oneBdrm, }, @@ -219,10 +227,10 @@ const groupRentNoRanges = [ const groupRentRanges = [ { - ...defaultUnitGroupSummary, + ...defaultUnitTypeGroupSummary, unitTypes: [ { - ...defaultUnit, + ...defaultUnitType, numBedrooms: 1, name: UnitTypeEnum.oneBdrm, }, @@ -230,10 +238,10 @@ const groupRentRanges = [ rentRange: { min: "1400", max: "1500" }, }, { - ...defaultUnitGroupSummary, + ...defaultUnitTypeGroupSummary, unitTypes: [ { - ...defaultUnit, + ...defaultUnitType, numBedrooms: 1, name: UnitTypeEnum.oneBdrm, }, @@ -244,10 +252,10 @@ const groupRentRanges = [ const groupPercentageRentNoRanges = [ { - ...defaultUnitGroupSummary, + ...defaultUnitTypeGroupSummary, unitTypes: [ { - ...defaultUnit, + ...defaultUnitType, numBedrooms: 1, name: UnitTypeEnum.oneBdrm, }, @@ -255,10 +263,10 @@ const groupPercentageRentNoRanges = [ rentAsPercentIncomeRange: { min: 30, max: 30 }, }, { - ...defaultUnitGroupSummary, + ...defaultUnitTypeGroupSummary, unitTypes: [ { - ...defaultUnit, + ...defaultUnitType, numBedrooms: 1, name: UnitTypeEnum.oneBdrm, }, @@ -269,10 +277,10 @@ const groupPercentageRentNoRanges = [ const groupPercentageRent = [ { - ...defaultUnitGroupSummary, + ...defaultUnitTypeGroupSummary, unitTypes: [ { - ...defaultUnit, + ...defaultUnitType, numBedrooms: 1, name: UnitTypeEnum.oneBdrm, }, @@ -280,10 +288,10 @@ const groupPercentageRent = [ rentAsPercentIncomeRange: { min: 5, max: 10 }, }, { - ...defaultUnitGroupSummary, + ...defaultUnitTypeGroupSummary, unitTypes: [ { - ...defaultUnit, + ...defaultUnitType, numBedrooms: 1, name: UnitTypeEnum.oneBdrm, }, @@ -294,10 +302,10 @@ const groupPercentageRent = [ const groupMixedRentUnits = [ { - ...defaultUnitGroupSummary, + ...defaultUnitTypeGroupSummary, unitTypes: [ { - ...defaultUnit, + ...defaultUnitType, numBedrooms: 1, name: UnitTypeEnum.oneBdrm, }, @@ -305,10 +313,10 @@ const groupMixedRentUnits = [ rentAsPercentIncomeRange: { min: 20, max: 30 }, }, { - ...defaultUnitGroupSummary, + ...defaultUnitTypeGroupSummary, unitTypes: [ { - ...defaultUnit, + ...defaultUnitType, numBedrooms: 3, name: UnitTypeEnum.threeBdrm, }, @@ -316,10 +324,10 @@ const groupMixedRentUnits = [ rentAsPercentIncomeRange: { min: 10, max: 15 }, }, { - ...defaultUnitGroupSummary, + ...defaultUnitTypeGroupSummary, unitTypes: [ { - ...defaultUnit, + ...defaultUnitType, numBedrooms: 1, name: UnitTypeEnum.oneBdrm, }, @@ -327,10 +335,10 @@ const groupMixedRentUnits = [ rentRange: { min: "450", max: "750" }, }, { - ...defaultUnitGroupSummary, + ...defaultUnitTypeGroupSummary, unitTypes: [ { - ...defaultUnit, + ...defaultUnitType, numBedrooms: 2, name: UnitTypeEnum.twoBdrm, }, @@ -341,20 +349,20 @@ const groupMixedRentUnits = [ const groupNoRentData = [ { - ...defaultUnitGroupSummary, + ...defaultUnitTypeGroupSummary, unitTypes: [ { - ...defaultUnit, + ...defaultUnitType, numBedrooms: 1, name: UnitTypeEnum.oneBdrm, }, ], }, { - ...defaultUnitGroupSummary, + ...defaultUnitTypeGroupSummary, unitTypes: [ { - ...defaultUnit, + ...defaultUnitType, numBedrooms: 1, name: UnitTypeEnum.oneBdrm, }, @@ -364,15 +372,15 @@ const groupNoRentData = [ const groupMultipleUnitTypes = [ { - ...defaultUnitGroupSummary, + ...defaultUnitTypeGroupSummary, unitTypes: [ { - ...defaultUnit, + ...defaultUnitType, numBedrooms: 1, name: UnitTypeEnum.oneBdrm, }, { - ...defaultUnit, + ...defaultUnitType, numBedrooms: 2, name: UnitTypeEnum.twoBdrm, }, @@ -679,10 +687,10 @@ describe("stackedUnitGroupsSummariesTable", () => { it("should show availability text for open waitlist", () => { const openWaitlistGroup = [ { - ...defaultUnitGroupSummary, + ...defaultUnitTypeGroupSummary, unitTypes: [ { - ...defaultUnit, + ...defaultUnitType, numBedrooms: 1, name: UnitTypeEnum.oneBdrm, }, @@ -701,10 +709,10 @@ describe("stackedUnitGroupsSummariesTable", () => { it("should show availability text for vacant units", () => { const vacantUnitsGroup = [ { - ...defaultUnitGroupSummary, + ...defaultUnitTypeGroupSummary, unitTypes: [ { - ...defaultUnit, + ...defaultUnitType, numBedrooms: 1, name: UnitTypeEnum.oneBdrm, }, @@ -725,10 +733,10 @@ describe("stackedUnitGroupsSummariesTable", () => { it("should show coming soon text when isComingSoon is true", () => { const comingSoonGroup = [ { - ...defaultUnitGroupSummary, + ...defaultUnitTypeGroupSummary, unitTypes: [ { - ...defaultUnit, + ...defaultUnitType, numBedrooms: 1, name: UnitTypeEnum.oneBdrm, }, @@ -747,29 +755,255 @@ describe("stackedUnitGroupsSummariesTable", () => { describe("getAvailabilityText", () => { it("should show closed waitlist text", () => { - expect(getAvailabilityText(defaultUnitGroupSummary)).toEqual({ text: "Closed waitlist" }) + expect(getAvailabilityText(defaultUnitTypeGroupSummary)).toEqual({ text: "Closed waitlist" }) }) it("should show open waitlist text", () => { - expect(getAvailabilityText({ ...defaultUnitGroupSummary, openWaitlist: true })).toEqual({ + expect(getAvailabilityText({ ...defaultUnitTypeGroupSummary, openWaitlist: true })).toEqual({ text: "Open waitlist", }) }) it("should show plural units available text", () => { expect( - getAvailabilityText({ ...defaultUnitGroupSummary, openWaitlist: true, unitVacancies: 10 }) + getAvailabilityText({ ...defaultUnitTypeGroupSummary, openWaitlist: true, unitVacancies: 10 }) ).toEqual({ text: "10 Vacant units & Open waitlist" }) }) it("should show singular units available text", () => { expect( - getAvailabilityText({ ...defaultUnitGroupSummary, openWaitlist: false, unitVacancies: 1 }) + getAvailabilityText({ ...defaultUnitTypeGroupSummary, openWaitlist: false, unitVacancies: 1 }) ).toEqual({ text: "1 Vacant unit & Closed waitlist" }) }) it("should show under construction text", () => { expect( getAvailabilityText( - { ...defaultUnitGroupSummary, openWaitlist: true, unitVacancies: 10 }, + { ...defaultUnitTypeGroupSummary, openWaitlist: true, unitVacancies: 10 }, true ) ).toEqual({ text: "Under construction" }) }) + + describe("getStackedUnitTableData", () => { + it("returns empty values for no data", () => { + expect(getStackedUnitTableData([], defaultUnitTypeSummary)).toEqual( + expect.objectContaining({ + floorSection: "", + areaRangeSection: "", + adjustedHeaders: {}, + }) + ) + }) + it("show all headers if all have data", () => { + const result = getStackedUnitTableData( + [ + { + ...defaultUnit, + floor: 1, + sqFeet: "550", + number: "101", + numBathrooms: 1, + unitAccessibilityPriorityTypes: { + id: "a", + createdAt: new Date(), + updatedAt: new Date(), + name: "Mobility", + }, + unitTypes: { ...defaultUnitType, name: UnitTypeEnum.oneBdrm, numBedrooms: 1 }, + }, + ], + { + ...defaultUnitTypeSummary, + unitTypes: { + ...defaultUnitType, + numBedrooms: 1, + name: UnitTypeEnum.oneBdrm, + }, + floorRange: { min: 1, max: 1 }, + areaRange: { min: 550, max: 550 }, + minIncomeRange: { min: "150", max: "150" }, + rentRange: { min: "1200", max: "1200" }, + } + ) + expect(result).toEqual( + expect.objectContaining({ + adjustedHeaders: { + accessibilityType: "listings.unit.accessibilityType", + floor: "t.floor", + number: "t.unit", + sqFeet: "t.area", + numBathrooms: "listings.bath", + }, + }) + ) + expect(renderToString(result.barContent)).toContain("1 BR") + expect(renderToString(result.barContent)).toContain("1 unit") + }) + it("hide headers if no data", () => { + const resultNoFloor = getStackedUnitTableData( + [ + { + ...defaultUnit, + sqFeet: "550", + number: "101", + numBathrooms: 1, + unitAccessibilityPriorityTypes: { + id: "a", + createdAt: new Date(), + updatedAt: new Date(), + name: "Mobility", + }, + unitTypes: { ...defaultUnitType, name: UnitTypeEnum.oneBdrm, numBedrooms: 1 }, + }, + ], + { + ...defaultUnitTypeSummary, + unitTypes: { + ...defaultUnitType, + numBedrooms: 1, + name: UnitTypeEnum.oneBdrm, + }, + } + ) + expect(resultNoFloor).toEqual( + expect.objectContaining({ + adjustedHeaders: { + accessibilityType: "listings.unit.accessibilityType", + number: "t.unit", + sqFeet: "t.area", + numBathrooms: "listings.bath", + }, + }) + ) + + const resultNoNumber = getStackedUnitTableData( + [ + { + ...defaultUnit, + sqFeet: "550", + floor: 1, + numBathrooms: 1, + unitAccessibilityPriorityTypes: { + id: "a", + createdAt: new Date(), + updatedAt: new Date(), + name: "Mobility", + }, + unitTypes: { ...defaultUnitType, name: UnitTypeEnum.oneBdrm, numBedrooms: 1 }, + }, + ], + { + ...defaultUnitTypeSummary, + unitTypes: { + ...defaultUnitType, + numBedrooms: 1, + name: UnitTypeEnum.oneBdrm, + }, + } + ) + expect(resultNoNumber).toEqual( + expect.objectContaining({ + adjustedHeaders: { + accessibilityType: "listings.unit.accessibilityType", + floor: "t.floor", + sqFeet: "t.area", + numBathrooms: "listings.bath", + }, + }) + ) + + const resultNoArea = getStackedUnitTableData( + [ + { + ...defaultUnit, + number: "101", + floor: 1, + numBathrooms: 1, + unitAccessibilityPriorityTypes: { + id: "a", + createdAt: new Date(), + updatedAt: new Date(), + name: "Mobility", + }, + unitTypes: { ...defaultUnitType, name: UnitTypeEnum.oneBdrm, numBedrooms: 1 }, + }, + ], + { + ...defaultUnitTypeSummary, + unitTypes: { + ...defaultUnitType, + numBedrooms: 1, + name: UnitTypeEnum.oneBdrm, + }, + } + ) + expect(resultNoArea).toEqual( + expect.objectContaining({ + adjustedHeaders: { + accessibilityType: "listings.unit.accessibilityType", + number: "t.unit", + floor: "t.floor", + numBathrooms: "listings.bath", + }, + }) + ) + + const resultNoA11y = getStackedUnitTableData( + [ + { + ...defaultUnit, + sqFeet: "550", + number: "101", + floor: 1, + numBathrooms: 1, + unitTypes: { ...defaultUnitType, name: UnitTypeEnum.oneBdrm, numBedrooms: 1 }, + }, + ], + { + ...defaultUnitTypeSummary, + unitTypes: { + ...defaultUnitType, + numBedrooms: 1, + name: UnitTypeEnum.oneBdrm, + }, + } + ) + expect(resultNoA11y).toEqual( + expect.objectContaining({ + adjustedHeaders: { + number: "t.unit", + floor: "t.floor", + sqFeet: "t.area", + numBathrooms: "listings.bath", + }, + }) + ) + + const resultNoBathrooms = getStackedUnitTableData( + [ + { + ...defaultUnit, + sqFeet: "550", + number: "101", + floor: 1, + unitTypes: { ...defaultUnitType, name: UnitTypeEnum.oneBdrm, numBedrooms: 1 }, + }, + ], + { + ...defaultUnitTypeSummary, + unitTypes: { + ...defaultUnitType, + numBedrooms: 1, + name: UnitTypeEnum.oneBdrm, + }, + } + ) + expect(resultNoBathrooms).toEqual( + expect.objectContaining({ + adjustedHeaders: { + number: "t.unit", + floor: "t.floor", + sqFeet: "t.area", + }, + }) + ) + }) + }) }) diff --git a/shared-helpers/index.ts b/shared-helpers/index.ts index 4680ea1ce7..9a507db35d 100644 --- a/shared-helpers/index.ts +++ b/shared-helpers/index.ts @@ -27,8 +27,11 @@ export * from "./src/utilities/regex" export * from "./src/utilities/stringFormatting" export * from "./src/utilities/token" export * from "./src/utilities/unitTypes" +export * from "./src/utilities/useIntersect" export * from "./src/views/components/BloomCard" export * from "./src/views/components/ClickableCard" +export * from "./src/views/components/Map" +export * from "./src/views/components/MultiLineAddress" export * from "./src/views/CustomIconMap" export * from "./src/views/forgot-password/FormForgotPassword" export * from "./src/views/layout/ExygyFooter" diff --git a/shared-helpers/package.json b/shared-helpers/package.json index 6017b237b2..cb7ed05495 100644 --- a/shared-helpers/package.json +++ b/shared-helpers/package.json @@ -17,33 +17,36 @@ "prettier": "prettier --write \"**/*.ts\"" }, "dependencies": { - "@bloom-housing/ui-components": "12.7.7", - "@bloom-housing/ui-seeds": "2.0.3", + "@bloom-housing/ui-components": "13.0.3", + "@bloom-housing/ui-seeds": "3.1.4", "@heroicons/react": "^2.1.1", - "axios-cookiejar-support": "^5.0.5" + "axios-cookiejar-support": "^5.0.5", + "markdown-to-jsx": "^7.7.16", + "react-map-gl": "^8.1.0" }, "devDependencies": { - "@testing-library/jest-dom": "^6.6.3", - "@testing-library/react": "^16.2.0", + "@testing-library/jest-dom": "6.9.1", + "@testing-library/react": "16.3.0", "@types/jest": "^26.0.14", "@types/node-polyglot": "^2.5.0", - "@types/react": "^18.0.33", - "@types/react-beautiful-dnd": "^13.1.1", - "@types/react-dom": "^16.9.5", - "@types/react-tabs": "^2.3.2", - "@types/react-test-renderer": "18.0.0", + "@types/react": "^19.2.7", + "@types/react-beautiful-dnd": "^13.1.8", + "@types/react-dom": "^19.2.3", + "@types/react-tabs": "^5.0.5", + "@types/react-test-renderer": "19.1.0", "@types/react-text-mask": "^5.4.14", - "@types/react-transition-group": "^4.4.0", + "@types/react-transition-group": "^4.4.12", "@google-cloud/translate": "^9.2.0", "csv-parse": "^5.5.5", + "dotenv": "^17.2.3", "identity-obj-proxy": "^3.0.0", "jest": "^26.5.3", - "next": "^14.2.28", - "react": "18.2.0", - "react-dom": "18.2.0", - "react-test-renderer": "18.2.0", + "next": "^15.5.7", + "react": "19.2.3", + "react-dom": "19.2.3", + "react-test-renderer": "19.2.3", "ts-jest": "^26.4.1", "ts-loader": "^9.5.2", - "typescript": "^4.5.5" + "typescript": "4.9.5" } } diff --git a/shared-helpers/src/auth/catchNetworkError.ts b/shared-helpers/src/auth/catchNetworkError.ts index 798ef790e0..5f22cd98e8 100644 --- a/shared-helpers/src/auth/catchNetworkError.ts +++ b/shared-helpers/src/auth/catchNetworkError.ts @@ -47,9 +47,9 @@ export const useCatchNetworkError = () => { if (message?.includes(NetworkErrorMessage.PasswordOutdated)) { setNetworkError({ title: t("authentication.signIn.passwordOutdated"), - description: `${t( + description: `${t( "authentication.signIn.changeYourPassword" - )} ${t("t.here")}`, + )}`, error, }) } else if (message === NetworkErrorMessage.MfaUnauthorized) { diff --git a/shared-helpers/src/locales/ar.json b/shared-helpers/src/locales/ar.json index b123d40bed..e7bfa3bf45 100644 --- a/shared-helpers/src/locales/ar.json +++ b/shared-helpers/src/locales/ar.json @@ -8,7 +8,7 @@ "account.application.lottery.applicantList": "من أصل %{applicants} من المتقدمين في هذه القائمة", "account.application.lottery.next": "سيتواصل مدير العقار مع المتقدمين حسب ترتيب الأولوية، بدءًا بالأولوية الأعلى. إذا تواصل معك مدير العقار، فسيطلب منك تقديم وثائق تدعم ما أجبت به في الطلب، مثل كشوف رواتب. قد يطلب منك أيضًا تعبئة طلب إضافي لجمع معلومات إضافية.", "account.application.lottery.nextHeader": "ماذا سيحدث بعد ذلك؟", - "account.application.lottery.preferences": "تظهر هنا تفضيلات اليانصيب لطلبك حسب ترتيب الأولوية. إذا لم تستوفِ أيًّا من تفضيلات اليانصيب، فسيتم تصنيفك ضمن فئة اليانصيب العامة. فئة اليانصيب العامة هي آخر مجموعة تمت معالجتها.", + "account.application.lottery.preferences": "يتم عرض تفضيلات اليانصيب الخاصة بطلبك هنا بترتيب الأولوية. إذا لم تكن مؤهلاً لأي من تفضيلات اليانصيب، فستكون ضمن فئة اليانصيب العامة، وهي آخر مجموعة تتم معالجتها. لاحظ أنك سترى فقط التصنيفات الخاصة بالتفضيلات التي قمت بالمطالبة بها، ولكن قد تكون هناك تفضيلات أخرى لهذا الإدراج.", "account.application.lottery.preferencesButton": "ما هي تفضيلات اليانصيب؟", "account.application.lottery.preferencesHeader": "تفضيلاتك في اليانصيب", "account.application.lottery.preferencesMessage": "هذه النتائج مبنية على المعلومات التي قدمتها في طلبك. أهلية التفضيل قابلة للتغيير بعد التحقق من معلوماتك.", @@ -16,8 +16,10 @@ "account.application.lottery.rawRankButton": "ما هي المرتبة الخام؟", "account.application.lottery.rawRankHeader": "رتبتك الخام", "account.application.lottery.resultsHeader": "إليكم نتائج اليانصيب الخاصة بك", + "account.application.lottery.resultsHeaderWaitlistLottery": "إليك نتائج السحب لقائمة الانتظار", "account.application.lottery.resultsSubheader": "تم تقديم %{applications} طلبًا لـ %{units} وحدة", "account.application.lottery.resultsSubheaderPlural": "تم تقديم %{applications} طلبًا لـ %{units} وحدة", + "account.application.lottery.resultsSubheaderWaitlistLottery": "تم تقديم %{applications} طلبًا", "account.application.lottery.viewResults": "عرض نتائج اليانصيب", "account.application.noAccessError": "لا يوجد تطبيق بهذا المعرف", "account.application.noApplicationError": "لا يوجد تطبيق بهذا المعرف", @@ -48,6 +50,8 @@ "account.pwdless.loginMessage": "إذا كان لديك حساب مُنشأ باستخدام %{email}، فسنرسل لك رمزًا خلال ١٠ دقائق. إذا لم تستلم الرمز، سجّل الدخول باستخدام كلمة مرورك وأكّد بريدك الإلكتروني في إعدادات الحساب.", "account.pwdless.loginReCaptchaMessage": "يُطلب منك تأكيد هويتك كخطوة إضافية لتعزيز الأمان. أرسلنا رمزًا إلى %{email} لإكمال تسجيل الدخول. يُرجى العلم أن صلاحية الرمز ستنتهي خلال ١٠ دقائق.", "account.pwdless.notReceived": "لم تستلم الرمز الخاص بك؟", + "account.pwdless.passwordOutdatedModalContent": "لقد انتهت صلاحية كلمة المرور الخاصة بك. انقر على متابعة لإعادة تعيين كلمة المرور والوصول إلى حسابك.", + "account.pwdless.passwordOutdatedModalHeader": "انتهت صلاحية كلمة المرور", "account.pwdless.resend": "إعادة الإرسال", "account.pwdless.resendCode": "إعادة إرسال الرمز", "account.pwdless.resendCodeButton": "إعادة إرسال الرمز", @@ -307,11 +311,13 @@ "application.review.confirmation.whatHappensNext.base": "### ماذا يحدث بعد ذلك؟\n\n* إذا تم الاتصال بك لإجراء مقابلة، سيُطلب منك ملء طلب أكثر تفصيلاً وتقديم المستندات الداعمة.", "application.review.confirmation.whatHappensNext.fcfs": "### ماذا يحدث بعد ذلك؟\n\n* سيتم الاتصال بالمتقدمين المؤهلين على أساس أسبقية الحضور حتى يتم شغل الوظائف الشاغرة.\n\n* ستؤثر تفضيلات السكن، إن وجدت، على نظام أسبقية الحضور.\n\n* إذا تم الاتصال بك لإجراء مقابلة، سيُطلب منك ملء طلب أكثر تفصيلاً وتقديم المستندات الداعمة.", "application.review.confirmation.whatHappensNext.lottery": "### ماذا يحدث بعد ذلك؟\n\n* بمجرد إغلاق فترة التقديم، سيتم ترتيب المتقدمين المؤهلين بناءً على ترتيب اليانصيب.\n\n* ستؤثر تفضيلات السكن، إن وجدت، على ترتيب اليانصيب.\n\n* إذا تم الاتصال بك لإجراء مقابلة، سيُطلب منك ملء طلب أكثر تفصيلاً وتقديم المستندات الداعمة.", - "application.review.confirmation.whatHappensNext.waitlist": "### ماذا يحدث بعد ذلك؟\n\n* سيتم وضع المتقدمين المؤهلين على قائمة الانتظار على أساس أسبقية الحضور حتى يتم شغل أماكن قائمة الانتظار.\n\n* ستؤثر تفضيلات السكن، إن وجدت، على ترتيب قائمة الانتظار.\n\n* إذا تم الاتصال بك لإجراء مقابلة، سيُطلب منك ملء طلب أكثر تفصيلاً وتقديم المستندات الداعمة.\n\n* قد يتم الاتصال بك أثناء وجودك في قائمة الانتظار لتأكيد رغبتك في البقاء في قائمة الانتظار.", + "application.review.confirmation.whatHappensNext.noPref.base": "### ماذا يحدث بعد ذلك؟\n\n* إذا تم الاتصال بك لإجراء مقابلة، سيُطلب منك ملء طلب أكثر تفصيلاً وتقديم المستندات الداعمة.", "application.review.confirmation.whatHappensNext.noPref.fcfs": "### ماذا يحدث بعد ذلك؟\n\n* سيتم الاتصال بالمتقدمين المؤهلين على أساس أسبقية الحضور حتى يتم شغل الوظائف الشاغرة.\n\n* إذا تم الاتصال بك لإجراء مقابلة، سيُطلب منك ملء طلب أكثر تفصيلاً وتقديم المستندات الداعمة.", "application.review.confirmation.whatHappensNext.noPref.lottery": "### ماذا يحدث بعد ذلك؟\n\n* بمجرد إغلاق فترة التقديم، سيتم ترتيب المتقدمين المؤهلين بناءً على ترتيب اليانصيب.\n\n* إذا تم الاتصال بك لإجراء مقابلة، سيُطلب منك ملء طلب أكثر تفصيلاً وتقديم المستندات الداعمة.", "application.review.confirmation.whatHappensNext.noPref.waitlist": "### ماذا يحدث بعد ذلك؟\n\n* سيتم وضع المتقدمين المؤهلين على قائمة الانتظار على أساس أسبقية الحضور حتى يتم شغل أماكن قائمة الانتظار.\n\n* إذا تم الاتصال بك لإجراء مقابلة، سيُطلب منك ملء طلب أكثر تفصيلاً وتقديم المستندات الداعمة.\n\n* قد يتم الاتصال بك أثناء وجودك في قائمة الانتظار لتأكيد رغبتك في البقاء في قائمة الانتظار.", - "application.review.confirmation.whatHappensNext.noPref.base": "### ماذا يحدث بعد ذلك؟\n\n* إذا تم الاتصال بك لإجراء مقابلة، سيُطلب منك ملء طلب أكثر تفصيلاً وتقديم المستندات الداعمة.", + "application.review.confirmation.whatHappensNext.noPref.waitlistLottery": "### ماذا يحدث بعد ذلك؟\n\n* سيتم وضع المتقدمين المؤهلين على قائمة الانتظار بناءً على ترتيب اليانصيب.\n\n* إذا تم الاتصال بك لإجراء مقابلة، سيُطلب منك ملء طلب أكثر تفصيلاً وتقديم المستندات الداعمة.\n\n* قد يتم الاتصال بك أثناء وجودك في قائمة الانتظار لتأكيد رغبتك في البقاء على قائمة الانتظار.", + "application.review.confirmation.whatHappensNext.waitlist": "### ماذا يحدث بعد ذلك؟\n\n* سيتم وضع المتقدمين المؤهلين على قائمة الانتظار على أساس أسبقية الحضور حتى يتم شغل أماكن قائمة الانتظار.\n\n* ستؤثر تفضيلات السكن، إن وجدت، على ترتيب قائمة الانتظار.\n\n* إذا تم الاتصال بك لإجراء مقابلة، سيُطلب منك ملء طلب أكثر تفصيلاً وتقديم المستندات الداعمة.\n\n* قد يتم الاتصال بك أثناء وجودك في قائمة الانتظار لتأكيد رغبتك في البقاء في قائمة الانتظار.", + "application.review.confirmation.whatHappensNext.waitlistLottery": "### ماذا يحدث بعد ذلك؟\n\n* سيتم وضع المتقدمين المؤهلين على قائمة الانتظار بناءً على ترتيب اليانصيب.\n\n* ستؤثر تفضيلات السكن، إن وجدت، على ترتيب قائمة الانتظار.\n\n* إذا تم الاتصال بك لإجراء مقابلة، سيُطلب منك ملء طلب أكثر تفصيلاً وتقديم المستندات الداعمة.\n\n* قد يتم الاتصال بك أثناء وجودك في قائمة الانتظار لتأكيد رغبتك في البقاء في قائمة الانتظار.", "application.review.demographics.ethnicityLabel": "ما هو أفضل وصف لعرقك؟", "application.review.demographics.ethnicityOptions.hispanicLatino": "اسباني / لاتيني", "application.review.demographics.ethnicityOptions.notHispanicLatino": "ليس من أصل اسباني / لاتيني", @@ -407,10 +413,11 @@ "application.review.noAdditionalMembers": "لا يوجد أفراد إضافيون في الأسرة", "application.review.sameAddressAsApplicant": "نفس عنوان مقدم الطلب", "application.review.takeAMomentToReview": "توقف لحظة لمراجعة معلوماتك قبل تقديم طلبك.", - "application.review.terms.confirmCheckboxText": "أوافق وأفهم أنه لا يمكنني تغيير أي شيء بعد الإرسال.", "application.review.terms.base.text": "* إذا تم الاتصال بك لإجراء مقابلة، فسيُطلب منك ملء طلب أكثر تفصيلاً وتقديم المستندات الداعمة.\n\n* سيتم التحقق من جميع المعلومات التي قدمتها وتأكيد أهليتك.\n\n* قد يتم إزالة طلبك إذا قدمت أي بيانات احتيالية.\n\nلمزيد من المعلومات، يرجى الاتصال بمطور الإسكان أو مدير العقار المذكور في القائمة.\n\nإن إكمال هذا الطلب لا يمنحك الحق في السكن أو يشير إلى أنك مؤهل للحصول على السكن. سيتم فحص جميع المتقدمين كما هو موضح في معايير اختيار المقيمين في العقار. لا يمكنك تغيير طلبك عبر الإنترنت بعد تقديمه. أعلن أن ما سبق صحيح ودقيق، وأقر بأن أي بيان خاطئ تم تقديمه عن طريق الاحتيال أو الإهمال في هذا الطلب قد يؤدي إلى الإزالة من عملية التقديم.", + "application.review.terms.confirmCheckboxText": "أوافق وأفهم أنه لا يمكنني تغيير أي شيء بعد الإرسال.", "application.review.terms.fcfs.text": "* يتقدم المتقدمون للشقق الشاغرة حاليًا على أساس أسبقية الحضور.\n\n* سيتم الاتصال بالمتقدمين المؤهلين على أساس أسبقية الحضور حتى يتم شغل الشواغر.\n\n* إذا تم الاتصال بك لإجراء مقابلة، فسيُطلب منك ملء طلب أكثر تفصيلاً وتقديم المستندات الداعمة.\n\n* سيتم التحقق من جميع المعلومات التي قدمتها وتأكيد أهليتك.\n\n* قد يتم إزالة طلبك إذا قدمت أي بيانات احتيالية.\n\n* بالنسبة للعقارات ذات تفضيلات السكن، إذا لم نتمكن من التحقق من تفضيل السكن الذي طالبت به، فلن تحصل على التفضيل ولكن لن يتم معاقبتك بخلاف ذلك.\n\nلمزيد من المعلومات، يرجى الاتصال بمطور الإسكان أو مدير العقار المذكور في القائمة.\n\nإن إكمال هذا الطلب لا يمنحك الحق في السكن أو يشير إلى أنك مؤهل للحصول على السكن. سيتم فحص جميع المتقدمين كما هو موضح في معايير اختيار المقيمين في العقار. لا يمكنك تغيير طلبك عبر الإنترنت بعد تقديمه. أعلن أن ما سبق صحيح ودقيق، وأقر بأن أي بيان خاطئ تم تقديمه عن طريق الاحتيال أو الإهمال في هذا الطلب قد يؤدي إلى الإزالة من عملية التقديم.", "application.review.terms.lottery.text": "* يتقدم المتقدمون بطلب للدخول في يانصيب للشقق الشاغرة حاليًا.\n\n* بمجرد إغلاق فترة التقديم، سيتم وضع المتقدمين المؤهلين في ترتيب اليانصيب.\n\n* إذا تم الاتصال بك لإجراء مقابلة، فسيُطلب منك ملء طلب أكثر تفصيلاً وتقديم المستندات الداعمة.\n\n* سيتم التحقق من جميع المعلومات التي قدمتها وتأكيد أهليتك.\n\n* قد يتم إزالة طلبك إذا قدمت أي بيانات احتيالية.\n\n* بالنسبة للعقارات ذات تفضيلات السكن، إذا لم نتمكن من التحقق من تفضيل السكن الذي طالبت به، فلن تحصل على التفضيل ولكن لن يتم معاقبتك بخلاف ذلك.\n\nلمزيد من المعلومات، يرجى الاتصال بمطور الإسكان أو مدير العقار المذكور في القائمة.\n\nإن إكمال هذا الطلب لا يمنحك الحق في السكن أو يشير إلى أنك مؤهل للحصول على السكن. سيتم فحص جميع المتقدمين كما هو موضح في معايير اختيار المقيمين في العقار. لا يمكنك تغيير طلبك عبر الإنترنت بعد تقديمه. أعلن أن ما سبق صحيح ودقيق، وأقر بأن أي بيان خاطئ تم تقديمه عن طريق الاحتيال أو الإهمال في هذا الطلب قد يؤدي إلى الإزالة من عملية التقديم.", + "application.review.terms.standard.text": "* بالنسبة للوحدات السكنية المتاحة التي تُعرض عن طريق القرعة، سيتم الاتصال بالمتقدمين المؤهلين حسب ترتيبهم في القرعة التي ستُجرى بعد فترة وجيزة من انتهاء موعد التقديم. أما بالنسبة للوحدات السكنية المتاحة التي تُعرض على أساس أسبقية التقديم، فسيتم الاتصال بالمتقدمين المؤهلين حسب الترتيب الزمني لتقديم طلباتهم حتى يتم شغل جميع الشواغر.\n\n* إذا تم التواصل معك لإجراء مقابلة، سيُطلب منك ملء استمارة طلب أكثر تفصيلاً وتقديم المستندات الداعمة. سيتم التحقق من جميع المعلومات التي قدمتها وتأكيد أهليتك. قد يتم استبعاد طلبك من قبل الشريك المهني إذا قدمت أي بيانات كاذبة. بالنسبة للعقارات التي تتضمن شروطًا تفضيلية للسكن، إذا تعذر التحقق من طلبك المتعلق بهذه الشروط، فلن تحصل على الأولوية. سيتم فحص جميع المتقدمين وفقًا للمعايير الموضحة في معايير اختيار السكان الخاصة بالعقار.\n\n* لا يمنحك إكمال هذا الطلب الحق في الحصول على سكن ولا يعني أنك مؤهل للحصول عليه. يرجى ملاحظة أنه لا يمكنك تعديل طلبك الإلكتروني بعد تقديمه.\n\n* يرجى توخي الحذر عند تقديم أكثر من طلب لأي إعلان، لأن تقديم أكثر من طلب من قبل أي شخص في أسرتك قد يؤدي إلى استبعادك أنت وجميع أفراد أسرتك من هذه الفرصة. يرجى التواصل معنا إذا كنت قد قدمت طلبًا يحتوي على خطأ.\n\nأؤكد أنني أبلغ من العمر ثمانية عشر عامًا على الأقل، وأنني مخول بتقديم المعلومات الشخصية التعريفية لأي فرد من أفراد الأسرة المذكورين في هذا الطلب. أوافق، نيابةً عن أفراد الأسرة المذكورين في الطلب وعن نفسي، على نقل هذه المعلومات الشخصية إلى الشريك المهني و/أو الحكومة المحلية. أوافق على الشروط المذكورة أعلاه، وأقر بأن جميع المعلومات المقدمة صحيحة ودقيقة، وأعلم أن أي معلومات غير صحيحة مقدمة عمدًا أو إهمالًا في هذا الطلب قد تؤدي إلى استبعادي من عملية التقديم.", "application.review.terms.submittingApplication": "تقديم الطلب", "application.review.terms.textSubmissionDate": "يجب تقديم هذا الطلب قبل %{applicationDueDate}.", "application.review.terms.title": "مصطلحات", @@ -424,6 +431,8 @@ "application.start.whatToExpect.title": "إليك ما يمكن توقعه من هذا التطبيق.", "application.start.whatToExpect.waitlist.finePrint": "* يتقدم المتقدمون بطلب للحصول على قائمة انتظار مفتوحة وليس شقة متاحة حاليًا.\n* يرجى العلم أنه لا يمكن لكل فرد من أفراد الأسرة الظهور إلا في طلب واحد لكل قائمة.\n* عندما تصبح الوظائف الشاغرة متاحة، سيتم الاتصال بالمتقدمين المؤهلين من قبل مدير العقار على أساس أسبقية الحضور.\n* سيتم التحقق من جميع المعلومات التي قدمتها وتأكيد أهليتك.\n* قد يتم إزالة طلبك إذا قدمت أي بيانات احتيالية.\n* بالنسبة للعقارات ذات تفضيلات الإسكان، إذا لم نتمكن من التحقق من تفضيل الإسكان الذي طالبت به، فلن تحصل على التفضيل ولكن لن يتم معاقبتك بخلاف ذلك.", "application.start.whatToExpect.waitlist.steps": "1. أولًا، سنسأل عنك وعن الأشخاص الذين تخطط للعيش معهم.\n2. بعد ذلك، سنسأل عن دخلك.\n3. وأخيرًا، سنرى ما إذا كنت مؤهلاً لأي تفضيلات للإسكان بأسعار معقولة، إن وجدت.", + "application.start.whatToExpect.waitlistLottery.finePrint": "* يتقدم المتقدمون بطلب للحصول على قائمة انتظار مفتوحة وليس شقة متاحة حاليًا.\n* يرجى العلم أنه لا يمكن لكل فرد من أفراد الأسرة الظهور إلا في طلب واحد لكل قائمة.\n* عندما تصبح الوظائف الشاغرة متاحة، سيتصل وكيل العقارات بالمتقدمين حسب ترتيب اليانصيب حتى يتم شغل الوظائف الشاغرة.\n* سيتم التحقق من جميع المعلومات التي قدمتها وتأكيد أهليتك.\n* قد يتم إزالة طلبك إذا قدمت أي بيانات احتيالية.\n* بالنسبة للعقارات ذات تفضيلات الإسكان، إذا لم نتمكن من التحقق من تفضيل الإسكان الذي طالبت به، فلن تحصل على التفضيل ولكن لن يتم معاقبتك بخلاف ذلك.", + "application.start.whatToExpect.waitlistLottery.steps": "1. أولاً، سنسألكم عنكم وعن الأشخاص الذين تخططون للعيش معهم.\n 2. ثم، سنسألكم عن دخلكم.\n 3. وأخيرًا، سنتحقق مما إذا كنتم مؤهلين للحصول على أي امتيازات للسكن بأسعار معقولة، إن وجدت.", "application.status": "حالة", "application.statuses.inProgress": "في تَقَدم", "application.statuses.neverSubmitted": "لم يتم تقديمه مطلقًا", @@ -465,10 +474,12 @@ "authentication.forgotPassword.errors.tokenMissing": "لم يتم العثور على الرمز. الرجاء طلب واحدة جديدة.", "authentication.forgotPassword.message": "إذا كان هناك حساب تم إنشاؤه باستخدام هذا البريد الإلكتروني ، فستتلقى بريدًا إلكترونيًا به ارتباط لإعادة تعيين كلمة المرور الخاصة بك.", "authentication.forgotPassword.passwordConfirmation": "تأكيد كلمة المرور", - "authentication.forgotPassword.sendEmail": "ارسل بريد الكتروني", + "authentication.forgotPassword.sendEmail": "أدخل بريدك الإلكتروني للحصول على رابط إعادة تعيين كلمة المرور", + "authentication.forgotPassword.sendEmailButton": "ارسل بريد الكتروني", + "authentication.forgotPassword.sendEmailNotes": "يرجى إدخال بريدك الإلكتروني لنرسل لك رابط إعادة تعيين كلمة المرور. إذا لم تستلم بريدًا إلكترونيًا، فقد لا يكون لديك حساب.", "authentication.signIn.accountHasBeenLocked": "لأسباب أمنية، تم قفل هذا الحساب.", "authentication.signIn.afterFailedAttempts": "لأسباب أمنية، بعد %{count} من المحاولات الفاشلة، سيتعين عليك الانتظار لمدة 30 دقيقة قبل المحاولة مرة أخرى.", - "authentication.signIn.changeYourPassword": "يمكنك تغيير كلمة المرور الخاصة بك", + "authentication.signIn.changeYourPassword": "انقر هنا لإعادة تعيين كلمة المرور الخاصة بك", "authentication.signIn.enterLoginEmail": "الرجاء إدخال بريدك الإلكتروني لتسجيل الدخول", "authentication.signIn.enterLoginPassword": "الرجاء إدخال كلمة المرور الخاصة بك", "authentication.signIn.enterValidEmailAndPassword": "الرجاء إدخال بريد إلكتروني وكلمة مرور صالحة", @@ -479,7 +490,7 @@ "authentication.signIn.loginError": "يرجى إدخال عنوان بريد إلكتروني صالح", "authentication.signIn.mfaError": "هذا حساب شريك، والذي لأسباب أمنية لا يمكنه تسجيل الدخول إلى الموقع العام.", "authentication.signIn.passwordError": "الرجاء إدخال كلمة السر الصحيحة", - "authentication.signIn.passwordOutdated": "انتهت صلاحية كلمة مرورك. يُرجى إعادة تعيينها.", + "authentication.signIn.passwordOutdated": "انتهت صلاحية كلمة المرور المرتبطة بحسابك. ستحتاج إلى إعادة تعيينها للوصول إلى حسابك.", "authentication.signIn.pwdless.createAccountCopy": "قم بالتسجيل بسرعة دون الحاجة إلى تذكر أي كلمة مرور.", "authentication.signIn.pwdless.emailHelperText": "أدخل بريدك الإلكتروني وسنرسل إليك رمزًا لتسجيل الدخول.", "authentication.signIn.pwdless.error": "الرمز الذي استخدمته غير صالح أو منتهي الصلاحية.", @@ -625,6 +636,7 @@ "languages.vi": "Tiếng Việt", "languages.zh": "中文", "leasingAgent.contact": "اتصل بوكيل التأجير", + "leasingAgent.contactManagerProp": "اتصل بوكيل التأجير أو مدير الممتلكات", "leasingAgent.dueToHighCallVolume": "نظرًا لارتفاع حجم المكالمات ، قد تسمع رسالة.", "leasingAgent.officeHours": "ساعات العمل", "listingFilters.clear": "مسح", @@ -638,12 +650,18 @@ "listings.additionalInformationEnvelope": "مظروف معلومات إضافية", "listings.allUnits": "جميع الوحدات", "listings.allUnitsReservedFor": "جميع الوحدات محجوزة لـ %{type}", + "listings.amenities.busStops": "محطات الحافلات", "listings.amenities.groceryStores": "محلات البقالة", "listings.amenities.healthCareResources": "موارد الرعاية الصحية", + "listings.amenities.hospitals": "المستشفيات", "listings.amenities.parksAndCommunityCenters": "الحدائق والمراكز المجتمعية", "listings.amenities.pharmacies": "الصيدليات", + "listings.amenities.playgrounds": "ملاعب", "listings.amenities.publicTransportation": "المواصلات العامة", + "listings.amenities.recreationalFacilities": "المرافق الترفيهية", "listings.amenities.schools": "المدارس", + "listings.amenities.seniorCenters": "مراكز كبار السن", + "listings.amenities.shoppingVenues": "أماكن التسوق", "listings.annualIncome": "%{income} في السنة", "listings.applicationAlreadySubmitted": "لقد تم تقديم هذا الطلب بالفعل.", "listings.applicationDeadline": "تاريخ استحقاق الطلب", @@ -702,6 +720,8 @@ "listings.confirmedPreferenceList": "تم تأكيد قائمة %{preference}", "listings.costsNotIncluded": "التكاليف غير متضمنة", "listings.creditHistory": "تاريخ الائتمان", + "listings.creditScreeningFee": "فحص الائتمان", + "listings.creditScreeningFeeDescription": "يغطي تكلفة مراجعة تاريخ الائتمان والإيجار الخاص بك", "listings.criminalBackground": "الخلفية الجنائية", "listings.depositMayBeHigherForLowerCredit": "قد تكون أعلى للحصول على درجات ائتمانية أقل", "listings.depositOrMonthsRent": "أو إيجار شهر", @@ -716,6 +736,7 @@ "listings.featuresCards": "بطاقات الميزات", "listings.forIncomeCalculations": "بالنسبة لحسابات الدخل ، يشمل حجم الأسرة جميع (جميع الأعمار) الذين يعيشون في الوحدة.", "listings.forIncomeCalculationsBMR": "تعتمد حسابات الدخل على نوع الوحدة", + "listings.hasEbllClearance": "لقد حصل هذا العقار على موافقة HUD EBLL.", "listings.hideClosedListings": "إخفاء القوائم المغلقة", "listings.homeType.apartment": "شقة", "listings.homeType.duplex": "دوبلكس", @@ -732,6 +753,7 @@ "listings.lotteryResults.completeResultsWillBePosted": "سيتم نشر نتائج اليانصيب الكاملة قريبًا.", "listings.lotteryResults.downloadResults": "تنزيل النتائج", "listings.lotteryResults.header": "نتائج اليانصيب", + "listings.marketing.header": "التسويق", "listings.maxIncomeMonth": "الحد الأقصى للدخل / الشهر", "listings.maxIncomeYear": "الحد الأقصى للدخل / السنة", "listings.maxRent": "الحد الأقصى للإيجار", @@ -743,12 +765,16 @@ "listings.neighborhoodBuildings": "مباني الحي", "listings.noAvailableUnits": "لا توجد وحدات متاحة في هذا الوقت.", "listings.noClosedListings": "لا توجد قوائم لديها طلبات مغلقة حاليًا", + "listings.noEbllClearance": "لم يحصل هذا العقار على موافقة HUD EBLL.", "listings.noMatchingClosedListings": "لا توجد قوائم مطابقة مع التطبيقات المغلقة", "listings.noMatchingOpenListings": "لا توجد قوائم مطابقة مع التطبيقات المفتوحة", "listings.noOpenListings": "لا توجد قوائم لديها تطبيقات مفتوحة حاليا.", "listings.occupancyDescriptionAllSro": "يقتصر إشغال هذا المبنى على شخص واحد لكل وحدة.", "listings.occupancyDescriptionNoSro": "تعتمد حدود الإشغال لهذا المبنى على نوع الوحدة.", "listings.occupancyDescriptionSomeSro": "يختلف معدل إشغال هذا المبنى باختلاف نوع الوحدة. يقتصر عدد الموظفين الإداريين على شخص واحد لكل وحدة، بغض النظر عن العمر. أما بالنسبة لجميع أنواع الوحدات الأخرى، فلا يشمل عدد الأطفال دون سن السادسة.", + "listings.openHouseAndMarketing.accessibleMarketingFlyerLink": "افتح نشرة التسويق الميسرة", + "listings.openHouseAndMarketing.header": "المنازل المفتوحة والتسويق", + "listings.openHouseAndMarketing.marketingFlyerLink": "افتح نشرة التسويق", "listings.openHouseEvent.header": "المنازل المفتوحة", "listings.openHouseEvent.seeVideo": "شاهد الفيديو", "listings.percentAMIUnit": "%{percent}٪ وحدة AMI", @@ -761,7 +787,17 @@ "listings.removeFilters": "حاول إزالة بعض المرشحات أو إظهار كافة القوائم.", "listings.rentalHistory": "تاريخ الإيجار", "listings.rePricing": "إعادة التسعير", + "listings.requiredDocuments.birthCertificate": "شهادة الميلاد (لجميع أفراد الأسرة ١٨ عامًا فأكثر)", + "listings.requiredDocuments.currentLandlordReference": "مرجع المالك الحالي", + "listings.requiredDocuments.governmentIssuedId": "بطاقة هوية صادرة عن جهة حكومية (لجميع أفراد الأسرة ١٨ عامًا فأكثر)", + "listings.requiredDocuments.previousLandlordReference": "مرجع المالك السابق", + "listings.requiredDocuments.proofOfAssets": "إثبات الأصول (كشوف الحسابات المصرفية، إلخ)", + "listings.requiredDocuments.proofOfCustody": "إثبات الحضانة/الوصاية", + "listings.requiredDocuments.proofOfIncome": "إثبات دخل الأسرة (قسائم شيكات، نموذج W-2، إلخ)", + "listings.requiredDocuments.residencyDocuments": "وثائق الهجرة/الإقامة (البطاقة الخضراء، إلخ)", + "listings.requiredDocuments.socialSecurityCard": "بطاقة الضمان الاجتماعي،", "listings.requiredDocuments": "المستندات المطلوبة", + "listings.requiredDocumentsAdditionalInfo": "المستندات المطلوبة (معلومات إضافية)", "listings.reservedCommunityBuilding": "مبنى %{type}", "listings.reservedCommunitySeniorTitle": "مبنى كبار السن", "listings.reservedCommunityTitleDefault": "مبنى محجوز", @@ -835,7 +871,17 @@ "listings.singleRoomOccupancyDescription": "يوفر هذا العقار غرفًا فردية لشخص واحد فقط. يُسمح للمستأجرين بمشاركة الحمامات، وأحيانًا استخدام مرافق المطبخ.", "listings.specialNotes": "ملاحظات خاصة", "listings.underConstruction": "تحت الإنشاء", + "listings.unit.accessibilityType.Hearing and Visual": "السمع والبصر", + "listings.unit.accessibilityType.Hearing": "السمع", + "listings.unit.accessibilityType.Mobility and Hearing": "الحركة والسمع", + "listings.unit.accessibilityType.Mobility and Visual": "الحركة والرؤية", + "listings.unit.accessibilityType.Mobility, Hearing and Visual": "الحركة والسمع والبصر", + "listings.unit.accessibilityType.Mobility": "التنقل", + "listings.unit.accessibilityType.Visual": "رؤية", + "listings.unit.accessibilityType": "نوع إمكانية الوصول", "listings.unit.sharedBathroom": "مشترك", + "listings.unit.showLessUnits": "إظهار عدد أقل من الوحدات %{type}", + "listings.unit.showMoreUnits": "عرض المزيد من الوحدات %{type}", "listings.unitsAreFor": "هذه الوحدات خاصة بـ %{type}.", "listings.unitsHaveAccessibilityFeaturesFor": "تحتوي هذه الوحدات على ميزات إمكانية الوصول للأشخاص الذين لديهم %{type}.", "listings.unitsSummary.notAvailable": "غير متوفر", @@ -878,18 +924,18 @@ "listings.waitlist.unitsAndWaitlist": "الوحدات المتاحة وقائمة الانتظار", "lottery.applicationsThatQualifyForPreference": "سيتم إعطاء الطلبات المؤهلة لهذا التفضيل أولوية أعلى.", "lottery.viewPreferenceList": "عرض قائمة التفضيلات", - "months.january": "يناير", + "months.april": "أبريل", + "months.august": "أغسطس", + "months.december": "ديسمبر", "months.february": "فبراير", + "months.january": "يناير", + "months.july": "يوليو", + "months.june": "يونيو", "months.march": "مارس", - "months.april": "أبريل", "months.may": "مايو", - "months.june": "يونيو", - "months.july": "يوليو", - "months.august": "أغسطس", - "months.september": "سبتمبر", - "months.october": "أكتوبر", "months.november": "نوفمبر", - "months.december": "ديسمبر", + "months.october": "أكتوبر", + "months.september": "سبتمبر", "nav.browseProperties": "تصفح الخصائص", "nav.getFeedback": "نحن نحب أن نحصل على تعليقاتك!", "nav.listings": "القوائم", @@ -1018,6 +1064,7 @@ "t.additionalAccessibility": "إمكانية الوصول الإضافية", "t.additionalPhone": "هاتف إضافي", "t.am": "صباحا", + "t.ami": "أمي", "t.area": "منطقة", "t.areYouStillWorking": "هل ما زلت تعمل؟", "t.at": "في", @@ -1089,6 +1136,8 @@ "t.or": "أو", "t.order": "ترتيب", "t.orUpTo": "أو ما يصل إلى", + "t.other": "آخر", + "t.parkingFee": "رسوم الإصطفاف", "t.people": "اشخاص", "t.perMonth": "كل شهر", "t.person": "شخص", diff --git a/shared-helpers/src/locales/bn.json b/shared-helpers/src/locales/bn.json index 588b475ecf..092551027b 100644 --- a/shared-helpers/src/locales/bn.json +++ b/shared-helpers/src/locales/bn.json @@ -8,7 +8,7 @@ "account.application.lottery.applicantList": "এই তালিকায় থাকা %{আবেদনকারীদের} জনের মধ্যে", "account.application.lottery.next": "সম্পত্তি ব্যবস্থাপক আবেদনকারীদের সাথে অগ্রাধিকারের ক্রমানুসারে যোগাযোগ করবেন। তারা সর্বোচ্চ অগ্রাধিকারের পছন্দ দিয়ে শুরু করবেন। যদি সম্পত্তি ব্যবস্থাপক আপনার সাথে যোগাযোগ করেন, তাহলে তারা আপনাকে আবেদনে আপনি যা উত্তর দিয়েছেন তা সমর্থন করার জন্য নথিপত্র সরবরাহ করতে বলবেন। উদাহরণস্বরূপ, সেই নথিপত্রে বেতনের কাগজপত্র অন্তর্ভুক্ত থাকতে পারে। আপনাকে একটি সম্পূরক আবেদনপত্র পূরণ করতে বলে তাদের আরও তথ্য সংগ্রহের প্রয়োজন হতে পারে।", "account.application.lottery.nextHeader": "এরপর কি হবে?", - "account.application.lottery.preferences": "আপনার আবেদনের জন্য লটারির পছন্দগুলি এখানে অগ্রাধিকার ক্রমে দেখানো হয়েছে। যদি আপনি কোনও লটারি পছন্দের জন্য যোগ্য না হন, তাহলে আপনি সাধারণ লটারি বিভাগের অংশ হবেন। সাধারণ লটারি বিভাগটি হল শেষ প্রক্রিয়াজাতকরণ গ্রুপ।", + "account.application.lottery.preferences": "আপনার আবেদনের লটারি পছন্দগুলি এখানে অগ্রাধিকারের ক্রমে প্রদর্শিত হয়েছে। যদি আপনি কোনো লটারি পছন্দের জন্য যোগ্য না হন, তবে আপনাকে সাধারণ লটারি বিভাগে রাখা হবে, যা সর্বশেষ প্রক্রিয়াকৃত গ্রুপ। মনে রাখবেন আপনি শুধুমাত্র আপনার দাবি করা পছন্দগুলির র‍্যাঙ্কিং দেখতে পাবেন, তবে এই তালিকার জন্য অন্যান্য পছন্দ থাকতে পারে এই তালিকার জন্য.", "account.application.lottery.preferencesButton": "লটারির পছন্দগুলি কী কী?", "account.application.lottery.preferencesHeader": "আপনার লটারি পছন্দ(গুলি)", "account.application.lottery.preferencesMessage": "এই ফলাফলগুলি আপনার আবেদনে প্রদত্ত তথ্যের উপর ভিত্তি করে। আপনার তথ্য যাচাই হয়ে গেলে পছন্দের যোগ্যতা পরিবর্তন হতে পারে।", @@ -16,8 +16,10 @@ "account.application.lottery.rawRankButton": "কাঁচা পদমর্যাদা কী?", "account.application.lottery.rawRankHeader": "আপনার কাঁচা র‍্যাঙ্ক", "account.application.lottery.resultsHeader": "এখানে আপনার লটারির ফলাফল দেওয়া হল", + "account.application.lottery.resultsHeaderWaitlistLottery": "এখানে অপেক্ষমাণ তালিকার জন্য আপনার লটারির ফলাফল", "account.application.lottery.resultsSubheader": "%{units} ইউনিটের জন্য %{applications} টি আবেদন জমা দেওয়া হয়েছে।", "account.application.lottery.resultsSubheaderPlural": "%{units} ইউনিটের জন্য %{applications} টি আবেদন জমা দেওয়া হয়েছে।", + "account.application.lottery.resultsSubheaderWaitlistLottery": "%{applications}টি আবেদন জমা দেওয়া হয়েছে", "account.application.lottery.viewResults": "লটারির ফলাফল দেখুন", "account.application.noAccessError": "সেই আইডি সহ কোন আবেদন নেই", "account.application.noApplicationError": "সেই আইডি সহ কোন আবেদন নেই", @@ -48,6 +50,8 @@ "account.pwdless.loginMessage": "যদি %{email} দিয়ে কোনও অ্যাকাউন্ট তৈরি করা হয়, তাহলে আমরা ১০ মিনিটের মধ্যে একটি কোড পাঠাবো। যদি আপনি কোনও কোড না পান, তাহলে আপনার পাসওয়ার্ড দিয়ে সাইন ইন করুন এবং অ্যাকাউন্ট সেটিংসের অধীনে আপনার ইমেল ঠিকানা নিশ্চিত করুন।", "account.pwdless.loginReCaptchaMessage": "অতিরিক্ত নিরাপত্তা স্তর হিসেবে আপনাকে আপনার পরিচয় যাচাই করতে বলা হচ্ছে। লগ ইন সম্পূর্ণ করার জন্য আমরা %{email} এ একটি কোড পাঠিয়েছি। মনে রাখবেন, কোডটির মেয়াদ ১০ মিনিটের মধ্যে শেষ হয়ে যাবে।", "account.pwdless.notReceived": "আপনার কোড পাননি?", + "account.pwdless.passwordOutdatedModalContent": "আমাদের পাসওয়ার্ডের মেয়াদ শেষ হয়ে গেছে। আপনার পাসওয়ার্ড রিসেট করতে এবং আপনার অ্যাকাউন্ট অ্যাক্সেস করতে অবিরত ক্লিক করুন।", + "account.pwdless.passwordOutdatedModalHeader": "পাসওয়ার্ডের মেয়াদ শেষ", "account.pwdless.resend": "পুনরায় পাঠান", "account.pwdless.resendCode": "কোড পুনরায় পাঠান", "account.pwdless.resendCodeButton": "কোডটি আবার পাঠান", @@ -304,14 +308,16 @@ "application.review.confirmation.whatExpectFirstParagraph.refer": "লটারি ফলাফলের তারিখের জন্য তালিকা দেখুন।", "application.review.confirmation.whatExpectSecondparagraph": "শূন্যপদ পূরণ না হওয়া পর্যন্ত আবেদনকারীদের সাথে যোগাযোগ করা হবে। আপনার আবেদন নির্বাচন করা উচিত, আরো বিস্তারিত আবেদন পূরণ এবং প্রয়োজনীয় সহায়ক নথি প্রদান করার জন্য প্রস্তুত থাকুন।", "application.review.confirmation.whatExpectTitle": "এরপর কি আশা করা যায়", + "application.review.confirmation.whatHappensNext.base": "### এরপর কী হবে?\n\n* যদি আপনার সাথে সাক্ষাৎকারের জন্য যোগাযোগ করা হয়, তাহলে আপনাকে আরও বিস্তারিত আবেদনপত্র পূরণ করতে এবং সহায়ক নথিপত্র সরবরাহ করতে বলা হবে।", "application.review.confirmation.whatHappensNext.fcfs": "### এরপর কী হবে?\n\n* শূন্যপদ পূরণ না হওয়া পর্যন্ত যোগ্য আবেদনকারীদের সাথে আগে আসলে আগে পাবেন ভিত্তিতে যোগাযোগ করা হবে।\n\n* প্রযোজ্য ক্ষেত্রে, আবাসন পছন্দগুলি আগে আসলে আগে পাবেন অর্ডারের উপর প্রভাব ফেলবে।\n\n* যদি আপনার সাথে সাক্ষাৎকারের জন্য যোগাযোগ করা হয়, তাহলে আপনাকে আরও বিস্তারিত আবেদনপত্র পূরণ করতে এবং সহায়ক নথি সরবরাহ করতে বলা হবে।", "application.review.confirmation.whatHappensNext.lottery": "### এরপর কী হবে?\n\n* আবেদনের সময়সীমা শেষ হয়ে গেলে, যোগ্য আবেদনকারীদের লটারির র‍্যাঙ্ক ক্রমের ভিত্তিতে ক্রমানুসারে রাখা হবে।\n\n* প্রযোজ্য ক্ষেত্রে, আবাসন পছন্দগুলি লটারির র‍্যাঙ্ক ক্রমের উপর প্রভাব ফেলবে।\n\n* যদি আপনার সাথে সাক্ষাৎকারের জন্য যোগাযোগ করা হয়, তাহলে আপনাকে আরও বিস্তারিত আবেদনপত্র পূরণ করতে এবং সহায়ক নথি সরবরাহ করতে বলা হবে।", - "application.review.confirmation.whatHappensNext.waitlist": "### এরপর কী হবে?\n\n* যোগ্য আবেদনকারীদের আগে আসলে আগে পাবেন ভিত্তিতে অপেক্ষা তালিকায় রাখা হবে যতক্ষণ না অপেক্ষা তালিকার আসনগুলি পূরণ হয়।\n\n* প্রযোজ্য ক্ষেত্রে, আবাসন পছন্দগুলি অপেক্ষা তালিকার ক্রমকে প্রভাবিত করবে।\n\n* যদি আপনার সাথে সাক্ষাৎকারের জন্য যোগাযোগ করা হয়, তাহলে আপনাকে আরও বিস্তারিত আবেদনপত্র পূরণ করতে এবং সহায়ক নথিপত্র সরবরাহ করতে বলা হবে।\n\n* আপনি অপেক্ষা তালিকায় থাকতে চান তা নিশ্চিত করার জন্য অপেক্ষা তালিকায় থাকাকালীন আপনার সাথে যোগাযোগ করা হতে পারে।", - "application.review.confirmation.whatHappensNext.base": "### এরপর কী হবে?\n\n* যদি আপনার সাথে সাক্ষাৎকারের জন্য যোগাযোগ করা হয়, তাহলে আপনাকে আরও বিস্তারিত আবেদনপত্র পূরণ করতে এবং সহায়ক নথিপত্র সরবরাহ করতে বলা হবে।", + "application.review.confirmation.whatHappensNext.noPref.base": "### এরপর কী হবে?\n\n* যদি আপনার সাথে সাক্ষাৎকারের জন্য যোগাযোগ করা হয়, তাহলে আপনাকে আরও বিস্তারিত আবেদনপত্র পূরণ করতে এবং সহায়ক নথিপত্র সরবরাহ করতে বলা হবে।", "application.review.confirmation.whatHappensNext.noPref.fcfs": "### এরপর কী হবে?\n\n* শূন্যপদ পূরণ না হওয়া পর্যন্ত যোগ্য আবেদনকারীদের সাথে আগে আসলে আগে পাবেন ভিত্তিতে যোগাযোগ করা হবে।\n\n* যদি আপনার সাথে সাক্ষাৎকারের জন্য যোগাযোগ করা হয়, তাহলে আপনাকে আরও বিস্তারিত আবেদনপত্র পূরণ করতে এবং সহায়ক নথি সরবরাহ করতে বলা হবে।", "application.review.confirmation.whatHappensNext.noPref.lottery": "### এরপর কী হবে?\n\n* আবেদনের সময়সীমা শেষ হয়ে গেলে, যোগ্য আবেদনকারীদের লটারির র‍্যাঙ্ক ক্রমের ভিত্তিতে ক্রমানুসারে রাখা হবে।\n\n* যদি আপনার সাথে সাক্ষাৎকারের জন্য যোগাযোগ করা হয়, তাহলে আপনাকে আরও বিস্তারিত আবেদনপত্র পূরণ করতে এবং সহায়ক নথি সরবরাহ করতে বলা হবে।", "application.review.confirmation.whatHappensNext.noPref.waitlist": "### এরপর কী হবে?\n\n* যোগ্য আবেদনকারীদের আগে আসলে আগে পাবেন ভিত্তিতে অপেক্ষা তালিকায় রাখা হবে যতক্ষণ না অপেক্ষা তালিকার আসনগুলি পূরণ হয়।\n\n* যদি আপনার সাথে সাক্ষাৎকারের জন্য যোগাযোগ করা হয়, তাহলে আপনাকে আরও বিস্তারিত আবেদনপত্র পূরণ করতে এবং সহায়ক নথিপত্র সরবরাহ করতে বলা হবে।\n\n* আপনি অপেক্ষা তালিকায় থাকতে চান তা নিশ্চিত করার জন্য অপেক্ষা তালিকায় থাকাকালীন আপনার সাথে যোগাযোগ করা হতে পারে।", - "application.review.confirmation.whatHappensNext.noPref.base": "### এরপর কী হবে?\n\n* যদি আপনার সাথে সাক্ষাৎকারের জন্য যোগাযোগ করা হয়, তাহলে আপনাকে আরও বিস্তারিত আবেদনপত্র পূরণ করতে এবং সহায়ক নথিপত্র সরবরাহ করতে বলা হবে।", + "application.review.confirmation.whatHappensNext.noPref.waitlistLottery": "### এরপর কী হবে?\n\n* লটারির র‍্যাঙ্ক অর্ডারের ভিত্তিতে যোগ্য আবেদনকারীদের অপেক্ষা তালিকায় রাখা হবে।\n\n* যদি আপনার সাথে সাক্ষাৎকারের জন্য যোগাযোগ করা হয়, তাহলে আপনাকে আরও বিস্তারিত আবেদনপত্র পূরণ করতে এবং সহায়ক নথিপত্র সরবরাহ করতে বলা হবে।\n\n* আপনি অপেক্ষা তালিকায় থাকতে চান কিনা তা নিশ্চিত করার জন্য অপেক্ষা তালিকায় থাকাকালীন আপনার সাথে যোগাযোগ করা হতে পারে।", + "application.review.confirmation.whatHappensNext.waitlist": "### এরপর কী হবে?\n\n* যোগ্য আবেদনকারীদের আগে আসলে আগে পাবেন ভিত্তিতে অপেক্ষা তালিকায় রাখা হবে যতক্ষণ না অপেক্ষা তালিকার আসনগুলি পূরণ হয়।\n\n* প্রযোজ্য ক্ষেত্রে, আবাসন পছন্দগুলি অপেক্ষা তালিকার ক্রমকে প্রভাবিত করবে।\n\n* যদি আপনার সাথে সাক্ষাৎকারের জন্য যোগাযোগ করা হয়, তাহলে আপনাকে আরও বিস্তারিত আবেদনপত্র পূরণ করতে এবং সহায়ক নথিপত্র সরবরাহ করতে বলা হবে।\n\n* আপনি অপেক্ষা তালিকায় থাকতে চান তা নিশ্চিত করার জন্য অপেক্ষা তালিকায় থাকাকালীন আপনার সাথে যোগাযোগ করা হতে পারে।", + "application.review.confirmation.whatHappensNext.waitlistLottery": "### এরপর কী হবে?\n\n* লটারির র‍্যাঙ্ক অর্ডারের ভিত্তিতে যোগ্য আবেদনকারীদের অপেক্ষা তালিকায় রাখা হবে।\n\n* প্রযোজ্য ক্ষেত্রে, আবাসন পছন্দগুলি অপেক্ষা তালিকার অর্ডারকে প্রভাবিত করবে।\n\n* যদি আপনার সাথে সাক্ষাৎকারের জন্য যোগাযোগ করা হয়, তাহলে আপনাকে আরও বিস্তারিত আবেদনপত্র পূরণ করতে এবং সহায়ক নথিপত্র সরবরাহ করতে বলা হবে।\n\n* আপনি অপেক্ষা তালিকায় থাকতে চান তা নিশ্চিত করার জন্য অপেক্ষা তালিকায় থাকাকালীন আপনার সাথে যোগাযোগ করা হতে পারে।", "application.review.demographics.ethnicityLabel": "কোনটি আপনার জাতিসত্তাকে সবচেয়ে ভালভাবে বর্ণনা করে?", "application.review.demographics.ethnicityOptions.hispanicLatino": "হিস্পানিক / ল্যাটিনো", "application.review.demographics.ethnicityOptions.notHispanicLatino": "হিস্পানিক / ল্যাটিনো নয়", @@ -407,10 +413,11 @@ "application.review.noAdditionalMembers": "বাড়ির অতিরিক্ত সদস্য নেই", "application.review.sameAddressAsApplicant": "আবেদনকারীর ঠিকানা একই", "application.review.takeAMomentToReview": "আপনার আবেদন জমা দেওয়ার আগে আপনার তথ্য পর্যালোচনা করার জন্য একটু সময় নিন।", - "application.review.terms.confirmCheckboxText": "আমি একমত এবং বুঝি যে আমি জমা দেওয়ার পরে আমি কিছু পরিবর্তন করতে পারি না।", "application.review.terms.base.text": "* যদি আপনার সাথে সাক্ষাৎকারের জন্য যোগাযোগ করা হয়, তাহলে আপনাকে আরও বিস্তারিত আবেদনপত্র পূরণ করতে এবং সহায়ক নথিপত্র সরবরাহ করতে বলা হবে।\n\n* আপনার দেওয়া সমস্ত তথ্য যাচাই করা হবে এবং আপনার যোগ্যতা নিশ্চিত করা হবে।\n\n* আপনি যদি কোনও প্রতারণামূলক বিবৃতি দিয়ে থাকেন তবে আপনার আবেদন বাতিল করা হতে পারে।\n\nআরও তথ্যের জন্য, অনুগ্রহ করে তালিকায় পোস্ট করা আবাসন বিকাশকারী বা সম্পত্তি ব্যবস্থাপকের সাথে যোগাযোগ করুন।\n\nএই আবেদনপত্র পূরণ করলে আপনি আবাসনের অধিকারী হবেন না বা আপনি আবাসনের জন্য যোগ্য কিনা তা নির্দেশ করবেন না। সমস্ত আবেদনকারীকে সম্পত্তির বাসিন্দা নির্বাচনের মানদণ্ড অনুসারে বাছাই করা হবে।\n\nআপনার অনলাইন আবেদন জমা দেওয়ার পরে আপনি পরিবর্তন করতে পারবেন না।\n\nআমি ঘোষণা করছি যে উপরের তথ্যগুলি সত্য এবং নির্ভুল, এবং স্বীকার করছি যে এই আবেদনে প্রতারণামূলক বা অবহেলার মাধ্যমে করা যেকোনো ভুল বিবৃতি আবেদন প্রক্রিয়া থেকে অপসারণ করা হতে পারে।", + "application.review.terms.confirmCheckboxText": "আমি একমত এবং বুঝি যে আমি জমা দেওয়ার পরে আমি কিছু পরিবর্তন করতে পারি না।", "application.review.terms.fcfs.text": "* আবেদনকারীরা বর্তমানে খালি থাকা অ্যাপার্টমেন্টগুলিতে আগে আসলে আগে পাবেন ভিত্তিতে আবেদন করছেন।\n\n* শূন্যপদ পূরণ না হওয়া পর্যন্ত যোগ্য আবেদনকারীদের সাথে আগে আসলে আগে পাবেন ভিত্তিতে যোগাযোগ করা হবে।\n\n* যদি আপনার সাথে সাক্ষাৎকারের জন্য যোগাযোগ করা হয়, তাহলে আপনাকে আরও বিস্তারিত আবেদনপত্র পূরণ করতে এবং সহায়ক নথিপত্র সরবরাহ করতে বলা হবে।\n\n* আপনার দেওয়া সমস্ত তথ্য যাচাই করা হবে এবং আপনার যোগ্যতা নিশ্চিত করা হবে।\n\n* আপনি যদি কোনও প্রতারণামূলক বিবৃতি দিয়ে থাকেন তবে আপনার আবেদন বাতিল করা হতে পারে।\n\n* আবাসন পছন্দের সম্পত্তির ক্ষেত্রে, যদি আমরা আপনার দাবি করা কোনও আবাসন পছন্দ যাচাই করতে না পারি, তাহলে আপনি পছন্দ পাবেন না তবে অন্যথায় শাস্তি দেওয়া হবে না।\n\nআরও তথ্যের জন্য, অনুগ্রহ করে তালিকায় পোস্ট করা আবাসন বিকাশকারী বা সম্পত্তি ব্যবস্থাপকের সাথে যোগাযোগ করুন।\n\nএই আবেদনপত্র পূরণ করলে আপনি আবাসনের অধিকারী হবেন না বা আপনি আবাসনের জন্য যোগ্য কিনা তা নির্দেশ করবেন না। সমস্ত আবেদনকারীকে সম্পত্তির বাসিন্দা নির্বাচনের মানদণ্ড অনুসারে বাছাই করা হবে।\n\nআপনার অনলাইন আবেদন জমা দেওয়ার পরে আপনি পরিবর্তন করতে পারবেন না।\n\nআমি ঘোষণা করছি যে উপরের তথ্যগুলি সত্য এবং নির্ভুল, এবং স্বীকার করছি যে এই আবেদনে প্রতারণামূলক বা অবহেলার মাধ্যমে করা যেকোনো ভুল বিবৃতি আবেদন প্রক্রিয়া থেকে অপসারণ করা হতে পারে।", "application.review.terms.lottery.text": "* আবেদনকারীরা বর্তমানে খালি থাকা অ্যাপার্টমেন্টের জন্য লটারিতে অংশ নিতে আবেদন করছেন।\n\n* আবেদনের সময়সীমা শেষ হয়ে গেলে, যোগ্য আবেদনকারীদের লটারির র‍্যাঙ্ক অর্ডারে রাখা হবে।\n\n* যদি আপনার সাথে সাক্ষাৎকারের জন্য যোগাযোগ করা হয়, তাহলে আপনাকে আরও বিস্তারিত আবেদনপত্র পূরণ করতে এবং সহায়ক নথিপত্র সরবরাহ করতে বলা হবে।\n\n* আপনার দেওয়া সমস্ত তথ্য যাচাই করা হবে এবং আপনার যোগ্যতা নিশ্চিত করা হবে।\n\n* আপনি যদি কোনও প্রতারণামূলক বিবৃতি দিয়ে থাকেন তবে আপনার আবেদন বাতিল করা হতে পারে।\n\n* আবাসন পছন্দের সম্পত্তির ক্ষেত্রে, যদি আমরা আপনার দাবি করা কোনও আবাসন পছন্দ যাচাই করতে না পারি, তাহলে আপনি পছন্দটি পাবেন না তবে অন্যথায় শাস্তি দেওয়া হবে না।\n\nআরও তথ্যের জন্য, অনুগ্রহ করে তালিকায় পোস্ট করা আবাসন বিকাশকারী বা সম্পত্তি ব্যবস্থাপকের সাথে যোগাযোগ করুন।\n\nএই আবেদনপত্র পূরণ করলে আপনি আবাসনের অধিকারী হবেন না বা আপনি আবাসনের জন্য যোগ্য কিনা তা নির্দেশ করবেন না। সমস্ত আবেদনকারীকে সম্পত্তির বাসিন্দা নির্বাচনের মানদণ্ড অনুসারে বাছাই করা হবে।\n\nআপনার অনলাইন আবেদন জমা দেওয়ার পরে আপনি পরিবর্তন করতে পারবেন না।\n\nআমি ঘোষণা করছি যে উপরের তথ্যগুলি সত্য এবং নির্ভুল, এবং স্বীকার করছি যে এই আবেদনে প্রতারণামূলক বা অবহেলার মাধ্যমে করা যেকোনো ভুল বিবৃতি আবেদন প্রক্রিয়া থেকে অপসারণ করা হতে পারে।", + "application.review.terms.standard.text": "* আবাসন লটারির মাধ্যমে অফার করা উপলব্ধ ইউনিটগুলির জন্য, আবেদনের শেষ তারিখের কিছুক্ষণ পরেই পরিচালিত লটারিতে যোগ্য আবেদনকারীদের সাথে তাদের পদমর্যাদার ক্রমানুসারে যোগাযোগ করা হবে। আগে আসলে আগে পাবেন ভিত্তিতে অফার করা উপলব্ধ ইউনিটগুলির জন্য, শূন্যপদ পূরণ না হওয়া পর্যন্ত যোগ্য আবেদনকারীদের সাথে তাদের আবেদনের কালানুক্রমিক ক্রমানুসারে যোগাযোগ করা হবে।\n\n* যদি আপনার সাথে সাক্ষাৎকারের জন্য যোগাযোগ করা হয়, তাহলে আপনাকে আরও বিস্তারিত আবেদনপত্র পূরণ করতে এবং সহায়ক নথিপত্র সরবরাহ করতে বলা হবে। আপনার প্রদত্ত সমস্ত তথ্য যাচাই করা হবে এবং আপনার যোগ্যতা নিশ্চিত করা হবে। আপনি যদি কোনও জালিয়াতিমূলক বিবৃতি দিয়ে থাকেন তবে পেশাদার অংশীদার আপনার আবেদনটি বিবেচনা থেকে বাতিল করতে পারে। আবাসন পছন্দের সম্পত্তির ক্ষেত্রে, যদি আপনার আবাসন পছন্দের দাবি যাচাই করা না যায়, তাহলে আপনি পছন্দ পাবেন না। তালিকার আবাসিক নির্বাচনের মানদণ্ড অনুসারে সমস্ত আবেদনকারীকে স্ক্রিন করা হবে।\n\n* এই আবেদনপত্র পূরণ করলেই আপনি আবাসনের অধিকারী হবেন না বা আপনি আবাসনের জন্য যোগ্য তা বোঝা যাবে না। মনে রাখবেন যে আপনার অনলাইন আবেদন জমা দেওয়ার পরে আপনি এটি পরিবর্তন করতে পারবেন না।\n\n* যেকোনো তালিকার জন্য একাধিকবার আবেদন করার সময় সতর্ক থাকুন, কারণ আপনার পরিবারের যেকোনো ব্যক্তির একাধিক আবেদন আপনাকে এবং পরিবারের সকল সদস্যকে সেই সুযোগ থেকে বঞ্চিত করতে পারে। যদি আপনি কোনও ত্রুটিযুক্ত আবেদন জমা দিয়ে থাকেন তবে দয়া করে আমাদের সাথে যোগাযোগ করুন।\n\nআমি নিশ্চিত করছি যে আমার বয়স কমপক্ষে আঠারো বছর এবং আবেদনপত্রে তালিকাভুক্ত যেকোনো পরিবারের সদস্যের ব্যক্তিগত শনাক্তযোগ্য তথ্য (PII) জমা দেওয়ার জন্য আমি অনুমোদিত। আবেদনপত্রে তালিকাভুক্ত পরিবারের সদস্যদের এবং আমার নিজের উভয়ের পক্ষ থেকে PII পেশাদার অংশীদার এবং/অথবা স্থানীয় সরকারের কাছে প্রেরণের জন্য আমি সম্মত। আমি উপরোক্ত শর্তাবলীতে সম্মত এবং ঘোষণা করছি যে উপরোক্ত প্রদত্ত তথ্য সত্য এবং নির্ভুল, এবং স্বীকার করছি যে এই আবেদনপত্রে প্রতারণামূলক বা অবহেলামূলকভাবে করা যেকোনো ভুল বিবৃতি আবেদন প্রক্রিয়া থেকে অপসারণ করা হতে পারে।", "application.review.terms.submittingApplication": "আবেদন জমা দেওয়া হচ্ছে", "application.review.terms.textSubmissionDate": "এই আবেদনটি %{applicationDueDate} এর মধ্যে জমা দিতে হবে।", "application.review.terms.title": "শর্তাবলী", @@ -424,6 +431,8 @@ "application.start.whatToExpect.title": "এই অ্যাপ্লিকেশন থেকে কি আশা করা যায় তা এখানে।", "application.start.whatToExpect.waitlist.finePrint": "* আবেদনকারীরা একটি উন্মুক্ত অপেক্ষা তালিকার জন্য আবেদন করছেন, বর্তমানে উপলব্ধ অ্যাপার্টমেন্টের জন্য নয়।\n* অনুগ্রহ করে মনে রাখবেন যে প্রতিটি পরিবারের সদস্য প্রতিটি তালিকার জন্য কেবল একটি আবেদনে উপস্থিত হতে পারবেন।\n* শূন্যপদ খালি হলে, সম্পত্তি ব্যবস্থাপক যোগ্য আবেদনকারীদের সাথে আগে আসলে আগে পাবেন ভিত্তিতে যোগাযোগ করবেন।\n* আপনার দেওয়া সমস্ত তথ্য যাচাই করা হবে এবং আপনার যোগ্যতা নিশ্চিত করা হবে।\n* আপনি যদি কোনও প্রতারণামূলক বিবৃতি দিয়ে থাকেন তবে আপনার আবেদন বাতিল করা হতে পারে।\n* আবাসন পছন্দের সম্পত্তির ক্ষেত্রে, যদি আমরা আপনার দাবি করা কোনও আবাসন পছন্দ যাচাই করতে না পারি, তাহলে আপনি পছন্দটি পাবেন না তবে অন্যথায় আপনাকে শাস্তি দেওয়া হবে না।", "application.start.whatToExpect.waitlist.steps": "১. প্রথমে আমরা আপনার এবং আপনি যাদের সাথে থাকার পরিকল্পনা করছেন তাদের সম্পর্কে জিজ্ঞাসা করব।\n২. তারপর, আমরা আপনার আয় সম্পর্কে জিজ্ঞাসা করব।\n৩. অবশেষে, আমরা দেখব যে আপনি প্রযোজ্য হলে, কোনও সাশ্রয়ী মূল্যের আবাসনের পছন্দের জন্য যোগ্য কিনা।", + "application.start.whatToExpect.waitlistLottery.finePrint": "* আবেদনকারীরা একটি উন্মুক্ত অপেক্ষা তালিকার জন্য আবেদন করছেন, বর্তমানে উপলব্ধ অ্যাপার্টমেন্টের জন্য নয়।\n* অনুগ্রহ করে মনে রাখবেন যে প্রতিটি পরিবারের সদস্য প্রতিটি তালিকার জন্য কেবল একটি আবেদনে উপস্থিত হতে পারবেন।\n* যখন শূন্যপদ খালি হবে, তখন শূন্যপদ পূরণ না হওয়া পর্যন্ত লটারির র‍্যাঙ্ক ক্রম অনুসারে সম্পত্তি এজেন্ট আবেদনকারীদের সাথে যোগাযোগ করবে।\n* আপনার দেওয়া সমস্ত তথ্য যাচাই করা হবে এবং আপনার যোগ্যতা নিশ্চিত করা হবে।\n* যদি আপনি কোনও প্রতারণামূলক বিবৃতি দিয়ে থাকেন তবে আপনার আবেদন বাতিল করা হতে পারে।\n* আবাসন পছন্দের সম্পত্তির ক্ষেত্রে, যদি আমরা আপনার দাবি করা কোনও আবাসন পছন্দ যাচাই করতে না পারি, তাহলে আপনি পছন্দটি পাবেন না তবে অন্যথায় আপনাকে শাস্তি দেওয়া হবে না।", + "application.start.whatToExpect.waitlistLottery.steps": "১. প্রথমে আমরা আপনার এবং আপনি যাদের সাথে থাকার পরিকল্পনা করছেন তাদের সম্পর্কে জিজ্ঞাসা করব।\n২. তারপর, আমরা আপনার আয় সম্পর্কে জিজ্ঞাসা করব।\n৩. অবশেষে, আমরা দেখব যে আপনি প্রযোজ্য হলে, কোনও সাশ্রয়ী মূল্যের আবাসনের পছন্দের জন্য যোগ্য কিনা।", "application.status": "স্থিতি", "application.statuses.inProgress": "চলছে", "application.statuses.neverSubmitted": "কখনও জমা দেওয়া হয়নি", @@ -465,10 +474,12 @@ "authentication.forgotPassword.errors.tokenMissing": "টোকেন পাওয়া যায়নি। অনুগ্রহ করে নতুনের জন্য অনুরোধ করুন।", "authentication.forgotPassword.message": "যদি সেই ইমেল দিয়ে একটি অ্যাকাউন্ট তৈরি করা হয়, তাহলে আপনি আপনার পাসওয়ার্ড রিসেট করার জন্য একটি লিঙ্ক সহ একটি ইমেল পাবেন।", "authentication.forgotPassword.passwordConfirmation": "পাসওয়ার্ড নিশ্চিতকরণ", - "authentication.forgotPassword.sendEmail": "ইমেইল পাঠান", + "authentication.forgotPassword.sendEmail": "পাসওয়ার্ড রিসেট লিঙ্ক পেতে আপনার ইমেল ঠিকানা লিখুন", + "authentication.forgotPassword.sendEmailButton": "ইমেইল", + "authentication.forgotPassword.sendEmailNotes": "আপনার ইমেল ঠিকানাটি লিখুন যাতে আমরা আপনাকে একটি পাসওয়ার্ড রিসেট লিঙ্ক পাঠাতে পারি। যদি আপনি কোনও ইমেল না পান, তাহলে আপনার কোনও অ্যাকাউন্ট নাও থাকতে পারে।", "authentication.signIn.accountHasBeenLocked": "নিরাপত্তার কারণে, এই অ্যাকাউন্টটি লক করা হয়েছে।", "authentication.signIn.afterFailedAttempts": "নিরাপত্তার কারণে, %{count} ব্যর্থ প্রচেষ্টার পরে, আবার চেষ্টা করার আগে আপনাকে 30 মিনিট অপেক্ষা করতে হবে।", - "authentication.signIn.changeYourPassword": "তুমি তোমার পাসওয়ার্ড পরিবর্তন করতে পারো।", + "authentication.signIn.changeYourPassword": "আপনার পাসওয়ার্ড রিসেট করতে এখানে ক্লিক করুন", "authentication.signIn.enterLoginEmail": "আপনার লগইন ইমেল লিখুন।", "authentication.signIn.enterLoginPassword": "আপনার লগইন পাসওয়ার্ড লিখুন।", "authentication.signIn.enterValidEmailAndPassword": "দয়া করে একটি বৈধ ইমেল এবং পাসওয়ার্ড লিখুন।", @@ -479,7 +490,7 @@ "authentication.signIn.loginError": "একটি বৈধ ইমেইল ঠিকানা লিখুন", "authentication.signIn.mfaError": "এটি একটি অংশীদার অ্যাকাউন্ট, যা নিরাপত্তার কারণে পাবলিক সাইটে লগইন করতে পারে না।", "authentication.signIn.passwordError": "একটি বৈধ পাসওয়ার্ড দিন", - "authentication.signIn.passwordOutdated": "আপনার পাসওয়ার্ডের মেয়াদ শেষ হয়ে গেছে। অনুগ্রহ করে আপনার পাসওয়ার্ড পুনরায় সেট করুন।", + "authentication.signIn.passwordOutdated": " আপনার অ্যাকাউন্টের সাথে সম্পর্কিত পাসওয়ার্ডের মেয়াদ শেষ হয়ে গেছে। আপনার অ্যাকাউন্ট অ্যাক্সেস করার জন্য আপনাকে এটি পুনরায় সেট করতে হবে।", "authentication.signIn.pwdless.createAccountCopy": "কোনও পাসওয়ার্ড মনে রাখার প্রয়োজন ছাড়াই দ্রুত সাইন আপ করুন।", "authentication.signIn.pwdless.emailHelperText": "আপনার ইমেল ঠিকানা লিখুন এবং আমরা আপনাকে সাইন ইন করার জন্য একটি কোড পাঠাবো।", "authentication.signIn.pwdless.error": "আপনি যে কোডটি ব্যবহার করেছেন তা অবৈধ অথবা মেয়াদোত্তীর্ণ।", @@ -625,6 +636,7 @@ "languages.vi": "Tiếng Việt", "languages.zh": "中文", "leasingAgent.contact": "লিজিং এজেন্টের সাথে যোগাযোগ করুন", + "leasingAgent.contactManagerProp": "লিজিং এজেন্ট বা সম্পত্তি ব্যবস্থাপকের সাথে যোগাযোগ করুন", "leasingAgent.dueToHighCallVolume": "উচ্চ কল ভলিউমের কারণে আপনি একটি বার্তা শুনতে পারেন।", "leasingAgent.officeHours": "অফিস সময়সূচী", "listingFilters.clear": "পরিষ্কার", @@ -638,12 +650,18 @@ "listings.additionalInformationEnvelope": "অতিরিক্ত তথ্যের খাম", "listings.allUnits": "সকল ইউনিট", "listings.allUnitsReservedFor": "সমস্ত ইউনিট %{type} এর জন্য সংরক্ষিত", + "listings.amenities.busStops": "বাস স্টপ", "listings.amenities.groceryStores": "মুদির দোকান", "listings.amenities.healthCareResources": "স্বাস্থ্যসেবা সম্পদ", + "listings.amenities.hospitals": "হাসপাতাল", "listings.amenities.parksAndCommunityCenters": "পার্ক এবং কমিউনিটি সেন্টার", "listings.amenities.pharmacies": "ফার্মেসী", + "listings.amenities.playgrounds": "খেলার মাঠ", "listings.amenities.publicTransportation": "গণপরিবহন", + "listings.amenities.recreationalFacilities": "বিনোদন সুবিধা", "listings.amenities.schools": "স্কুল", + "listings.amenities.seniorCenters": "সিনিয়র সেন্টারসমূহ", + "listings.amenities.shoppingVenues": "শপিং ভেন্যু", "listings.annualIncome": "%{আয়} প্রতি বছর", "listings.applicationAlreadySubmitted": "এই আবেদনটি ইতিমধ্যেই জমা দেওয়া হয়েছে।", "listings.applicationDeadline": "আবেদনের শেষ তারিখ", @@ -702,6 +720,8 @@ "listings.confirmedPreferenceList": "নিশ্চিত %{preference} তালিকা", "listings.costsNotIncluded": "খরচ অন্তর্ভুক্ত নয়", "listings.creditHistory": "ক্রেডিট ইতিহাস", + "listings.creditScreeningFee": "ক্রেডিট স্ক্রিনিং", + "listings.creditScreeningFeeDescription": "আপনার ক্রেডিট এবং ভাড়ার ইতিহাস পর্যালোচনার খরচ বহন করে", "listings.criminalBackground": "অপরাধমূলক পটভূমি", "listings.depositMayBeHigherForLowerCredit": "কম ক্রেডিট স্কোরের জন্য উচ্চতর হতে পারে", "listings.depositOrMonthsRent": "অথবা এক মাসের ভাড়া", @@ -716,6 +736,7 @@ "listings.featuresCards": "ফিচার কার্ড", "listings.forIncomeCalculations": "আয়ের হিসাবের জন্য, পরিবারের আকারে ইউনিটে বসবাসকারী সবাই (সব বয়সী) অন্তর্ভুক্ত।", "listings.forIncomeCalculationsBMR": "আয়ের হিসাব ইউনিটের প্রকারভিত্তিক", + "listings.hasEbllClearance": "এই সম্পত্তিটি HUD EBLL ছাড়পত্র পেয়েছে।", "listings.hideClosedListings": "বন্ধ তালিকা লুকান", "listings.homeType.apartment": "অ্যাপার্টমেন্ট", "listings.homeType.duplex": "ডুপ্লেক্স", @@ -732,6 +753,7 @@ "listings.lotteryResults.completeResultsWillBePosted": "সম্পূর্ণ লটারির ফলাফল শীঘ্রই পোস্ট করা হবে।", "listings.lotteryResults.downloadResults": "ফলাফল ডাউনলোড করুন", "listings.lotteryResults.header": "লটারির ফলাফল", + "listings.marketing.header": "বিপণন", "listings.maxIncomeMonth": "সর্বোচ্চ আয় / মাস", "listings.maxIncomeYear": "সর্বোচ্চ আয় / বছর", "listings.maxRent": "সর্বোচ্চ ভাড়া", @@ -743,12 +765,16 @@ "listings.neighborhoodBuildings": "আশেপাশের ভবন", "listings.noAvailableUnits": "এই সময়ে কোন উপলব্ধ ইউনিট নেই।", "listings.noClosedListings": "বর্তমানে কোনও তালিকায় বন্ধ থাকা অ্যাপ্লিকেশন নেই", + "listings.noEbllClearance": "এই সম্পত্তিটি HUD EBLL ছাড়পত্র পায়নি।", "listings.noMatchingClosedListings": "বন্ধ অ্যাপ্লিকেশনের সাথে কোনও মিলিত তালিকা নেই", "listings.noMatchingOpenListings": "খোলা অ্যাপ্লিকেশনগুলির সাথে কোনও মিলযুক্ত তালিকা নেই", "listings.noOpenListings": "বর্তমানে কোন তালিকা খোলা অ্যাপ্লিকেশন নেই।", "listings.occupancyDescriptionAllSro": "এই ভবনের বাসিন্দা প্রতি ইউনিটে ১ জনের মধ্যে সীমাবদ্ধ।", "listings.occupancyDescriptionNoSro": "এই ভবনের জন্য দখলের সীমাগুলি ইউনিটের প্রকারের উপর ভিত্তি করে।", "listings.occupancyDescriptionSomeSro": "এই ভবনের বাসিন্দার সংখ্যা ইউনিটের ধরণ অনুসারে পরিবর্তিত হয়। বয়স নির্বিশেষে, প্রতি ইউনিটে ১ জন ব্যক্তির জন্য SRO সীমাবদ্ধ। অন্যান্য সমস্ত ধরণের ইউনিটের ক্ষেত্রে, বাসিন্দার সীমা ৬ বছরের কম বয়সী শিশুদের গণনা করা হয় না।", + "listings.openHouseAndMarketing.accessibleMarketingFlyerLink": "অ্যাক্সেসযোগ্য বিপণন ফ্লায়ার খুলুন", + "listings.openHouseAndMarketing.header": "খোলা ঘর এবং বিপণন", + "listings.openHouseAndMarketing.marketingFlyerLink": "বিপণন ফ্লায়ার খুলুন", "listings.openHouseEvent.header": "খোলা ঘর", "listings.openHouseEvent.seeVideo": "ভিডিও দেখুন", "listings.percentAMIUnit": "%{percent}% AMI ইউনিট", @@ -761,7 +787,17 @@ "listings.removeFilters": "আপনার কিছু ফিল্টার সরানোর চেষ্টা করুন অথবা সমস্ত তালিকা দেখান।", "listings.rentalHistory": "ভাড়ার ইতিহাস", "listings.rePricing": "পুনরায় মূল্য নির্ধারণ", + "listings.requiredDocuments.birthCertificate": "জন্ম সনদ (১৮+ বয়সী সকল পরিবারের সদস্য)", + "listings.requiredDocuments.currentLandlordReference": "বর্তমান বাড়িওয়ালার রেফারেন্স", + "listings.requiredDocuments.governmentIssuedId": "সরকার কর্তৃক প্রদত্ত পরিচয়পত্র (১৮+ বয়সী সকল পরিবারের সদস্য)", + "listings.requiredDocuments.previousLandlordReference": "পূর্ববর্তী বাড়িওয়ালার রেফারেন্স", + "listings.requiredDocuments.proofOfAssets": "সম্পদ প্রমাণ (ব্যাংক স্টেটমেন্ট, ইত্যাদি)", + "listings.requiredDocuments.proofOfCustody": "হেফাজত/অভিভাবকের প্রমাণ", + "listings.requiredDocuments.proofOfIncome": "পরিবারের আয়ের প্রমাণ (চেক স্টাব, W-2, ইত্যাদি)", + "listings.requiredDocuments.residencyDocuments": "অভিবাসন/আবাসিক নথি (সবুজ কার্ড, ইত্যাদি)", + "listings.requiredDocuments.socialSecurityCard": "সামাজিক নিরাপত্তা কার্ড", "listings.requiredDocuments": "প্রয়োজনীয় কাগজপত্র", + "listings.requiredDocumentsAdditionalInfo": "প্রয়োজনীয় কাগজপত্র (অতিরিক্ত তথ্য)", "listings.reservedCommunityBuilding": "%{টাইপ} বিল্ডিং", "listings.reservedCommunitySeniorTitle": "সিনিয়র ভবন", "listings.reservedCommunityTitleDefault": "সংরক্ষিত ভবন", @@ -835,7 +871,17 @@ "listings.singleRoomOccupancyDescription": "এই সম্পত্তিতে শুধুমাত্র একজনের জন্য একক কক্ষ রয়েছে। ভাড়াটেরা বাথরুম এবং কখনও কখনও রান্নাঘরের সুবিধা ভাগ করে নিতে পারে।", "listings.specialNotes": "বিশেষ নোট", "listings.underConstruction": "নির্মাণাধীন", + "listings.unit.accessibilityType.Hearing and Visual": "শ্রবণশক্তি এবং দৃষ্টিশক্তি", + "listings.unit.accessibilityType.Hearing": "শ্রবণ", + "listings.unit.accessibilityType.Mobility and Hearing": "গতিশীলতা এবং শ্রবণশক্তি", + "listings.unit.accessibilityType.Mobility and Visual": "গতিশীলতা এবং দৃষ্টিভঙ্গি", + "listings.unit.accessibilityType.Mobility, Hearing and Visual": "গতিশীলতা, শ্রবণশক্তি এবং দৃষ্টিশক্তি", + "listings.unit.accessibilityType.Mobility": "গতিশীলতা", + "listings.unit.accessibilityType.Visual": "দৃষ্টি", + "listings.unit.accessibilityType": "অ্যাক্সেসিবিলিটির ধরণ", "listings.unit.sharedBathroom": "ভাগ করা হয়েছে", + "listings.unit.showLessUnits": "কম সংখ্যক %{type} ইউনিট দেখান", + "listings.unit.showMoreUnits": "আরও %{type} ইউনিট দেখান", "listings.unitsAreFor": "এই ইউনিটগুলি %{type} এর জন্য।", "listings.unitsHaveAccessibilityFeaturesFor": "এই ইউনিটে %{type} থাকা ব্যক্তিদের জন্য অ্যাক্সেসযোগ্যতার বৈশিষ্ট্য রয়েছে।", "listings.unitsSummary.notAvailable": "পাওয়া যায় না", @@ -878,18 +924,18 @@ "listings.waitlist.unitsAndWaitlist": "উপলব্ধ ইউনিট এবং অপেক্ষা তালিকা", "lottery.applicationsThatQualifyForPreference": "এই অগ্রাধিকার জন্য যোগ্যতা অর্জনকারী অ্যাপ্লিকেশনগুলিকে একটি উচ্চ অগ্রাধিকার দেওয়া হবে।", "lottery.viewPreferenceList": "পছন্দের তালিকা দেখুন", - "months.january": "জানুয়ারী", + "months.april": "এপ্রিল", + "months.august": "আগস্ট", + "months.december": "ডিসেম্বর", "months.february": "ফেব্রুয়ারী", + "months.january": "জানুয়ারী", + "months.july": "জুলাই", + "months.june": "জুন", "months.march": "মার্চ", - "months.april": "এপ্রিল", "months.may": "মে", - "months.june": "জুন", - "months.july": "জুলাই", - "months.august": "আগস্ট", - "months.september": "সেপ্টেম্বর", - "months.october": "অক্টোবর", "months.november": "নভেম্বর", - "months.december": "ডিসেম্বর", + "months.october": "অক্টোবর", + "months.september": "সেপ্টেম্বর", "nav.browseProperties": "প্রপার্টি ব্রাউজ করুন", "nav.getFeedback": "আমরা আপনার প্রতিক্রিয়া পেতে চাই!", "nav.listings": "তালিকা", @@ -1020,6 +1066,7 @@ "t.additionalAccessibility": "অতিরিক্ত অ্যাক্সেসযোগ্যতা", "t.additionalPhone": "অতিরিক্ত ফোন নম্বর", "t.am": "এএম", + "t.ami": "এএমআই", "t.area": "এলাকা", "t.areYouStillWorking": "তুমি কি এখনও কাজ করছো?", "t.at": "এ", @@ -1092,6 +1139,8 @@ "t.or": "অথবা", "t.order": "আদেশ", "t.orUpTo": "বা পর্যন্ত", + "t.other": "অন্যান্য", + "t.parkingFee": "পার্কিং ফি", "t.people": "মানুষ", "t.perMonth": "প্রতি মাসে", "t.person": "ব্যক্তি", diff --git a/shared-helpers/src/locales/es.json b/shared-helpers/src/locales/es.json index 3839ca16d0..3a5d887e66 100644 --- a/shared-helpers/src/locales/es.json +++ b/shared-helpers/src/locales/es.json @@ -8,7 +8,7 @@ "account.application.lottery.applicantList": "De %{applicants} solicitantes en esta lista", "account.application.lottery.next": "El administrador de la propiedad se comunicará con los solicitantes en orden de preferencia. Comenzarán con la prioridad más alta. Si el administrador de la propiedad se comunica con usted, le pedirá que proporcione documentación para respaldar lo que respondió en la solicitud. Esa documentación podría incluir recibos de sueldo, por ejemplo. También es posible que necesiten recopilar más información pidiéndole que complete una solicitud complementaria.", "account.application.lottery.nextHeader": "¿Qué pasa después?", - "account.application.lottery.preferences": "Las preferencias de lotería para su solicitud se muestran aquí en orden de prioridad. Si no califica para ninguna preferencia de lotería, formará parte de la categoría de lotería general. La categoría de lotería general es el último grupo procesado.", + "account.application.lottery.preferences": "Las preferencias de lotería para su solicitud se muestran aquí en orden de prioridad. Si no califica para ninguna preferencia de lotería, formará parte de la categoría de lotería general. La categoría de lotería general es el último grupo procesado. Tenga en cuenta que solo verá las clasificaciones de las preferencias que haya seleccionado, pero puede haber otras preferencias para este anuncio.", "account.application.lottery.preferencesButton": "¿Qué son las preferencias de lotería?", "account.application.lottery.preferencesHeader": "Sus preferencias de lotería", "account.application.lottery.preferencesMessage": "Estos resultados se basan en la información que proporcionó en su solicitud. La elegibilidad para la preferencia está sujeta a cambios una vez que se verifique su información.", @@ -16,8 +16,10 @@ "account.application.lottery.rawRankButton": "¿Qué es el rango bruto?", "account.application.lottery.rawRankHeader": "Tu rango bruto", "account.application.lottery.resultsHeader": "Aquí están tus resultados de lotería", + "account.application.lottery.resultsHeaderWaitlistLottery": "Aquí están los resultados de la lotería para la lista de espera", "account.application.lottery.resultsSubheader": "Se enviaron %{applications} solicitudes para %{units} unidad", "account.application.lottery.resultsSubheaderPlural": "Se enviaron %{applications} solicitudes para %{units} unidades", + "account.application.lottery.resultsSubheaderWaitlistLottery": "se presentaron %{applications} solicitudes", "account.application.lottery.viewResults": "Ver resultados de lotería", "account.application.noAccessError": "Usted no está autorizado para ver esta aplicación", "account.application.noApplicationError": "No existe ninguna aplicación con esa ID", @@ -48,6 +50,8 @@ "account.pwdless.loginMessage": "Si hay una cuenta creada con ese correo electrónico, le enviaremos un código en 10 minutos. Si no recibe un código, inicie sesión con su contraseña y confirme su dirección de correo electrónico en la configuración de la cuenta.", "account.pwdless.loginReCaptchaMessage": "Se le solicita que verifique su identidad como capa adicional de seguridad. Enviamos un código a %{email} para finalizar el inicio de sesión. Tenga en cuenta que el código caducará en 10 minutos.", "account.pwdless.notReceived": "¿No recibiste tu código?", + "account.pwdless.passwordOutdatedModalContent": "Su contraseña ha caducado. Haga clic en continuar para restablecer su contraseña y acceder a su cuenta.", + "account.pwdless.passwordOutdatedModalHeader": "Contraseña caducada", "account.pwdless.resend": "Reenviar", "account.pwdless.resendCode": "Reenviar codigo", "account.pwdless.resendCodeButton": "Reenviar el código", @@ -307,11 +311,13 @@ "application.review.confirmation.whatHappensNext.base": "### ¿Qué sucede a continuación?\n\n* Si lo contactan para una entrevista, se le pedirá que complete una solicitud más detallada y proporcione los documentos de respaldo.", "application.review.confirmation.whatHappensNext.fcfs": "### ¿Qué sucede a continuación?\n\n* Los solicitantes elegibles serán contactados por orden de llegada hasta que se cubran las vacantes.\n\n* Las preferencias de vivienda, si corresponde, afectarán el orden por orden de llegada. \n\n* Si lo contactan para una entrevista, se le pedirá que complete una solicitud más detallada y proporcione documentos de respaldo.", "application.review.confirmation.whatHappensNext.lottery": "### ¿Qué sucede a continuación?\n\n* Una vez que se cierre el período de solicitud, los solicitantes elegibles serán colocados en orden según el orden de clasificación de la lotería.\n\n* Las preferencias de vivienda, si corresponde, afectarán el orden de clasificación de la lotería.\n\n* Si lo contactan para una entrevista, se le pedirá que complete una solicitud más detallada y proporcione documentos de respaldo.", - "application.review.confirmation.whatHappensNext.waitlist": "### ¿Qué sucede a continuación?\n\n* Los solicitantes elegibles se colocarán en la lista de espera por orden de llegada hasta que se cubran los lugares de la lista de espera.\n\n* Las preferencias de alojamiento, si corresponde, afectarán el orden de la lista de espera.\n\n* Si lo contactan para una entrevista, se le pedirá que complete una solicitud más detallada y proporcione los documentos de respaldo.\n\n* Es posible que lo contactemos mientras esté en la lista de espera para confirmar que desea permanecer en la lista de espera.", + "application.review.confirmation.whatHappensNext.noPref.base": "### ¿Qué sucede a continuación?\n\n* Si lo contactan para una entrevista, se le pedirá que complete una solicitud más detallada y proporcione los documentos de respaldo.", "application.review.confirmation.whatHappensNext.noPref.fcfs": "### ¿Qué sucede a continuación?\n\n* Los solicitantes elegibles serán contactados por orden de llegada hasta que se cubran las vacantes. \n\n* Si lo contactan para una entrevista, se le pedirá que complete una solicitud más detallada y proporcione documentos de respaldo.", "application.review.confirmation.whatHappensNext.noPref.lottery": "### ¿Qué sucede a continuación?\n\n* Una vez que se cierre el período de solicitud, los solicitantes elegibles serán colocados en orden según el orden de clasificación de la lotería.\n\n* Si lo contactan para una entrevista, se le pedirá que complete una solicitud más detallada y proporcione documentos de respaldo.", "application.review.confirmation.whatHappensNext.noPref.waitlist": "### ¿Qué sucede a continuación?\n\n* Los solicitantes elegibles se colocarán en la lista de espera por orden de llegada hasta que se cubran los lugares de la lista de espera.\n\n* Si lo contactan para una entrevista, se le pedirá que complete una solicitud más detallada y proporcione los documentos de respaldo.\n\n* Es posible que lo contactemos mientras esté en la lista de espera para confirmar que desea permanecer en la lista de espera.", - "application.review.confirmation.whatHappensNext.noPref.base": "### ¿Qué sucede a continuación?\n\n* Si lo contactan para una entrevista, se le pedirá que complete una solicitud más detallada y proporcione los documentos de respaldo.", + "application.review.confirmation.whatHappensNext.noPref.waitlistLottery": "### ¿Qué sucede a continuación?\n\n* Los solicitantes elegibles serán colocados en la lista de espera según el orden de clasificación de la lotería.\n\n* Si lo contactan para una entrevista, se le pedirá que complete una solicitud más detallada y proporcione los documentos de respaldo.\n\n* Es posible que lo contactemos mientras esté en la lista de espera para confirmar que desea permanecer en la lista de espera.", + "application.review.confirmation.whatHappensNext.waitlist": "### ¿Qué sucede a continuación?\n\n* Los solicitantes elegibles se colocarán en la lista de espera por orden de llegada hasta que se cubran los lugares de la lista de espera.\n\n* Las preferencias de alojamiento, si corresponde, afectarán el orden de la lista de espera.\n\n* Si lo contactan para una entrevista, se le pedirá que complete una solicitud más detallada y proporcione los documentos de respaldo.\n\n* Es posible que lo contactemos mientras esté en la lista de espera para confirmar que desea permanecer en la lista de espera.", + "application.review.confirmation.whatHappensNext.waitlistLottery": "### ¿Qué sucede a continuación?\n\n* Los solicitantes elegibles serán colocados en la lista de espera según el orden de clasificación de la lotería.\n\n* Las preferencias de alojamiento, si corresponde, afectarán el orden de la lista de espera.\n\n* Si lo contactan para una entrevista, se le pedirá que complete una solicitud más detallada y proporcione los documentos de respaldo.\n\n* Es posible que lo contactemos mientras esté en la lista de espera para confirmar que desea permanecer en la lista de espera.", "application.review.demographics.ethnicityLabel": "¿Cuál de las siguientes opciones describe mejor su origen étnico?", "application.review.demographics.ethnicityOptions.hispanicLatino": "Hispano o latino", "application.review.demographics.ethnicityOptions.notHispanicLatino": "Ni hispano ni latino", @@ -411,6 +417,7 @@ "application.review.terms.confirmCheckboxText": "Convengo y comprendo que no puedo cambiar nada después de enviar la solicitud.", "application.review.terms.fcfs.text": "* Los solicitantes solicitan apartamentos actualmente desocupados por orden de llegada.\n\n* Los solicitantes elegibles serán contactados por orden de llegada hasta que se cubran las vacantes.\n\n* Si lo contactan para una entrevista, se le pedirá que complete una solicitud más detallada y proporcione documentos de respaldo.\n\n* Toda la información que haya proporcionado será verificada y se confirmará su elegibilidad.\n\n* Su solicitud puede ser eliminada si ha realizado declaraciones fraudulentas.\n\n* Para propiedades con preferencias de vivienda, si no podemos verificar una preferencia de vivienda que usted haya reclamado, no recibirá la preferencia pero no será penalizado de otra manera.\n\nPara obtener más información, comuníquese con el desarrollador de viviendas o el administrador de la propiedad publicado en el listado.\n\nCompletar esta solicitud no le da derecho a una vivienda ni indica que es elegible para recibirla. Todos los solicitantes serán evaluados como se describe en los Criterios de selección de residentes de la propiedad.\n\nNo puede cambiar su solicitud en línea después de enviarla.\n\nDeclaro que lo anterior es verdadero y exacto, y reconozco que cualquier declaración errónea realizada de manera fraudulenta o negligente en Esta solicitud puede resultar en la eliminación del proceso de solicitud.", "application.review.terms.lottery.text": "* Los solicitantes están solicitando participar en una lotería para apartamentos actualmente desocupados.\n\n* Una vez que se cierre el período de solicitud, los solicitantes elegibles serán colocados en el orden de clasificación de la lotería.\n\n* Si lo contactan para una entrevista, se le Se le pedirá que complete una solicitud más detallada y proporcione documentos de respaldo.\n\n* Toda la información que ha proporcionado será verificada y se confirmará su elegibilidad.\n\n* Su solicitud puede ser eliminada si ha realizado algún fraude. declaraciones.\n\n* Para propiedades con preferencias de vivienda, si no podemos verificar una preferencia de vivienda que usted ha reclamado, no recibirá la preferencia pero no será penalizado de otra manera.\n\nPara obtener más información, comuníquese con el desarrollador de viviendas o administrador de la propiedad publicado en el listado.\n\nCompletar esta solicitud no le da derecho a una vivienda ni indica que es elegible para recibirla. Todos los solicitantes serán evaluados como se describe en los Criterios de selección de residentes de la propiedad.\n\nNo puede cambiar su solicitud en línea después de enviarla.\n\nDeclaro que lo anterior es verdadero y exacto, y reconozco que cualquier declaración errónea realizada de manera fraudulenta o negligente en Esta solicitud puede resultar en la eliminación del proceso de solicitud.", + "application.review.terms.standard.text": "* Para las viviendas disponibles que se ofrezcan mediante sorteo, se contactará con los solicitantes por orden de puntuación en un sorteo que se celebrará poco después de la fecha límite de presentación de solicitudes. Para las viviendas disponibles que se ofrecen por orden de llegada, se contactará con los solicitantes admisibles por orden cronológico de sus solicitudes hasta que se cubran las vacantes.\n\n* Si se ponen en contacto con usted para una entrevista, se le pedirá que rellene una solicitud más detallada y que aporte documentos justificativos. Se verificará toda la información que haya proporcionado y se confirmará su idoneidad. El socio profesional podrá retirar su solicitud si ha hecho alguna declaración fraudulenta. En el caso de inmuebles con preferencias de vivienda, si su solicitud de preferencia de vivienda no puede verificarse, no recibirá la preferencia. Todos los solicitantes se examinarán según se indica en los criterios de selección de residentes del anuncio.\n\n* El hecho de rellenar esta solicitud no le da derecho a una vivienda ni indica que pueda optar a ella. Tenga en cuenta que no puede modificar su solicitud en línea después de enviarla.\n\n* Tenga cuidado al presentar más de una solicitud para cualquier oferta, ya que más de una solicitud por parte de cualquier persona de su hogar podría descalificarlos a usted y a todos los miembros de su familia para esa oportunidad. Si ha enviado una solicitud con algún error, póngase en contacto con nosotros.\n\nDeclaro que tengo al menos dieciocho años de edad y estoy autorizado para proporcionar la información personal identificable (PII) de cualquier miembro del hogar que figure en la solicitud. Doy mi consentimiento, tanto en nombre de los miembros del hogar que figuran en la solicitud como en el mío propio, para que la información personal identificable se transmita al socio profesional y/o al gobierno local. Acepto los términos anteriores y declaro que la información proporcionada es veraz y precisa, y reconozco que cualquier declaración falsa, ya sea fraudulenta o por negligencia, en esta solicitud puede resultar en mi exclusión del proceso de solicitud.", "application.review.terms.submittingApplication": "Presentando solicitud", "application.review.terms.textSubmissionDate": "Esta solicitud debe enviarse antes del %{applicationDueDate}.", "application.review.terms.title": "Términos", @@ -424,6 +431,8 @@ "application.start.whatToExpect.title": "Esto es lo que puede esperar de esta solicitud.", "application.start.whatToExpect.waitlist.finePrint": "* Los solicitantes solicitan una lista de espera abierta y no un apartamento actualmente disponible.\n* Tenga en cuenta que cada miembro del hogar solo puede aparecer en una solicitud para cada anuncio.\n* Cuando haya vacantes disponibles, el solicitante se comunicará con los solicitantes elegibles. administrador de la propiedad por orden de llegada.\n* Toda la información que haya proporcionado será verificada y se confirmará su elegibilidad.\n* Su solicitud puede eliminarse si ha realizado declaraciones fraudulentas.\n* Para propiedades con preferencias de vivienda, si no podemos verificar una preferencia de vivienda que usted haya reclamado, no recibirá la preferencia pero no será penalizado de ninguna otra manera.", "application.start.whatToExpect.waitlist.steps": "1. Primero le preguntaremos sobre usted y las personas con las que planea vivir.\n2. Luego, le preguntaremos sobre sus ingresos.\n3. Finalmente, veremos si califica para alguna preferencia de vivienda asequible, si corresponde.", + "application.start.whatToExpect.waitlistLottery.finePrint": "* Los solicitantes solicitan una lista de espera abierta y no un apartamento actualmente disponible.\n* Tenga en cuenta que cada miembro del hogar solo puede aparecer en una solicitud para cada anuncio.\n* Cuando haya vacantes disponibles, el agente de propiedad se comunicará con los solicitantes en orden de lotería hasta que se cubran las vacantes.\n* Toda la información que haya proporcionado será verificada y se confirmará su elegibilidad.\n* Su solicitud puede eliminarse si ha realizado declaraciones fraudulentas.\n* Para propiedades con preferencias de vivienda, si no podemos verificar una preferencia de vivienda que usted haya reclamado, no recibirá la preferencia pero no será penalizado de ninguna otra manera.", + "application.start.whatToExpect.waitlistLottery.steps": "1. Primero le preguntaremos sobre usted y las personas con las que planea vivir.\n2. Luego, le preguntaremos sobre sus ingresos.\n3. Finalmente, veremos si califica para alguna preferencia de vivienda asequible, si corresponde.", "application.status": "Estatus", "application.statuses.inProgress": "En curso", "application.statuses.neverSubmitted": "Nunca fue enviada", @@ -465,10 +474,12 @@ "authentication.forgotPassword.errors.tokenMissing": "El token no fue encontrado. Por favor, solicite uno nuevo.", "authentication.forgotPassword.message": "Si hay una cuenta creada con ese correo electrónico, recibirás un correo electrónico con un enlace para restablecer tu contraseña. El enlace de reinicio es válido por 1 hora.", "authentication.forgotPassword.passwordConfirmation": "Confirmación de contraseña", - "authentication.forgotPassword.sendEmail": "Enviar correo electrónico", + "authentication.forgotPassword.sendEmail": "Ingrese su correo electrónico para obtener un enlace de restablecimiento de contraseña", + "authentication.forgotPassword.sendEmailButton": "Enviar correo electrónico", + "authentication.forgotPassword.sendEmailNotes": "Introduce tu correo electrónico para que podamos enviarte un enlace para restablecer tu contraseña. Si no recibes un correo electrónico, es posible que no tengas una cuenta.", "authentication.signIn.accountHasBeenLocked": "Por razones de seguridad, esta cuenta ha sido bloqueada.", "authentication.signIn.afterFailedAttempts": "Por razones de seguridad, después de %{count} intentos fallidos, deberá esperar 30 minutos antes de volver a intentarlo.", - "authentication.signIn.changeYourPassword": "Puede cambiar su contraseña", + "authentication.signIn.changeYourPassword": "Haga clic aquí para restablecer su contraseña", "authentication.signIn.enterLoginEmail": "Por favor, escriba su correo electrónico de inicio de sesión", "authentication.signIn.enterLoginPassword": "Por favor, escriba su contraseña de inicio de sesión", "authentication.signIn.enterValidEmailAndPassword": "Por favor, escriba un correo electrónico y una contraseña válidos", @@ -479,7 +490,7 @@ "authentication.signIn.loginError": "Por favor, escriba una dirección de correo electrónico válida", "authentication.signIn.mfaError": "Esta es una cuenta de socio que, por razones de seguridad, no puede iniciar sesión en el sitio público.", "authentication.signIn.passwordError": "Por favor, escriba una contraseña válida", - "authentication.signIn.passwordOutdated": "Su contraseña ha expirado. Por favor, elija una nueva contraseña.", + "authentication.signIn.passwordOutdated": "La contraseña de tu cuenta ha expirado. Necesitarás restablecerla para acceder a ella.", "authentication.signIn.pwdless.createAccountCopy": "Regístrese rápidamente sin necesidad de recordar ninguna contraseña.", "authentication.signIn.pwdless.emailHelperText": "Ingresa tu correo electrónico y te enviaremos un código para iniciar sesión.", "authentication.signIn.pwdless.error": "El código que has utilizado no es válido o ha caducado.", @@ -625,6 +636,7 @@ "languages.vi": "Tiếng Việt", "languages.zh": "中文", "leasingAgent.contact": "Comuníquese con el agente de alquiler", + "leasingAgent.contactManagerProp": "Comuníquese con el agente de arrendamiento o el administrador de la propiedad", "leasingAgent.dueToHighCallVolume": "Debido al alto volumen de llamadas, usted podría escuchar un mensaje.", "leasingAgent.officeHours": "Horario de oficina", "listingFilters.clear": "Borrar", @@ -638,12 +650,18 @@ "listings.additionalInformationEnvelope": "Sobre de información adicional", "listings.allUnits": "Todas las viviendas", "listings.allUnitsReservedFor": "Todas las viviendas reservadas para %{type}", + "listings.amenities.busStops": "Paradas de autobús", "listings.amenities.groceryStores": "Tiendas de comestibles", "listings.amenities.healthCareResources": "Recursos de atención médica", + "listings.amenities.hospitals": "Hospitales", "listings.amenities.parksAndCommunityCenters": "Parques y centros comunitarios", "listings.amenities.pharmacies": "Farmacias", + "listings.amenities.playgrounds": "Parques infantiles", "listings.amenities.publicTransportation": "Transporte público", + "listings.amenities.recreationalFacilities": "Instalaciones recreativas", "listings.amenities.schools": "Escuelas", + "listings.amenities.seniorCenters": "Centros para personas mayores", + "listings.amenities.shoppingVenues": "Lugares de compras", "listings.annualIncome": "%{income} al año", "listings.applicationAlreadySubmitted": "Ya ha enviado una solicitud para este listado.", "listings.applicationDeadline": "Fecha límite de solicitud", @@ -702,6 +720,8 @@ "listings.confirmedPreferenceList": "Lista de %{preference} confirmada", "listings.costsNotIncluded": "Costos no incluidos", "listings.creditHistory": "Historial de crédito", + "listings.creditScreeningFee": "Investigación de crédito", + "listings.creditScreeningFeeDescription": "cubre el costo de revisar su historial crediticio y de alquiler", "listings.criminalBackground": "Antecedentes penales", "listings.depositMayBeHigherForLowerCredit": "Puede ser mayor para calificaciones de crédito más bajas", "listings.depositOrMonthsRent": "o el alquiler de un mes", @@ -716,6 +736,7 @@ "listings.featuresCards": "Tarjetas de características", "listings.forIncomeCalculations": "Para realizar el cálculo de los ingresos, el número de miembros del hogar incluye a todas las personas (de todas las edades) que residan en la vivienda.", "listings.forIncomeCalculationsBMR": "Los cálculos de los ingresos están basados en el tipo de vivienda", + "listings.hasEbllClearance": "Esta propiedad ha recibido la autorización EBLL de HUD.", "listings.hideClosedListings": "Ocultar los Listados cerrados", "listings.homeType.apartment": "Departamento", "listings.homeType.duplex": "Dúplex", @@ -732,6 +753,7 @@ "listings.lotteryResults.completeResultsWillBePosted": "Los resultados de la lotería serán publicados pronto.", "listings.lotteryResults.downloadResults": "Descargar resultados", "listings.lotteryResults.header": "Resultados de la lotería", + "listings.marketing.header": "Marketing", "listings.maxIncomeMonth": "Ingreso máximo/mes", "listings.maxIncomeYear": "Ingresos máximos/año", "listings.maxRent": "Alquiler máximo", @@ -743,12 +765,16 @@ "listings.neighborhoodBuildings": "Edificaciones en la comunidad", "listings.noAvailableUnits": "No hay viviendas disponibles en este momento.", "listings.noClosedListings": "Actualmente no hay listados con solicitudes cerradas", + "listings.noEbllClearance": "Esta propiedad no ha recibido la autorización EBLL de HUD", "listings.noMatchingClosedListings": "No hay listados coincidentes con solicitudes cerradas", "listings.noMatchingOpenListings": "No hay listados coincidentes con solicitudes abiertas", "listings.noOpenListings": "Actualmente ningún listado tiene solicitudes abiertas.", "listings.occupancyDescriptionAllSro": "La ocupación de esta edificación está limitada a 1 persona por vivienda.", "listings.occupancyDescriptionNoSro": "Los límites de ocupación de esta edificación están basados en el tipo de vivienda.", "listings.occupancyDescriptionSomeSro": "La ocupación de esta edificación varía según el tipo de vivienda. Los SROs están limitados a 1 persona por vivienda, sin importar la edad. Para todos los demás tipos de vivienda, los límites de ocupación no toman en cuenta a niños menores de 6 años.", + "listings.openHouseAndMarketing.accessibleMarketingFlyerLink": "Abrir folleto de marketing accesible", + "listings.openHouseAndMarketing.header": "Eventos de puerta abierta y marketing", + "listings.openHouseAndMarketing.marketingFlyerLink": "Abrir folleto de marketing", "listings.openHouseEvent.header": "Eventos de puerta abierta", "listings.openHouseEvent.seeVideo": "Ver vídeo", "listings.percentAMIUnit": "Vivienda AMI de %{percent}%", @@ -761,7 +787,17 @@ "listings.removeFilters": "Intente eliminar algunos de sus filtros o mostrar todos los listados.", "listings.rentalHistory": "Historial de alquiler", "listings.rePricing": "Asignar un nuevo precio", + "listings.requiredDocuments.birthCertificate": "Certificado de nacimiento (todos los miembros del hogar mayores de 18 años)", + "listings.requiredDocuments.currentLandlordReference": "Referencia actual del arrendador", + "listings.requiredDocuments.governmentIssuedId": "Documento de identidad oficial (todos los miembros del hogar mayores de 18 años)", + "listings.requiredDocuments.previousLandlordReference": "Referencia anterior del arrendador", + "listings.requiredDocuments.proofOfAssets": "Comprobante de bienes (extractos bancarios, etc.)", + "listings.requiredDocuments.proofOfCustody": "Comprobante de custodia/tutela", + "listings.requiredDocuments.proofOfIncome": "Comprobante de ingresos del hogar (talones de cheque, formulario W-2, etc.)", + "listings.requiredDocuments.residencyDocuments": "Documentos de inmigración/residencia (tarjeta verde, etc.)", + "listings.requiredDocuments.socialSecurityCard": "Social Security card", "listings.requiredDocuments": "Documentos requeridos", + "listings.requiredDocumentsAdditionalInfo": "Documentos requeridos (Información adicional)", "listings.reservedCommunityBuilding": "Edificación %{type}", "listings.reservedCommunitySeniorTitle": "Edificio para personas mayores", "listings.reservedCommunityTitleDefault": "Edificio reservado", @@ -835,7 +871,17 @@ "listings.singleRoomOccupancyDescription": "Esta propiedad ofrece habitaciones individuales para una persona solamente. Los inquilinos pueden compartir baños y, en ocasiones, cocinas.", "listings.specialNotes": "Observaciones especiales", "listings.underConstruction": "Bajo construcción", + "listings.unit.accessibilityType.Hearing and Visual": "Audición y visión", + "listings.unit.accessibilityType.Hearing": "Audiencia", + "listings.unit.accessibilityType.Mobility and Hearing": "Movilidad y audición", + "listings.unit.accessibilityType.Mobility and Visual": "Movilidad y Visión", + "listings.unit.accessibilityType.Mobility, Hearing and Visual": "Movilidad, audición y visión", + "listings.unit.accessibilityType.Mobility": "Movilidad", + "listings.unit.accessibilityType.Visual": "Visión", + "listings.unit.accessibilityType": "Tipo de accesibilidad", "listings.unit.sharedBathroom": "Compartido", + "listings.unit.showLessUnits": "Mostrar menos unidades de %{type}", + "listings.unit.showMoreUnits": "Mostrar más unidades de %{type}", "listings.unitsAreFor": "Estas viviendas son para %{type}.", "listings.unitsHaveAccessibilityFeaturesFor": "Estas viviendas tienen características de accesibilidad para personas con %{type}.", "listings.unitsSummary.notAvailable": "No disponible", @@ -878,18 +924,18 @@ "listings.waitlist.unitsAndWaitlist": "Viviendas disponibles y lista de espera", "lottery.applicationsThatQualifyForPreference": "Las solicitudes que reúnan los requisitos de esta preferencia recibirán una prioridad más alta.", "lottery.viewPreferenceList": "Ver la lista de preferencias", - "months.january": "Enero", + "months.april": "Abril", + "months.august": "Agosto", + "months.december": "Diciembre", "months.february": "Febrero", + "months.january": "Enero", + "months.july": "Julio", + "months.june": "Junio", "months.march": "Marzo", - "months.april": "Abril", "months.may": "Mayo", - "months.june": "Junio", - "months.july": "Julio", - "months.august": "Agosto", - "months.september": "Septiembre", - "months.october": "Octubre", "months.november": "Noviembre", - "months.december": "Diciembre", + "months.october": "Octubre", + "months.september": "Septiembre", "nav.browseProperties": "Buscar propiedades", "nav.getFeedback": "Nos encantaría recibir sus comentarios", "nav.listings": "Listados", @@ -1018,6 +1064,7 @@ "t.additionalAccessibility": "Accesibilidad adicional", "t.additionalPhone": "Teléfono adicional", "t.am": "AM", + "t.ami": "AMI", "t.area": "área", "t.areYouStillWorking": "¿Sigue usted trabajando?", "t.at": "en", @@ -1089,6 +1136,8 @@ "t.or": "o", "t.order": "Orden", "t.orUpTo": "o hasta", + "t.other": "Otro", + "t.parkingFee": "Tarifa de estacionamiento", "t.people": "personas", "t.perMonth": "al mes", "t.person": "persona", diff --git a/shared-helpers/src/locales/general.json b/shared-helpers/src/locales/general.json index 6bac89e344..4afed035c1 100644 --- a/shared-helpers/src/locales/general.json +++ b/shared-helpers/src/locales/general.json @@ -8,7 +8,7 @@ "account.application.lottery.applicantList": "Out of %{applicants} applicants on this list", "account.application.lottery.next": "The property manager will contact applicants in preference order. They will start with the highest priority preference. If the property manager contacts you, they will ask you to provide documentation to support what you answered in the application. That documentation could include paystubs, for example. They might also need to gather more information by asking you to complete a supplemental application.", "account.application.lottery.nextHeader": "What happens next?", - "account.application.lottery.preferences": "Lottery preferences for your application are shown here in priority order. If you do not qualify for any lottery preferences, you will be part of the general lottery category. The general lottery category is the last group processed.", + "account.application.lottery.preferences": "Lottery preferences for your application are shown here in priority order. If you do not qualify for any lottery preferences, you will be part of the general lottery category, which is the last group processed. Note you will only see rankings for the preferences that you claimed, but there may be other preferences for this listing.", "account.application.lottery.preferencesButton": "What are lottery preferences?", "account.application.lottery.preferencesHeader": "Your lottery preference(s)", "account.application.lottery.preferencesMessage": "These results are based on the information you provided in your application. Preference eligibility is subject to change once your information is verified.", @@ -16,8 +16,10 @@ "account.application.lottery.rawRankButton": "What is raw rank?", "account.application.lottery.rawRankHeader": "Your raw rank", "account.application.lottery.resultsHeader": "Here are your lottery results", + "account.application.lottery.resultsHeaderWaitlistLottery": "Here are your lottery results for the waitlist", "account.application.lottery.resultsSubheader": "%{applications} applications were submitted for %{units} unit", "account.application.lottery.resultsSubheaderPlural": "%{applications} applications were submitted for %{units} units", + "account.application.lottery.resultsSubheaderWaitlistLottery": "%{applications} applications were submitted", "account.application.lottery.viewResults": "View lottery results", "account.application.noAccessError": "You are unauthorized to view this application", "account.application.noApplicationError": "No application with that ID exists", @@ -48,6 +50,8 @@ "account.pwdless.loginMessage": "If there is an account made with %{email}, we’ll send a code within 10 minutes. If you don’t receive a code, sign in with your password and confirm your email address under account settings.", "account.pwdless.loginReCaptchaMessage": "You are being asked to verify your identity as an extra layer of security. We sent a code to %{email} to finish logging in. Be aware, the code will expire in 10 minutes.", "account.pwdless.notReceived": "Didn't receive your code?", + "account.pwdless.passwordOutdatedModalContent": "Your password has expired. Click continue to reset your password and access your account.", + "account.pwdless.passwordOutdatedModalHeader": "Password Expired", "account.pwdless.resend": "Resend", "account.pwdless.resendCode": "Resend code", "account.pwdless.resendCodeButton": "Resend the code", @@ -151,9 +155,9 @@ "application.contact.state": "State", "application.contact.streetAddress": "Street address", "application.contact.suggestedAddress": "Suggested address:", - "application.contact.verifyMultipleAddresses": "Since there are multiple options for this preference, you’ll need to verify multiple addresses.", "application.contact.title": "Thanks %{firstName}, now we need to know how to contact you about your application", "application.contact.verifyAddressTitle": "We have located the following address, please confirm it's correct", + "application.contact.verifyMultipleAddresses": "Since there are multiple options for this preference, you’ll need to verify multiple addresses.", "application.contact.workAddress": "Work address", "application.contact.youEntered": "You entered:", "application.contact.yourAdditionalPhoneNumber": "Your second phone number", @@ -304,14 +308,16 @@ "application.review.confirmation.whatExpectFirstParagraph.refer": "Please refer to the listing for the lottery results date.", "application.review.confirmation.whatExpectSecondparagraph": "Applicants will be contacted in order until vacancies are filled. Should your application be chosen, be prepared to fill out a more detailed application and provide required supporting documents.", "application.review.confirmation.whatExpectTitle": "What to expect next", + "application.review.confirmation.whatHappensNext.base": "### What happens next?\n\n* If you are contacted for an interview, you will be asked to fill out a more detailed application and provide supporting documents.", "application.review.confirmation.whatHappensNext.fcfs": "### What happens next?\n\n* Eligible applicants will be contacted on a first come first serve basis until vacancies are filled.\n\n* Housing preferences, if applicable, will affect first come, first serve order.\n\n* If you are contacted for an interview, you will be asked to fill out a more detailed application and provide supporting documents.", "application.review.confirmation.whatHappensNext.lottery": "### What happens next?\n\n* Once the application period closes, eligible applicants will be placed in order based on lottery rank order.\n\n* Housing preferences, if applicable, will affect lottery rank order.\n\n* If you are contacted for an interview, you will be asked to fill out a more detailed application and provide supporting documents.", - "application.review.confirmation.whatHappensNext.waitlist": "### What happens next?\n\n* Eligible applicants will be placed on the waitlist on a first come first serve basis until waitlist spots are filled.\n\n* Housing preferences, if applicable, will affect waitlist order.\n\n* If you are contacted for an interview, you will be asked to fill out a more detailed application and provide supporting documents.\n\n* You may be contacted while on the waitlist to confirm that you wish to remain on the waitlist.", - "application.review.confirmation.whatHappensNext.base": "### What happens next?\n\n* If you are contacted for an interview, you will be asked to fill out a more detailed application and provide supporting documents.", + "application.review.confirmation.whatHappensNext.noPref.base": "### What happens next?\n\n* If you are contacted for an interview, you will be asked to fill out a more detailed application and provide supporting documents.", "application.review.confirmation.whatHappensNext.noPref.fcfs": "### What happens next?\n\n* Eligible applicants will be contacted on a first come first serve basis until vacancies are filled.\n\n* If you are contacted for an interview, you will be asked to fill out a more detailed application and provide supporting documents.", "application.review.confirmation.whatHappensNext.noPref.lottery": "### What happens next?\n\n* Once the application period closes, eligible applicants will be placed in order based on lottery rank order.\n\n* If you are contacted for an interview, you will be asked to fill out a more detailed application and provide supporting documents.", "application.review.confirmation.whatHappensNext.noPref.waitlist": "### What happens next?\n\n* Eligible applicants will be placed on the waitlist on a first come first serve basis until waitlist spots are filled.\n\n* If you are contacted for an interview, you will be asked to fill out a more detailed application and provide supporting documents.\n\n* You may be contacted while on the waitlist to confirm that you wish to remain on the waitlist.", - "application.review.confirmation.whatHappensNext.noPref.base": "### What happens next?\n\n* If you are contacted for an interview, you will be asked to fill out a more detailed application and provide supporting documents.", + "application.review.confirmation.whatHappensNext.noPref.waitlistLottery": "### What happens next?\n\n* Eligible applicants will be placed on the waitlist based on lottery rank order.\n\n* If you are contacted for an interview, you will be asked to fill out a more detailed application and provide supporting documents.\n\n* You may be contacted while on the waitlist to confirm that you wish to remain on the waitlist.", + "application.review.confirmation.whatHappensNext.waitlist": "### What happens next?\n\n* Eligible applicants will be placed on the waitlist on a first come first serve basis until waitlist spots are filled.\n\n* Housing preferences, if applicable, will affect waitlist order.\n\n* If you are contacted for an interview, you will be asked to fill out a more detailed application and provide supporting documents.\n\n* You may be contacted while on the waitlist to confirm that you wish to remain on the waitlist.", + "application.review.confirmation.whatHappensNext.waitlistLottery": "### What happens next?\n\n* Eligible applicants will be placed on the waitlist based on lottery rank order.\n\n* Housing preferences, if applicable, will affect waitlist order.\n\n* If you are contacted for an interview, you will be asked to fill out a more detailed application and provide supporting documents.\n\n* You may be contacted while on the waitlist to confirm that you wish to remain on the waitlist.", "application.review.demographics.ethnicityLabel": "Which best describes your ethnicity?", "application.review.demographics.ethnicityOptions.hispanicLatino": "Hispanic / Latino", "application.review.demographics.ethnicityOptions.notHispanicLatino": "Not Hispanic / Latino", @@ -409,12 +415,10 @@ "application.review.takeAMomentToReview": "Take a moment to review your information before submitting your application.", "application.review.terms.base.text": "* If you are contacted for an interview, you will be asked to fill out a more detailed application and provide supporting documents.\n\n* All of the information that you have provided will be verified and your eligibility confirmed.\n\n* Your application may be removed if you have made any fraudulent statements.\n\nFor more information, please contact the housing developer or property manager posted in the listing.\n\nCompleting this application does not entitle you to housing or indicate you are eligible for housing. All applicants will be screened as outlined in the property's Resident Selection Criteria.\n\nYou cannot change your online application after you submit.\n\nI declare that the foregoing is true and accurate, and acknowledge that any misstatement fraudulently or negligently made on this application may result in removal from the application process.", "application.review.terms.confirmCheckboxText": "I agree and understand that I cannot change anything after I submit.", - "application.review.terms.fcfs.text": "* Applicants are applying to currently vacant apartments on a first come, first serve basis.\n\n* Eligible applicants will be contacted on a first come first serve basis until vacancies are filled.\n\n* If you are contacted for an interview, you will be asked to fill out a more detailed application and provide supporting documents.\n\n* All of the information that you have provided will be verified and your eligibility confirmed.\n\n* Your application may be removed if you have made any fraudulent statements.\n\n* For properties with housing preferences, if we cannot verify a housing preference that you have claimed, you will not receive the preference but will not be otherwise penalized.\n\nFor more information, please contact the housing developer or property manager posted in the listing.\n\nCompleting this application does not entitle you to housing or indicate you are eligible for housing. All applicants will be screened as outlined in the property's Resident Selection Criteria.\n\nYou cannot change your online application after you submit.\n\nI declare that the foregoing is true and accurate, and acknowledge that any misstatement fraudulently or negligently made on this application may result in removal from the application process.", - "application.review.terms.lottery.text": "* Applicants are applying to enter a lottery for currently vacant apartments.\n\n* Once the application period closes, eligible applicants will be placed in lottery rank order.\n\n* If you are contacted for an interview, you will be asked to fill out a more detailed application and provide supporting documents.\n\n* All of the information that you have provided will be verified and your eligibility confirmed.\n\n* Your application may be removed if you have made any fraudulent statements.\n\n* For properties with housing preferences, if we cannot verify a housing preference that you have claimed, you will not receive the preference but will not be otherwise penalized.\n\nFor more information, please contact the housing developer or property manager posted in the listing.\n\nCompleting this application does not entitle you to housing or indicate you are eligible for housing. All applicants will be screened as outlined in the property's Resident Selection Criteria.\n\nYou cannot change your online application after you submit.\n\nI declare that the foregoing is true and accurate, and acknowledge that any misstatement fraudulently or negligently made on this application may result in removal from the application process.", + "application.review.terms.standard.text": "* For available units that are offered via a housing lottery, eligible applicants will be contacted in order of their rank in a lottery conducted shortly after the application deadline. For available units that are offered on a first-come, first-served basis, eligible applicants will be contacted in chronological order of their applications until vacancies are filled.\n\n* If you are contacted for an interview, you will be asked to fill out a more detailed application and provide supporting documents. All of the information that you have provided will be verified and your eligibility confirmed. Your application may be removed from consideration by the Professional Partner if you have made any fraudulent statements. For properties with housing preferences, if your housing preference claim cannot be verified, you will not receive the preference. All applicants will be screened as outlined in the Listing’s Resident Selection Criteria.\n\n* Completing this application does not entitle you to housing or indicate you are eligible for housing. Note that you cannot change your online application after you submit it.\n\n* Be careful about applying more than once for any listing, because more than one application from any person in your household could disqualify you and all of the household members from that opportunity. Please reach out if you have submitted an application with an error.\n\nI affirm that I am at least eighteen years of age and am authorized to submit the personal identifiable information (PII) of any household member listed in the Application. I consent on both behalf of the household members listed in the Application and myself for the PII to be transmitted to the Professional Partner and/or Local Government. I agree to the above terms and declare that the foregoing provided information is true and accurate, and acknowledge that any misstatement fraudulently or negligently made on this application may result in removal from the application process.", "application.review.terms.submittingApplication": "Submitting application", "application.review.terms.textSubmissionDate": "This application must be submitted by %{applicationDueDate}.", "application.review.terms.title": "Terms", - "application.review.terms.waitlist.text": "* Applicants are applying for an open waitlist and not a currently vacant apartment.\n\n* When vacancies become available, eligible applicants will be contacted by the property manager on a first come, first serve basis.\n\n* If you are contacted for an interview, you will be asked to fill out a more detailed application and provide supporting documents.\n\n* All of the information that you have provided will be verified and your eligibility confirmed.\n\n* Your application may be removed if you have made any fraudulent statements.\n\n* For properties with housing preferences, if we cannot verify a housing preference that you have claimed, you will not receive the preference but will not be otherwise penalized.\n\n* You may be contacted while on the waitlist to confirm that you wish to remain on the waitlist.\n\nFor more information, please contact the housing developer or property manager posted in the listing.\n\nCompleting this application does not entitle you to housing or indicate you are eligible for housing. All applicants will be screened as outlined in the property's Resident Selection Criteria.\n\nYou cannot change your online application after you submit.\n\nI declare that the foregoing is true and accurate, and acknowledge that any misstatement fraudulently or negligently made on this application may result in removal from the application process.", "application.review.voucherOrSubsidy": "Housing voucher or rental subsidy", "application.start.whatToExpect.base.finePrint": "* Please be aware that each household member can only appear on one application for each listing.\n* All of the information that you have provided will be verified and your eligibility confirmed.\n* Your application may be removed if you have made any fraudulent statements.", "application.start.whatToExpect.fcfs.finePrint": "* Applicants are applying to currently vacant apartments on a first come, first serve basis.\n* Please be aware that each household member can only appear on one application for each listing.\n* Applicants will be contacted by the property manager on a first come, first serve basis, until vacancies are filled.\n* All of the information that you have provided will be verified and your eligibility confirmed.\n* Your application may be removed if you have made any fraudulent statements.\n* For properties with housing preferences, if we cannot verify a housing preference that you have claimed, you will not receive the preference but will not be otherwise penalized.", @@ -424,6 +428,8 @@ "application.start.whatToExpect.title": "Here's what to expect from this application.", "application.start.whatToExpect.waitlist.finePrint": "* Applicants are applying for an open waitlist and not a currently available apartment.\n* Please be aware that each household member can only appear on one application for each listing.\n* When vacancies become available, eligible applicants will be contacted by the property manager on a first come, first serve basis.\n* All of the information that you have provided will be verified and your eligibility confirmed.\n* Your application may be removed if you have made any fraudulent statements.\n* For properties with housing preferences, if we cannot verify a housing preference that you have claimed, you will not receive the preference but will not be otherwise penalized.", "application.start.whatToExpect.waitlist.steps": "1. First we'll ask about you and the people you plan to live with.\n2. Then, we'll ask about your income.\n3. Finally, we'll see if you qualify for any affordable housing preferences, if applicable.", + "application.start.whatToExpect.waitlistLottery.finePrint": "* Applicants are applying for an open waitlist and not a currently available apartment.\n* Please be aware that each household member can only appear on one application for each listing.\n* When vacancies become available, applicants will be contacted by the property agent in lottery rank order until vacancies are filled.\n* All of the information that you have provided will be verified and your eligibility confirmed.\n* Your application may be removed if you have made any fraudulent statements.\n* For properties with housing preferences, if we cannot verify a housing preference that you have claimed, you will not receive the preference but will not be otherwise penalized.", + "application.start.whatToExpect.waitlistLottery.steps": "1. First we'll ask about you and the people you plan to live with.\n2. Then, we'll ask about your income.\n3. Finally, we'll see if you qualify for any affordable housing preferences, if applicable.", "application.status": "Status", "application.statuses.inProgress": "In progress", "application.statuses.neverSubmitted": "Never submitted", @@ -465,10 +471,12 @@ "authentication.forgotPassword.errors.tokenMissing": "Token not found. Please request for a new one.", "authentication.forgotPassword.message": "If there is an account made with that email, you'll receive an email with a link to reset your password. The reset link is valid for 1 hour.", "authentication.forgotPassword.passwordConfirmation": "Password confirmation", - "authentication.forgotPassword.sendEmail": "Send email", + "authentication.forgotPassword.sendEmail": "Enter your email to get a password reset link", + "authentication.forgotPassword.sendEmailButton": "Send email", + "authentication.forgotPassword.sendEmailNotes": "Please enter your email address so we can send you a password reset link. If you don’t receive an email, you may not have an account.", "authentication.signIn.accountHasBeenLocked": "For security reasons, this account has been locked.", "authentication.signIn.afterFailedAttempts": "For security reasons, after %{count} failed attempts, you’ll have to wait 30 minutes before trying again.", - "authentication.signIn.changeYourPassword": "You can change your password", + "authentication.signIn.changeYourPassword": "Click here to reset your password", "authentication.signIn.enterLoginEmail": "Please enter your login email", "authentication.signIn.enterLoginPassword": "Please enter your login password", "authentication.signIn.enterValidEmailAndPassword": "Please enter a valid email and password", @@ -479,7 +487,7 @@ "authentication.signIn.loginError": "Please enter a valid email address", "authentication.signIn.mfaError": "This is a partner account, which for security reasons cannot login to the public site.", "authentication.signIn.passwordError": "Please enter a valid password", - "authentication.signIn.passwordOutdated": "Your password has expired. Please reset your password.", + "authentication.signIn.passwordOutdated": "The password associated with your account has expired. You will need to reset it in order to access your account.", "authentication.signIn.pwdless.createAccountCopy": "Sign up quickly with no need to remember any passwords.", "authentication.signIn.pwdless.emailHelperText": "Enter your email and we'll send you a code to sign in.", "authentication.signIn.pwdless.error": "The code you've used is invalid or expired.", @@ -625,6 +633,7 @@ "languages.vi": "Tiếng Việt", "languages.zh": "中文", "leasingAgent.contact": "Contact leasing agent", + "leasingAgent.contactManagerProp": "Contact leasing agent or property manager", "leasingAgent.dueToHighCallVolume": "Due to high call volume you may hear a message.", "leasingAgent.officeHours": "Office hours", "listingFilters.clear": "Clear", @@ -638,12 +647,18 @@ "listings.additionalInformationEnvelope": "Additional information envelope", "listings.allUnits": "All units", "listings.allUnitsReservedFor": "All units reserved for %{type}", + "listings.amenities.busStops": "Bus stops", "listings.amenities.groceryStores": "Grocery stores", "listings.amenities.healthCareResources": "Health care resources", + "listings.amenities.hospitals": "Hospitals", "listings.amenities.parksAndCommunityCenters": "Parks and community centers", "listings.amenities.pharmacies": "Pharmacies", + "listings.amenities.playgrounds": "Playgrounds", "listings.amenities.publicTransportation": "Public transportation", + "listings.amenities.recreationalFacilities": "Recreational facilities", "listings.amenities.schools": "Schools", + "listings.amenities.seniorCenters": "Senior centers", + "listings.amenities.shoppingVenues": "Shopping venues", "listings.annualIncome": "%{income} per year", "listings.applicationAlreadySubmitted": "This application has already been submitted.", "listings.applicationDeadline": "Application due date", @@ -702,6 +717,8 @@ "listings.confirmedPreferenceList": "Confirmed %{preference} List", "listings.costsNotIncluded": "Costs not included", "listings.creditHistory": "Credit history", + "listings.creditScreeningFee": "Credit screening", + "listings.creditScreeningFeeDescription": "covers the cost of reviewing your credit and rental history", "listings.criminalBackground": "Criminal background", "listings.depositMayBeHigherForLowerCredit": "May be higher for lower credit scores", "listings.depositOrMonthsRent": "or one month's rent", @@ -716,6 +733,7 @@ "listings.featuresCards": "Features cards", "listings.forIncomeCalculations": "For income calculations, household size includes everyone (all ages) living in the unit.", "listings.forIncomeCalculationsBMR": "Income calculations are based on unit type", + "listings.hasEbllClearance": "This property has received HUD EBLL clearance.", "listings.hideClosedListings": "Hide closed listings", "listings.homeType.apartment": "Apartment", "listings.homeType.duplex": "Duplex", @@ -732,6 +750,7 @@ "listings.lotteryResults.completeResultsWillBePosted": "Complete lottery results will be posted soon.", "listings.lotteryResults.downloadResults": "Download results", "listings.lotteryResults.header": "Lottery results", + "listings.marketing.header": "Marketing", "listings.maxIncomeMonth": "Maximum income / month", "listings.maxIncomeYear": "Maximum income / year", "listings.maxRent": "Max rent", @@ -743,12 +762,16 @@ "listings.neighborhoodBuildings": "Neighborhood buildings", "listings.noAvailableUnits": "There are no available units at this time.", "listings.noClosedListings": "No listings currently have closed applications", + "listings.noEbllClearance": "This property has not received HUD EBLL clearance.", "listings.noMatchingClosedListings": "No matching listings with closed applications", "listings.noMatchingOpenListings": "No matching listings with open applications", "listings.noOpenListings": "No listings currently have open applications", "listings.occupancyDescriptionAllSro": "Occupancy for this building is limited to 1 person per unit.", "listings.occupancyDescriptionNoSro": "Occupancy limits for this building are based on unit type.", "listings.occupancyDescriptionSomeSro": "Occupancy for this building varies by unit type. SROs are limited to 1 person per unit, regardless of age. For all other unit types, occupancy limits do not count children under 6.", + "listings.openHouseAndMarketing.accessibleMarketingFlyerLink": "Open accessible marketing flyer", + "listings.openHouseAndMarketing.header": "Open houses and marketing", + "listings.openHouseAndMarketing.marketingFlyerLink": "Open marketing flyer", "listings.openHouseEvent.header": "Open houses", "listings.openHouseEvent.seeVideo": "See video", "listings.percentAMIUnit": "%{percent}% AMI unit", @@ -761,7 +784,17 @@ "listings.removeFilters": "Try removing some of your filters or show all listings.", "listings.rentalHistory": "Rental history", "listings.rePricing": "Re-pricing", + "listings.requiredDocuments.birthCertificate": "Birth Certificate (all household members 18+)", + "listings.requiredDocuments.currentLandlordReference": "Current landlord reference", + "listings.requiredDocuments.governmentIssuedId": "Government-issued ID (all household members 18+)", + "listings.requiredDocuments.previousLandlordReference": "Previous landlord reference", + "listings.requiredDocuments.proofOfAssets": "Proof of Assets (bank statements, etc.)", + "listings.requiredDocuments.proofOfCustody": "Proof of Custody/Guardianship", + "listings.requiredDocuments.proofOfIncome": "Proof of household income (check stubs, W-2, etc.)", + "listings.requiredDocuments.residencyDocuments": "Immigration/Residency documents (green card, etc.)", + "listings.requiredDocuments.socialSecurityCard": "Social Security card", "listings.requiredDocuments": "Required documents", + "listings.requiredDocumentsAdditionalInfo": "Required documents (Additional Info)", "listings.reservedCommunityBuilding": "%{type} building", "listings.reservedCommunitySeniorTitle": "Senior building", "listings.reservedCommunityTitleDefault": "Reserved building", @@ -835,7 +868,17 @@ "listings.singleRoomOccupancyDescription": "This property offers single rooms for one person only. Tenants may share bathrooms, and sometimes kitchen facilities.", "listings.specialNotes": "Special notes", "listings.underConstruction": "Under construction", + "listings.unit.accessibilityType.Hearing and Visual": "Hearing and Vision", + "listings.unit.accessibilityType.Hearing": "Hearing", + "listings.unit.accessibilityType.Mobility and Hearing": "Mobility and Hearing", + "listings.unit.accessibilityType.Mobility and Visual": "Mobility and Vision", + "listings.unit.accessibilityType.Mobility, Hearing and Visual": "Mobility, Hearing and Vision", + "listings.unit.accessibilityType.Mobility": "Mobility", + "listings.unit.accessibilityType.Visual": "Vision", + "listings.unit.accessibilityType": "Accessibility type", "listings.unit.sharedBathroom": "Shared", + "listings.unit.showLessUnits": "Show fewer %{type} units", + "listings.unit.showMoreUnits": "Show more %{type} units", "listings.unitsAreFor": "These units are for %{type}.", "listings.unitsHaveAccessibilityFeaturesFor": "These units have accessibility features for people with %{type}.", "listings.unitsSummary.notAvailable": "Not available", @@ -878,18 +921,18 @@ "listings.waitlist.unitsAndWaitlist": "Available units and waitlist", "lottery.applicationsThatQualifyForPreference": "Applications that qualify for this preference will be given a higher priority.", "lottery.viewPreferenceList": "View preference list", - "months.january": "January", + "months.april": "April", + "months.august": "August", + "months.december": "December", "months.february": "February", + "months.january": "January", + "months.july": "July", + "months.june": "June", "months.march": "March", - "months.april": "April", "months.may": "May", - "months.june": "June", - "months.july": "July", - "months.august": "August", - "months.september": "September", - "months.october": "October", "months.november": "November", - "months.december": "December", + "months.october": "October", + "months.september": "September", "nav.browseProperties": "Browse properties", "nav.getFeedback": "We'd love to get your feedback!", "nav.listings": "Listings", @@ -1018,6 +1061,7 @@ "t.additionalAccessibility": "Additional accessibility", "t.additionalPhone": "Additional phone", "t.am": "AM", + "t.ami": "AMI", "t.area": "area", "t.areYouStillWorking": "Are you still working?", "t.at": "at", @@ -1089,6 +1133,8 @@ "t.or": "or", "t.order": "Order", "t.orUpTo": "or up to", + "t.other": "Other", + "t.parkingFee": "Parking fee", "t.people": "people", "t.perMonth": "per month", "t.person": "person", diff --git a/shared-helpers/src/locales/tl.json b/shared-helpers/src/locales/tl.json index 0ade609522..ca6f7b982b 100644 --- a/shared-helpers/src/locales/tl.json +++ b/shared-helpers/src/locales/tl.json @@ -8,7 +8,7 @@ "account.application.lottery.applicantList": "Sa %{applicants} na mga aplikante sa listahang ito", "account.application.lottery.next": "Makikipag-ugnayan ang property manager sa mga aplikante sa preference order. Magsisimula sila sa pinakamataas na priority preference. Kung makikipag-ugnayan sa iyo ang tagapamahala ng ari-arian, hihilingin ka nilang magbigay ng dokumentasyon upang suportahan ang iyong sinagot sa aplikasyon. Maaaring kasama sa dokumentasyong iyon ang mga paystub, halimbawa. Maaaring kailanganin din nilang mangalap ng higit pang impormasyon sa pamamagitan ng paghiling sa iyo na kumpletuhin ang isang karagdagang aplikasyon.", "account.application.lottery.nextHeader": "Ano ang susunod na mangyayari?", - "account.application.lottery.preferences": "Ang mga kagustuhan sa lottery para sa iyong aplikasyon ay ipinapakita dito sa priority order. Kung hindi ka kwalipikado para sa anumang mga kagustuhan sa lottery, ikaw ay magiging bahagi ng pangkalahatang kategorya ng lottery. Ang pangkalahatang kategorya ng lottery ay ang huling pangkat na naproseso.", + "account.application.lottery.preferences": "Ang mga kagustuhan sa lottery para sa iyong aplikasyon ay ipinapakita dito sa priority order. Kung hindi ka kwalipikado para sa anumang mga kagustuhan sa lottery, ikaw ay magiging bahagi ng pangkalahatang kategorya ng lottery. Ang pangkalahatang kategorya ng lottery ay ang huling pangkat na naproseso. Tandaan na makikita mo lamang ang mga ranggo para sa mga kagustuhan na iyong na-claim, ngunit maaaring may iba pang mga kagustuhan para sa listahang ito.", "account.application.lottery.preferencesButton": "Ano ang mga kagustuhan sa lottery?", "account.application.lottery.preferencesHeader": "Ang iyong (mga) kagustuhan sa lottery", "account.application.lottery.preferencesMessage": "Ang mga resultang ito ay batay sa impormasyong ibinigay mo sa iyong aplikasyon. Maaaring magbago ang pagiging karapat-dapat sa kagustuhan kapag na-verify na ang iyong impormasyon.", @@ -16,8 +16,10 @@ "account.application.lottery.rawRankButton": "Ano ang hilaw na ranggo?", "account.application.lottery.rawRankHeader": "Ang ranggo mo raw", "account.application.lottery.resultsHeader": "Narito ang mga resulta ng iyong lottery", + "account.application.lottery.resultsHeaderWaitlistLottery": "Narito ang iyong resulta ng lottery para sa waitlist", "account.application.lottery.resultsSubheader": "%{applications} applications ay isinumite para sa %{units} unit", "account.application.lottery.resultsSubheaderPlural": "%{applications} applications ay isinumite para sa %{units} units", + "account.application.lottery.resultsSubheaderWaitlistLottery": "Naipasa ang %{applications} aplikasyon", "account.application.lottery.viewResults": "Tingnan ang mga resulta ng lottery", "account.application.noAccessError": "Hindi ka pinapayagang makita ang application na ito", "account.application.noApplicationError": "Walang application gamit ang ID na iyan", @@ -48,6 +50,8 @@ "account.pwdless.loginMessage": "Kung may account na ginawa gamit ang email na iyon, magpapadala kami ng code sa loob ng 10 minuto. Kung hindi ka makatanggap ng code, mag-sign in gamit ang iyong password at kumpirmahin ang iyong email address sa ilalim ng mga setting ng account.", "account.pwdless.loginReCaptchaMessage": "Hinihiling sa iyo na i-verify ang iyong pagkakakilanlan bilang isang karagdagang layer ng seguridad. Nagpadala kami ng code sa %{email} upang tapusin ang pag-log in. Magkaroon ng kamalayan, mag-e-expire ang code sa loob ng 10 minuto.", "account.pwdless.notReceived": "Hindi natanggap ang iyong code?", + "account.pwdless.passwordOutdatedModalContent": "Ang iyong password ay nag-expire na. I-click ang magpatuloy upang i-reset ang iyong password at i-access ang iyong account.", + "account.pwdless.passwordOutdatedModalHeader": "Nag-expire ang Password", "account.pwdless.resend": "Muling ipadala", "account.pwdless.resendCode": "Muling ipadala ang code", "account.pwdless.resendCodeButton": "Ipadala muli ang code", @@ -307,11 +311,13 @@ "application.review.confirmation.whatHappensNext.base": "### Ano ang susunod na mangyayari?\n\n* Kung makikipag-ugnayan ka para sa isang panayam, hihilingin sa iyong punan ang isang mas detalyadong aplikasyon at magbigay ng mga sumusuportang dokumento.", "application.review.confirmation.whatHappensNext.fcfs": "### Ano ang susunod na mangyayari?\n\n* Kokontakin ang mga kwalipikadong aplikante sa first come first serve basis hanggang sa mapunan ang mga bakante.\n\n* Ang mga kagustuhan sa pabahay, kung naaangkop, ay makakaapekto sa first come, first serve order.\n\n* Kung makikipag-ugnayan ka para sa isang panayam, hihilingin sa iyong punan ang isang mas detalyadong aplikasyon at magbigay ng mga sumusuportang dokumento.", "application.review.confirmation.whatHappensNext.lottery": "### Ano ang susunod na mangyayari?\n\n* Kapag nagsara na ang panahon ng aplikasyon, ang mga kwalipikadong aplikante ay ilalagay sa pagkakasunud-sunod batay sa pagkakasunud-sunod ng ranggo ng lottery.\n\n* Ang mga kagustuhan sa pabahay, kung naaangkop, ay makakaapekto sa pagkakasunud-sunod ng ranggo ng lottery.\n\n* Kung makikipag-ugnayan ka para sa isang panayam, hihilingin sa iyong punan ang isang mas detalyadong aplikasyon at magbigay ng mga sumusuportang dokumento.", - "application.review.confirmation.whatHappensNext.waitlist": "### Ano ang susunod na mangyayari?\n\n* Ang mga kwalipikadong aplikante ay ilalagay sa waitlist sa first come first serve basis hanggang sa mapunan ang mga waitlist spot.\n\n* Ang mga kagustuhan sa pabahay, kung naaangkop, ay makakaapekto sa order ng waitlist. \n\n* Kung makikipag-ugnayan sa iyo para sa isang panayam, hihilingin sa iyong punan ang isang mas detalyadong aplikasyon at magbigay ng mga sumusuportang dokumento.\n\n* Maaari kang makontak habang nasa waitlist upang kumpirmahin na nais mong manatili sa waitlist.", + "application.review.confirmation.whatHappensNext.noPref.base": "### Ano ang susunod na mangyayari?\n\n* Kung makikipag-ugnayan ka para sa isang panayam, hihilingin sa iyong punan ang isang mas detalyadong aplikasyon at magbigay ng mga sumusuportang dokumento.", "application.review.confirmation.whatHappensNext.noPref.fcfs": "### Ano ang susunod na mangyayari?\n\n* Kokontakin ang mga kwalipikadong aplikante sa first come first serve basis hanggang sa mapunan ang mga bakante.\n\n* Kung makikipag-ugnayan ka para sa isang panayam, hihilingin sa iyong punan ang isang mas detalyadong aplikasyon at magbigay ng mga sumusuportang dokumento.", "application.review.confirmation.whatHappensNext.noPref.lottery": "### Ano ang susunod na mangyayari?\n\n* Kapag nagsara na ang panahon ng aplikasyon, ang mga kwalipikadong aplikante ay ilalagay sa pagkakasunud-sunod batay sa pagkakasunud-sunod ng ranggo ng lottery.\n\n* Kung makikipag-ugnayan ka para sa isang panayam, hihilingin sa iyong punan ang isang mas detalyadong aplikasyon at magbigay ng mga sumusuportang dokumento.", "application.review.confirmation.whatHappensNext.noPref.waitlist": "### Ano ang susunod na mangyayari?\n\n* Ang mga kwalipikadong aplikante ay ilalagay sa waitlist sa first come first serve basis hanggang sa mapunan ang mga waitlist spot.\n\n* Kung makikipag-ugnayan sa iyo para sa isang panayam, hihilingin sa iyong punan ang isang mas detalyadong aplikasyon at magbigay ng mga sumusuportang dokumento.\n\n* Maaari kang makontak habang nasa waitlist upang kumpirmahin na nais mong manatili sa waitlist.", - "application.review.confirmation.whatHappensNext.noPref.base": "### Ano ang susunod na mangyayari?\n\n* Kung makikipag-ugnayan ka para sa isang panayam, hihilingin sa iyong punan ang isang mas detalyadong aplikasyon at magbigay ng mga sumusuportang dokumento.", + "application.review.confirmation.whatHappensNext.noPref.waitlistLottery": "### Ano ang susunod na mangyayari?\n\n* Ang mga karapat-dapat na aplikante ay ilalagay sa waitlist batay sa order ng ranggo ng lottery.\n\n* Kung makikipag-ugnayan sa iyo para sa isang panayam, hihilingin sa iyong punan ang isang mas detalyadong aplikasyon at magbigay ng mga sumusuportang dokumento.\n\n* Maaari kang makontak habang nasa waitlist upang kumpirmahin na nais mong manatili sa waitlist.", + "application.review.confirmation.whatHappensNext.waitlist": "### Ano ang susunod na mangyayari?\n\n* Ang mga kwalipikadong aplikante ay ilalagay sa waitlist sa first come first serve basis hanggang sa mapunan ang mga waitlist spot.\n\n* Ang mga kagustuhan sa pabahay, kung naaangkop, ay makakaapekto sa order ng waitlist. \n\n* Kung makikipag-ugnayan sa iyo para sa isang panayam, hihilingin sa iyong punan ang isang mas detalyadong aplikasyon at magbigay ng mga sumusuportang dokumento.\n\n* Maaari kang makontak habang nasa waitlist upang kumpirmahin na nais mong manatili sa waitlist.", + "application.review.confirmation.whatHappensNext.waitlistLottery": "### Ano ang susunod na mangyayari?\n\n* Ang mga karapat-dapat na aplikante ay ilalagay sa waitlist batay sa order ng ranggo ng lottery.\n\n* Ang mga kagustuhan sa pabahay, kung naaangkop, ay makakaapekto sa order ng waitlist. \n\n* Kung makikipag-ugnayan sa iyo para sa isang panayam, hihilingin sa iyong punan ang isang mas detalyadong aplikasyon at magbigay ng mga sumusuportang dokumento.\n\n* Maaari kang makontak habang nasa waitlist upang kumpirmahin na nais mong manatili sa waitlist.", "application.review.demographics.ethnicityLabel": "Alin ang pinakanaglalarawan sa iyong etnisidad?", "application.review.demographics.ethnicityOptions.hispanicLatino": "Hispaniko / Latino", "application.review.demographics.ethnicityOptions.notHispanicLatino": "Hindi Hispaniko / Latino", @@ -407,10 +413,11 @@ "application.review.noAdditionalMembers": "Walang karagdagang mga miyembro ng sambahayan", "application.review.sameAddressAsApplicant": "Parehong address ng aplikante", "application.review.takeAMomentToReview": "Maglaan ng ilang sandali upang suriin ang iyong impormasyon bago isumite ang iyong application.", - "application.review.terms.confirmCheckboxText": "Sumasang-ayon ako at nauunawaan na hindi ko mababago ang anuman pagkatapos kong magsumite.", "application.review.terms.base.text": "* Kung ikaw ay makontak para sa isang pakikipanayam, hihilingin sa iyong punan ang isang mas detalyadong aplikasyon at magbigay ng mga sumusuportang dokumento.\n\n* Ang lahat ng impormasyong ibinigay mo ay mabe-verify at makumpirma ang iyong pagiging kwalipikado.\n\n* Maaaring alisin ang iyong aplikasyon kung gumawa ka ng anumang mapanlinlang na pahayag.\n\nPara sa karagdagang impormasyon, mangyaring makipag-ugnayan sa developer ng pabahay o tagapamahala ng ari-arian na naka-post sa listahan.\n\nAng pagkumpleto ng application na ito ay hindi nagbibigay sa iyo ng karapatan sa pabahay o nagpapahiwatig na ikaw ay karapat-dapat para sa pabahay. Ang lahat ng mga aplikante ay susuriin ayon sa nakabalangkas sa Pamantayan sa Pagpili ng Residente.\n\nHindi mo maaaring baguhin ang iyong online na aplikasyon pagkatapos mong isumite.\n\nIpinapahayag ko na ang nabanggit ay totoo at tumpak, at kinikilala na ang anumang maling pahayag ay mapanlinlang o kapabayaan na ginawa noong ang application na ito ay maaaring magresulta sa pagtanggal sa proseso ng aplikasyon.", + "application.review.terms.confirmCheckboxText": "Sumasang-ayon ako at nauunawaan na hindi ko mababago ang anuman pagkatapos kong magsumite.", "application.review.terms.fcfs.text": "* Ang mga aplikante ay nag-a-apply sa mga kasalukuyang bakanteng apartment sa first come, first serve basis.\n\n* Ang mga kwalipikadong aplikante ay tatawagan sa first come first serve basis hanggang sa mapunan ang mga bakante.\n\n* Kung ikaw ay makontak para sa isang pakikipanayam, hihilingin sa iyong punan ang isang mas detalyadong aplikasyon at magbigay ng mga sumusuportang dokumento.\n\n* Ang lahat ng impormasyong ibinigay mo ay mabe-verify at makumpirma ang iyong pagiging kwalipikado.\n\n* Maaaring alisin ang iyong aplikasyon kung gumawa ka ng anumang mapanlinlang na pahayag.\n\n* Para sa mga ari-arian na may mga kagustuhan sa pabahay, kung hindi namin ma-verify ang isang kagustuhan sa pabahay na iyong na-claim, hindi mo matatanggap ang kagustuhan ngunit hindi mapaparusahan.\n\nPara sa karagdagang impormasyon, mangyaring makipag-ugnayan sa developer ng pabahay o tagapamahala ng ari-arian na naka-post sa listahan.\n\nAng pagkumpleto ng application na ito ay hindi nagbibigay sa iyo ng karapatan sa pabahay o nagpapahiwatig na ikaw ay karapat-dapat para sa pabahay. Ang lahat ng mga aplikante ay susuriin ayon sa nakabalangkas sa Pamantayan sa Pagpili ng Residente.\n\nHindi mo maaaring baguhin ang iyong online na aplikasyon pagkatapos mong isumite.\n\nIpinapahayag ko na ang nabanggit ay totoo at tumpak, at kinikilala na ang anumang maling pahayag ay mapanlinlang o kapabayaan na ginawa noong ang application na ito ay maaaring magresulta sa pagtanggal sa proseso ng aplikasyon.", "application.review.terms.lottery.text": "* Ang mga aplikante ay nag-a-apply para makapasok sa lottery para sa kasalukuyang mga bakanteng apartment.\n\n* Kapag nagsara na ang application period, ang mga kwalipikadong aplikante ay ilalagay sa lottery rank order.\n\n* Kung ikaw ay makontak para sa isang panayam, ikaw ay magiging hiniling na punan ang isang mas detalyadong aplikasyon at magbigay ng mga sumusuportang dokumento.\n\n* Ang lahat ng impormasyong ibinigay mo ay mabe-verify at makumpirma ang iyong pagiging karapat-dapat.\n\n* Maaaring maalis ang iyong aplikasyon kung nakagawa ka ng anumang panloloko. mga pahayag.\n\n* Para sa mga ari-arian na may mga kagustuhan sa pabahay, kung hindi namin ma-verify ang isang kagustuhan sa pabahay na iyong na-claim, hindi mo matatanggap ang kagustuhan ngunit hindi mapaparusahan.\n\nPara sa karagdagang impormasyon, mangyaring makipag-ugnayan sa developer ng pabahay o tagapamahala ng ari-arian na naka-post sa listahan.\n\nAng pagkumpleto sa application na ito ay hindi nagbibigay sa iyo ng karapatan sa pabahay o nagpapahiwatig na ikaw ay karapat-dapat para sa pabahay. Ang lahat ng mga aplikante ay susuriin ayon sa nakabalangkas sa Pamantayan sa Pagpili ng Residente.\n\nHindi mo maaaring baguhin ang iyong online na aplikasyon pagkatapos mong isumite.\n\nIpinapahayag ko na ang nabanggit ay totoo at tumpak, at kinikilala na ang anumang maling pahayag ay mapanlinlang o kapabayaan na ginawa noong ang application na ito ay maaaring magresulta sa pagtanggal sa proseso ng aplikasyon.", + "application.review.terms.standard.text": "* Para sa mga unit na iniaalok sa pamamagitan ng loterya ng pabahay, ang mga kwalipikadong aplikante ay kokontakin ayon sa pagkakasunod-sunod ng kanilang ranggo sa isang loterya na isinasagawa di-katagalan pagkalipas ng huling araw ng pagsusumite ng aplikasyon. Para sa mga unit na iniaalok ayon sa batayang ang unang-dumating, ang unang-paglilingkuran, ang mga kwalipikadong aplikante ay kokontakin ayon sa kronolohikal na pagkakasunod-sunod ng kanilang mga aplikasyon hanggang sa mapunan ang mga bakante.\n\n* Kung makontak ka para sa isang panayam, hihilingin sa iyong punan ang isang mas detalyadong aplikasyon at magbigay ng mga pansuportang dokumento. Ang lahat ng impormasyong iyong ibinigay ay beberipikahin at kukumpirmahin ang iyong pagiging kwalipikado. Ang iyong aplikasyon ay maaaring alisin sa pagsasaalang-alang ng Propesyonal na Katuwang kung gumawa ka ng anumang mga mapanlinlang na pahayag. Para sa mga ari-arian na may mga preperensya sa pabahay, kung hindi mapapatotohanan ang iyong hininging preperensya sa pabahay, hindi mo matatanggap ang preperensya. Ang lahat ng aplikante ay susuriin ayon sa nakabalangkas sa Mga Pamantayan sa Pagpili ng Residente ng Listahan.\n\n* Ang pagkumpleto sa aplikasyong ito ay hindi nagbibigay sa iyo ng karapatan sa pabahay o nagpapahiwatig na ikaw ay kwalipikado para sa pabahay. Tandaan na hindi mo mababago ang iyong online na aplikasyon pagkatapos mong isumite ito.\n\n* Mag-ingat sa pag-aplay ng higit sa isang beses para sa anumang listahan, dahil higit sa isang aplikasyon mula sa sinumang tao sa iyong sambahayan ay maaaring mag-disqualify sa iyo at sa lahat ng miyembro ng sambahayan mula sa pagkakataong iyon. Mangyaring makipag-ugnayan kung nagsumite ka ng aplikasyon nang may error.\n\nPinatutunayan ko na ako ay hindi bababa sa labing walong taong gulang at awtorisado akong magsumite ng personal identifiable information (PII) ng sinumang miyembro ng sambahayan na nakalista sa Aplikasyon. Ako ay pumapayag sa parehong ngalan ng mga miyembro ng sambahayan na nakalista sa Aplikasyon at sa aking sarili para sa PII na maipadala sa Propesyonal na Kasosyo at/o Lokal na Pamahalaan. Sumasang-ayon ako sa mga tuntunin sa itaas at ipinapahayag na ang naunang ibinigay na impormasyon ay totoo at tumpak, at kinikilala ko na ang anumang maling pahayag na mapanlinlang o kapabayaan na ginawa sa application na ito ay maaaring magresulta sa pag-alis mula sa proseso ng aplikasyon.", "application.review.terms.submittingApplication": "Pagsusumite ng aplikasyon", "application.review.terms.textSubmissionDate": "Ang application na ito ay dapat na isumite bago ang %{applicationDueDate}.", "application.review.terms.title": "Mga Tuntunin", @@ -424,6 +431,8 @@ "application.start.whatToExpect.title": "Narito ang aasahan mula sa application na ito.", "application.start.whatToExpect.waitlist.finePrint": "* Ang mga aplikante ay nag-a-apply para sa isang bukas na waitlist at hindi isang kasalukuyang available na apartment.\n* Mangyaring magkaroon ng kamalayan na ang bawat miyembro ng sambahayan ay maaari lamang lumitaw sa isang aplikasyon para sa bawat listahan.\n* Kapag ang mga bakante ay naging available, ang mga kwalipikadong aplikante ay tatawagan ng property manager on a first come, first serve basis.\n* Lahat ng impormasyong ibinigay mo ay mabe-verify at makumpirma ang iyong pagiging kwalipikado.\n* Maaaring alisin ang iyong aplikasyon kung gumawa ka ng anumang mapanlinlang na pahayag.\n* Para sa mga ari-arian na may mga kagustuhan sa pabahay, kung hindi namin ma-verify ang isang kagustuhan sa pabahay na iyong na-claim, hindi mo matatanggap ang kagustuhan ngunit hindi mapaparusahan.", "application.start.whatToExpect.waitlist.steps": "1. Magtatanong muna kami tungkol sa iyo at sa mga taong pinaplano mong makasama.\n2. Pagkatapos, tatanungin namin ang tungkol sa iyong kita.\n3. Sa wakas, titingnan namin kung kwalipikado ka para sa anumang mga kagustuhan sa abot-kayang pabahay, kung naaangkop.", + "application.start.whatToExpect.waitlistLottery.finePrint": "* Ang mga aplikante ay nag-a-apply para sa isang bukas na waitlist at hindi isang kasalukuyang available na apartment.\n* Mangyaring magkaroon ng kamalayan na ang bawat miyembro ng sambahayan ay maaari lamang lumitaw sa isang aplikasyon para sa bawat listahan.\n* Kapag available na ang mga bakante, ang mga aplikante ay tatawagan ng ahente ng ari-arian sa pagkakasunud-sunod ng ranggo ng lottery hanggang sa mapunan ang mga bakante.\n* Lahat ng impormasyong ibinigay mo ay mabe-verify at makumpirma ang iyong pagiging kwalipikado.\n* Maaaring alisin ang iyong aplikasyon kung gumawa ka ng anumang mapanlinlang na pahayag.\n* Para sa mga ari-arian na may mga kagustuhan sa pabahay, kung hindi namin ma-verify ang isang kagustuhan sa pabahay na iyong na-claim, hindi mo matatanggap ang kagustuhan ngunit hindi mapaparusahan.", + "application.start.whatToExpect.waitlistLottery.steps": "1. Magtatanong muna kami tungkol sa iyo at sa mga taong pinaplano mong makasama.\n2. Pagkatapos, tatanungin namin ang tungkol sa iyong kita.\n3. Sa wakas, titingnan namin kung kwalipikado ka para sa anumang mga kagustuhan sa abot-kayang pabahay, kung naaangkop.", "application.status": "Status", "application.statuses.inProgress": "Isinasagawa", "application.statuses.neverSubmitted": "Hindi kailanman isinumite", @@ -465,7 +474,9 @@ "authentication.forgotPassword.errors.tokenMissing": "Hindi nahanap ang token. Humiling ng bago.", "authentication.forgotPassword.message": "Kung may account na ginawa gamit ang email na iyon, makakatanggap ka ng email na may link para i-reset ang iyong password. Ang link sa pag-reset ay may bisa sa loob ng 1 oras.", "authentication.forgotPassword.passwordConfirmation": "Pagkumpirma ng password", - "authentication.forgotPassword.sendEmail": "Magpadala ng email", + "authentication.forgotPassword.sendEmail": "Ilagay ang iyong email para makakuha ng link sa pag-reset ng password", + "authentication.forgotPassword.sendEmailButton": "Magpadala ng email", + "authentication.forgotPassword.sendEmailNotes": "Mangyaring ipasok ang iyong email address upang mapadalhan ka namin ng link sa pag-reset ng password. Kung hindi ka nakatanggap ng email, maaaring wala kang account.", "authentication.signIn.accountHasBeenLocked": "Para sa mga kadahilanang pangseguridad, ang account na ito isinara na.", "authentication.signIn.afterFailedAttempts": "Para sa mga kadahilanang pangseguridad, pagkatapos ng %{count} nabigong pagtatangka, kailangan mong maghintay ng 30 minuto bago subukang muli.", "authentication.signIn.changeYourPassword": "Maaari mong palitan ang iyong password", @@ -479,7 +490,7 @@ "authentication.signIn.loginError": "Pakilagay ang tamang email address", "authentication.signIn.mfaError": "Ito ay isang partner na account, na para sa mga kadahilanang pangseguridad ay hindi makapag-log in sa pampublikong site.", "authentication.signIn.passwordError": "Pakilagay ang tamang password", - "authentication.signIn.passwordOutdated": "Nag-expire na ang password mo. Paki-reset ang password mo.", + "authentication.signIn.passwordOutdated": "Ang password na nauugnay sa iyong account ay nag-expire na. Kakailanganin mong i-reset ito upang ma-access ang iyong account.", "authentication.signIn.pwdless.createAccountCopy": "Mag-sign up nang mabilis nang hindi na kailangang tandaan ang anumang mga password.", "authentication.signIn.pwdless.emailHelperText": "Ilagay ang iyong email at padadalhan ka namin ng code para mag-sign in.", "authentication.signIn.pwdless.error": "Ang code na iyong ginamit ay hindi wasto o nag-expire.", @@ -625,6 +636,7 @@ "languages.vi": "Tiếng Việt", "languages.zh": "中文", "leasingAgent.contact": "Makipag-ugnayan sa ahente sa pagpapaupa", + "leasingAgent.contactManagerProp": "Makipag-ugnayan sa ahente sa pagpapaupa o tagapamahala ng ari-arian", "leasingAgent.dueToHighCallVolume": "Dahil maraming tumatawag maaari kang makarinig ng mensahe.", "leasingAgent.officeHours": "Oras ng opisina", "listingFilters.clear": "Maaliwalas", @@ -638,12 +650,18 @@ "listings.additionalInformationEnvelope": "Karagdagang impormasyon na sobre", "listings.allUnits": "Lahat ng unit", "listings.allUnitsReservedFor": "Ang lahat ng unit ay nakareserba para sa %{type}", + "listings.amenities.busStops": "Mga hintuan ng bus", "listings.amenities.groceryStores": "Mga tindahan ng grocery", "listings.amenities.healthCareResources": "Mga mapagkukunan ng pangangalagang pangkalusugan", + "listings.amenities.hospitals": "Mga ospital", "listings.amenities.parksAndCommunityCenters": "Mga parke at sentro ng komunidad", "listings.amenities.pharmacies": "Mga botika", + "listings.amenities.playgrounds": "Mga palaruan", "listings.amenities.publicTransportation": "Pampublikong transportasyon", + "listings.amenities.recreationalFacilities": "Mga pasilidad ng libangan", "listings.amenities.schools": "Mga paaralan", + "listings.amenities.seniorCenters": "Sentro ng mga nakatatanda", + "listings.amenities.shoppingVenues": "Mga lugar ng pamimili", "listings.annualIncome": "%{income} kada taon", "listings.applicationAlreadySubmitted": "Nagsumite ka na ng aplikasyon para sa listahang ito.", "listings.applicationDeadline": "Takdang petsa ng aplikasyon", @@ -702,6 +720,8 @@ "listings.confirmedPreferenceList": "Kinumpirma ang %{preference} Listahan", "listings.costsNotIncluded": "Hindi kasama ang mga gastos", "listings.creditHistory": "Kasaysayan ng kredito", + "listings.creditScreeningFee": "Screening ng credit", + "listings.creditScreeningFeeDescription": "sumasaklaw sa gastos ng pagsusuri ng iyong kasaysayan ng kredito at pag-upa", "listings.criminalBackground": "Kriminal na background", "listings.depositMayBeHigherForLowerCredit": "Maaaring mas mataas o mas mababa ang mga credit score", "listings.depositOrMonthsRent": "o isang buwang renta", @@ -716,6 +736,7 @@ "listings.featuresCards": "Nagtatampok ng mga card", "listings.forIncomeCalculations": "Para sa mga kalkulasyon ng kita, ang laki ng sambahayan ay kinabibilangan ng bawat isa (lahat ng edad) na nakatira sa unit.", "listings.forIncomeCalculationsBMR": "Ang mga kalkulasyon ng kita ay batay sa uri ng unit", + "listings.hasEbllClearance": "Nakatanggap ang property na ito ng HUD EBLL clearance.", "listings.hideClosedListings": "Itago ang mga saradong listahan", "listings.homeType.apartment": "Apartment", "listings.homeType.duplex": "Duplex", @@ -732,6 +753,7 @@ "listings.lotteryResults.completeResultsWillBePosted": "Ang kumpletong resulta ng lottery ay ipapaskil sa ibang pagkakataon.", "listings.lotteryResults.downloadResults": "I-download ang mga resulta", "listings.lotteryResults.header": "Mga resulta ng lottery", + "listings.marketing.header": "Pagmemerkado", "listings.maxIncomeMonth": "Pinakamataas na kita / buwan", "listings.maxIncomeYear": "Pinakamataas na kita / taon", "listings.maxRent": "Max na upa", @@ -743,12 +765,16 @@ "listings.neighborhoodBuildings": "Mga gusali sa kapitbahayan", "listings.noAvailableUnits": "Walang mga available na unit sa pagkakataong ito.", "listings.noClosedListings": "Walang mga listahan na kasalukuyang may mga saradong aplikasyon", + "listings.noEbllClearance": "Hindi pa nakatanggap ng HUD EBLL clearance ang ari-ariang ito.", "listings.noMatchingClosedListings": "Walang tumutugmang listahan na may mga saradong application", "listings.noMatchingOpenListings": "Walang katugmang listahan na may mga bukas na application", "listings.noOpenListings": "Walang kasalukuyang mga listahan ang bukas para sa mga application.", "listings.occupancyDescriptionAllSro": "Ang naninirahan para sa gusaling ito ay limitado lamang sa 1 tao sa bawat unit.", "listings.occupancyDescriptionNoSro": "Ang mga limit ng paninirahan para sa gusaling ito ay batay sa uri ng unit.", "listings.occupancyDescriptionSomeSro": "Iba't iba ang naninirahan para sa gusaling ito ayon sa uri ng unit. Ang mga SRO ay limitado sa 1 tao bawat unit, anuman ang edad. Para sa lahat ng iba pang uri ng unit, hindi binibilang ng mga limitasyon sa paninirahan ang mga batang wala pang 6 taong gulang.", + "listings.openHouseAndMarketing.accessibleMarketingFlyerLink": "Buksan ang accessible marketing flyer", + "listings.openHouseAndMarketing.header": "Mga bukas na bahay at marketing", + "listings.openHouseAndMarketing.marketingFlyerLink": "Buksan ang marketing flyer", "listings.openHouseEvent.header": "Mga bukas na bahay", "listings.openHouseEvent.seeVideo": "Tingnan ang video", "listings.percentAMIUnit": "%{percent}% AMI unit", @@ -761,7 +787,17 @@ "listings.removeFilters": "Subukang alisin ang ilan sa iyong mga filter o ipakita ang lahat ng listahan.", "listings.rentalHistory": "Kasaysayan ng pagrenta", "listings.rePricing": "Pagbabago ng Presyo", + "listings.requiredDocuments.birthCertificate": "Sertipiko ng Kapanganakan (lahat ng miyembro ng sambahayan na may edad 18 pataas)", + "listings.requiredDocuments.currentLandlordReference": "Reperensya mula sa kasalukuyang may-ari ng inuupahan", + "listings.requiredDocuments.governmentIssuedId": "ID na inisyu ng pamahalaan (lahat ng miyembro ng sambahayan na may edad 18 pataas)", + "listings.requiredDocuments.previousLandlordReference": "Reperensya mula sa dating may-ari ng inuupahan", + "listings.requiredDocuments.proofOfAssets": "Patunay ng mga Ari-arian (mga pahayag ng bangko, atbp.)", + "listings.requiredDocuments.proofOfCustody": "Patunay ng Pagkakaroon ng Pag-iingat o Pagiging Tagapag-alaga", + "listings.requiredDocuments.proofOfIncome": "Patunay ng kita ng sambahayan (mga payslip, W-2, atbp.)", + "listings.requiredDocuments.residencyDocuments": "Mga dokumento ng Imigrasyon/Paninirahan (green card, atbp.)", + "listings.requiredDocuments.socialSecurityCard": "Kard ng Seguridad Panlipunan", "listings.requiredDocuments": "Mga kinakailangang dokumento", + "listings.requiredDocumentsAdditionalInfo": "Mga kinakailangang dokumento (Karagdagang Impormasyon)", "listings.reservedCommunityBuilding": "%{type} gusali", "listings.reservedCommunitySeniorTitle": "Matandang gusali", "listings.reservedCommunityTitleDefault": "Nakareserbang gusali", @@ -835,7 +871,17 @@ "listings.singleRoomOccupancyDescription": "Nag-aalok ang property na ito ng isahang mga kwarto para sa isang tao lamang. Ang mga nangungupahan ay maaaring maghati sa mga banyo, at kung minsan ay mga kagamitan sa kusina.", "listings.specialNotes": "Mga espesyal na tala", "listings.underConstruction": "Under construction", + "listings.unit.accessibilityType.Hearing and Visual": "Pandinig at Paningin", + "listings.unit.accessibilityType.Hearing": "Pagdinig", + "listings.unit.accessibilityType.Mobility and Hearing": "Mobility at Pagdinig", + "listings.unit.accessibilityType.Mobility and Visual": "Mobility at Vision", + "listings.unit.accessibilityType.Mobility, Hearing and Visual": "Mobility, Pandinig at Paningin", + "listings.unit.accessibilityType.Mobility": "Mobility", + "listings.unit.accessibilityType.Visual": "Pangitain", + "listings.unit.accessibilityType": "Uri ng pagiging naa-access", "listings.unit.sharedBathroom": "Ibinahagi", + "listings.unit.showLessUnits": "Magpakita ng mas kaunting %{type} unit", + "listings.unit.showMoreUnits": "Magpakita ng higit pang %{type} unit", "listings.unitsAreFor": "Ang mga unit na ito ay para sa %{type}.", "listings.unitsHaveAccessibilityFeaturesFor": "Ang mga unit na ito ay may mga feature ng accessibility para sa mga taong may %{type}.", "listings.unitsSummary.notAvailable": "Hindi available", @@ -878,18 +924,18 @@ "listings.waitlist.unitsAndWaitlist": "Mga available na unit at waitlist", "lottery.applicationsThatQualifyForPreference": "Ang mga application na kwalipikado para sa pagpipilian na ito ay bibigyan ng mas mataas na prayoridad.", "lottery.viewPreferenceList": "Tingnan ang listahan ng kagustuhan", - "months.january": "Enero", + "months.april": "Abril", + "months.august": "Agosto", + "months.december": "Disyembre", "months.february": "Pebrero", + "months.january": "Enero", + "months.july": "Hulyo", + "months.june": "Hunyo", "months.march": "Marso", - "months.april": "Abril", "months.may": "Mayo", - "months.june": "Hunyo", - "months.july": "Hulyo", - "months.august": "Agosto", - "months.september": "Setyembre", - "months.october": "Oktubre", "months.november": "Nobyembre", - "months.december": "Disyembre", + "months.october": "Oktubre", + "months.september": "Setyembre", "nav.browseProperties": "Mag-browse ng mga katangian", "nav.getFeedback": "Gusto naming makuha ang iyong feedback!", "nav.listings": "Mga listahan", @@ -1018,6 +1064,7 @@ "t.additionalAccessibility": "Karagdagang accessibility", "t.additionalPhone": "Karagdagang telepono", "t.am": "AM", + "t.ami": "AMI", "t.area": "lugar", "t.areYouStillWorking": "Nagtatrabaho ka pa rin ba?", "t.at": "sa", @@ -1089,6 +1136,8 @@ "t.or": "o", "t.order": "Umorder", "t.orUpTo": "o hanggang sa", + "t.other": "Iba pa", + "t.parkingFee": "Bayad sa paradahan", "t.people": "mga tao", "t.perMonth": "kada buwan", "t.person": "tao", diff --git a/shared-helpers/src/locales/vi.json b/shared-helpers/src/locales/vi.json index 772de76bdb..a61ad53b36 100644 --- a/shared-helpers/src/locales/vi.json +++ b/shared-helpers/src/locales/vi.json @@ -8,7 +8,7 @@ "account.application.lottery.applicantList": "Trong số %{applicants} ứng viên trong danh sách này", "account.application.lottery.next": "Người quản lý bất động sản sẽ liên hệ với người nộp đơn theo thứ tự ưu tiên. Họ sẽ bắt đầu với ưu tiên có mức độ ưu tiên cao nhất. Nếu người quản lý bất động sản liên hệ với bạn, họ sẽ yêu cầu bạn cung cấp tài liệu để hỗ trợ những gì bạn đã trả lời trong đơn. Tài liệu đó có thể bao gồm phiếu lương, ví dụ. Họ cũng có thể cần thu thập thêm thông tin bằng cách yêu cầu bạn hoàn thành đơn bổ sung.", "account.application.lottery.nextHeader": "Chuyện gì xảy ra tiếp theo?", - "account.application.lottery.preferences": "Các ưu tiên xổ số cho đơn đăng ký của bạn được hiển thị ở đây theo thứ tự ưu tiên. Nếu bạn không đủ điều kiện cho bất kỳ ưu tiên xổ số nào, bạn sẽ thuộc danh mục xổ số chung. Danh mục xổ số chung là nhóm cuối cùng được xử lý.", + "account.application.lottery.preferences": "Các ưu tiên xổ số cho đơn đăng ký của bạn được hiển thị ở đây theo thứ tự ưu tiên. Nếu bạn không đủ điều kiện cho bất kỳ ưu tiên xổ số nào, bạn sẽ thuộc danh mục xổ số chung. Danh mục xổ số chung là nhóm cuối cùng được xử lý. Lưu ý rằng bạn sẽ chỉ thấy thứ hạng cho những sở thích mà bạn đã chọn, nhưng có thể có những sở thích khác cho danh sách này.", "account.application.lottery.preferencesButton": "Sở thích xổ số là gì?", "account.application.lottery.preferencesHeader": "Sở thích xổ số của bạn", "account.application.lottery.preferencesMessage": "Những kết quả này dựa trên thông tin bạn cung cấp trong đơn đăng ký. Điều kiện ưu tiên có thể thay đổi sau khi thông tin của bạn được xác minh.", @@ -16,8 +16,10 @@ "account.application.lottery.rawRankButton": "Xếp hạng thô là gì?", "account.application.lottery.rawRankHeader": "Xếp hạng thô của bạn", "account.application.lottery.resultsHeader": "Đây là kết quả xổ số của bạn", + "account.application.lottery.resultsHeaderWaitlistLottery": "Đây là kết quả xổ số cho danh sách chờ", "account.application.lottery.resultsSubheader": "%{applications} đơn đăng ký đã được gửi cho %{units} đơn vị", "account.application.lottery.resultsSubheaderPlural": "%{applications} đơn xin đã được nộp cho %{units} đơn vị", + "account.application.lottery.resultsSubheaderWaitlistLottery": "%{applications} đơn đã được nộp", "account.application.lottery.viewResults": "Xem kết quả xổ số", "account.application.noAccessError": "Quý vị không được phép xem đơn đăng ký này", "account.application.noApplicationError": "Không tồn tại đơn đăng ký nào có ID đó", @@ -48,6 +50,8 @@ "account.pwdless.loginMessage": "Nếu có tài khoản được tạo bằng email đó, chúng tôi sẽ gửi mã trong vòng 10 phút. Nếu bạn không nhận được mã, hãy đăng nhập bằng mật khẩu và xác nhận địa chỉ email của bạn trong cài đặt tài khoản.", "account.pwdless.loginReCaptchaMessage": "Bạn đang được yêu cầu xác minh danh tính của mình như một lớp bảo mật bổ sung. Chúng tôi đã gửi mã tới %{email} để hoàn tất đăng nhập. Xin lưu ý, mã sẽ hết hạn sau 10 phút.", "account.pwdless.notReceived": "Bạn không nhận được mã của mình?", + "account.pwdless.passwordOutdatedModalContent": "Mật khẩu của bạn đã hết hạn. Vui lòng nhấp vào tiếp tục để đặt lại mật khẩu và truy cập vào tài khoản của bạn.", + "account.pwdless.passwordOutdatedModalHeader": "Mật khẩu đã hết hạn", "account.pwdless.resend": "Gửi lại", "account.pwdless.resendCode": "Mã gửi lại", "account.pwdless.resendCodeButton": "Gửi lại mã", @@ -307,11 +311,13 @@ "application.review.confirmation.whatHappensNext.base": "### Điều gì xảy ra tiếp theo?\n\n* Nếu được liên hệ để phỏng vấn, bạn sẽ được yêu cầu điền đơn đăng ký chi tiết hơn và cung cấp các tài liệu hỗ trợ.", "application.review.confirmation.whatHappensNext.fcfs": "### Điều gì xảy ra tiếp theo?\n\n* Những ứng viên đủ điều kiện sẽ được liên hệ trên cơ sở ai đến trước được phục vụ trước cho đến khi các vị trí tuyển dụng được lấp đầy.\n\n* Ưu tiên về nhà ở, nếu có, sẽ ảnh hưởng đến thứ tự đến trước được phục vụ trước.\n\n* Nếu được liên hệ để phỏng vấn, bạn sẽ được yêu cầu điền đơn đăng ký chi tiết hơn và cung cấp các tài liệu hỗ trợ.", "application.review.confirmation.whatHappensNext.lottery": "### Điều gì xảy ra tiếp theo?\n\n* Sau khi thời gian đăng ký kết thúc, những người nộp đơn đủ điều kiện sẽ được sắp xếp theo thứ tự dựa trên thứ tự xếp hạng xổ số.\n\n* Ưu tiên về nhà ở, nếu có, sẽ ảnh hưởng đến thứ tự xếp hạng xổ số.\n\n* Nếu được liên hệ để phỏng vấn, bạn sẽ được yêu cầu điền đơn đăng ký chi tiết hơn và cung cấp các tài liệu hỗ trợ.", - "application.review.confirmation.whatHappensNext.waitlist": "### Điều gì xảy ra tiếp theo?\n\n* Những người đăng ký đủ điều kiện sẽ được đưa vào danh sách chờ trên cơ sở ai đến trước được phục vụ trước cho đến khi các chỗ trong danh sách chờ được lấp đầy.\n\n* Ưu tiên về nhà ở, nếu có, sẽ ảnh hưởng đến thứ tự danh sách chờ.\n\n* Nếu bạn được liên hệ để phỏng vấn, bạn sẽ được yêu cầu điền đơn đăng ký chi tiết hơn và cung cấp các tài liệu hỗ trợ.\n\n* Bạn có thể được liên hệ khi đang trong danh sách chờ để xác nhận rằng bạn muốn tiếp tục tham gia danh sách chờ.", + "application.review.confirmation.whatHappensNext.noPref.base": "### Điều gì xảy ra tiếp theo?\n\n* Nếu được liên hệ để phỏng vấn, bạn sẽ được yêu cầu điền đơn đăng ký chi tiết hơn và cung cấp các tài liệu hỗ trợ.", "application.review.confirmation.whatHappensNext.noPref.fcfs": "### Điều gì xảy ra tiếp theo?\n\n* Những ứng viên đủ điều kiện sẽ được liên hệ trên cơ sở ai đến trước được phục vụ trước cho đến khi các vị trí tuyển dụng được lấp đầy.\n\n* Nếu được liên hệ để phỏng vấn, bạn sẽ được yêu cầu điền đơn đăng ký chi tiết hơn và cung cấp các tài liệu hỗ trợ.", "application.review.confirmation.whatHappensNext.noPref.lottery": "### Điều gì xảy ra tiếp theo?\n\n* Sau khi thời gian đăng ký kết thúc, những người nộp đơn đủ điều kiện sẽ được sắp xếp theo thứ tự dựa trên thứ tự xếp hạng xổ số.\n\n* Nếu được liên hệ để phỏng vấn, bạn sẽ được yêu cầu điền đơn đăng ký chi tiết hơn và cung cấp các tài liệu hỗ trợ.", "application.review.confirmation.whatHappensNext.noPref.waitlist": "### Điều gì xảy ra tiếp theo?\n\n* Những người đăng ký đủ điều kiện sẽ được đưa vào danh sách chờ trên cơ sở ai đến trước được phục vụ trước cho đến khi các chỗ trong danh sách chờ được lấp đầy.\n\n* Nếu bạn được liên hệ để phỏng vấn, bạn sẽ được yêu cầu điền đơn đăng ký chi tiết hơn và cung cấp các tài liệu hỗ trợ.\n\n* Bạn có thể được liên hệ khi đang trong danh sách chờ để xác nhận rằng bạn muốn tiếp tục tham gia danh sách chờ.", - "application.review.confirmation.whatHappensNext.noPref.base": "### Điều gì xảy ra tiếp theo?\n\n* Nếu được liên hệ để phỏng vấn, bạn sẽ được yêu cầu điền đơn đăng ký chi tiết hơn và cung cấp các tài liệu hỗ trợ.", + "application.review.confirmation.whatHappensNext.noPref.waitlistLottery": "### Điều gì xảy ra tiếp theo?\n\n* Những người nộp đơn đủ điều kiện sẽ được đưa vào danh sách chờ dựa trên thứ hạng xổ số.\n\n* Nếu bạn được liên hệ để phỏng vấn, bạn sẽ được yêu cầu điền đơn đăng ký chi tiết hơn và cung cấp các tài liệu hỗ trợ.\n\n* Bạn có thể được liên hệ khi đang trong danh sách chờ để xác nhận rằng bạn muốn tiếp tục tham gia danh sách chờ.", + "application.review.confirmation.whatHappensNext.waitlist": "### Điều gì xảy ra tiếp theo?\n\n* Những người đăng ký đủ điều kiện sẽ được đưa vào danh sách chờ trên cơ sở ai đến trước được phục vụ trước cho đến khi các chỗ trong danh sách chờ được lấp đầy.\n\n* Ưu tiên về nhà ở, nếu có, sẽ ảnh hưởng đến thứ tự danh sách chờ.\n\n* Nếu bạn được liên hệ để phỏng vấn, bạn sẽ được yêu cầu điền đơn đăng ký chi tiết hơn và cung cấp các tài liệu hỗ trợ.\n\n* Bạn có thể được liên hệ khi đang trong danh sách chờ để xác nhận rằng bạn muốn tiếp tục tham gia danh sách chờ.", + "application.review.confirmation.whatHappensNext.waitlistLottery": "### Điều gì xảy ra tiếp theo?\n\n* Những người nộp đơn đủ điều kiện sẽ được đưa vào danh sách chờ dựa trên thứ hạng xổ số.\n\n* Ưu tiên về nhà ở, nếu có, sẽ ảnh hưởng đến thứ tự danh sách chờ.\n\n* Nếu bạn được liên hệ để phỏng vấn, bạn sẽ được yêu cầu điền đơn đăng ký chi tiết hơn và cung cấp các tài liệu hỗ trợ.\n\n* Bạn có thể được liên hệ khi đang trong danh sách chờ để xác nhận rằng bạn muốn tiếp tục tham gia danh sách chờ.", "application.review.demographics.ethnicityLabel": "Điều nào dưới đây mô tả đúng nhất về dân tộc của quý vị?", "application.review.demographics.ethnicityOptions.hispanicLatino": "Gốc Tây Ban Nha / La-tinh", "application.review.demographics.ethnicityOptions.notHispanicLatino": "Không phải người gốc Tây Ban Nha / La tinh", @@ -407,10 +413,11 @@ "application.review.noAdditionalMembers": "Không có thành viên gia đình bổ sung", "application.review.sameAddressAsApplicant": "Cùng địa chỉ với người nộp đơn", "application.review.takeAMomentToReview": "Hãy dành một chút thời gian để xem lại thông tin của quý vị trước khi nộp đơn ghi danh.", - "application.review.terms.confirmCheckboxText": "Tôi đồng ý và hiểu rằng tôi không thể thay đổi bất cứ thông tin nào sau khi tôi nộp đơn.", "application.review.terms.base.text": "* Nếu bạn được liên hệ để xin phỏng vấn, bạn sẽ được yêu cầu điền đơn đăng ký chi tiết hơn và cung cấp các tài liệu hỗ trợ.\n\n* Tất cả thông tin bạn đã cung cấp sẽ được xác minh và xác nhận khả năng đủ điều kiện của bạn.\n\n* Đơn đăng ký của bạn có thể bị xóa nếu bạn đã đưa ra bất kỳ tuyên bố gian lận nào.\n\nĐể biết thêm thông tin, vui lòng liên hệ với nhà phát triển nhà ở hoặc người quản lý tài sản được đăng trong danh sách.\n\nViệc hoàn thành đơn đăng ký này không cho phép bạn có nhà ở hoặc cho biết bạn đủ điều kiện có nhà ở. Tất cả những người nộp đơn sẽ được sàng lọc như được nêu trong Tiêu chí lựa chọn cư dân của nơi lưu trú.\n\nBạn không thể thay đổi đơn đăng ký trực tuyến của mình sau khi gửi.\n\nTôi tuyên bố rằng những điều đã nói ở trên là đúng và chính xác, đồng thời thừa nhận rằng bất kỳ sai sót nào được thực hiện một cách gian lận hoặc cẩu thả trên ứng dụng này có thể dẫn đến việc bị loại khỏi quá trình ứng dụng.", + "application.review.terms.confirmCheckboxText": "Tôi đồng ý và hiểu rằng tôi không thể thay đổi bất cứ thông tin nào sau khi tôi nộp đơn.", "application.review.terms.fcfs.text": "* Người nộp đơn đăng ký vào các căn hộ hiện đang bỏ trống trên cơ sở ai đến trước được phục vụ trước.\n\n* Những người nộp đơn đủ điều kiện sẽ được liên hệ trên cơ sở ai đến trước được phục vụ trước cho đến khi chỗ trống được lấp đầy.\n\n* Nếu bạn được liên hệ để xin phỏng vấn, bạn sẽ được yêu cầu điền đơn đăng ký chi tiết hơn và cung cấp các tài liệu hỗ trợ.\n\n* Tất cả thông tin bạn đã cung cấp sẽ được xác minh và xác nhận khả năng đủ điều kiện của bạn.\n\n* Đơn đăng ký của bạn có thể bị xóa nếu bạn đã đưa ra bất kỳ tuyên bố gian lận nào.\n\n* Đối với những tài sản có ưu tiên nhà ở, nếu chúng tôi không thể xác minh ưu tiên nhà ở mà bạn đã yêu cầu, bạn sẽ không nhận được ưu tiên nhưng sẽ không bị phạt.\n\nĐể biết thêm thông tin, vui lòng liên hệ với nhà phát triển nhà ở hoặc người quản lý tài sản được đăng trong danh sách.\n\nViệc hoàn thành đơn đăng ký này không cho phép bạn có nhà ở hoặc cho biết bạn đủ điều kiện có nhà ở. Tất cả những người nộp đơn sẽ được sàng lọc như được nêu trong Tiêu chí lựa chọn cư dân của nơi lưu trú.\n\nBạn không thể thay đổi đơn đăng ký trực tuyến của mình sau khi gửi.\n\nTôi tuyên bố rằng những điều đã nói ở trên là đúng và chính xác, đồng thời thừa nhận rằng bất kỳ sai sót nào được thực hiện một cách gian lận hoặc cẩu thả trên ứng dụng này có thể dẫn đến việc bị loại khỏi quá trình ứng dụng.", "application.review.terms.lottery.text": "* Những người nộp đơn đang nộp đơn để tham gia xổ số cho những căn hộ hiện đang bỏ trống.\n\n* Sau khi thời gian nộp đơn kết thúc, những người nộp đơn đủ điều kiện sẽ được xếp theo thứ tự xổ số.\n\n* Nếu bạn được liên hệ để phỏng vấn, bạn sẽ được được yêu cầu điền vào đơn đăng ký chi tiết hơn và cung cấp các tài liệu hỗ trợ.\n\n* Tất cả thông tin bạn đã cung cấp sẽ được xác minh và xác nhận khả năng đủ điều kiện của bạn.\n\n* Đơn đăng ký của bạn có thể bị xóa nếu bạn thực hiện bất kỳ hành vi gian lận nào tuyên bố.\n\n* Đối với những bất động sản có ưu tiên nhà ở, nếu chúng tôi không thể xác minh ưu tiên nhà ở mà bạn đã yêu cầu, bạn sẽ không nhận được ưu tiên nhưng sẽ không bị phạt.\n\nĐể biết thêm thông tin, vui lòng liên hệ với nhà phát triển nhà ở hoặc người quản lý tài sản được đăng trong danh sách.\n\nViệc hoàn thành đơn đăng ký này không cho phép bạn có nhà ở hoặc cho biết bạn đủ điều kiện có nhà ở. Tất cả những người nộp đơn sẽ được sàng lọc như được nêu trong Tiêu chí lựa chọn cư dân của nơi lưu trú.\n\nBạn không thể thay đổi đơn đăng ký trực tuyến của mình sau khi gửi.\n\nTôi tuyên bố rằng những điều đã nói ở trên là đúng và chính xác, đồng thời thừa nhận rằng bất kỳ sai sót nào được thực hiện một cách gian lận hoặc cẩu thả trên ứng dụng này có thể dẫn đến việc bị loại khỏi quá trình ứng dụng.", + "application.review.terms.standard.text": "* Đối với các đơn vị nhà có sẵn được cung cấp thông qua bốc thăm nhà ở, những người nộp đơn đủ điều kiện sẽ được liên hệ theo thứ tự xếp hạng của họ trong đợt bốc thăm được thực hiện ít lâu sau hạn đăng ký. Đối với các đơn vị nhà có sẵn được cung cấp trên cơ sở ai đến trước được phục vụ trước, những người nộp đơn đủ điều kiện sẽ được liên hệ theo thứ tự thời gian đăng ký cho đến khi hết suất trống.\n\n* Nếu bạn được liên hệ để phỏng vấn, bạn sẽ cần điền vào một hồ sơ chi tiết hơn và cung cấp các giấy tờ chứng thực. Chúng tôi sẽ xác minh mọi thông tin mà bạn đã cung cấp, cũng như xác nhận rằng bạn đủ điều kiện đăng ký hay không. Hồ sơ của bạn có thể sẽ bị Đối tác chuyên nghiệp loại khỏi danh sách xem xét nếu bạn đưa ra bất kỳ thông tin nào sai sự thật. Đối với những bất động sản có ưu tiên về nhà ở, nếu chúng tôi không thể xác minh yêu cầu xin ưu tiên của bạn, bạn sẽ không được ưu tiên. Tất cả những người nộp đơn sẽ trải qua quá trình sàng lọc như được nêu trong Tiêu chí lựa chọn cư dân của tin đăng.\n\n* Việc hoàn thành hồ sơ này không đồng nghĩa với việc bạn có quyền được cấp nhà, hay cho thấy rằng bạn đủ điều kiện được cấp nhà. Xin lưu ý rằng bạn sẽ không thể thay đổi hồ sơ trực tuyến của mình sau khi nộp.\n\n* Hãy cẩn thận khi nộp đơn nhiều lần cho cùng một vị trí tuyển dụng, vì việc nộp nhiều đơn từ bất kỳ người nào trong gia đình bạn có thể khiến bạn và tất cả các thành viên trong gia đình bị loại khỏi cơ hội đó. Vui lòng liên hệ với chúng tôi nếu bạn đã gửi đơn đăng ký có lỗi.\n\nTôi xác nhận rằng tôi ít nhất 18 tuổi và được ủy quyền để cung cấp thông tin nhận dạng cá nhân (PII) của bất kỳ thành viên nào trong gia đình được liệt kê trong Đơn đăng ký. Tôi đồng ý thay mặt cho các thành viên trong gia đình được liệt kê trong Đơn đăng ký và bản thân tôi cho phép chuyển giao thông tin nhận dạng cá nhân (PII) cho Đối tác chuyên nghiệp và/hoặc Chính quyền địa phương. Tôi đồng ý với các điều khoản trên và tuyên bố rằng tất cả thông tin đã cung cấp là đúng và chính xác, và thừa nhận rằng bất kỳ thông tin sai lệch nào được cung cấp một cách gian lận hoặc do sơ suất trong đơn đăng ký này có thể dẫn đến việc bị loại khỏi quá trình xét duyệt đơn đăng ký.", "application.review.terms.submittingApplication": "Nộp hồ sơ", "application.review.terms.textSubmissionDate": "Đơn đăng ký này phải được gửi trước %{applicationDueDate}.", "application.review.terms.title": "Các điều khoản", @@ -424,6 +431,8 @@ "application.start.whatToExpect.title": "Dưới đây là những điều sẽ xảy ra trong đơn ghi danh này.", "application.start.whatToExpect.waitlist.finePrint": "* Người nộp đơn đang đăng ký một danh sách chờ mở chứ không phải một căn hộ hiện có sẵn.\n* Xin lưu ý rằng mỗi thành viên trong gia đình chỉ có thể xuất hiện trên một đơn đăng ký cho mỗi danh sách.\n* Khi có chỗ trống, người nộp đơn đủ điều kiện sẽ được liên hệ bởi người nộp đơn. người quản lý tài sản trên cơ sở ai đến trước được phục vụ trước.\n* Tất cả thông tin bạn đã cung cấp sẽ được xác minh và xác nhận khả năng đủ điều kiện của bạn.\n* Đơn đăng ký của bạn có thể bị xóa nếu bạn đưa ra bất kỳ tuyên bố gian lận nào.\n* Dành cho các tài sản có ưu tiên nhà ở, nếu chúng tôi không thể xác minh ưu tiên nhà ở mà bạn đã yêu cầu, bạn sẽ không nhận được ưu tiên nhưng sẽ không bị phạt.", "application.start.whatToExpect.waitlist.steps": "1. Trước tiên, chúng tôi sẽ hỏi về bạn và những người bạn định sống cùng.\n2. Sau đó, chúng tôi sẽ hỏi về thu nhập của bạn.\n3. Cuối cùng, chúng tôi sẽ xem liệu bạn có đủ điều kiện nhận bất kỳ ưu đãi nhà ở giá phải chăng nào không, nếu có.", + "application.start.whatToExpect.waitlistLottery.finePrint": "* Người nộp đơn đang đăng ký một danh sách chờ mở chứ không phải một căn hộ hiện có sẵn.\n* Xin lưu ý rằng mỗi thành viên trong gia đình chỉ có thể xuất hiện trên một đơn đăng ký cho mỗi danh sách.\n* Khi có chỗ trống, đại lý bất động sản sẽ liên hệ với ứng viên theo thứ tự xổ số cho đến khi tuyển đủ người.\n* Tất cả thông tin bạn đã cung cấp sẽ được xác minh và xác nhận khả năng đủ điều kiện của bạn.\n* Đơn đăng ký của bạn có thể bị xóa nếu bạn đưa ra bất kỳ tuyên bố gian lận nào.\n* Dành cho các tài sản có ưu tiên nhà ở, nếu chúng tôi không thể xác minh ưu tiên nhà ở mà bạn đã yêu cầu, bạn sẽ không nhận được ưu tiên nhưng sẽ không bị phạt.", + "application.start.whatToExpect.waitlistLottery.steps": "1. Trước tiên, chúng tôi sẽ hỏi về bạn và những người bạn định sống cùng.\n2. Sau đó, chúng tôi sẽ hỏi về thu nhập của bạn.\n3. Cuối cùng, chúng tôi sẽ xem liệu bạn có đủ điều kiện nhận bất kỳ ưu đãi nhà ở giá phải chăng nào không, nếu có.", "application.status": "Tình Trạng", "application.statuses.inProgress": "Đang tiến hành", "application.statuses.neverSubmitted": "Không bao giờ nộp", @@ -465,10 +474,12 @@ "authentication.forgotPassword.errors.tokenMissing": "Không tìm thấy mã thông báo. Vui lòng yêu cầu mã mới.", "authentication.forgotPassword.message": "Nếu có tài khoản được tạo bằng email đó, bạn sẽ nhận được email có liên kết để đặt lại mật khẩu của mình. Liên kết đặt lại có hiệu lực trong 1 giờ.", "authentication.forgotPassword.passwordConfirmation": "Xác nhận mật khẩu", - "authentication.forgotPassword.sendEmail": "Gửi email", + "authentication.forgotPassword.sendEmail": "Nhập email của bạn để nhận liên kết đặt lại mật khẩu", + "authentication.forgotPassword.sendEmailButton": "Gửi email", + "authentication.forgotPassword.sendEmailNotes": "Vui lòng nhập địa chỉ email của bạn để chúng tôi có thể gửi cho bạn liên kết đặt lại mật khẩu. Nếu bạn không nhận được email, có thể bạn chưa có tài khoản.", "authentication.signIn.accountHasBeenLocked": "Vì lý do bảo mật, tài khoản này đã bị khóa.", "authentication.signIn.afterFailedAttempts": "Vì lý do bảo mật, quý vị sẽ phải chờ 30 phút trước khi thử lại sau %{count} lần thử không thành công.", - "authentication.signIn.changeYourPassword": "Quý vị có thể đổi mật khẩu", + "authentication.signIn.changeYourPassword": "Mag-click dito upang i-reset ang iyong password", "authentication.signIn.enterLoginEmail": "Vui lòng nhập email đăng nhập của quý vị", "authentication.signIn.enterLoginPassword": "Vui lòng nhập mật khẩu đăng nhập của quý vị", "authentication.signIn.enterValidEmailAndPassword": "Vui lòng nhập email và mật khẩu hợp lệ", @@ -479,7 +490,7 @@ "authentication.signIn.loginError": "Vui lòng nhập địa chỉ email hợp lệ", "authentication.signIn.mfaError": "Đây là tài khoản đối tác, vì lý do bảo mật nên không thể đăng nhập vào trang web công cộng.", "authentication.signIn.passwordError": "Vui lòng nhập mật khẩu hợp lệ", - "authentication.signIn.passwordOutdated": "Mật khẩu của quý vị đã hết hạn. Vui lòng đặt lại mật khẩu.", + "authentication.signIn.passwordOutdated": "Mật khẩu liên kết với tài khoản của bạn đã hết hạn. Bạn cần đặt lại mật khẩu để truy cập tài khoản.", "authentication.signIn.pwdless.createAccountCopy": "Đăng ký nhanh chóng mà không cần nhớ mật khẩu.", "authentication.signIn.pwdless.emailHelperText": "Nhập email của bạn và chúng tôi sẽ gửi cho bạn mã để đăng nhập.", "authentication.signIn.pwdless.error": "Mã bạn đã sử dụng không hợp lệ hoặc đã hết hạn.", @@ -625,6 +636,7 @@ "languages.vi": "Tiếng Việt", "languages.zh": "中文", "leasingAgent.contact": "Liên hệ với đại lý cho thuê", + "leasingAgent.contactManagerProp": "Liên hệ với đại lý cho thuê hoặc người quản lý tài sản", "leasingAgent.dueToHighCallVolume": "Do có nhiều cuộc gọi đến, quý vị có thể nghe thấy một lời nhắn.", "leasingAgent.officeHours": "Giờ làm việc", "listingFilters.clear": "Xóa", @@ -638,12 +650,18 @@ "listings.additionalInformationEnvelope": "Phong bì thông tin bổ sung", "listings.allUnits": "Tất cả các đơn vị", "listings.allUnitsReservedFor": "Tất cả các căn nhà được dành cho %{type}", + "listings.amenities.busStops": "Trạm xe buýt", "listings.amenities.groceryStores": "Cửa hàng tạp hóa", "listings.amenities.healthCareResources": "Tài nguyên chăm sóc sức khỏe", + "listings.amenities.hospitals": "Bệnh viện", "listings.amenities.parksAndCommunityCenters": "Công viên và trung tâm cộng đồng", "listings.amenities.pharmacies": "Nhà thuốc", + "listings.amenities.playgrounds": "Sân chơi", "listings.amenities.publicTransportation": "Giao thông công cộng", + "listings.amenities.recreationalFacilities": "Cơ sở giải trí", "listings.amenities.schools": "Trường học", + "listings.amenities.seniorCenters": "Các trung tâm người cao tuổi", + "listings.amenities.shoppingVenues": "Địa điểm mua sắm", "listings.annualIncome": "%{income} mỗi năm", "listings.applicationAlreadySubmitted": "Bạn đã nộp đơn đăng ký cho danh sách này.", "listings.applicationDeadline": "Ngày hết hạn nộp đơn", @@ -702,6 +720,8 @@ "listings.confirmedPreferenceList": "Danh sách nhà %{preference} Đã xác nhận", "listings.costsNotIncluded": "Chi phí không bao gồm", "listings.creditHistory": "Lịch sử tín dụng", + "listings.creditScreeningFee": "Sàng lọc tín dụng", + "listings.creditScreeningFeeDescription": "trang trải chi phí xem xét lịch sử tín dụng và cho thuê của bạn", "listings.criminalBackground": "Tiền án tiền sự", "listings.depositMayBeHigherForLowerCredit": "Có thể cao hơn nếu điểm tín dụng thấp hơn", "listings.depositOrMonthsRent": "hoặc tiền thuê nhà một tháng", @@ -716,6 +736,7 @@ "listings.featuresCards": "Thẻ tính năng", "listings.forIncomeCalculations": "Để tính thu nhập, quy mô hộ gia đình bao gồm tất cả mọi người (mọi lứa tuổi) sống trong căn nhà.", "listings.forIncomeCalculationsBMR": "Tính thu nhập dựa trên loại căn nhà", + "listings.hasEbllClearance": "Bất động sản này đã được HUD EBLL cấp phép.", "listings.hideClosedListings": "Ẩn danh sách đã đóng", "listings.homeType.apartment": "Căn hộ", "listings.homeType.duplex": "Căn hộ song lập", @@ -732,6 +753,7 @@ "listings.lotteryResults.completeResultsWillBePosted": "Toàn bộ kết quả quay xổ số sẽ sớm được đăng.", "listings.lotteryResults.downloadResults": "Tải xuống kết quả", "listings.lotteryResults.header": "Kết quả xổ số", + "listings.marketing.header": "Tiếp thị", "listings.maxIncomeMonth": "Thu nhập tối đa / tháng", "listings.maxIncomeYear": "Thu nhập tối đa / năm", "listings.maxRent": "Tiền thuê tối đa", @@ -743,12 +765,16 @@ "listings.neighborhoodBuildings": "Các tòa nhà lân cận", "listings.noAvailableUnits": "Không có căn nhà nào còn trống tại thời điểm này.", "listings.noClosedListings": "Không có danh sách nào hiện có ứng dụng đã đóng", + "listings.noEbllClearance": "Bất động sản này chưa được HUD EBLL chấp thuận.", "listings.noMatchingClosedListings": "Không có danh sách phù hợp với các ứng dụng đã đóng", "listings.noMatchingOpenListings": "Không có danh sách phù hợp với các ứng dụng mở", "listings.noOpenListings": "Không có danh sách nhà nào hiện đang mở nhận đơn ghi danh.", "listings.occupancyDescriptionAllSro": "Số người ở của tòa nhà này giới hạn 1 người mỗi căn.", "listings.occupancyDescriptionNoSro": "Giới hạn số người ở cho tòa nhà này dựa trên loại căn nhà.", "listings.occupancyDescriptionSomeSro": "Số người ở của tòa nhà này thay đổi tùy theo loại căn nhà. SRO được giới hạn cho 1 người mỗi căn, bất kể độ tuổi. Đối với tất cả các căn nhà khác, giới hạn số người ở không tính trẻ em dưới 6 tuổi.", + "listings.openHouseAndMarketing.accessibleMarketingFlyerLink": "Mở tờ rơi tiếp thị dễ tiếp cận", + "listings.openHouseAndMarketing.header": "Nhà mở cửa và tiếp thị", + "listings.openHouseAndMarketing.marketingFlyerLink": "Mở tờ rơi tiếp thị", "listings.openHouseEvent.header": "Nhà mở cửa", "listings.openHouseEvent.seeVideo": "Xem video", "listings.percentAMIUnit": "Đơn vị AMI %{percent}%", @@ -761,7 +787,17 @@ "listings.removeFilters": "Hãy thử xóa một số bộ lọc hoặc hiển thị tất cả danh sách.", "listings.rentalHistory": "Lịch sử cho thuê", "listings.rePricing": "Định giá lại", + "listings.requiredDocuments.birthCertificate": "Giấy khai sinh (tất cả thành viên trong hộ gia đình từ 18 tuổi trở lên)", + "listings.requiredDocuments.currentLandlordReference": "Tham chiếu chủ nhà hiện tại", + "listings.requiredDocuments.governmentIssuedId": "Giấy tờ tùy thân do chính phủ cấp (tất cả các thành viên trong hộ gia đình từ 18 tuổi trở lên)", + "listings.requiredDocuments.previousLandlordReference": "Tài liệu tham khảo chủ nhà trước đây", + "listings.requiredDocuments.proofOfAssets": "Bằng chứng về tài sản (sao kê ngân hàng, v.v.)", + "listings.requiredDocuments.proofOfCustody": "Bằng chứng về quyền nuôi con/quyền giám hộ", + "listings.requiredDocuments.proofOfIncome": "Bằng chứng về thu nhập hộ gia đình (cuống séc, mẫu W-2, v.v.)", + "listings.requiredDocuments.residencyDocuments": "Giấy tờ nhập cư/cư trú (thẻ xanh, v.v.)", + "listings.requiredDocuments.socialSecurityCard": "Thẻ An sinh Xã hội", "listings.requiredDocuments": "Tài liệu cần thiết", + "listings.requiredDocumentsAdditionalInfo": "Tài liệu cần thiết (Thông tin bổ sung)", "listings.reservedCommunityBuilding": "Tòa nhà %{type}", "listings.reservedCommunitySeniorTitle": "Tòa nhà cao cấp", "listings.reservedCommunityTitleDefault": "Tòa nhà dành riêng", @@ -835,7 +871,17 @@ "listings.singleRoomOccupancyDescription": "Toà nhà này cung cấp các phòng đơn chỉ dành cho một người. Những người thuê nhà có thể dùng chung phòng tắm và đôi khi là các tiện nghi ở nhà bếp.", "listings.specialNotes": "Ghi chú đặc biệt", "listings.underConstruction": "Đang xây dựng", + "listings.unit.accessibilityType.Hearing and Visual": "Thính giác và thị giác", + "listings.unit.accessibilityType.Hearing": "Thính giác", + "listings.unit.accessibilityType.Mobility and Hearing": "Khả năng vận động và thính giác", + "listings.unit.accessibilityType.Mobility and Visual": "Khả năng di chuyển và tầm nhìn", + "listings.unit.accessibilityType.Mobility, Hearing and Visual": "Vận động, Thính giác và Thị giác", + "listings.unit.accessibilityType.Mobility": "Tính di động", + "listings.unit.accessibilityType.Visual": "Tầm nhìn", + "listings.unit.accessibilityType": "Loại khả năng truy cập", "listings.unit.sharedBathroom": "Chia sẻ", + "listings.unit.showLessUnits": "Hiển thị ít đơn vị %{type} hơn", + "listings.unit.showMoreUnits": "Hiển thị thêm %{type} đơn vị", "listings.unitsAreFor": "Các căn nhà này được dành cho %{type}.", "listings.unitsHaveAccessibilityFeaturesFor": "Các căn nhà này có các tính năng trợ giúp cho những người bị %{type}.", "listings.unitsSummary.notAvailable": "Không có sẵn", @@ -878,18 +924,18 @@ "listings.waitlist.unitsAndWaitlist": "Các đơn vị có sẵn và danh sách chờ", "lottery.applicationsThatQualifyForPreference": "Các đơn ghi danh đủ điều kiện cho lựa chọn ưu tiên này sẽ được ưu tiên cao hơn.", "lottery.viewPreferenceList": "Xem danh sách tùy chọn", - "months.january": "Tháng Một", + "months.april": "Tháng tư", + "months.august": "Tháng tám", + "months.december": "Tháng mười hai", "months.february": "Tháng hai", + "months.january": "Tháng Một", + "months.july": "Tháng bảy", + "months.june": "Tháng sáu", "months.march": "Tháng ba", - "months.april": "Tháng tư", "months.may": "Tháng năm", - "months.june": "Tháng sáu", - "months.july": "Tháng bảy", - "months.august": "Tháng tám", - "months.september": "Tháng chín", - "months.october": "Tháng Mười", "months.november": "Tháng mười một", - "months.december": "Tháng mười hai", + "months.october": "Tháng Mười", + "months.september": "Tháng chín", "nav.browseProperties": "Duyệt các thuộc tính", "nav.getFeedback": "Chúng tôi rất muốn nhận được phản hồi của bạn", "nav.listings": "Các danh sách nhà", @@ -1018,6 +1064,7 @@ "t.additionalAccessibility": "Khả năng truy cập bổ sung", "t.additionalPhone": "Điện thoại bổ sung", "t.am": "Sáng", + "t.ami": "AMI", "t.area": "diện tích", "t.areYouStillWorking": "Quý vị có vẫn đang làm việc hay không?", "t.at": "vào", @@ -1089,6 +1136,8 @@ "t.or": "hoặc", "t.order": "Đặt hàng", "t.orUpTo": "hoặc lên đến", + "t.other": "Khác", + "t.parkingFee": "Phí đỗ xe", "t.people": "người", "t.perMonth": "mỗi tháng", "t.person": "người", diff --git a/shared-helpers/src/locales/zh.json b/shared-helpers/src/locales/zh.json index 2ad0f3b6bd..ad377f3753 100644 --- a/shared-helpers/src/locales/zh.json +++ b/shared-helpers/src/locales/zh.json @@ -8,7 +8,7 @@ "account.application.lottery.applicantList": "此列表中的 %{applicants} 名申请人中", "account.application.lottery.next": "物业经理将按优先顺序联系申请人。他们将从优先级最高的申请人开始。如果物业经理联系您,他们会要求您提供文件来支持您在申请中回答的内容。例如,该文件可能包括工资单。他们可能还需要通过要求您填写补充申请来收集更多信息。", "account.application.lottery.nextHeader": "接下来会发生什么?", - "account.application.lottery.preferences": "此处按优先顺序显示您申请的抽签偏好。如果您不符合任何抽签偏好,您将属于一般抽签类别。一般抽签类别是最后处理的组。", + "account.application.lottery.preferences": "此处按优先顺序显示您申请的抽签偏好。如果您不符合任何抽签偏好,您将属于一般抽签类别。一般抽签类别是最后处理的组。請注意,您只能看到您已選擇的偏好設定的排名, 但此清單中可能還有其他偏好設定。", "account.application.lottery.preferencesButton": "彩票偏好是什么?", "account.application.lottery.preferencesHeader": "您的彩票偏好", "account.application.lottery.preferencesMessage": "这些结果基于您在申请中提供的信息。您的信息经过验证后,优先资格可能会发生变化。", @@ -16,8 +16,10 @@ "account.application.lottery.rawRankButton": "什么是原始排名?", "account.application.lottery.rawRankHeader": "您的原始排名", "account.application.lottery.resultsHeader": "这是您的彩票结果", + "account.application.lottery.resultsHeaderWaitlistLottery": "以下是候补名单的抽签结果", "account.application.lottery.resultsSubheader": "已提交 %{applications} 份申请,共 %{units} 个单位", "account.application.lottery.resultsSubheaderPlural": "已提交 %{applications} 份申请,共 %{units} 个单位", + "account.application.lottery.resultsSubheaderWaitlistLottery": "已提交 %{applications} 份申请", "account.application.lottery.viewResults": "查看抽奖结果", "account.application.noAccessError": "您未經授權檢視此申請", "account.application.noApplicationError": "具有該 ID 的申請不存在", @@ -48,6 +50,8 @@ "account.pwdless.loginMessage": "如果有使用該電子郵件建立的帳戶,我們將在 10 分鐘內發送代碼。如果您沒有收到驗證碼,請使用密碼登入並在帳戶設定下確認您的電子郵件地址。", "account.pwdless.loginReCaptchaMessage": "系統會要求您驗證身份,作為額外的安全層。我們向 %{email} 發送了一個代碼以完成登入。", "account.pwdless.notReceived": "沒有收到您的代碼?", + "account.pwdless.passwordOutdatedModalContent": "您的密碼已過期。點擊繼續重設密碼並存取您的帳戶。", + "account.pwdless.passwordOutdatedModalHeader": "密碼已過期", "account.pwdless.resend": "重发", "account.pwdless.resendCode": "重新发送代码", "account.pwdless.resendCodeButton": "重新发送代码", @@ -307,11 +311,13 @@ "application.review.confirmation.whatHappensNext.base": "### 接下来会发生什么?\n\n* 如果有人联系您进行面试,您将被要求填写更详细的申请表并提供支持文件。", "application.review.confirmation.whatHappensNext.fcfs": "### 接下来会发生什么?\n\n* 我们将按照先到先得的原则联系符合条件的申请人,直到空缺被填满。\n\n* 如果适用,住房偏好将影响先到先得的顺序。\n\n* 如果有人联系您进行面试,您将被要求填写更详细的申请表并提供支持文件。", "application.review.confirmation.whatHappensNext.lottery": "### 接下来会发生什么?\n\n* 申请期结束后,符合条件的申请人将根据抽签排名顺序进行排序。\n\n* 如果适用,住房偏好将影响抽签排名顺序。\n\n* 如果有人联系您进行面试,您将被要求填写更详细的申请表并提供支持文件。", - "application.review.confirmation.whatHappensNext.waitlist": "### 接下来会发生什么?\n\n* 符合条件的申请人将按照先到先得的原则被列入候补名单,直到候补名单上的名额被填满。\n\n* 住房偏好(如适用)将影响候补名单的顺序。\n\n* 如果有人联系您进行面试,您将被要求填写更详细的申请表并提供支持文件。\n\n* 您可能会在候补名单上被联系以确认您是否希望继续留在候补名单上。", + "application.review.confirmation.whatHappensNext.noPref.base": "### 接下来会发生什么?\n\n* 如果有人联系您进行面试,您将被要求填写更详细的申请表并提供支持文件。", "application.review.confirmation.whatHappensNext.noPref.fcfs": "### 接下来会发生什么?\n\n* 我们将按照先到先得的原则联系符合条件的申请人,直到空缺被填满。\n\n* 如果有人联系您进行面试,您将被要求填写更详细的申请表并提供支持文件。", "application.review.confirmation.whatHappensNext.noPref.lottery": "### 接下来会发生什么?\n\n* 申请期结束后,符合条件的申请人将根据抽签排名顺序进行排序。\n\n* 如果有人联系您进行面试,您将被要求填写更详细的申请表并提供支持文件。", "application.review.confirmation.whatHappensNext.noPref.waitlist": "### 接下来会发生什么?\n\n* 符合条件的申请人将按照先到先得的原则被列入候补名单,直到候补名单上的名额被填满。\n\n* 如果有人联系您进行面试,您将被要求填写更详细的申请表并提供支持文件。\n\n* 您可能会在候补名单上被联系以确认您是否希望继续留在候补名单上。", - "application.review.confirmation.whatHappensNext.noPref.base": "### 接下来会发生什么?\n\n* 如果有人联系您进行面试,您将被要求填写更详细的申请表并提供支持文件。", + "application.review.confirmation.whatHappensNext.noPref.waitlistLottery": "### 接下来会发生什么?\n\n* 符合資格的申請人將根據抽籤順序列入候補名單。\n\n* 如果有人联系您进行面试,您将被要求填写更详细的申请表并提供支持文件。\n\n* 您可能会在候补名单上被联系以确认您是否希望继续留在候补名单上。", + "application.review.confirmation.whatHappensNext.waitlist": "### 接下来会发生什么?\n\n* 符合条件的申请人将按照先到先得的原则被列入候补名单,直到候补名单上的名额被填满。\n\n* 住房偏好(如适用)将影响候补名单的顺序。\n\n* 如果有人联系您进行面试,您将被要求填写更详细的申请表并提供支持文件。\n\n* 您可能会在候补名单上被联系以确认您是否希望继续留在候补名单上。", + "application.review.confirmation.whatHappensNext.waitlistLottery": "### 接下来会发生什么?\n\n* 符合資格的申請人將根據抽籤順序列入候補名單。\n\n* 住房偏好(如适用)将影响候补名单的顺序。\n\n* 如果有人联系您进行面试,您将被要求填写更详细的申请表并提供支持文件。\n\n* 您可能会在候补名单上被联系以确认您是否希望继续留在候补名单上。", "application.review.demographics.ethnicityLabel": "哪項最能形容您的族裔?", "application.review.demographics.ethnicityOptions.hispanicLatino": "西班牙語裔/拉美裔", "application.review.demographics.ethnicityOptions.notHispanicLatino": "非西班牙語裔/拉美裔", @@ -407,10 +413,11 @@ "application.review.noAdditionalMembers": "沒有其他家庭成員", "application.review.sameAddressAsApplicant": "与申请人地址相同", "application.review.takeAMomentToReview": "在提交申請前,請花一點時間檢視您的資料。", - "application.review.terms.confirmCheckboxText": "本人同意並明白,在提交申請後,本人便不能更改任何內容。", "application.review.terms.base.text": "* 如果联系您进行面试,您将被要求填写更详细的申请表并提供支持文件。\n\n* 我们将核实您提供的所有信息并确认您的资格。\n\n* 如果您做出任何欺诈性陈述,您的申请可能会被删除。\n\n* 对于有住房优惠的房产,如果我们无法核实您声称的住房优惠,您将不会获得优惠,但不会受到其他处罚。\n\n如需更多信息,请联系列表中公布的住房开发商或物业经理。\n\n完成此申请并不代表您有权获得住房或表明您有资格获得住房。所有申请人都将按照物业的住户选择标准进行筛选。\n\n提交后,您无法更改您的在线申请。\n\n我声明上述内容真实准确,并承认在此申请中出现任何欺诈或疏忽的错误陈述可能会导致被取消申请资格。", + "application.review.terms.confirmCheckboxText": "本人同意並明白,在提交申請後,本人便不能更改任何內容。", "application.review.terms.fcfs.text": "* 申请人将按照先到先得的原则申请目前空置的公寓。\n\n* 符合条件的申请人将按照先到先得的原则获得联系,直到空缺职位被填满。\n\n* 如果联系您进行面试,您将被要求填写更详细的申请表并提供支持文件。\n\n* 我们将核实您提供的所有信息并确认您的资格。\n\n* 如果您做出任何欺诈性陈述,您的申请可能会被删除。\n\n* 对于有住房优惠的房产,如果我们无法核实您声称的住房优惠,您将不会获得优惠,但不会受到其他处罚。\n\n如需更多信息,请联系列表中公布的住房开发商或物业经理。\n\n完成此申请并不代表您有权获得住房或表明您有资格获得住房。所有申请人都将按照物业的住户选择标准进行筛选。\n\n提交后,您无法更改您的在线申请。\n\n我声明上述内容真实准确,并承认在此申请中出现任何欺诈或疏忽的错误陈述可能会导致被取消申请资格。", "application.review.terms.lottery.text": "* 申请者正在申请参加目前空置公寓的抽签。\n\n* 申请期结束后,符合条件的申请者将按抽签顺序排列。\n\n* 如果有人联系您进行面试,您将被要求填写更详细的申请表并提供支持文件。\n\n* 我们将会核实您提供的所有信息,并确认您的资格。\n\n* 如果您做出任何欺诈性陈述,您的申请可能会被删除。\n\n* 对于有住房优惠的房产,如果我们无法核实您声称的住房优惠,您将不会获得优惠,但不会受到其他处罚。\n\n如需更多信息,请联系列表中公布的住房开发商或物业经理。\n\n完成此申请并不代表您有权获得住房,也不表明您有资格获得住房。所有申请人都将按照物业的住户选择标准进行筛选。\n\n提交后,您无法更改您的在线申请。\n\n我声明上述内容真实准确,并承认在此申请中出现任何欺诈或疏忽的错误陈述可能会导致被取消申请资格。", + "application.review.terms.standard.text": "* 對於透過住房抽籤提供的空房,將在申請截止不久後進行抽籤,接著會依照抽籤的排序聯絡申請人。而對於先到者優先的住房,將依照申請時間順序與符合條件的申請人聯絡,直到有空房已招滿為止。\n\n* 若收到聯絡要進行面談,您會被要求填寫更為詳細的申請表及提供證明文件。您提供的所有資訊將經過查核並確認您的資格。如果有任何不實資訊,您的申請可能會被專業的合作夥伴取消資格。對於有住房優先權的住房,如果您的住房優先資格無法核實,則您將無法獲得優先權。所有申請者都將依照「房源居民遴選標準」來進行篩選。\n\n* 完成送出本申請並不代表您有權獲得住房,或表示您符合住房資格。請注意,提交線上申請後將無法再更改申請內容。\n\n* 請注意,不要重複申請任何職位,因為您家庭中的任何成員提交多次申請都可能導致您和所有家庭成員失去申請資格。如果您提交的申請有誤,請與我們聯絡。\n\n我確認我已年滿十八歲,並有權提交申請表中列出的所有家庭成員的個人識別資訊(PII)。我代表申請表中列出的所有家庭成員以及我自己同意將這些個人識別資訊傳輸給專業合作夥伴和/或地方政府。我同意上述條款,並聲明以上提供的資訊真實且準確。我知曉,在本申請中任何故意或過失的虛假陳述都可能導致我被取消申請資格。", "application.review.terms.submittingApplication": "提交申请", "application.review.terms.textSubmissionDate": "此申請必須於 %{applicationDueDate} 前完成提交。", "application.review.terms.title": "條款", @@ -424,6 +431,8 @@ "application.start.whatToExpect.title": "這次申請預期事項。", "application.start.whatToExpect.waitlist.finePrint": "* 申请人申请的是开放的候补名单,而不是目前可用的公寓。\n* 请注意,每个家庭成员只能出现在每个列表的一个申请中。\n* 当有空置房屋时,物业经理将按照先到先得的原则联系符合条件的申请人。\n* 您提供的所有信息都将得到验证,并确认您的资格。\n* 如果您做出任何欺诈性陈述,您的申请可能会被删除。\n* 对于有住房偏好的物业,如果我们无法验证您所声称的住房偏好,您将不会获得该偏好,但不会受到其他处罚。", "application.start.whatToExpect.waitlist.steps": "1. 首先,我们会询问您和您计划一起生活的人的情况。\n2. 然后,我们会询问您的收入。\n3. 最后,我们会看看您是否有资格获得任何经济适用住房优惠(如适用)。", + "application.start.whatToExpect.waitlistLottery.finePrint": "* 申请人申请的是开放的候补名单,而不是目前可用的公寓。\n* 请注意,每个家庭成员只能出现在每个列表的一个申请中。\n* 當有空位時,房地產經紀人將按照抽籤順序聯繫申請人,直到填滿為止.\n* 您提供的所有信息都将得到验证,并确认您的资格。\n* 如果您做出任何欺诈性陈述,您的申请可能会被删除。\n* 对于有住房偏好的物业,如果我们无法验证您所声称的住房偏好,您将不会获得该偏好,但不会受到其他处罚。", + "application.start.whatToExpect.waitlistLottery.steps": "1. 首先,我们会询问您和您计划一起生活的人的情况。\n2. 然后,我们会询问您的收入。\n3. 最后,我们会看看您是否有资格获得任何经济适用住房优惠(如适用)。", "application.status": "狀態", "application.statuses.inProgress": "进行中", "application.statuses.neverSubmitted": "从未提交", @@ -465,10 +474,12 @@ "authentication.forgotPassword.errors.tokenMissing": "找不到權杖。請要求新的權杖。", "authentication.forgotPassword.message": "如果使用该电子邮件创建了帐户,您将收到一封包含重置密码链接的电子邮件。 重置链接有效期为1小时。", "authentication.forgotPassword.passwordConfirmation": "密码确认", - "authentication.forgotPassword.sendEmail": "傳送電子郵件", + "authentication.forgotPassword.sendEmail": "輸入您的電子郵件地址以獲取密碼重設鏈接", + "authentication.forgotPassword.sendEmailButton": "傳送電子郵件", + "authentication.forgotPassword.sendEmailNotes": "請輸入您的電子郵件地址,以便我們向您發送密碼重設連結。如果您沒有收到電子郵件,則可能您還沒有帳戶。", "authentication.signIn.accountHasBeenLocked": "基於安全原因,此帳戶已遭到鎖定。", "authentication.signIn.afterFailedAttempts": "基於安全原因,只要失敗嘗試達 %{count} 次,您就必須等待 30 分鐘才能再試一次。", - "authentication.signIn.changeYourPassword": "您可以變更密碼", + "authentication.signIn.changeYourPassword": "點這裡重設密碼", "authentication.signIn.enterLoginEmail": "請輸入您的登入電子郵件", "authentication.signIn.enterLoginPassword": "請輸入您的登入密碼", "authentication.signIn.enterValidEmailAndPassword": "請輸入有效的電子郵件和密碼", @@ -479,8 +490,8 @@ "authentication.signIn.loginError": "請輸入有效的電子郵件", "authentication.signIn.mfaError": "这是一个合作伙伴帐户,出于安全原因,无法登录公共网站。", "authentication.signIn.passwordError": "請輸入有效的密碼", - "authentication.signIn.passwordOutdated": "您的密碼已到期。請重設您的密碼。", - "authentication.signIn.pwdless.createAccountCopy": "快速注册,无需记住任何密码。", + "authentication.signIn.passwordOutdated": "您的账户密码已过期。您需要重置密码才能访问您的账户。", + "authentication.signIn.pwdless.createAccountCopy": "您的帳戶密碼已過期。您需要重設密碼才能存取您的帳戶。", "authentication.signIn.pwdless.emailHelperText": "输入您的电子邮件,我们将向您发送登录代码。", "authentication.signIn.pwdless.error": "您使用的代码无效或已过期。", "authentication.signIn.pwdless.getCode": "获取代码以登录", @@ -625,6 +636,7 @@ "languages.vi": "Tiếng Việt", "languages.zh": "中文", "leasingAgent.contact": "联系租赁代理", + "leasingAgent.contactManagerProp": "聯絡租賃代理或物業經理", "leasingAgent.dueToHighCallVolume": "由於來電人數眾多,您可能會聽到訊息留言。", "leasingAgent.officeHours": "办公时间", "listingFilters.clear": "清除", @@ -638,12 +650,18 @@ "listings.additionalInformationEnvelope": "附加信息信封", "listings.allUnits": "所有单位", "listings.allUnitsReservedFor": "保留給 %{type} 的所有單位", + "listings.amenities.busStops": "公車站", "listings.amenities.groceryStores": "杂货店", "listings.amenities.healthCareResources": "医疗保健资源", + "listings.amenities.hospitals": "醫院", "listings.amenities.parksAndCommunityCenters": "公园和社区中心", "listings.amenities.pharmacies": "药店", + "listings.amenities.playgrounds": "遊樂場", "listings.amenities.publicTransportation": "公共交通", + "listings.amenities.recreationalFacilities": "休閒設施", "listings.amenities.schools": "学校", + "listings.amenities.seniorCenters": "老年中心", + "listings.amenities.shoppingVenues": "購物場所", "listings.annualIncome": "每年 %{income}", "listings.applicationAlreadySubmitted": "您已經提交了此清單的申請。", "listings.applicationDeadline": "申请截止日期", @@ -702,6 +720,8 @@ "listings.confirmedPreferenceList": "確認 %{preference} 名單", "listings.costsNotIncluded": "费用不包括", "listings.creditHistory": "信用记录", + "listings.creditScreeningFee": "信用筛选", + "listings.creditScreeningFeeDescription": "支付审核您的信用和租赁历史的费用", "listings.criminalBackground": "犯罪背景", "listings.depositMayBeHigherForLowerCredit": "可能高於或低於的信用分數", "listings.depositOrMonthsRent": "或一個月租金", @@ -716,6 +736,7 @@ "listings.featuresCards": "特色卡", "listings.forIncomeCalculations": "收入計算、家庭人數(包括所有住在單位內所有年齡的人)。", "listings.forIncomeCalculationsBMR": "收入計算是以單位類型為準", + "listings.hasEbllClearance": "该房产已获得美国住房和城市发展部 (HUD) EBLL 许可。", "listings.hideClosedListings": "隐藏已关闭的列表", "listings.homeType.apartment": "公寓", "listings.homeType.duplex": "双面打印", @@ -732,6 +753,7 @@ "listings.lotteryResults.completeResultsWillBePosted": "全部抽籤結果將很快發佈。", "listings.lotteryResults.downloadResults": "下载结果", "listings.lotteryResults.header": "彩票结果", + "listings.marketing.header": "行銷", "listings.maxIncomeMonth": "最高收入/月", "listings.maxIncomeYear": "最高收入/年", "listings.maxRent": "最高租金", @@ -743,12 +765,16 @@ "listings.neighborhoodBuildings": "邻近建筑", "listings.noAvailableUnits": "目前並無單位提供。", "listings.noClosedListings": "目前没有已关闭申请的房源", + "listings.noEbllClearance": "該房產尚未獲得美國住房和城市發展部 (HUD) 的 EBLL 許可。", "listings.noMatchingClosedListings": "申请已关闭,无匹配房源", "listings.noMatchingOpenListings": "没有符合条件的开放申请", "listings.noOpenListings": "目前沒有上市名單接受申請。", "listings.occupancyDescriptionAllSro": "此樓宇的入住人數限制為每單位 1 人。", "listings.occupancyDescriptionNoSro": "此樓宇的入住人數限制根據單位類型而定。", "listings.occupancyDescriptionSomeSro": "此樓宇的入住人數因單位類型而異。單人房 (SRO) 限制 1 人入住一個單位,但不限年齡。至於所有其他類型的單位,入住人數限制不會算入 6 歲以下兒童。", + "listings.openHouseAndMarketing.accessibleMarketingFlyerLink": "開啟無障礙行銷傳單", + "listings.openHouseAndMarketing.header": "开放参观和行銷資料", + "listings.openHouseAndMarketing.marketingFlyerLink": "開啟行銷傳單", "listings.openHouseEvent.header": "开放参观", "listings.openHouseEvent.seeVideo": "观看视频", "listings.percentAMIUnit": "%{percent}% AMI 单位", @@ -761,7 +787,17 @@ "listings.removeFilters": "尝试删除一些过滤器或显示所有列表。", "listings.rentalHistory": "租赁历史", "listings.rePricing": "重新定價", + "listings.requiredDocuments.birthCertificate": "出生证明(所有18岁及以上的家庭成员)", + "listings.requiredDocuments.currentLandlordReference": "现任房东推荐信", + "listings.requiredDocuments.governmentIssuedId": "政府签发的身份证件(所有18岁及以上的家庭成员)", + "listings.requiredDocuments.previousLandlordReference": "前任房东推荐信", + "listings.requiredDocuments.proofOfAssets": "资产证明(银行对账单等)", + "listings.requiredDocuments.proofOfCustody": "监护权/监护人证明", + "listings.requiredDocuments.proofOfIncome": "家庭收入证明(工资单、W-2表等)", + "listings.requiredDocuments.residencyDocuments": "移民/居留文件(绿卡等)", + "listings.requiredDocuments.socialSecurityCard": "社会安全卡", "listings.requiredDocuments": "所需文件", + "listings.requiredDocumentsAdditionalInfo": "所需文件(附加信息)", "listings.reservedCommunityBuilding": "%{type} 樓宇", "listings.reservedCommunitySeniorTitle": "高级楼", "listings.reservedCommunityTitleDefault": "预留楼", @@ -835,7 +871,17 @@ "listings.singleRoomOccupancyDescription": "此物業提供限一人居住的單人房。租戶可以共用浴室,有時還可使用廚房設施。", "listings.specialNotes": "特别说明", "listings.underConstruction": "建设中", + "listings.unit.accessibilityType.Hearing and Visual": "听力和视力", + "listings.unit.accessibilityType.Hearing": "听力", + "listings.unit.accessibilityType.Mobility and Hearing": "行动能力和听力", + "listings.unit.accessibilityType.Mobility and Visual": "移动性和视觉", + "listings.unit.accessibilityType.Mobility, Hearing and Visual": "行动能力、听力和视力", + "listings.unit.accessibilityType.Mobility": "流动性", + "listings.unit.accessibilityType.Visual": "想象", + "listings.unit.accessibilityType": "无障碍类型", "listings.unit.sharedBathroom": "共享", + "listings.unit.showLessUnits": "顯示較少的 %{type} 單位", + "listings.unit.showMoreUnits": "顯示更多 %{type} 單位", "listings.unitsAreFor": "這些單位供 %{type} 申請。", "listings.unitsHaveAccessibilityFeaturesFor": "這些單位提供無障礙設施,供 %{type} 人士申請。", "listings.unitsSummary.notAvailable": "无法使用", @@ -878,18 +924,18 @@ "listings.waitlist.unitsAndWaitlist": "可用单位和候补名单", "lottery.applicationsThatQualifyForPreference": "任何符合此優先權資格的申請,將獲得較前面的名次。", "lottery.viewPreferenceList": "查看偏好列表", - "months.january": "一月", + "months.april": "四月", + "months.august": "八月", + "months.december": "十二月", "months.february": "二月", + "months.january": "一月", + "months.july": "七月", + "months.june": "六月", "months.march": "三月", - "months.april": "四月", "months.may": "五月", - "months.june": "六月", - "months.july": "七月", - "months.august": "八月", - "months.september": "九月", - "months.october": "十月", "months.november": "十一月", - "months.december": "十二月", + "months.october": "十月", + "months.september": "九月", "nav.browseProperties": "浏览房产", "nav.getFeedback": "我們希望得到您的回饋", "nav.listings": "好屋推薦", @@ -1018,6 +1064,7 @@ "t.additionalAccessibility": "额外的无障碍设施", "t.additionalPhone": "附加电话", "t.am": "上午", + "t.ami": "AMI", "t.area": "地區", "t.areYouStillWorking": "您是否仍在工作?", "t.at": "於", @@ -1089,6 +1136,8 @@ "t.or": "或", "t.order": "命令", "t.orUpTo": "或最多", + "t.other": "其他", + "t.parkingFee": "停車費", "t.people": "人", "t.perMonth": "每月", "t.person": "人", diff --git a/shared-helpers/src/scripts/.env.template b/shared-helpers/src/scripts/.env.template new file mode 100644 index 0000000000..fb7edac6ef --- /dev/null +++ b/shared-helpers/src/scripts/.env.template @@ -0,0 +1,6 @@ +# google translate api email +GOOGLE_API_EMAIL= +# google translate api id +GOOGLE_API_ID= +# google translate api key +GOOGLE_API_KEY= \ No newline at end of file diff --git a/shared-helpers/src/scripts/get-machine-translations.ts b/shared-helpers/src/scripts/get-machine-translations.ts index 91b83f6236..fc311926bf 100644 --- a/shared-helpers/src/scripts/get-machine-translations.ts +++ b/shared-helpers/src/scripts/get-machine-translations.ts @@ -1,17 +1,13 @@ /* eslint-disable @typescript-eslint/no-var-requires, import/no-unresolved */ - -// Takes in a CSV file with two columns (t_key,t_value) with the key being the translation file key and the value being the associated English string, and prints out in the JSON translation file format the "key": "translated string" -// CSV format: -// t_key,t_value -// "key.here","Translation here" -// -// example from within this directory, first argument is one of LanguagesEnum and second argument is the formatted CSV filename with keys and english strings, piped to a new file: `ts-node get-machine-translations es english-keys.csv > any-filename-here.json` -import fs from "node:fs" -import { parse } from "csv-parse/sync" - +// Finds missing translations and automatically translates them using Google Translate API +// Prints out translations in the JSON translation file format: "key": "translated string" +// You will need to add the environment variables for Google Translate API access from the api env file into this env file +// Example: `ts-node get-machine-translations.ts > any-filename-here.json` import { Translate } from "@google-cloud/translate/build/src/v2" +import dotenv from "dotenv" +dotenv.config({ quiet: true }) -async function main(argv: string[]) { +async function main() { enum LanguagesEnum { "en" = "en", "es" = "es", @@ -22,9 +18,9 @@ async function main(argv: string[]) { "bn" = "bn", } - const GOOGLE_API_EMAIL = "SECRET_VALUE" - const GOOGLE_API_ID = "SECRET_VALUE" - const GOOGLE_API_KEY = "SECRET_VALUE" + const GOOGLE_API_EMAIL = process.env.GOOGLE_API_EMAIL || `` + const GOOGLE_API_ID = process.env.GOOGLE_API_ID || `` + const GOOGLE_API_KEY = process.env.GOOGLE_API_KEY || `` const makeTranslateService = () => { return new Translate({ @@ -40,26 +36,77 @@ async function main(argv: string[]) { return await makeTranslateService().translate(values, { from: LanguagesEnum.en, to: language, + format: "html", + }) + } + + type TranslationsType = { + [key: string]: string + } + + const findMissingStrings = ( + baseTranslations: TranslationsType, + checkedTranslations: TranslationsType + ) => { + const baseKeys = Object.keys(baseTranslations) + const checkedKeys = Object.keys(checkedTranslations) + const missingKeys: string[] = [] + baseKeys.forEach((key) => { + if (checkedKeys.indexOf(key) < 0) { + missingKeys.push(key) + } }) + return missingKeys } - const [language, englishStringsCsv] = argv.slice(2) + // Load translations + const englishTranslations = require("../locales/general.json") + const spanishTranslations = require("../locales/es.json") + const chineseTranslations = require("../locales/zh.json") + const vietnameseTranslations = require("../locales/vi.json") + const tagalogTranslations = require("../locales/tl.json") + const arabicTranslations = require("../locales/ar.json") + const bengaliTranslations = require("../locales/bn.json") + + const allTranslations = [ + { translationKeys: spanishTranslations, language: "Spanish", code: LanguagesEnum.es }, + { translationKeys: chineseTranslations, language: "Chinese", code: LanguagesEnum.zh }, + { translationKeys: vietnameseTranslations, language: "Vietnamese", code: LanguagesEnum.vi }, + { translationKeys: tagalogTranslations, language: "Tagalog", code: LanguagesEnum.tl }, + { translationKeys: arabicTranslations, language: "Arabic", code: LanguagesEnum.ar }, + { translationKeys: bengaliTranslations, language: "Bengali", code: LanguagesEnum.bn }, + ] + + console.log( + "Note that Google Translate does not preserve markdown well, and you may need to adjust some translations manually to add back in new lines and other missing formatting if there is any markdown.\n" + ) + console.log( + "You can paste these lines directly into each translation file, and then be sure to sort ascending! In VSCode, you can Command + Shift + P --> Sort Ascending.\n" + ) - const csvFile = fs.readFileSync(englishStringsCsv) + // Process each language + for (const foreignTranslations of allTranslations) { + console.log("\n--------------------") + console.log(`${foreignTranslations.language} Missing Translations:`) + console.log("--------------------") - const csvData = parse(csvFile, { - columns: true, - skip_empty_lines: true, - }) + const missingKeys = findMissingStrings(englishTranslations, foreignTranslations.translationKeys) - for (const row of csvData) { - const tKey = row["t_key"].trim() - const tValue = row["t_value"].trim() - const translatedValue = await fetch([tValue], language as LanguagesEnum) - console.log(`"${tKey}": "${translatedValue[0][0]}",`) + if (missingKeys.length === 0) { + console.log(`No missing translations for ${foreignTranslations.language}`) + continue + } + + const englishStrings = missingKeys.map((key) => englishTranslations[key]) + const translatedValues = await fetch(englishStrings, foreignTranslations.code) + + missingKeys.forEach((key, index) => { + const translatedString = translatedValues[0][index] + console.log(`"${key}": "${translatedString}",`) + }) } } -void main(process.argv) +void main() export {} diff --git a/shared-helpers/src/types/backend-swagger.ts b/shared-helpers/src/types/backend-swagger.ts index 7b3a811508..55eef23bc2 100644 --- a/shared-helpers/src/types/backend-swagger.ts +++ b/shared-helpers/src/types/backend-swagger.ts @@ -1273,8 +1273,20 @@ export class MultiselectQuestionsService { */ list( params: { + /** */ + page?: number + /** */ + limit?: number | "all" /** */ filter?: MultiselectQuestionFilterParams[] + /** */ + orderBy?: MultiselectQuestionOrderByKeys[] + /** */ + orderDir?: OrderByEnum[] + /** */ + search?: string + /** */ + view?: MultiselectQuestionViews } = {} as any, options: IRequestOptions = {} ): Promise { @@ -1282,7 +1294,15 @@ export class MultiselectQuestionsService { let url = basePath + "/multiselectQuestions" const configs: IRequestConfig = getConfigs("get", "application/json", url, options) - configs.params = { filter: params["filter"] } + configs.params = { + page: params["page"], + limit: params["limit"], + filter: params["filter"], + orderBy: params["orderBy"], + orderDir: params["orderDir"], + search: params["search"], + view: params["view"], + } /** 适配ios13,get请求不允许带body */ @@ -1311,6 +1331,28 @@ export class MultiselectQuestionsService { axios(configs, resolve, reject) }) } + /** + * Update multiselect question + */ + update( + params: { + /** requestBody */ + body?: MultiselectQuestionUpdate + } = {} as any, + options: IRequestOptions = {} + ): Promise { + return new Promise((resolve, reject) => { + let url = basePath + "/multiselectQuestions" + + const configs: IRequestConfig = getConfigs("put", "application/json", url, options) + + let data = params.body + + configs.data = data + + axios(configs, resolve, reject) + }) + } /** * Delete multiselect question by id */ @@ -1334,38 +1376,39 @@ export class MultiselectQuestionsService { }) } /** - * Get multiselect question by id + * Re-activate a multiselect question */ - retrieve( + reActivate( params: { - /** */ - multiselectQuestionId: string + /** requestBody */ + body?: IdDTO } = {} as any, options: IRequestOptions = {} - ): Promise { + ): Promise { return new Promise((resolve, reject) => { - let url = basePath + "/multiselectQuestions/{multiselectQuestionId}" - url = url.replace("{multiselectQuestionId}", params["multiselectQuestionId"] + "") + let url = basePath + "/multiselectQuestions/reActivate" - const configs: IRequestConfig = getConfigs("get", "application/json", url, options) + const configs: IRequestConfig = getConfigs("put", "application/json", url, options) - /** 适配ios13,get请求不允许带body */ + let data = params.body + + configs.data = data axios(configs, resolve, reject) }) } /** - * Update multiselect question + * Retire a multiselect question */ - update( + retire( params: { /** requestBody */ - body?: MultiselectQuestionUpdate + body?: IdDTO } = {} as any, options: IRequestOptions = {} - ): Promise { + ): Promise { return new Promise((resolve, reject) => { - let url = basePath + "/multiselectQuestions/{multiselectQuestionId}" + let url = basePath + "/multiselectQuestions/retire" const configs: IRequestConfig = getConfigs("put", "application/json", url, options) @@ -1373,6 +1416,43 @@ export class MultiselectQuestionsService { configs.data = data + axios(configs, resolve, reject) + }) + } + /** + * Trigger the retirement of multiselect questions cron job + */ + retireMultiselectQuestions(options: IRequestOptions = {}): Promise { + return new Promise((resolve, reject) => { + let url = basePath + "/multiselectQuestions/retireMultiselectQuestions" + + const configs: IRequestConfig = getConfigs("put", "application/json", url, options) + + let data = null + + configs.data = data + + axios(configs, resolve, reject) + }) + } + /** + * Get multiselect question by id + */ + retrieve( + params: { + /** */ + multiselectQuestionId: string + } = {} as any, + options: IRequestOptions = {} + ): Promise { + return new Promise((resolve, reject) => { + let url = basePath + "/multiselectQuestions/{multiselectQuestionId}" + url = url.replace("{multiselectQuestionId}", params["multiselectQuestionId"] + "") + + const configs: IRequestConfig = getConfigs("get", "application/json", url, options) + + /** 适配ios13,get请求不允许带body */ + axios(configs, resolve, reject) }) } @@ -1698,6 +1778,22 @@ export class ApplicationsService { axios(configs, resolve, reject) }) } + /** + * trigger the remove PII cron job + */ + removePiiCronJob(options: IRequestOptions = {}): Promise { + return new Promise((resolve, reject) => { + let url = basePath + "/applications/removePIICronJob" + + const configs: IRequestConfig = getConfigs("put", "application/json", url, options) + + let data = null + + configs.data = data + + axios(configs, resolve, reject) + }) + } /** * Update application by id */ @@ -1766,19 +1862,22 @@ export class UserService { }) } /** - * Delete user by id + * Creates a public only user */ - delete( + create( params: { + /** */ + noWelcomeEmail?: boolean /** requestBody */ - body?: IdDTO + body?: UserCreate } = {} as any, options: IRequestOptions = {} - ): Promise { + ): Promise { return new Promise((resolve, reject) => { let url = basePath + "/user" - const configs: IRequestConfig = getConfigs("delete", "application/json", url, options) + const configs: IRequestConfig = getConfigs("post", "application/json", url, options) + configs.params = { noWelcomeEmail: params["noWelcomeEmail"] } let data = params.body @@ -1788,22 +1887,19 @@ export class UserService { }) } /** - * Creates a public only user + * Delete user by id */ - create( + delete( params: { - /** */ - noWelcomeEmail?: boolean /** requestBody */ - body?: UserCreate + body?: UserDeleteDTO } = {} as any, options: IRequestOptions = {} - ): Promise { + ): Promise { return new Promise((resolve, reject) => { let url = basePath + "/user" - const configs: IRequestConfig = getConfigs("post", "application/json", url, options) - configs.params = { noWelcomeEmail: params["noWelcomeEmail"] } + const configs: IRequestConfig = getConfigs("delete", "application/json", url, options) let data = params.body @@ -1859,23 +1955,22 @@ export class UserService { }) } /** - * Forgot Password + * Get the ids of the user favorites */ - forgotPassword( + favoriteListings( params: { - /** requestBody */ - body?: EmailAndAppUrl + /** */ + id: string } = {} as any, options: IRequestOptions = {} - ): Promise { + ): Promise { return new Promise((resolve, reject) => { - let url = basePath + "/user/forgot-password" - - const configs: IRequestConfig = getConfigs("put", "application/json", url, options) + let url = basePath + "/user/favoriteListings/{id}" + url = url.replace("{id}", params["id"] + "") - let data = params.body + const configs: IRequestConfig = getConfigs("get", "application/json", url, options) - configs.data = data + /** 适配ios13,get请求不允许带body */ axios(configs, resolve, reject) }) @@ -1991,22 +2086,23 @@ export class UserService { }) } /** - * Get the ids of the user favorites + * Forgot Password */ - favoriteListings( + forgotPassword( params: { - /** */ - id: string + /** requestBody */ + body?: EmailAndAppUrl } = {} as any, options: IRequestOptions = {} - ): Promise { + ): Promise { return new Promise((resolve, reject) => { - let url = basePath + "/user/favoriteListings/{id}" - url = url.replace("{id}", params["id"] + "") + let url = basePath + "/user/forgot-password" - const configs: IRequestConfig = getConfigs("get", "application/json", url, options) + const configs: IRequestConfig = getConfigs("put", "application/json", url, options) - /** 适配ios13,get请求不允许带body */ + let data = params.body + + configs.data = data axios(configs, resolve, reject) }) @@ -2033,6 +2129,38 @@ export class UserService { axios(configs, resolve, reject) }) } + /** + * trigger the user warn of deletion cron job + */ + userWarnCronJob(options: IRequestOptions = {}): Promise { + return new Promise((resolve, reject) => { + let url = basePath + "/user/userWarnCronJob" + + const configs: IRequestConfig = getConfigs("put", "application/json", url, options) + + let data = null + + configs.data = data + + axios(configs, resolve, reject) + }) + } + /** + * trigger the delete inactive users cron job + */ + deleteInactiveUsersCronJob(options: IRequestOptions = {}): Promise { + return new Promise((resolve, reject) => { + let url = basePath + "/user/deleteInactiveUsersCronJob" + + const configs: IRequestConfig = getConfigs("put", "application/json", url, options) + + let data = null + + configs.data = data + + axios(configs, resolve, reject) + }) + } /** * Update user */ @@ -2964,6 +3092,9 @@ export interface ListingFilterParams { /** */ zipCode?: string + + /** */ + listingType?: ListingTypeEnum } export interface ListingsQueryBody { @@ -3041,6 +3172,35 @@ export interface IdDTO { ordinal?: number } +export interface ListingDocuments { + /** */ + socialSecurityCard?: boolean + + /** */ + currentLandlordReference?: boolean + + /** */ + birthCertificate?: boolean + + /** */ + previousLandlordReference?: boolean + + /** */ + governmentIssuedId?: boolean + + /** */ + proofOfAssets?: boolean + + /** */ + proofOfIncome?: boolean + + /** */ + residencyDocuments?: boolean + + /** */ + proofOfCustody?: boolean +} + export interface MultiselectLink { /** */ title: string @@ -3050,6 +3210,15 @@ export interface MultiselectLink { } export interface MultiselectOption { + /** */ + id: string + + /** */ + createdAt: Date + + /** */ + updatedAt: Date + /** */ collectAddress?: boolean @@ -3101,6 +3270,9 @@ export interface MultiselectOption { /** */ text: string + /** */ + untranslatedName?: string + /** */ untranslatedText?: string @@ -3160,6 +3332,9 @@ export interface MultiselectQuestion { /** */ text: string + /** */ + untranslatedName?: string + /** */ untranslatedText?: string @@ -3317,9 +3492,21 @@ export interface ListingImage { /** */ ordinal?: number + + /** */ + description?: string } export interface ListingFeatures { + /** */ + id: string + + /** */ + createdAt: Date + + /** */ + updatedAt: Date + /** */ elevator?: boolean @@ -3382,6 +3569,15 @@ export interface ListingFeatures { } export interface ListingUtilities { + /** */ + id: string + + /** */ + createdAt: Date + + /** */ + updatedAt: Date + /** */ water?: boolean @@ -3614,7 +3810,10 @@ export interface UnitGroup { flatRentValueTo?: number /** */ - floorMin?: number + monthlyRent?: number + + /** */ + floorMin?: number /** */ floorMax?: number @@ -3641,7 +3840,7 @@ export interface UnitGroup { sqFeetMax?: number /** */ - rentType?: string + rentType?: RentTypeEnum /** */ unitAccessibilityPriorityTypes?: UnitAccessibilityPriorityType @@ -3885,13 +4084,16 @@ export interface UnitsSummary { totalAvailable?: number /** */ - rentType?: string + rentType?: RentTypeEnum /** */ flatRentValueFrom?: number /** */ flatRentValueTo?: number + + /** */ + monthlyRent?: number } export interface ApplicationLotteryTotal { @@ -3906,6 +4108,15 @@ export interface ApplicationLotteryTotal { } export interface ListingNeighborhoodAmenities { + /** */ + id: string + + /** */ + createdAt: Date + + /** */ + updatedAt: Date + /** */ groceryStores?: string @@ -3923,6 +4134,24 @@ export interface ListingNeighborhoodAmenities { /** */ healthCareResources?: string + + /** */ + shoppingVenues?: string + + /** */ + hospitals?: string + + /** */ + seniorCenters?: string + + /** */ + recreationalFacilities?: string + + /** */ + playgrounds?: string + + /** */ + busStops?: string } export interface Listing { @@ -3962,6 +4191,9 @@ export interface Listing { /** */ developer?: string + /** */ + listingFileNumber?: string + /** */ householdSizeMax?: number @@ -4001,6 +4233,9 @@ export interface Listing { /** */ applicationFee?: string + /** */ + creditScreeningFee?: string + /** */ applicationOrganization?: string @@ -4022,6 +4257,12 @@ export interface Listing { /** */ buildingSelectionCriteria?: string + /** */ + marketingFlyer?: string + + /** */ + accessibleMarketingFlyer?: string + /** */ cocInfo?: string @@ -4046,12 +4287,6 @@ export interface Listing { /** */ depositValue?: number - /** */ - depositRangeMin?: number - - /** */ - depositRangeMax?: number - /** */ depositHelperText?: string @@ -4085,6 +4320,9 @@ export interface Listing { /** */ name: string + /** */ + parkingFee?: string + /** */ postmarkedApplicationsReceivedByDate?: Date @@ -4100,6 +4338,9 @@ export interface Listing { /** */ requiredDocuments?: string + /** */ + requiredDocumentsList?: ListingDocuments + /** */ specialNotes?: string @@ -4205,6 +4446,12 @@ export interface Listing { /** */ listingsBuildingSelectionCriteriaFile?: Asset + /** */ + listingsMarketingFlyerFile?: Asset + + /** */ + listingsAccessibleMarketingFlyerFile?: Asset + /** */ jurisdictions: IdDTO @@ -4329,6 +4576,152 @@ export interface ListingMapMarker { lng: number } +export interface AssetCreate { + /** */ + fileId: string + + /** */ + label: string + + /** */ + id?: string +} + +export interface UnitsSummaryCreate { + /** */ + unitTypes: IdDTO + + /** */ + monthlyRentMin?: number + + /** */ + monthlyRentMax?: number + + /** */ + monthlyRentAsPercentOfIncome?: string + + /** */ + amiPercentage?: number + + /** */ + minimumIncomeMin?: string + + /** */ + minimumIncomeMax?: string + + /** */ + maxOccupancy?: number + + /** */ + minOccupancy?: number + + /** */ + floorMin?: number + + /** */ + floorMax?: number + + /** */ + sqFeetMin?: string + + /** */ + sqFeetMax?: string + + /** */ + unitAccessibilityPriorityTypes?: IdDTO + + /** */ + totalCount?: number + + /** */ + totalAvailable?: number + + /** */ + rentType?: RentTypeEnum + + /** */ + flatRentValueFrom?: number + + /** */ + flatRentValueTo?: number + + /** */ + monthlyRent?: number +} + +export interface ListingImageCreate { + /** */ + ordinal?: number + + /** */ + assets: AssetCreate + + /** */ + description?: string +} + +export interface ListingFeaturesCreate { + /** */ + elevator?: boolean + + /** */ + wheelchairRamp?: boolean + + /** */ + serviceAnimalsAllowed?: boolean + + /** */ + accessibleParking?: boolean + + /** */ + parkingOnSite?: boolean + + /** */ + inUnitWasherDryer?: boolean + + /** */ + laundryInBuilding?: boolean + + /** */ + barrierFreeEntrance?: boolean + + /** */ + rollInShower?: boolean + + /** */ + grabBars?: boolean + + /** */ + heatingInUnit?: boolean + + /** */ + acInUnit?: boolean + + /** */ + hearing?: boolean + + /** */ + visual?: boolean + + /** */ + mobility?: boolean + + /** */ + barrierFreeUnitEntrance?: boolean + + /** */ + loweredLightSwitch?: boolean + + /** */ + barrierFreeBathroom?: boolean + + /** */ + wideDoorways?: boolean + + /** */ + loweredCabinets?: boolean +} + export interface UnitAmiChartOverrideCreate { /** */ items: AmiChartItem[] @@ -4423,6 +4816,9 @@ export interface UnitGroupCreate { /** */ flatRentValueTo?: number + /** */ + monthlyRent?: number + /** */ floorMin?: number @@ -4451,7 +4847,7 @@ export interface UnitGroupCreate { sqFeetMax?: number /** */ - rentType?: string + rentType?: RentTypeEnum /** */ unitAccessibilityPriorityTypes?: IdDTO @@ -4463,17 +4859,6 @@ export interface UnitGroupCreate { unitGroupAmiLevels?: UnitGroupAmiLevelCreate[] } -export interface AssetCreate { - /** */ - fileId: string - - /** */ - label: string - - /** */ - id?: string -} - export interface PaperApplicationCreate { /** */ language: LanguagesEnum @@ -4502,126 +4887,123 @@ export interface ApplicationMethodCreate { paperApplications?: PaperApplicationCreate[] } -export interface UnitsSummaryCreate { +export interface AddressCreate { /** */ - unitTypes: IdDTO + placeName?: string /** */ - monthlyRentMin?: number + city: string /** */ - monthlyRentMax?: number + county?: string /** */ - monthlyRentAsPercentOfIncome?: string + state: string /** */ - amiPercentage?: number + street: string /** */ - minimumIncomeMin?: string + street2?: string /** */ - minimumIncomeMax?: string + zipCode: string /** */ - maxOccupancy?: number + latitude?: number /** */ - minOccupancy?: number + longitude?: number +} +export interface ListingEventCreate { /** */ - floorMin?: number + type: ListingEventsTypeEnum /** */ - floorMax?: number + startDate?: Date /** */ - sqFeetMin?: string + startTime?: Date /** */ - sqFeetMax?: string + endTime?: Date /** */ - unitAccessibilityPriorityTypes?: IdDTO + url?: string /** */ - totalCount?: number + note?: string /** */ - totalAvailable?: number + label?: string /** */ - rentType?: string + assets?: AssetCreate +} +export interface ListingUtilitiesCreate { /** */ - flatRentValueFrom?: number + water?: boolean /** */ - flatRentValueTo?: number -} + gas?: boolean -export interface ListingImageCreate { /** */ - ordinal?: number + trash?: boolean /** */ - assets: AssetCreate -} + sewer?: boolean -export interface AddressCreate { /** */ - placeName?: string - - /** */ - city: string + electricity?: boolean /** */ - county?: string + cable?: boolean /** */ - state: string + phone?: boolean /** */ - street: string + internet?: boolean +} +export interface ListingNeighborhoodAmenitiesCreate { /** */ - street2?: string + groceryStores?: string /** */ - zipCode: string + publicTransportation?: string /** */ - latitude?: number + schools?: string /** */ - longitude?: number -} + parksAndCommunityCenters?: string -export interface ListingEventCreate { /** */ - type: ListingEventsTypeEnum + pharmacies?: string /** */ - startDate?: Date + healthCareResources?: string /** */ - startTime?: Date + shoppingVenues?: string /** */ - endTime?: Date + hospitals?: string /** */ - url?: string + seniorCenters?: string /** */ - note?: string + recreationalFacilities?: string /** */ - label?: string + playgrounds?: string /** */ - assets?: AssetCreate + busStops?: string } export interface ListingCreate { @@ -4652,6 +5034,9 @@ export interface ListingCreate { /** */ developer?: string + /** */ + listingFileNumber?: string + /** */ householdSizeMax?: number @@ -4691,6 +5076,9 @@ export interface ListingCreate { /** */ applicationFee?: string + /** */ + creditScreeningFee?: string + /** */ applicationOrganization?: string @@ -4712,6 +5100,12 @@ export interface ListingCreate { /** */ buildingSelectionCriteria?: string + /** */ + marketingFlyer?: string + + /** */ + accessibleMarketingFlyer?: string + /** */ cocInfo?: string @@ -4736,12 +5130,6 @@ export interface ListingCreate { /** */ depositValue?: number - /** */ - depositRangeMin?: number - - /** */ - depositRangeMax?: number - /** */ depositHelperText?: string @@ -4775,6 +5163,9 @@ export interface ListingCreate { /** */ name: string + /** */ + parkingFee?: string + /** */ postmarkedApplicationsReceivedByDate?: Date @@ -4790,6 +5181,9 @@ export interface ListingCreate { /** */ requiredDocuments?: string + /** */ + requiredDocumentsList?: ListingDocuments + /** */ specialNotes?: string @@ -4866,102 +5260,487 @@ export interface ListingCreate { includeCommunityDisclaimer?: boolean /** */ - communityDisclaimerTitle?: string + communityDisclaimerTitle?: string + + /** */ + communityDisclaimerDescription?: string + + /** */ + marketingType?: MarketingTypeEnum + + /** */ + marketingYear?: number + + /** */ + marketingSeason?: MarketingSeasonEnum + + /** */ + marketingMonth?: MonthEnum + + /** */ + homeType?: HomeTypeEnum + + /** */ + isVerified?: boolean + + /** */ + section8Acceptance?: boolean + + /** */ + lastUpdatedByUser?: IdDTO + + /** */ + listingMultiselectQuestions?: IdDTO[] + + /** */ + assets?: AssetCreate[] + + /** */ + unitsSummary: UnitsSummaryCreate[] + + /** */ + listingImages?: ListingImageCreate[] + + /** */ + listingsBuildingSelectionCriteriaFile?: AssetCreate + + /** */ + listingsMarketingFlyerFile?: AssetCreate + + /** */ + listingsAccessibleMarketingFlyerFile?: AssetCreate + + /** */ + listingsResult?: AssetCreate + + /** */ + listingFeatures?: ListingFeaturesCreate + + /** */ + requestedChangesUser?: IdDTO + + /** */ + units?: UnitCreate[] + + /** */ + unitGroups?: UnitGroupCreate[] + + /** */ + applicationMethods?: ApplicationMethodCreate[] + + /** */ + listingsApplicationPickUpAddress?: AddressCreate + + /** */ + listingsApplicationMailingAddress?: AddressCreate + + /** */ + listingsApplicationDropOffAddress?: AddressCreate + + /** */ + listingsLeasingAgentAddress?: AddressCreate + + /** */ + listingsBuildingAddress?: AddressCreate + + /** */ + listingEvents: ListingEventCreate[] + + /** */ + listingUtilities?: ListingUtilitiesCreate + + /** */ + listingNeighborhoodAmenities?: ListingNeighborhoodAmenitiesCreate +} + +export interface ListingDuplicate { + /** */ + name: string + + /** */ + includeUnits: boolean + + /** */ + storedListing: IdDTO +} + +export interface UnitAmiChartOverrideUpdate { + /** */ + items: AmiChartItem[] + + /** */ + id?: string +} + +export interface UnitUpdate { + /** */ + amiPercentage?: string + + /** */ + annualIncomeMin?: string + + /** */ + monthlyIncomeMin?: string + + /** */ + floor?: number + + /** */ + annualIncomeMax?: string + + /** */ + maxOccupancy?: number + + /** */ + minOccupancy?: number + + /** */ + monthlyRent?: string + + /** */ + numBathrooms?: number + + /** */ + numBedrooms?: number + + /** */ + number?: string + + /** */ + sqFeet?: string + + /** */ + monthlyRentAsPercentOfIncome?: string + + /** */ + bmrProgramChart?: boolean + + /** */ + id?: string + + /** */ + unitTypes?: IdDTO + + /** */ + amiChart?: IdDTO + + /** */ + unitAccessibilityPriorityTypes?: IdDTO + + /** */ + unitRentTypes?: IdDTO + + /** */ + unitAmiChartOverrides?: UnitAmiChartOverrideUpdate +} + +export interface UnitGroupAmiLevelUpdate { + /** */ + amiPercentage?: number + + /** */ + monthlyRentDeterminationType?: EnumUnitGroupAmiLevelUpdateMonthlyRentDeterminationType + + /** */ + percentageOfIncomeValue?: number + + /** */ + flatRentValue?: number + + /** */ + id?: string + + /** */ + amiChart?: IdDTO +} + +export interface UnitGroupUpdate { + /** */ + maxOccupancy?: number + + /** */ + minOccupancy?: number + + /** */ + flatRentValueFrom?: number + + /** */ + flatRentValueTo?: number + + /** */ + monthlyRent?: number + + /** */ + floorMin?: number + + /** */ + floorMax?: number + + /** */ + totalCount?: number + + /** */ + totalAvailable?: number + + /** */ + bathroomMin?: number + + /** */ + bathroomMax?: number + + /** */ + openWaitlist?: boolean + + /** */ + sqFeetMin?: number + + /** */ + sqFeetMax?: number + + /** */ + rentType?: RentTypeEnum + + /** */ + id?: string + + /** */ + unitAccessibilityPriorityTypes?: IdDTO + + /** */ + unitTypes?: IdDTO[] + + /** */ + unitGroupAmiLevels?: UnitGroupAmiLevelUpdate[] +} + +export interface PaperApplicationUpdate { + /** */ + language: LanguagesEnum + + /** */ + id?: string + + /** */ + assets?: AssetCreate +} + +export interface ApplicationMethodUpdate { + /** */ + type: ApplicationMethodsTypeEnum + + /** */ + label?: string + + /** */ + externalReference?: string + + /** */ + acceptsPostmarkedApplications?: boolean + + /** */ + phoneNumber?: string + + /** */ + id?: string + + /** */ + paperApplications?: PaperApplicationUpdate[] +} + +export interface AddressUpdate { + /** */ + placeName?: string + + /** */ + city: string + + /** */ + county?: string + + /** */ + state: string + + /** */ + street: string + + /** */ + street2?: string + + /** */ + zipCode: string + + /** */ + latitude?: number + + /** */ + longitude?: number + + /** */ + id?: string +} + +export interface ListingEventUpdate { + /** */ + type: ListingEventsTypeEnum + + /** */ + startDate?: Date + + /** */ + startTime?: Date + + /** */ + endTime?: Date + + /** */ + url?: string + + /** */ + note?: string + + /** */ + label?: string + + /** */ + assets?: AssetCreate + + /** */ + id?: string +} + +export interface ListingFeaturesUpdate { + /** */ + elevator?: boolean + + /** */ + wheelchairRamp?: boolean + + /** */ + serviceAnimalsAllowed?: boolean + + /** */ + accessibleParking?: boolean + + /** */ + parkingOnSite?: boolean + + /** */ + inUnitWasherDryer?: boolean + + /** */ + laundryInBuilding?: boolean + + /** */ + barrierFreeEntrance?: boolean + + /** */ + rollInShower?: boolean + + /** */ + grabBars?: boolean + + /** */ + heatingInUnit?: boolean + + /** */ + acInUnit?: boolean /** */ - communityDisclaimerDescription?: string + hearing?: boolean /** */ - marketingType?: MarketingTypeEnum + visual?: boolean /** */ - marketingYear?: number + mobility?: boolean /** */ - marketingSeason?: MarketingSeasonEnum + barrierFreeUnitEntrance?: boolean /** */ - marketingMonth?: MonthEnum + loweredLightSwitch?: boolean /** */ - homeType?: HomeTypeEnum + barrierFreeBathroom?: boolean /** */ - isVerified?: boolean + wideDoorways?: boolean /** */ - section8Acceptance?: boolean + loweredCabinets?: boolean /** */ - listingNeighborhoodAmenities?: ListingNeighborhoodAmenities + id?: string +} +export interface ListingUtilitiesUpdate { /** */ - lastUpdatedByUser?: IdDTO + water?: boolean /** */ - listingMultiselectQuestions?: IdDTO[] + gas?: boolean /** */ - units?: UnitCreate[] + trash?: boolean /** */ - unitGroups?: UnitGroupCreate[] + sewer?: boolean /** */ - applicationMethods?: ApplicationMethodCreate[] + electricity?: boolean /** */ - assets?: AssetCreate[] + cable?: boolean /** */ - unitsSummary: UnitsSummaryCreate[] + phone?: boolean /** */ - listingImages?: ListingImageCreate[] + internet?: boolean /** */ - listingsApplicationPickUpAddress?: AddressCreate + id?: string +} +export interface ListingNeighborhoodAmenitiesUpdate { /** */ - listingsApplicationMailingAddress?: AddressCreate + groceryStores?: string /** */ - listingsApplicationDropOffAddress?: AddressCreate + publicTransportation?: string /** */ - listingsLeasingAgentAddress?: AddressCreate + schools?: string /** */ - listingsBuildingAddress?: AddressCreate + parksAndCommunityCenters?: string /** */ - listingsBuildingSelectionCriteriaFile?: AssetCreate + pharmacies?: string /** */ - listingsResult?: AssetCreate + healthCareResources?: string /** */ - listingEvents: ListingEventCreate[] + shoppingVenues?: string /** */ - listingFeatures?: ListingFeatures + hospitals?: string /** */ - listingUtilities?: ListingUtilities + seniorCenters?: string /** */ - requestedChangesUser?: IdDTO -} + recreationalFacilities?: string -export interface ListingDuplicate { /** */ - name: string + playgrounds?: string /** */ - includeUnits: boolean + busStops?: string /** */ - storedListing: IdDTO + id?: string } export interface ListingUpdate { @@ -4995,6 +5774,9 @@ export interface ListingUpdate { /** */ developer?: string + /** */ + listingFileNumber?: string + /** */ householdSizeMax?: number @@ -5034,6 +5816,9 @@ export interface ListingUpdate { /** */ applicationFee?: string + /** */ + creditScreeningFee?: string + /** */ applicationOrganization?: string @@ -5055,6 +5840,12 @@ export interface ListingUpdate { /** */ buildingSelectionCriteria?: string + /** */ + marketingFlyer?: string + + /** */ + accessibleMarketingFlyer?: string + /** */ cocInfo?: string @@ -5079,12 +5870,6 @@ export interface ListingUpdate { /** */ depositValue?: number - /** */ - depositRangeMin?: number - - /** */ - depositRangeMax?: number - /** */ depositHelperText?: string @@ -5118,6 +5903,9 @@ export interface ListingUpdate { /** */ name: string + /** */ + parkingFee?: string + /** */ postmarkedApplicationsReceivedByDate?: Date @@ -5133,6 +5921,9 @@ export interface ListingUpdate { /** */ requiredDocuments?: string + /** */ + requiredDocumentsList?: ListingDocuments + /** */ specialNotes?: string @@ -5235,9 +6026,6 @@ export interface ListingUpdate { /** */ section8Acceptance?: boolean - /** */ - listingNeighborhoodAmenities?: ListingNeighborhoodAmenities - /** */ lastUpdatedByUser?: IdDTO @@ -5245,13 +6033,13 @@ export interface ListingUpdate { listingMultiselectQuestions?: IdDTO[] /** */ - units?: UnitCreate[] + units?: UnitUpdate[] /** */ - unitGroups?: UnitGroupCreate[] + unitGroups?: UnitGroupUpdate[] /** */ - applicationMethods?: ApplicationMethodCreate[] + applicationMethods?: ApplicationMethodUpdate[] /** */ assets?: AssetCreate[] @@ -5263,37 +6051,46 @@ export interface ListingUpdate { listingImages?: ListingImageCreate[] /** */ - listingsApplicationPickUpAddress?: AddressCreate + listingsApplicationPickUpAddress?: AddressUpdate /** */ - listingsApplicationMailingAddress?: AddressCreate + listingsApplicationMailingAddress?: AddressUpdate /** */ - listingsApplicationDropOffAddress?: AddressCreate + listingsApplicationDropOffAddress?: AddressUpdate /** */ - listingsLeasingAgentAddress?: AddressCreate + listingsLeasingAgentAddress?: AddressUpdate /** */ - listingsBuildingAddress?: AddressCreate + listingsBuildingAddress?: AddressUpdate /** */ listingsBuildingSelectionCriteriaFile?: AssetCreate + /** */ + listingsMarketingFlyerFile?: AssetCreate + + /** */ + listingsAccessibleMarketingFlyerFile?: AssetCreate + /** */ listingsResult?: AssetCreate /** */ - listingEvents: ListingEventCreate[] + listingEvents: ListingEventUpdate[] /** */ - listingFeatures?: ListingFeatures + listingFeatures?: ListingFeaturesUpdate /** */ - listingUtilities?: ListingUtilities + listingUtilities?: ListingUtilitiesUpdate /** */ requestedChangesUser?: IdDTO + + /** */ + listingNeighborhoodAmenities?: ListingNeighborhoodAmenitiesUpdate } export interface Accessibility { @@ -5486,7 +6283,7 @@ export interface HouseholdMember { householdMemberAddress: Address } -export interface ApplicationSelectionOptions { +export interface ApplicationSelectionOption { /** */ id: string @@ -5497,7 +6294,7 @@ export interface ApplicationSelectionOptions { updatedAt: Date /** */ - addressHolderAddress: IdDTO + addressHolderAddress: Address /** */ addressHolderName?: string @@ -5515,7 +6312,7 @@ export interface ApplicationSelectionOptions { multiselectOption: IdDTO } -export interface ApplicationSelections { +export interface ApplicationSelection { /** */ id: string @@ -5535,7 +6332,7 @@ export interface ApplicationSelections { multiselectQuestion: IdDTO /** */ - selections: ApplicationSelectionOptions + selections: ApplicationSelectionOption } export interface ApplicationMultiselectQuestionOption { @@ -5635,6 +6432,15 @@ export interface Application { /** */ status: ApplicationStatusEnum + /** */ + accessibleUnitWaitlistNumber?: number + + /** */ + conventionalUnitWaitlistNumber?: number + + /** */ + manualLotteryPositionNumber?: number + /** */ language?: LanguagesEnum @@ -5684,7 +6490,7 @@ export interface Application { householdMember: HouseholdMember[] /** */ - applicationSelections?: ApplicationSelections[] + applicationSelections?: ApplicationSelection[] /** */ preferences?: ApplicationMultiselectQuestion[] @@ -5925,6 +6731,9 @@ export interface JurisdictionCreate { /** */ languages: LanguagesEnum[] + /** */ + minimumListingPublishImagesRequired?: number + /** */ partnerTerms?: string @@ -5987,6 +6796,9 @@ export interface JurisdictionUpdate { /** */ languages: LanguagesEnum[] + /** */ + minimumListingPublishImagesRequired?: number + /** */ partnerTerms?: string @@ -6081,6 +6893,9 @@ export interface Jurisdiction { /** */ multiselectQuestions: IdDTO[] + /** */ + minimumListingPublishImagesRequired?: number + /** */ partnerTerms?: string @@ -6133,6 +6948,62 @@ export interface Jurisdiction { visibleNeighborhoodAmenities: NeighborhoodAmenitiesEnum[] } +export interface MultiselectOptionCreate { + /** */ + collectAddress?: boolean + + /** */ + collectName?: boolean + + /** */ + collectRelationship?: boolean + + /** */ + description?: string + + /** */ + exclusive?: boolean + + /** */ + isOptOut?: boolean + + /** */ + links?: MultiselectLink[] + + /** */ + mapLayerId?: string + + /** */ + mapPinPosition?: string + + /** */ + multiselectQuestion?: IdDTO + + /** */ + name?: string + + /** */ + ordinal: number + + /** */ + radiusSize?: number + + /** */ + shouldCollectAddress?: boolean + + /** */ + shouldCollectName?: boolean + + /** */ + shouldCollectRelationship?: boolean + + /** */ + text: string + + /** */ + validationMethod?: ValidationMethodEnum +} + export interface MultiselectQuestionCreate { /** */ applicationSection: MultiselectQuestionsApplicationSectionEnum @@ -6155,17 +7026,14 @@ export interface MultiselectQuestionCreate { /** */ links?: MultiselectLink[] - /** */ - multiselectOptions?: MultiselectOption[] - /** */ name?: string /** */ - options?: MultiselectOption[] + optOutText?: string /** */ - optOutText?: string + status: MultiselectQuestionsStatusEnum /** */ subText?: string @@ -6175,6 +7043,71 @@ export interface MultiselectQuestionCreate { /** */ untranslatedOptOutText?: string + + /** */ + multiselectOptions?: MultiselectOptionCreate[] + + /** */ + options?: MultiselectOptionCreate[] +} + +export interface MultiselectOptionUpdate { + /** */ + collectAddress?: boolean + + /** */ + collectName?: boolean + + /** */ + collectRelationship?: boolean + + /** */ + description?: string + + /** */ + exclusive?: boolean + + /** */ + isOptOut?: boolean + + /** */ + links?: MultiselectLink[] + + /** */ + mapLayerId?: string + + /** */ + mapPinPosition?: string + + /** */ + multiselectQuestion?: IdDTO + + /** */ + name?: string + + /** */ + ordinal: number + + /** */ + radiusSize?: number + + /** */ + shouldCollectAddress?: boolean + + /** */ + shouldCollectName?: boolean + + /** */ + shouldCollectRelationship?: boolean + + /** */ + text: string + + /** */ + validationMethod?: ValidationMethodEnum + + /** */ + id?: string } export interface MultiselectQuestionUpdate { @@ -6188,56 +7121,80 @@ export interface MultiselectQuestionUpdate { description?: string /** */ - isExclusive?: boolean + isExclusive?: boolean + + /** */ + hideFromListing?: boolean + + /** */ + jurisdiction?: IdDTO + + /** */ + jurisdictions: IdDTO[] + + /** */ + links?: MultiselectLink[] + + /** */ + name?: string + + /** */ + optOutText?: string + + /** */ + status: MultiselectQuestionsStatusEnum /** */ - hideFromListing?: boolean + subText?: string /** */ - jurisdiction?: IdDTO + text: string /** */ - jurisdictions: IdDTO[] + untranslatedOptOutText?: string /** */ - links?: MultiselectLink[] + multiselectOptions?: MultiselectOptionUpdate[] /** */ - multiselectOptions?: MultiselectOption[] + options?: MultiselectOptionUpdate[] +} +export interface MultiselectQuestionQueryParams { /** */ - name?: string + page?: number /** */ - options?: MultiselectOption[] + limit?: number | "all" /** */ - optOutText?: string + filter?: string[] /** */ - subText?: string + orderBy?: MultiselectQuestionOrderByKeys[] /** */ - text: string + orderDir?: OrderByEnum[] /** */ - untranslatedOptOutText?: string -} + search?: string -export interface MultiselectQuestionQueryParams { /** */ - filter?: string[] + view?: MultiselectQuestionViews } export interface MultiselectQuestionFilterParams { /** */ $comparison: EnumMultiselectQuestionFilterParamsComparison + /** */ + applicationSection?: MultiselectQuestionsApplicationSectionEnum + /** */ jurisdiction?: string /** */ - applicationSection?: MultiselectQuestionsApplicationSectionEnum + status?: MultiselectQuestionsStatusEnum } export interface AddressInput { @@ -6336,6 +7293,15 @@ export interface PublicAppsFiltered { /** */ status: ApplicationStatusEnum + /** */ + accessibleUnitWaitlistNumber?: number + + /** */ + conventionalUnitWaitlistNumber?: number + + /** */ + manualLotteryPositionNumber?: number + /** */ language?: LanguagesEnum @@ -6385,7 +7351,7 @@ export interface PublicAppsFiltered { householdMember: HouseholdMember[] /** */ - applicationSelections?: ApplicationSelections[] + applicationSelections?: ApplicationSelection[] /** */ preferences?: ApplicationMultiselectQuestion[] @@ -6425,91 +7391,91 @@ export interface PublicAppsViewResponse { applicationsCount: PublicAppsCount } -export interface ApplicantUpdate { +export interface AccessibilityUpdate { /** */ - firstName?: string + mobility?: boolean /** */ - middleName?: string + vision?: boolean /** */ - lastName?: string + hearing?: boolean /** */ - birthMonth?: string + other?: boolean +} +export interface AlternateContactUpdate { /** */ - birthDay?: string + type?: AlternateContactRelationship /** */ - birthYear?: string + otherType?: string /** */ - emailAddress?: string + firstName?: string /** */ - noEmail?: boolean + lastName?: string /** */ - phoneNumber?: string + agency?: string /** */ - phoneNumberType?: string + phoneNumber?: string /** */ - noPhone?: boolean + emailAddress?: string /** */ - workInRegion?: YesNoEnum + address: AddressCreate +} +export interface ApplicantUpdate { /** */ - fullTimeStudent?: YesNoEnum + firstName?: string /** */ - applicantAddress: AddressCreate + middleName?: string /** */ - applicantWorkAddress: AddressCreate -} + lastName?: string -export interface AlternateContactUpdate { /** */ - type?: AlternateContactRelationship + birthMonth?: string /** */ - otherType?: string + birthDay?: string /** */ - firstName?: string + birthYear?: string /** */ - lastName?: string + emailAddress?: string /** */ - agency?: string + noEmail?: boolean /** */ phoneNumber?: string /** */ - emailAddress?: string + phoneNumberType?: string /** */ - address: AddressCreate -} + noPhone?: boolean -export interface AccessibilityUpdate { /** */ - mobility?: boolean + workInRegion?: YesNoEnum /** */ - vision?: boolean + fullTimeStudent?: YesNoEnum /** */ - hearing?: boolean + applicantAddress: AddressCreate /** */ - other?: boolean + applicantWorkAddress: AddressCreate } export interface DemographicUpdate { @@ -6573,6 +7539,69 @@ export interface HouseholdMemberUpdate { householdMemberWorkAddress?: AddressCreate } +export interface AddressUpdate { + /** */ + placeName?: string + + /** */ + city: string + + /** */ + county?: string + + /** */ + state: string + + /** */ + street: string + + /** */ + street2?: string + + /** */ + zipCode: string + + /** */ + latitude?: number + + /** */ + longitude?: number + + /** */ + id?: string +} + +export interface ApplicationSelectionOptionCreate { + /** */ + addressHolderName?: string + + /** */ + addressHolderRelationship?: string + + /** */ + isGeocodingVerified?: boolean + + /** */ + multiselectOption: IdDTO + + /** */ + addressHolderAddress?: AddressUpdate + + /** */ + applicationSelection?: IdDTO +} + +export interface ApplicationSelectionCreate { + /** */ + hasOptedOut?: boolean + + /** */ + multiselectQuestion: IdDTO + + /** */ + selections: ApplicationSelectionOptionCreate[] +} + export interface ApplicationCreate { /** */ appUrl?: string @@ -6616,6 +7645,15 @@ export interface ApplicationCreate { /** */ status: ApplicationStatusEnum + /** */ + accessibleUnitWaitlistNumber?: number + + /** */ + conventionalUnitWaitlistNumber?: number + + /** */ + manualLotteryPositionNumber?: number + /** */ language?: LanguagesEnum @@ -6631,9 +7669,6 @@ export interface ApplicationCreate { /** */ reviewStatus?: ApplicationReviewStatusEnum - /** */ - applicationSelections?: ApplicationSelections[] - /** */ preferences?: ApplicationMultiselectQuestion[] @@ -6647,19 +7682,19 @@ export interface ApplicationCreate { isNewest?: boolean /** */ - applicant: ApplicantUpdate + accessibility: AccessibilityUpdate /** */ - applicationsMailingAddress: AddressCreate + alternateContact: AlternateContactUpdate /** */ - applicationsAlternateAddress: AddressCreate + applicant: ApplicantUpdate /** */ - alternateContact: AlternateContactUpdate + applicationsMailingAddress: AddressCreate /** */ - accessibility: AccessibilityUpdate + applicationsAlternateAddress: AddressCreate /** */ demographics: DemographicUpdate @@ -6669,6 +7704,49 @@ export interface ApplicationCreate { /** */ preferredUnitTypes: IdDTO[] + + /** */ + applicationSelections?: ApplicationSelectionCreate[] +} + +export interface ApplicationSelectionOptionUpdate { + /** */ + addressHolderName?: string + + /** */ + addressHolderRelationship?: string + + /** */ + isGeocodingVerified?: boolean + + /** */ + multiselectOption: IdDTO + + /** */ + id?: string + + /** */ + addressHolderAddress?: AddressUpdate + + /** */ + applicationSelection?: IdDTO +} + +export interface ApplicationSelectionUpdate { + /** */ + application: IdDTO + + /** */ + hasOptedOut?: boolean + + /** */ + multiselectQuestion: IdDTO + + /** */ + id?: string + + /** */ + selections: ApplicationSelectionOptionUpdate[] } export interface ApplicationUpdate { @@ -6717,6 +7795,15 @@ export interface ApplicationUpdate { /** */ status: ApplicationStatusEnum + /** */ + accessibleUnitWaitlistNumber?: number + + /** */ + conventionalUnitWaitlistNumber?: number + + /** */ + manualLotteryPositionNumber?: number + /** */ language?: LanguagesEnum @@ -6732,9 +7819,6 @@ export interface ApplicationUpdate { /** */ reviewStatus?: ApplicationReviewStatusEnum - /** */ - applicationSelections?: ApplicationSelections[] - /** */ preferences?: ApplicationMultiselectQuestion[] @@ -6748,19 +7832,22 @@ export interface ApplicationUpdate { isNewest?: boolean /** */ - applicant: ApplicantUpdate + accessibility: AccessibilityUpdate /** */ - applicationsMailingAddress: AddressCreate + alternateContact: AlternateContactUpdate /** */ - applicationsAlternateAddress: AddressCreate + applicant: ApplicantUpdate /** */ - alternateContact: AlternateContactUpdate + applicationSelections?: ApplicationSelectionUpdate[] /** */ - accessibility: AccessibilityUpdate + applicationsMailingAddress: AddressCreate + + /** */ + applicationsAlternateAddress: AddressCreate /** */ demographics: DemographicUpdate @@ -6950,6 +8037,14 @@ export interface UserCreate { jurisdictions?: IdDTO[] } +export interface UserDeleteDTO { + /** */ + id: string + + /** */ + shouldRemoveApplication?: boolean +} + export interface UserInvite { /** */ firstName: string @@ -7293,6 +8388,11 @@ export enum ListingsStatusEnum { "pendingReview" = "pendingReview", "changesRequested" = "changesRequested", } + +export enum ListingTypeEnum { + "regulated" = "regulated", + "nonRegulated" = "nonRegulated", +} export enum EnumListingFilterParamsComparison { "=" = "=", "<>" = "<>", @@ -7323,6 +8423,7 @@ export enum ListingOrderByKeys { "marketingType" = "marketingType", "marketingYear" = "marketingYear", "marketingSeason" = "marketingSeason", + "listingType" = "listingType", } export enum OrderByEnum { @@ -7353,6 +8454,7 @@ export enum ListingFilterKeys { "section8Acceptance" = "section8Acceptance", "status" = "status", "zipCode" = "zipCode", + "listingType" = "listingType", } export enum ApplicationAddressTypeEnum { @@ -7434,6 +8536,11 @@ export enum UnitRentTypeEnum { "fixed" = "fixed", "percentageOfIncome" = "percentageOfIncome", } + +export enum RentTypeEnum { + "fixedRent" = "fixedRent", + "rentRange" = "rentRange", +} export enum EnumUnitGroupAmiLevelMonthlyRentDeterminationType { "flatRent" = "flatRent", "percentageOfIncome" = "percentageOfIncome", @@ -7484,6 +8591,10 @@ export enum EnumListingCreateListingType { "regulated" = "regulated", "nonRegulated" = "nonRegulated", } +export enum EnumUnitGroupAmiLevelUpdateMonthlyRentDeterminationType { + "flatRent" = "flatRent", + "percentageOfIncome" = "percentageOfIncome", +} export enum EnumListingUpdateDepositType { "fixedDeposit" = "fixedDeposit", "depositRange" = "depositRange", @@ -7517,9 +8628,11 @@ export enum IncomePeriodEnum { } export enum ApplicationStatusEnum { - "draft" = "draft", "submitted" = "submitted", - "removed" = "removed", + "declined" = "declined", + "receivedUnit" = "receivedUnit", + "waitlist" = "waitlist", + "waitlistDeclined" = "waitlistDeclined", } export enum ApplicationSubmissionTypeEnum { @@ -7581,9 +8694,16 @@ export enum NeighborhoodAmenitiesEnum { "parksAndCommunityCenters" = "parksAndCommunityCenters", "pharmacies" = "pharmacies", "healthCareResources" = "healthCareResources", + "shoppingVenues" = "shoppingVenues", + "hospitals" = "hospitals", + "seniorCenters" = "seniorCenters", + "recreationalFacilities" = "recreationalFacilities", + "playgrounds" = "playgrounds", + "busStops" = "busStops", } export enum FeatureFlagEnum { + "disableBuildingSelectionCriteria" = "disableBuildingSelectionCriteria", "disableCommonApplication" = "disableCommonApplication", "disableJurisdictionalAdmin" = "disableJurisdictionalAdmin", "disableListingPreferences" = "disableListingPreferences", @@ -7591,7 +8711,9 @@ export enum FeatureFlagEnum { "enableAccessibilityFeatures" = "enableAccessibilityFeatures", "enableAdaOtherOption" = "enableAdaOtherOption", "enableAdditionalResources" = "enableAdditionalResources", + "enableApplicationStatus" = "enableApplicationStatus", "enableCompanyWebsite" = "enableCompanyWebsite", + "enableCreditScreeningFee" = "enableCreditScreeningFee", "enableFullTimeStudentQuestion" = "enableFullTimeStudentQuestion", "enableGeocodingPreferences" = "enableGeocodingPreferences", "enableGeocodingRadiusMethod" = "enableGeocodingRadiusMethod", @@ -7600,20 +8722,28 @@ export enum FeatureFlagEnum { "enableIsVerified" = "enableIsVerified", "enableLimitedHowDidYouHear" = "enableLimitedHowDidYouHear", "enableListingFavoriting" = "enableListingFavoriting", + "enableListingFileNumber" = "enableListingFileNumber", "enableListingFiltering" = "enableListingFiltering", + "enableLeasingAgentAltText" = "enableLeasingAgentAltText", + "enableListingImageAltText" = "enableListingImageAltText", "enableListingOpportunity" = "enableListingOpportunity", "enableListingPagination" = "enableListingPagination", "enableListingUpdatedAt" = "enableListingUpdatedAt", + "enableMarketingFlyer" = "enableMarketingFlyer", "enableMarketingStatus" = "enableMarketingStatus", "enableMarketingStatusMonths" = "enableMarketingStatusMonths", "enableNeighborhoodAmenities" = "enableNeighborhoodAmenities", "enableNeighborhoodAmenitiesDropdown" = "enableNeighborhoodAmenitiesDropdown", "enableNonRegulatedListings" = "enableNonRegulatedListings", + "enableParkingFee" = "enableParkingFee", "enablePartnerDemographics" = "enablePartnerDemographics", "enablePartnerSettings" = "enablePartnerSettings", + "enableProperties" = "enableProperties", + "enableReferralQuestionUnits" = "enableReferralQuestionUnits", "enableRegions" = "enableRegions", "enableSection8Question" = "enableSection8Question", "enableSingleUseCode" = "enableSingleUseCode", + "enableSmokingPolicyRadio" = "enableSmokingPolicyRadio", "enableSupportAdmin" = "enableSupportAdmin", "enableUnderConstructionHome" = "enableUnderConstructionHome", "enableUnitGroups" = "enableUnitGroups", @@ -7626,6 +8756,18 @@ export enum FeatureFlagEnum { "hideCloseListingButton" = "hideCloseListingButton", "swapCommunityTypeWithPrograms" = "swapCommunityTypeWithPrograms", } + +export enum MultiselectQuestionOrderByKeys { + "jurisdiction" = "jurisdiction", + "name" = "name", + "status" = "status", + "updatedAt" = "updatedAt", +} + +export enum MultiselectQuestionViews { + "base" = "base", + "fundamentals" = "fundamentals", +} export enum EnumMultiselectQuestionFilterParamsComparison { "=" = "=", "<>" = "<>", diff --git a/shared-helpers/src/utilities/blankApplication.ts b/shared-helpers/src/utilities/blankApplication.ts index a4771ca233..e0e7783b6a 100644 --- a/shared-helpers/src/utilities/blankApplication.ts +++ b/shared-helpers/src/utilities/blankApplication.ts @@ -13,6 +13,9 @@ export const blankApplication = { language: LanguagesEnum.en, acceptedTerms: false, status: ApplicationStatusEnum.submitted, + accessibleUnitWaitlistNumber: null, + conventionalUnitWaitlistNumber: null, + manualLotteryPositionNumber: null, applicant: { // orderId: undefined, firstName: "", diff --git a/shared-helpers/src/utilities/formKeys.ts b/shared-helpers/src/utilities/formKeys.ts index 95099b3fe2..1c6ff3fbfc 100644 --- a/shared-helpers/src/utilities/formKeys.ts +++ b/shared-helpers/src/utilities/formKeys.ts @@ -277,6 +277,18 @@ export const listingFeatures = [ "loweredCabinets", ] +export const listingRequiredDocumentsOptions = [ + "socialSecurityCard", + "currentLandlordReference", + "birthCertificate", + "previousLandlordReference", + "governmentIssuedId", + "proofOfAssets", + "proofOfIncome", + "residencyDocuments", + "proofOfCustody", +] + export enum RoleOption { Administrator = "administrator", Partner = "partner", diff --git a/shared-helpers/src/utilities/useIntersect.ts b/shared-helpers/src/utilities/useIntersect.ts new file mode 100644 index 0000000000..c752ff4a0e --- /dev/null +++ b/shared-helpers/src/utilities/useIntersect.ts @@ -0,0 +1,48 @@ +import { useEffect, useRef, useState } from "react" + +interface useIntersectProps { + root?: Element | null + rootMargin?: string + threshold?: number +} + +/** + * Use this hook to obtain state when an element is visible or not relative to + * an ancestor element (or by default the browser viewport) + */ +export const useIntersect = ({ + root = null, + rootMargin = "0px", + threshold = 0, +}: useIntersectProps) => { + // broadly based on this source code: + // https://non-traditional.dev/how-to-use-an-intersectionobserver-in-a-react-hook-9fb061ac6cb5 + + const [intersecting, setIntersecting] = useState(false) + const [element, setIntersectingElement] = useState(null) + + const observer = useRef(null) + + // Instantiate the observer upon element mount + useEffect(() => { + if (observer.current) observer.current.disconnect() + + if (element) { + observer.current = new IntersectionObserver( + ([entry]) => setIntersecting(entry.isIntersecting), + { + root, + rootMargin, + threshold, + } + ) + + observer.current.observe(element) + } + + // Unmount callback + return () => observer?.current?.disconnect() + }, [element, root, rootMargin, threshold]) + + return { setIntersectingElement, intersecting } +} diff --git a/shared-helpers/src/views/components/BloomCard.tsx b/shared-helpers/src/views/components/BloomCard.tsx index 2178ce29b1..5e4bb9f1ff 100644 --- a/shared-helpers/src/views/components/BloomCard.tsx +++ b/shared-helpers/src/views/components/BloomCard.tsx @@ -32,11 +32,10 @@ const BloomCard = (props: BloomCardProps) => { if (props.subtitle) { return ( ) } diff --git a/shared-helpers/src/views/components/Map.module.scss b/shared-helpers/src/views/components/Map.module.scss new file mode 100644 index 0000000000..d1c2d56e70 --- /dev/null +++ b/shared-helpers/src/views/components/Map.module.scss @@ -0,0 +1,43 @@ +.map { + position: relative; + min-height: 12rem; +} + +.map-listing-name { + margin-block-end: var(--seeds-spacer-label); +} + +.map-address-popup { + background-color: var(--seeds-color-white); + padding: var(--seeds-s4); + box-shadow: var(--seeds-shadow-md); + position: absolute; + left: 15px; + top: 15px; + z-index: 1; +} + +.map-pin { + position: absolute; + width: 30px; + height: 30px; + border-radius: 50% 50% 50% 0; + background: var(--seeds-color-gray-650); + transform: rotate(-45deg); + left: 50%; + top: 50%; + margin: -20px 0 0 -20px; + animation-name: bounce; + animation-fill-mode: both; + animation-duration: 1s; + + &:after { + position: absolute; + border-radius: 9999px; + content: ""; + width: 14px; + height: 14px; + margin: 8px 0 0 8px; + background: var(--seeds-color-gray-900); + } +} \ No newline at end of file diff --git a/shared-helpers/src/views/components/Map.tsx b/shared-helpers/src/views/components/Map.tsx new file mode 100644 index 0000000000..decd252ecd --- /dev/null +++ b/shared-helpers/src/views/components/Map.tsx @@ -0,0 +1,144 @@ +import React, { useState, useCallback, useEffect, useMemo } from "react" +import "mapbox-gl/dist/mapbox-gl.css" +import { Map as MapGL, Marker, MarkerDragEvent } from "@vis.gl/react-mapbox" +import { Heading } from "@bloom-housing/ui-seeds" +import { MultiLineAddress } from "./MultiLineAddress" +import { useIntersect } from "../../.." +import { Address } from "../../types/backend-swagger" +import styles from "./Map.module.scss" + +export interface MapProps { + address?: Omit + listingName?: string + enableCustomPinPositioning?: boolean + setCustomMapPositionChosen?: (customMapPosition: boolean) => void + setLatLong?: (latLong: LatitudeLongitude) => void +} + +export interface LatitudeLongitude { + latitude: number + longitude: number +} + +export interface Viewport { + width: string | number + height: string | number + latitude?: number + longitude?: number + zoom: number +} + +const isValidLatitude = (latitude: number) => { + return latitude >= -90 && latitude <= 90 +} + +const isValidLongitude = (longitude: number) => { + return longitude >= -180 && longitude <= 180 +} + +const Map = (props: MapProps) => { + // Lazy load the map component only when it will become visible on screen + const { setIntersectingElement, intersecting } = useIntersect({ + // `window` isn't set for SSR, so we'll use `global` instead—doesn't really + // matter because the map won't ever get rendered in SSR anyway + rootMargin: `${global.innerHeight || 0}px`, + }) + const [hasIntersected, setHasIntersected] = useState(false) + if (intersecting && !hasIntersected) setHasIntersected(true) + + const [marker, setMarker] = useState({ + latitude: props.address?.latitude, + longitude: props.address?.longitude, + }) + + const viewport = useMemo(() => { + return { + latitude: marker.latitude, + longitude: marker.longitude, + zoom: 13, + } + }, [marker]) + + useEffect(() => { + setMarker({ + latitude: props.address?.latitude, + longitude: props.address?.longitude, + }) + }, [props.address?.latitude, props.address?.longitude, props.enableCustomPinPositioning]) + + const { setLatLong, setCustomMapPositionChosen } = props + + const onMarkerDragEnd = useCallback( + (event: MarkerDragEvent) => { + if (setLatLong) { + setLatLong({ + latitude: event.lngLat.lat, + longitude: event.lngLat.lng, + }) + } + if (setCustomMapPositionChosen) { + setCustomMapPositionChosen(true) + } + setMarker({ + latitude: event.lngLat.lat, + longitude: event.lngLat.lng, + }) + }, + [setLatLong, setCustomMapPositionChosen, setMarker] + ) + + if ( + !props.address || + !props.address.latitude || + !props.address.longitude || + !viewport.latitude || + !viewport.longitude + ) + return null + + return ( +
+
+ {props.listingName && ( + + {props.listingName} + + )} + +
+ {(process.env.mapBoxToken || process.env.MAPBOX_TOKEN) && hasIntersected && ( + + {marker.latitude && + marker.longitude && + isValidLatitude(marker.latitude) && + isValidLongitude(marker.longitude) && ( + <> + {props.enableCustomPinPositioning ? ( + +
+
+ ) : ( + +
+
+ )} + + )} +
+ )} +
+ ) +} +export { Map as default, Map } diff --git a/shared-helpers/src/views/components/MultiLineAddress.tsx b/shared-helpers/src/views/components/MultiLineAddress.tsx new file mode 100644 index 0000000000..bdbdd660af --- /dev/null +++ b/shared-helpers/src/views/components/MultiLineAddress.tsx @@ -0,0 +1,32 @@ +import * as React from "react" +import Markdown from "markdown-to-jsx" +import { Address } from "../../types/backend-swagger" + +export interface MultiLineAddressProps { + address: Omit +} + +const MultiLineAddress = ({ address }: MultiLineAddressProps) => { + if (!address) return null + + let addressHTML = "" + + if (address.placeName) { + addressHTML += `${address.placeName}
` + } + + if (address.street || address.street2) { + addressHTML += `${address.street || ""} ${address.street2 || ""}
` + } + + if (address.city || address.state || address.zipCode) { + addressHTML += `${address.city ? `${address.city},` : ""} ${address.state || ""} ${ + address.zipCode || "" + }` + } + addressHTML = `${addressHTML}` + + return +} + +export { MultiLineAddress as default, MultiLineAddress } diff --git a/shared-helpers/src/views/forgot-password/FormForgotPassword.tsx b/shared-helpers/src/views/forgot-password/FormForgotPassword.tsx index ca7bbc41ac..5b08af3981 100644 --- a/shared-helpers/src/views/forgot-password/FormForgotPassword.tsx +++ b/shared-helpers/src/views/forgot-password/FormForgotPassword.tsx @@ -67,6 +67,7 @@ const FormForgotPassword = ({ networkError.reset()} labelClassName={"text__caps-spaced"} /> - -
diff --git a/shared-helpers/src/views/summaryTables.tsx b/shared-helpers/src/views/summaryTables.tsx index 3913fa250e..5f150458d1 100644 --- a/shared-helpers/src/views/summaryTables.tsx +++ b/shared-helpers/src/views/summaryTables.tsx @@ -15,6 +15,7 @@ import { UnitGroupSummary, UnitSummary, Listing, + EnumListingListingType, } from "../types/backend-swagger" import { numberOrdinal } from "../utilities/numberOrdinal" @@ -28,6 +29,7 @@ export const unitsHeaders = { sqFeet: "t.area", numBathrooms: "listings.bath", floor: "t.floor", + accessibilityType: "listings.unit.accessibilityType", } export const unitSummariesTable = ( @@ -443,7 +445,8 @@ export const stackedUnitSummariesTable = ( export const getAvailabilityText = ( group: UnitGroupSummary, - isComingSoon?: boolean + isComingSoon?: boolean, + isNonRegulated?: boolean ): { text: string } => { if (isComingSoon) { return { @@ -466,7 +469,9 @@ export const getAvailabilityText = ( ) } - statusElements.push(waitlistStatus) + if (!isNonRegulated) { + statusElements.push(waitlistStatus) + } // Combine statuses with proper formatting let availability = null @@ -486,7 +491,8 @@ export const getAvailabilityText = ( export const getAvailabilityTextForGroup = ( groups: UnitGroupSummary[], - isComingSoon?: boolean + isComingSoon?: boolean, + isNonRegulated?: boolean ): { text: string } => { // Add coming soon status if needed if (isComingSoon) { @@ -498,11 +504,13 @@ export const getAvailabilityTextForGroup = ( const statusSet = new Set() // Collect information from all groups - statusSet.add( - groups.some((entry) => entry.openWaitlist) - ? t("listings.availability.openWaitlist") - : t("listings.availability.closedWaitlist") - ) + if (!isNonRegulated) { + statusSet.add( + groups.some((entry) => entry.openWaitlist) + ? t("listings.availability.openWaitlist") + : t("listings.availability.closedWaitlist") + ) + } const totalVacantUnits = groups.reduce((acc, group) => (acc += group.unitVacancies), 0) @@ -533,7 +541,8 @@ export const getAvailabilityTextForGroup = ( export const stackedUnitGroupsSummariesTable = ( summaries: UnitGroupSummary[], - isComingSoon?: boolean + isComingSoon?: boolean, + isNonRegulated?: boolean ): Record[] => { const ranges = mergeGroupSummaryRows(summaries) @@ -582,7 +591,7 @@ export const stackedUnitGroupsSummariesTable = ( const availability = summaries.length > 0 - ? getAvailabilityTextForGroup(summaries, isComingSoon) + ? getAvailabilityTextForGroup(summaries, isComingSoon, isNonRegulated) : { text: t("t.n/a"), subText: "" } const rowData = { @@ -627,12 +636,13 @@ export const getStackedSummariesTable = ( export const getStackedGroupSummariesTable = ( summaries: UnitGroupSummary[], - isComingSoon?: boolean + isComingSoon?: boolean, + isNonRegulated?: boolean ): Record[] => { let unitSummaries: Record[] = [] if (summaries?.length > 0) { - unitSummaries = stackedUnitGroupsSummariesTable(summaries, isComingSoon) + unitSummaries = stackedUnitGroupsSummariesTable(summaries, isComingSoon, isNonRegulated) } return unitSummaries } @@ -694,6 +704,11 @@ export const getUnitTableData = (units: Unit[], unitSummary: UnitSummary) => { ), }, floor: { content: {unit.floor} }, + accessibilityType: { + content: unit.unitAccessibilityPriorityTypes + ? t(`listings.unit.accessibilityType.${unit.unitAccessibilityPriorityTypes.name}`) + : t("t.n/a"), + }, } }) @@ -725,50 +740,121 @@ export const getUnitTableData = (units: Unit[], unitSummary: UnitSummary) => { } } +type FormattedUnit = { + number?: { + cellText: string + } + sqFeet?: { + cellText: string + cellSubText: string + } + numBathrooms?: { + cellText: string + } + floor?: { + cellText: string + } + accessibilityType?: { + cellText: string + } +} + export const getStackedUnitTableData = (units: Unit[], unitSummary: UnitSummary) => { const availableUnits = units.filter( (unit: Unit) => unit.unitTypes?.name == unitSummary.unitTypes.name ) - let floorSection: React.ReactNode - const unitsFormatted = availableUnits.map((unit: Unit) => { - return { - number: { cellText: unit.number ? unit.number : "" }, - sqFeet: { - cellText: unit.sqFeet ? `${unit.sqFeet} ${t("t.sqFeet")}` : "", - }, - numBathrooms: { - cellText: - unit.numBathrooms === 0 - ? t("listings.unit.sharedBathroom") - : unit.numBathrooms - ? unit.numBathrooms.toString() - : "", - }, - floor: { cellText: unit.floor ? unit.floor.toString() : "" }, - } - }) + let adjustedHeaders: { [key: string]: string } = {} + + // Determine which fields have data to display, hide columns if all units have no data + const noNumbers = !availableUnits.some((unit) => !!unit.number) + const noSqFeet = !availableUnits.some((unit) => !!unit.sqFeet) + const noBathrooms = !availableUnits.some((unit) => !!unit.numBathrooms) + const noFloors = !availableUnits.some((unit) => !!unit.floor) + const noA11yTypes = !availableUnits.some((unit) => !!unit.unitAccessibilityPriorityTypes) + let unitsFormatted: FormattedUnit[] = [] + + if (!(noNumbers && noSqFeet && noBathrooms && noFloors && noA11yTypes)) { + unitsFormatted = availableUnits.map((unit: Unit) => { + let unitFormatted: FormattedUnit = {} + + if (!noA11yTypes) { + unitFormatted = { + accessibilityType: { + cellText: unit.unitAccessibilityPriorityTypes + ? t(`listings.unit.accessibilityType.${unit.unitAccessibilityPriorityTypes.name}`) + : t("t.n/a"), + }, + } + adjustedHeaders = { accessibilityType: "listings.unit.accessibilityType" } + } + if (!noFloors) { + unitFormatted = { + floor: { + cellText: unit.floor ? unit.floor.toString() : t("t.n/a"), + }, + ...unitFormatted, + } + adjustedHeaders = { floor: "t.floor", ...adjustedHeaders } + } + if (!noBathrooms) { + unitFormatted = { + numBathrooms: { + cellText: + unit.numBathrooms === 0 + ? t("listings.unit.sharedBathroom") + : unit.numBathrooms + ? unit.numBathrooms.toString() + : t("t.n/a"), + }, + ...unitFormatted, + } + adjustedHeaders = { numBathrooms: "listings.bath", ...adjustedHeaders } + } + if (!noSqFeet) { + unitFormatted = { + sqFeet: { + cellText: unit.sqFeet ? unit.sqFeet : t("t.n/a"), + cellSubText: unit.sqFeet ? t("t.sqFeet") : "", + }, + ...unitFormatted, + } + adjustedHeaders = { sqFeet: "t.area", ...adjustedHeaders } + } + if (!noNumbers) { + unitFormatted = { + number: { cellText: unit.number ? unit.number : t("t.n/a") }, + ...unitFormatted, + } + adjustedHeaders = { number: "t.unit", ...adjustedHeaders } + } + return unitFormatted + }) + } - let areaRangeSection: React.ReactNode + let areaRangeSection = "" if (unitSummary.areaRange?.min || unitSummary.areaRange?.max) { areaRangeSection = `, ${formatRange(unitSummary.areaRange)} ${t("t.squareFeet")}` } + let floorSection = "" if (unitSummary.floorRange && unitSummary.floorRange.min) { - floorSection = `, ${formatRange(unitSummary.floorRange, true)} - ${unitSummary.floorRange.max > unitSummary.floorRange.min ? t("t.floors") : t("t.floor")}` + floorSection = `, ${formatRange(unitSummary.floorRange, true)} ${ + unitSummary.floorRange.max > unitSummary.floorRange.min ? t("t.floors") : t("t.floor") + }` } const barContent = (
- {t("listings.unitTypes." + unitSummary.unitTypes.name)}:  - {unitsLabel(availableUnits)} - {areaRangeSection} - {floorSection} + + {unitSummary.unitTypes.name ? t("listings.unitTypes." + unitSummary.unitTypes.name) : ""} + + {` ${unitsLabel(availableUnits)}${areaRangeSection}${floorSection}`}
) return { + adjustedHeaders, availableUnits, areaRangeSection, floorSection, @@ -809,10 +895,10 @@ export const UnitTables = (props: UnitTablesProps) => { export const getUnitGroupSummariesTable = (listing: Listing) => { const unitGroupsSummariesHeaders = { - unitType: t("t.unitType"), - rent: t("t.rent"), - availability: t("t.availability"), - ami: "ami", + unitType: "t.unitType", + rent: "t.rent", + availability: "t.availability", + ...(listing.listingType === EnumListingListingType.regulated ? { ami: "t.ami" } : {}), } let groupedUnitData = null @@ -863,7 +949,11 @@ export const getUnitGroupSummariesTable = (listing: Listing) => { ami = `${group.amiPercentageRange.min} - ${group.amiPercentageRange.max}%` } - const availability = getAvailabilityText(group, isComingSoon) + const availability = getAvailabilityText( + group, + isComingSoon, + listing.listingType === EnumListingListingType.nonRegulated + ) return { unitType: { diff --git a/sites/partners/.jest/setup-tests.js b/sites/partners/.jest/setup-tests.js index 474fdf756a..7721848e6e 100644 --- a/sites/partners/.jest/setup-tests.js +++ b/sites/partners/.jest/setup-tests.js @@ -1,8 +1,8 @@ // Future home of additional Jest config +import "@testing-library/jest-dom" import { addTranslation } from "@bloom-housing/ui-components" import generalTranslations from "@bloom-housing/shared-helpers/src/locales/general.json" import { serviceOptions } from "@bloom-housing/shared-helpers/src/types/backend-swagger" -import "@testing-library/jest-dom" import axios from "axios" import general from "../page_content/locale_overrides/general.json" addTranslation({ ...generalTranslations, ...general }) diff --git a/sites/partners/__tests__/components/applications/PaperApplicationDetails/sections/DetailApplicationData.test.tsx b/sites/partners/__tests__/components/applications/PaperApplicationDetails/sections/DetailApplicationData.test.tsx new file mode 100644 index 0000000000..2e3812f03d --- /dev/null +++ b/sites/partners/__tests__/components/applications/PaperApplicationDetails/sections/DetailApplicationData.test.tsx @@ -0,0 +1,59 @@ +/* eslint-disable import/no-named-as-default */ +import React from "react" +import { LanguagesEnum } from "@bloom-housing/shared-helpers/src/types/backend-swagger" +import { application, user } from "@bloom-housing/shared-helpers/__tests__/testHelpers" +import { mockNextRouter, render, screen, within } from "../../../../testUtils" +import DetailsApplicationData from "../../../../../src/components/applications/PaperApplicationDetails/sections/DetailsApplicationData" +import { ApplicationContext } from "../../../../../src/components/applications/ApplicationContext" +import { AuthContext } from "@bloom-housing/shared-helpers" + +describe("DetailApplicationData", () => { + it("should display Application Data section info", () => { + mockNextRouter({ id: "application_1" }) + render( + + + + + + ) + + expect(screen.getByRole("heading", { name: "Application data", level: 2 })).toBeInTheDocument() + expect(within(screen.getByTestId("number")).getByText("Confirmation code")).toBeInTheDocument() + expect(within(screen.getByTestId("number")).getByText("ABCD1234")).toBeInTheDocument() + expect( + within(screen.getByTestId("type")).getByText("Application submission type") + ).toBeInTheDocument() + expect(within(screen.getByTestId("type")).getByText("Electronic")).toBeInTheDocument() + expect( + within(screen.getByTestId("submittedDate")).getByText("Application submitted date") + ).toBeInTheDocument() + expect(within(screen.getByTestId("submittedDate")).getByText("1/28/2025")).toBeInTheDocument() + expect( + within(screen.getByTestId("timeDate")).getByText("Application submitted time") + ).toBeInTheDocument() + expect(within(screen.getByTestId("timeDate")).getByText("1:09:00 PM UTC")).toBeInTheDocument() + expect( + within(screen.getByTestId("language")).getByText("Application language") + ).toBeInTheDocument() + expect(within(screen.getByTestId("language")).getByText("Español")).toBeInTheDocument() + expect( + within(screen.getByTestId("totalSize")).getByText("Total household size") + ).toBeInTheDocument() + expect(within(screen.getByTestId("totalSize")).getByText("2")).toBeInTheDocument() + expect(within(screen.getByTestId("submittedBy")).getByText("Submitted by")).toBeInTheDocument() + expect( + within(screen.getByTestId("submittedBy")).getByText("Applicant First Applicant Last") + ).toBeInTheDocument() + }) +}) diff --git a/sites/partners/__tests__/components/applications/PaperApplicationDetails/sections/DetailsHouseholdMembers.test.tsx b/sites/partners/__tests__/components/applications/PaperApplicationDetails/sections/DetailsHouseholdMembers.test.tsx new file mode 100644 index 0000000000..46262c6c8f --- /dev/null +++ b/sites/partners/__tests__/components/applications/PaperApplicationDetails/sections/DetailsHouseholdMembers.test.tsx @@ -0,0 +1,151 @@ +/* eslint-disable import/no-named-as-default */ +import React from "react" +import { mockNextRouter, render, within } from "../../../../testUtils" +import DetailsHouseholdMembers from "../../../../../src/components/applications/PaperApplicationDetails/sections/DetailsHouseholdMembers" +import { application, householdMember } from "@bloom-housing/shared-helpers/__tests__/testHelpers" +import { ApplicationContext } from "../../../../../src/components/applications/ApplicationContext" + +describe("DetailsHouseholdMembers", () => { + mockNextRouter({ id: "application_1" }) + + it("should display Houshold Members section table", () => { + const { getByRole, queryByText } = render( + + {/* eslint-disable-next-line @typescript-eslint/no-empty-function */} + {}} /> + + ) + + // Check the section header + expect(getByRole("heading", { name: "Household members" })).toBeInTheDocument() + + // Get the table and check headers + const table = getByRole("table") + const tableHeaders = within(table).getAllByRole("columnheader") + expect(tableHeaders).toHaveLength(6) + + const [name, dob, relationship, residence, work, actions] = tableHeaders + expect(name).toHaveTextContent(/name/i) + expect(dob).toHaveTextContent(/date of birth/i) + expect(relationship).toHaveTextContent(/relationship/i) + expect(residence).toHaveTextContent(/same residence/i) + expect(work).toHaveTextContent(/work in region/i) + expect(actions).toHaveTextContent(/actions/i) + + // Check table body rows + const tableBodyRows = within(table).getAllByRole("row") + expect(tableBodyRows).toHaveLength(2) // 1 for the header row + 1 for the Household member row + + const [nameVal, dobVal, relationshipVal, residenceVal, workVal, actionsVal] = within( + tableBodyRows[1] + ).getAllByRole("cell") + + expect(nameVal).toHaveTextContent("Household First Household Last") + expect(dobVal).toHaveTextContent("11/25/1966") + expect(relationshipVal).toHaveTextContent("Friend") + expect(residenceVal).toHaveTextContent("No") + expect(workVal).toHaveTextContent("Yes") + expect(within(actionsVal).getByText("View")).toBeInTheDocument() + + expect(queryByText("Full-time student")).not.toBeInTheDocument() + }) + + it("should display Houshold Members section table with full time student question", () => { + const { getByRole } = render( + + {/* eslint-disable-next-line @typescript-eslint/no-empty-function */} + {}} enableFullTimeStudentQuestion={true} /> + + ) + + // Check the section header + expect(getByRole("heading", { name: "Household members" })).toBeInTheDocument() + + // Get the table and check headers + const table = getByRole("table") + const tableHeaders = within(table).getAllByRole("columnheader") + expect(tableHeaders).toHaveLength(7) + + const [name, dob, relationship, residence, work, student, actions] = tableHeaders + expect(name).toHaveTextContent(/name/i) + expect(dob).toHaveTextContent(/date of birth/i) + expect(relationship).toHaveTextContent(/relationship/i) + expect(residence).toHaveTextContent(/same residence/i) + expect(work).toHaveTextContent(/work in region/i) + expect(student).toHaveTextContent("Full-time student") + expect(actions).toHaveTextContent(/actions/i) + + // Check table body rows + const tableBodyRows = within(table).getAllByRole("row") + expect(tableBodyRows).toHaveLength(2) // 1 for the header row + 1 for the Household member row + + const [nameVal, dobVal, relationshipVal, residenceVal, workVal, studentVal, actionsVal] = + within(tableBodyRows[1]).getAllByRole("cell") + + expect(nameVal).toHaveTextContent("Household First Household Last") + expect(dobVal).toHaveTextContent("11/25/1966") + expect(relationshipVal).toHaveTextContent("Friend") + expect(residenceVal).toHaveTextContent("No") + expect(workVal).toHaveTextContent("Yes") + expect(studentVal).toHaveTextContent("No") + expect(within(actionsVal).getByText("View")).toBeInTheDocument() + }) + + it("should display Houshold Members section table with disableWorkInRegion", () => { + const { getByRole } = render( + + {/* eslint-disable-next-line @typescript-eslint/no-empty-function */} + {}} disableWorkInRegion={true} /> + + ) + + // Check the section header + expect(getByRole("heading", { name: "Household members" })).toBeInTheDocument() + + // Get the table and check headers + const table = getByRole("table") + const tableHeaders = within(table).getAllByRole("columnheader") + expect(tableHeaders).toHaveLength(5) + + const [name, dob, relationship, residence, actions] = tableHeaders + expect(name).toHaveTextContent(/name/i) + expect(dob).toHaveTextContent(/date of birth/i) + expect(relationship).toHaveTextContent(/relationship/i) + expect(residence).toHaveTextContent(/same residence/i) + expect(actions).toHaveTextContent(/actions/i) + }) + + it("should display name as n/a", () => { + const { getByRole } = render( + + {/* eslint-disable-next-line @typescript-eslint/no-empty-function */} + {}} enableFullTimeStudentQuestion={true} /> + + ) + + // Get the table and check headers + const table = getByRole("table") + + // Check table body rows + const tableBodyRows = within(table).getAllByRole("row") + + expect(within(tableBodyRows[1]).getAllByRole("cell")[0]).toHaveTextContent( + "Household First Household Last" + ) + expect(within(tableBodyRows[2]).getAllByRole("cell")[0]).toHaveTextContent("n/a") + expect(within(tableBodyRows[3]).getAllByRole("cell")[0]).toHaveTextContent("Last") + expect(within(tableBodyRows[4]).getAllByRole("cell")[0]).toHaveTextContent("First") + expect(within(tableBodyRows[5]).getAllByRole("cell")[0]).toHaveTextContent("middle") + }) +}) diff --git a/sites/partners/__tests__/components/applications/PaperApplicationDetails/sections/DetailsPrimaryApplicant.test.tsx b/sites/partners/__tests__/components/applications/PaperApplicationDetails/sections/DetailsPrimaryApplicant.test.tsx new file mode 100644 index 0000000000..0650bbec21 --- /dev/null +++ b/sites/partners/__tests__/components/applications/PaperApplicationDetails/sections/DetailsPrimaryApplicant.test.tsx @@ -0,0 +1,151 @@ +/* eslint-disable import/no-named-as-default */ +import React from "react" +import { application } from "@bloom-housing/shared-helpers/__tests__/testHelpers" +import { ApplicationContext } from "../../../../../src/components/applications/ApplicationContext" +import { mockNextRouter, render, screen, within } from "../../../../testUtils" +import DetailsPrimaryApplicant from "../../../../../src/components/applications/PaperApplicationDetails/sections/DetailsPrimaryApplicant" + +describe("DetailsPrimaryApplicant", () => { + mockNextRouter({ id: "application_1" }) + + it("should display Primary Applicant section info", () => { + render( + + + + ) + + expect(screen.getByRole("heading", { name: "Primary applicant", level: 2 })).toBeInTheDocument() + expect(within(screen.getByTestId("firstName")).getByText("First name")).toBeInTheDocument() + expect(within(screen.getByTestId("firstName")).getByText("Applicant First")).toBeInTheDocument() + expect(within(screen.getByTestId("middleName")).getByText("Middle name")).toBeInTheDocument() + expect( + within(screen.getByTestId("middleName")).getByText("Applicant Middle") + ).toBeInTheDocument() + expect(within(screen.getByTestId("lastName")).getByText("Last name")).toBeInTheDocument() + expect(within(screen.getByTestId("lastName")).getByText("Applicant Last")).toBeInTheDocument() + expect(within(screen.getByTestId("dateOfBirth")).getByText("Date of birth")).toBeInTheDocument() + expect(within(screen.getByTestId("dateOfBirth")).getByText("10/10/1990")).toBeInTheDocument() + expect(within(screen.getByTestId("emailAddress")).getByText("Email")).toBeInTheDocument() + expect( + within(screen.getByTestId("emailAddress")).getByText("first.last@example.com") + ).toBeInTheDocument() + expect(within(screen.getByTestId("phoneNumber")).getByText("Phone")).toBeInTheDocument() + expect( + within(screen.getByTestId("phoneNumber")).getByText("(123) 123-1231") + ).toBeInTheDocument() + expect(within(screen.getByTestId("phoneNumber")).getByText("Home")).toBeInTheDocument() + expect( + within(screen.getByTestId("additionalPhoneNumber")).getByText("Second phone") + ).toBeInTheDocument() + expect( + within(screen.getByTestId("additionalPhoneNumber")).getByText("(456) 456-4564") + ).toBeInTheDocument() + expect( + within(screen.getByTestId("additionalPhoneNumber")).getByText("Cell") + ).toBeInTheDocument() + expect( + within(screen.getByTestId("preferredContact")).getByText("Preferred contact") + ).toBeInTheDocument() + expect(within(screen.getByTestId("preferredContact")).getByText("n/a")).toBeInTheDocument() + expect( + within(screen.getByTestId("workInRegion")).getByText("Work in region") + ).toBeInTheDocument() + expect(within(screen.getByTestId("workInRegion")).getByText("Yes")).toBeInTheDocument() + + expect(screen.getByRole("heading", { name: "Residence address", level: 3 })).toBeInTheDocument() + expect( + within(screen.getByTestId("residenceAddress.streetAddress")).getByText("Street address") + ).toBeInTheDocument() + expect( + within(screen.getByTestId("residenceAddress.streetAddress")).getByText( + "3200 Old Faithful Inn Rd" + ) + ).toBeInTheDocument() + expect( + within(screen.getByTestId("residenceAddress.street2")).getByText("Apt or unit #") + ).toBeInTheDocument() + expect( + within(screen.getByTestId("residenceAddress.street2")).getByText("12") + ).toBeInTheDocument() + expect( + within(screen.getByTestId("residenceAddress.city")).getByText("City") + ).toBeInTheDocument() + expect( + within(screen.getByTestId("residenceAddress.city")).getByText("Yellowstone National Park") + ).toBeInTheDocument() + expect( + within(screen.getByTestId("residenceAddress.state")).getByText("State") + ).toBeInTheDocument() + expect(within(screen.getByTestId("residenceAddress.state")).getByText("WY")).toBeInTheDocument() + expect( + within(screen.getByTestId("residenceAddress.zipCode")).getByText("Zip code") + ).toBeInTheDocument() + expect( + within(screen.getByTestId("residenceAddress.zipCode")).getByText("82190") + ).toBeInTheDocument() + + expect(screen.getByRole("heading", { name: "Mailing address", level: 3 })).toBeInTheDocument() + expect( + within(screen.getByTestId("mailingAddress.streetAddress")).getByText("Street address") + ).toBeInTheDocument() + expect( + within(screen.getByTestId("mailingAddress.streetAddress")).getByText("1000 US-36") + ).toBeInTheDocument() + expect( + within(screen.getByTestId("mailingAddress.street2")).getByText("Apt or unit #") + ).toBeInTheDocument() + expect( + within(screen.getByTestId("mailingAddress.street2")).getByText("n/a") + ).toBeInTheDocument() + expect(within(screen.getByTestId("mailingAddress.city")).getByText("City")).toBeInTheDocument() + expect( + within(screen.getByTestId("mailingAddress.city")).getByText("Estes Park") + ).toBeInTheDocument() + expect( + within(screen.getByTestId("mailingAddress.state")).getByText("State") + ).toBeInTheDocument() + expect(within(screen.getByTestId("mailingAddress.state")).getByText("CO")).toBeInTheDocument() + expect( + within(screen.getByTestId("mailingAddress.zipCode")).getByText("Zip code") + ).toBeInTheDocument() + expect( + within(screen.getByTestId("mailingAddress.zipCode")).getByText("80517") + ).toBeInTheDocument() + + expect(screen.getByRole("heading", { name: "Work address", level: 3 })).toBeInTheDocument() + expect( + within(screen.getByTestId("workAddress.streetAddress")).getByText("Street address") + ).toBeInTheDocument() + expect( + within(screen.getByTestId("workAddress.streetAddress")).getByText("9035 Village Dr") + ).toBeInTheDocument() + expect( + within(screen.getByTestId("workAddress.street2")).getByText("Apt or unit #") + ).toBeInTheDocument() + expect(within(screen.getByTestId("workAddress.street2")).getByText("n/a")).toBeInTheDocument() + expect(within(screen.getByTestId("workAddress.city")).getByText("City")).toBeInTheDocument() + expect( + within(screen.getByTestId("workAddress.city")).getByText("Yosemite Valley") + ).toBeInTheDocument() + expect(within(screen.getByTestId("workAddress.state")).getByText("State")).toBeInTheDocument() + expect(within(screen.getByTestId("workAddress.state")).getByText("CA")).toBeInTheDocument() + expect( + within(screen.getByTestId("workAddress.zipCode")).getByText("Zip code") + ).toBeInTheDocument() + expect(within(screen.getByTestId("workAddress.zipCode")).getByText("95389")).toBeInTheDocument() + + expect(screen.queryAllByText("Full-time student")).toHaveLength(0) + }) + + it("should display Primary Applicant section info with full time student question", () => { + render( + + + + ) + + expect(screen.getByText("Full-time student")).toBeInTheDocument() + expect(screen.getByText("No")).toBeInTheDocument() + }) +}) diff --git a/sites/partners/__tests__/components/applications/sections/FormAlternateContact.test.tsx b/sites/partners/__tests__/components/applications/PaperApplicationForm/sections/FormAlternateContact.test.tsx similarity index 91% rename from sites/partners/__tests__/components/applications/sections/FormAlternateContact.test.tsx rename to sites/partners/__tests__/components/applications/PaperApplicationForm/sections/FormAlternateContact.test.tsx index 90cc2d56d9..5a82b94e11 100644 --- a/sites/partners/__tests__/components/applications/sections/FormAlternateContact.test.tsx +++ b/sites/partners/__tests__/components/applications/PaperApplicationForm/sections/FormAlternateContact.test.tsx @@ -1,7 +1,7 @@ import { render, screen } from "@testing-library/react" import React from "react" -import { FormProviderWrapper } from "./helpers" -import { FormAlternateContact } from "../../../../src/components/applications/PaperApplicationForm/sections/FormAlternateContact" +import { FormProviderWrapper } from "../../../../testUtils" +import { FormAlternateContact } from "../../../../../src/components/applications/PaperApplicationForm/sections/FormAlternateContact" import userEvent from "@testing-library/user-event" describe("", () => { diff --git a/sites/partners/__tests__/components/applications/sections/FormApplicationData.test.tsx b/sites/partners/__tests__/components/applications/PaperApplicationForm/sections/FormApplicationData.test.tsx similarity index 64% rename from sites/partners/__tests__/components/applications/sections/FormApplicationData.test.tsx rename to sites/partners/__tests__/components/applications/PaperApplicationForm/sections/FormApplicationData.test.tsx index c5e8b2c5f0..3aa3beb8eb 100644 --- a/sites/partners/__tests__/components/applications/sections/FormApplicationData.test.tsx +++ b/sites/partners/__tests__/components/applications/PaperApplicationForm/sections/FormApplicationData.test.tsx @@ -1,15 +1,18 @@ import { render, screen, waitFor } from "@testing-library/react" import React from "react" import userEvent from "@testing-library/user-event" -import { LanguagesEnum } from "@bloom-housing/shared-helpers/src/types/backend-swagger" -import { FormProviderWrapper } from "./helpers" -import { FormApplicationData } from "../../../../src/components/applications/PaperApplicationForm/sections/FormApplicationData" +import { + LanguagesEnum, + ApplicationStatusEnum, +} from "@bloom-housing/shared-helpers/src/types/backend-swagger" +import { FormProviderWrapper } from "../../../../testUtils" +import { FormApplicationData } from "../../../../../src/components/applications/PaperApplicationForm/sections/FormApplicationData" describe("", () => { it("renders the form with application data fields", () => { render( - + ) expect(screen.getByRole("heading", { level: 2, name: /application data/i })).toBeInTheDocument() @@ -30,7 +33,7 @@ describe("", () => { it("time fields are disabled when date is not fully entered", async () => { render( - + ) @@ -60,7 +63,7 @@ describe("", () => { it("language selection works correctly", async () => { render( - + ) @@ -73,7 +76,7 @@ describe("", () => { it("clearing date fields does not resets time fields", async () => { render( - + ) @@ -107,4 +110,41 @@ describe("", () => { expect((timeMinutes as HTMLInputElement).value).toBe("30") expect((timePeriod as HTMLSelectElement).value).toBe("pm") }) + + describe("application status dropdown", () => { + it("does not render when enableApplicationStatus is false", () => { + render( + + + + ) + + expect(screen.queryByLabelText(/status/i)).not.toBeInTheDocument() + }) + + it("allows selecting different application status values", async () => { + render( + + + + ) + + const statusSelect = screen.getByLabelText(/status/i) + + await userEvent.selectOptions(statusSelect, ApplicationStatusEnum.submitted) + expect((statusSelect as HTMLSelectElement).value).toBe(ApplicationStatusEnum.submitted) + + await userEvent.selectOptions(statusSelect, ApplicationStatusEnum.declined) + expect((statusSelect as HTMLSelectElement).value).toBe(ApplicationStatusEnum.declined) + + await userEvent.selectOptions(statusSelect, ApplicationStatusEnum.receivedUnit) + expect((statusSelect as HTMLSelectElement).value).toBe(ApplicationStatusEnum.receivedUnit) + + await userEvent.selectOptions(statusSelect, ApplicationStatusEnum.waitlist) + expect((statusSelect as HTMLSelectElement).value).toBe(ApplicationStatusEnum.waitlist) + + await userEvent.selectOptions(statusSelect, ApplicationStatusEnum.waitlistDeclined) + expect((statusSelect as HTMLSelectElement).value).toBe(ApplicationStatusEnum.waitlistDeclined) + }) + }) }) diff --git a/sites/partners/__tests__/components/applications/sections/FormDemographics.test.tsx b/sites/partners/__tests__/components/applications/PaperApplicationForm/sections/FormDemographics.test.tsx similarity index 96% rename from sites/partners/__tests__/components/applications/sections/FormDemographics.test.tsx rename to sites/partners/__tests__/components/applications/PaperApplicationForm/sections/FormDemographics.test.tsx index 0449832ba5..a1157e88e9 100644 --- a/sites/partners/__tests__/components/applications/sections/FormDemographics.test.tsx +++ b/sites/partners/__tests__/components/applications/PaperApplicationForm/sections/FormDemographics.test.tsx @@ -1,6 +1,5 @@ -import { FormDemographics } from "../../../../src/components/applications/PaperApplicationForm/sections/FormDemographics" -import { mockNextRouter, render, screen } from "../../../testUtils" -import { FormProviderWrapper } from "./helpers" +import { FormDemographics } from "../../../../../src/components/applications/PaperApplicationForm/sections/FormDemographics" +import { mockNextRouter, render, screen, FormProviderWrapper } from "../../../../testUtils" import React from "react" import userEvent from "@testing-library/user-event" diff --git a/sites/partners/__tests__/components/applications/sections/FormHouseholdDetails.test.tsx b/sites/partners/__tests__/components/applications/PaperApplicationForm/sections/FormHouseholdDetails.test.tsx similarity index 98% rename from sites/partners/__tests__/components/applications/sections/FormHouseholdDetails.test.tsx rename to sites/partners/__tests__/components/applications/PaperApplicationForm/sections/FormHouseholdDetails.test.tsx index f6f825112a..15f47ce266 100644 --- a/sites/partners/__tests__/components/applications/sections/FormHouseholdDetails.test.tsx +++ b/sites/partners/__tests__/components/applications/PaperApplicationForm/sections/FormHouseholdDetails.test.tsx @@ -1,7 +1,6 @@ import React from "react" -import { mockNextRouter, render, screen } from "../../../testUtils" -import { FormProviderWrapper } from "./helpers" -import { FormHouseholdDetails } from "../../../../src/components/applications/PaperApplicationForm/sections/FormHouseholdDetails" +import { mockNextRouter, render, screen, FormProviderWrapper } from "../../../../testUtils" +import { FormHouseholdDetails } from "../../../../../src/components/applications/PaperApplicationForm/sections/FormHouseholdDetails" import { UnitTypeEnum } from "@bloom-housing/shared-helpers/src/types/backend-swagger" import { unit } from "@bloom-housing/shared-helpers/__tests__/testHelpers" diff --git a/sites/partners/__tests__/components/applications/sections/FormHouseholdIncome.test.tsx b/sites/partners/__tests__/components/applications/PaperApplicationForm/sections/FormHouseholdIncome.test.tsx similarity index 87% rename from sites/partners/__tests__/components/applications/sections/FormHouseholdIncome.test.tsx rename to sites/partners/__tests__/components/applications/PaperApplicationForm/sections/FormHouseholdIncome.test.tsx index 4cb6aad569..5150a7232e 100644 --- a/sites/partners/__tests__/components/applications/sections/FormHouseholdIncome.test.tsx +++ b/sites/partners/__tests__/components/applications/PaperApplicationForm/sections/FormHouseholdIncome.test.tsx @@ -1,7 +1,6 @@ import React from "react" -import { FormHouseholdIncome } from "../../../../src/components/applications/PaperApplicationForm/sections/FormHouseholdIncome" -import { mockNextRouter, render, screen } from "../../../testUtils" -import { FormProviderWrapper } from "./helpers" +import { FormHouseholdIncome } from "../../../../../src/components/applications/PaperApplicationForm/sections/FormHouseholdIncome" +import { mockNextRouter, render, screen, FormProviderWrapper } from "../../../../testUtils" import userEvent from "@testing-library/user-event" beforeAll(() => { diff --git a/sites/partners/__tests__/components/applications/sections/FormHouseholdMembers.test.tsx b/sites/partners/__tests__/components/applications/PaperApplicationForm/sections/FormHouseholdMembers.test.tsx similarity index 98% rename from sites/partners/__tests__/components/applications/sections/FormHouseholdMembers.test.tsx rename to sites/partners/__tests__/components/applications/PaperApplicationForm/sections/FormHouseholdMembers.test.tsx index e2029f627c..325e3686d5 100644 --- a/sites/partners/__tests__/components/applications/sections/FormHouseholdMembers.test.tsx +++ b/sites/partners/__tests__/components/applications/PaperApplicationForm/sections/FormHouseholdMembers.test.tsx @@ -1,7 +1,6 @@ import React from "react" -import { FormProviderWrapper } from "./helpers" -import { FormHouseholdMembers } from "../../../../src/components/applications/PaperApplicationForm/sections/FormHouseholdMembers" -import { render, screen, within } from "@testing-library/react" +import { FormHouseholdMembers } from "../../../../../src/components/applications/PaperApplicationForm/sections/FormHouseholdMembers" +import { render, screen, within, FormProviderWrapper, mockNextRouter } from "../../../../testUtils" import userEvent from "@testing-library/user-event" import { HouseholdMemberRelationship, @@ -33,6 +32,10 @@ const mockHouseholdMember = { }, } +beforeAll(() => { + mockNextRouter() +}) + describe("", () => { it("should render the add household member button and drawer", async () => { render( diff --git a/sites/partners/__tests__/components/applications/sections/FormMultiselectQuestions.test.tsx b/sites/partners/__tests__/components/applications/PaperApplicationForm/sections/FormMultiselectQuestions.test.tsx similarity index 97% rename from sites/partners/__tests__/components/applications/sections/FormMultiselectQuestions.test.tsx rename to sites/partners/__tests__/components/applications/PaperApplicationForm/sections/FormMultiselectQuestions.test.tsx index 9053f6ed72..0a4f93cd80 100644 --- a/sites/partners/__tests__/components/applications/sections/FormMultiselectQuestions.test.tsx +++ b/sites/partners/__tests__/components/applications/PaperApplicationForm/sections/FormMultiselectQuestions.test.tsx @@ -1,8 +1,7 @@ import React from "react" -import { FormMultiselectQuestions } from "../../../../src/components/applications/PaperApplicationForm/sections/FormMultiselectQuestions" -import { FormProviderWrapper } from "./helpers" +import { FormMultiselectQuestions } from "../../../../../src/components/applications/PaperApplicationForm/sections/FormMultiselectQuestions" import { MultiselectQuestionsApplicationSectionEnum } from "@bloom-housing/shared-helpers/src/types/backend-swagger" -import { mockNextRouter, render, screen } from "../../../testUtils" +import { mockNextRouter, render, screen, FormProviderWrapper } from "../../../../testUtils" import { multiselectQuestionPreference } from "@bloom-housing/shared-helpers/__tests__/testHelpers" import userEvent from "@testing-library/user-event" diff --git a/sites/partners/__tests__/components/applications/sections/FormPrimaryApplicant.test.tsx b/sites/partners/__tests__/components/applications/PaperApplicationForm/sections/FormPrimaryApplicant.test.tsx similarity index 95% rename from sites/partners/__tests__/components/applications/sections/FormPrimaryApplicant.test.tsx rename to sites/partners/__tests__/components/applications/PaperApplicationForm/sections/FormPrimaryApplicant.test.tsx index e8858aa179..ac24f20610 100644 --- a/sites/partners/__tests__/components/applications/sections/FormPrimaryApplicant.test.tsx +++ b/sites/partners/__tests__/components/applications/PaperApplicationForm/sections/FormPrimaryApplicant.test.tsx @@ -1,9 +1,19 @@ -import { render, screen, waitFor, within } from "@testing-library/react" +import { + render, + screen, + waitFor, + within, + FormProviderWrapper, + mockNextRouter, +} from "../../../../testUtils" import React from "react" -import { FormPrimaryApplicant } from "../../../../src/components/applications/PaperApplicationForm/sections/FormPrimaryApplicant" -import { FormProviderWrapper } from "./helpers" +import { FormPrimaryApplicant } from "../../../../../src/components/applications/PaperApplicationForm/sections/FormPrimaryApplicant" import userEvent from "@testing-library/user-event" +beforeAll(() => { + mockNextRouter() +}) + describe("", () => { it("renders the form with primary applicant fields", () => { render( diff --git a/sites/partners/__tests__/components/applications/sections/FormTerms.test.tsx b/sites/partners/__tests__/components/applications/PaperApplicationForm/sections/FormTerms.test.tsx similarity index 71% rename from sites/partners/__tests__/components/applications/sections/FormTerms.test.tsx rename to sites/partners/__tests__/components/applications/PaperApplicationForm/sections/FormTerms.test.tsx index 985cd03b71..dfc362c5ae 100644 --- a/sites/partners/__tests__/components/applications/sections/FormTerms.test.tsx +++ b/sites/partners/__tests__/components/applications/PaperApplicationForm/sections/FormTerms.test.tsx @@ -1,6 +1,5 @@ -import { FormTerms } from "../../../../src/components/applications/PaperApplicationForm/sections/FormTerms" -import { mockNextRouter, render, screen } from "../../../testUtils" -import { FormProviderWrapper } from "./helpers" +import { FormTerms } from "../../../../../src/components/applications/PaperApplicationForm/sections/FormTerms" +import { mockNextRouter, render, screen, FormProviderWrapper } from "../../../../testUtils" import React from "react" beforeAll(() => { diff --git a/sites/partners/__tests__/components/applications/sections/helpers.tsx b/sites/partners/__tests__/components/applications/sections/helpers.tsx deleted file mode 100644 index abe6258cbc..0000000000 --- a/sites/partners/__tests__/components/applications/sections/helpers.tsx +++ /dev/null @@ -1,8 +0,0 @@ -import { FormProvider, useForm } from "react-hook-form" -import { FormTypes } from "../../../../src/lib/applications/FormTypes" -import React from "react" - -export const FormProviderWrapper = ({ children }: React.PropsWithChildren) => { - const formMethods = useForm({}) - return {children} -} diff --git a/sites/partners/__tests__/components/listings/PaperListingDetails/sections/ApplicationType.test.tsx b/sites/partners/__tests__/components/listings/PaperListingDetails/sections/ApplicationType.test.tsx new file mode 100644 index 0000000000..39ba35c019 --- /dev/null +++ b/sites/partners/__tests__/components/listings/PaperListingDetails/sections/ApplicationType.test.tsx @@ -0,0 +1,194 @@ +import React from "react" +import { AuthContext } from "@bloom-housing/shared-helpers" +import { render, screen, within } from "@testing-library/react" +import { listing } from "@bloom-housing/shared-helpers/__tests__/testHelpers" +import { ListingContext } from "../../../../../src/components/listings/ListingContext" +import DetailApplicationTypes from "../../../../../src/components/listings/PaperListingDetails/sections/DetailApplicationTypes" +import { + ApplicationMethodsTypeEnum, + FeatureFlagEnum, + LanguagesEnum, +} from "@bloom-housing/shared-helpers/src/types/backend-swagger" + +describe("", () => { + it("should render the basic section component content", () => { + render( + false, + }} + > + + + + + ) + + expect(screen.getByRole("heading", { level: 2, name: "Application types" })).toBeInTheDocument() + expect(screen.getByText("Online applications")).toBeInTheDocument() + expect(screen.getByText("Paper applications")).toBeInTheDocument() + expect(screen.getByText("Referral")).toBeInTheDocument() + expect(screen.getAllByText("No")).toHaveLength(3) + + expect(screen.queryByText("Common digital application")).not.toBeInTheDocument() + }) + + it("should render full section component content", () => { + render( + false, + }} + > + + + + + ) + + expect(screen.getByRole("heading", { level: 2, name: "Application types" })).toBeInTheDocument() + expect(screen.getByText("Online applications")).toBeInTheDocument() + expect(screen.getByText("Common digital application")).toBeInTheDocument() + expect(screen.getByText("Custom online application URL")).toBeInTheDocument() + expect(screen.getByText("https://testexternallink.com")).toBeInTheDocument() + expect(screen.getAllByText("Paper applications")).toHaveLength(2) + const applicationsTable = screen.getByRole("table") + expect(applicationsTable).toBeInTheDocument() + const [head, body] = within(applicationsTable).getAllByRole("rowgroup") + const headers = within(head).getAllByRole("columnheader") + expect(headers).toHaveLength(2) + expect(headers[0]).toHaveTextContent(/file name/i) + expect(headers[1]).toHaveTextContent(/language/i) + const rows = within(body).getAllByRole("row") + expect(rows).toHaveLength(1) + const [fileName, language] = within(rows[0]).getAllByRole("cell") + expect(fileName).toHaveTextContent("Test File Name.pdf") + expect(language).toHaveTextContent("English") + expect(screen.getByText("Referral")).toBeInTheDocument() + expect(screen.getByText("Referral contact phone")).toBeInTheDocument() + expect(screen.getByText("(123) 456-7890")).toBeInTheDocument() + expect(screen.getByText("Referral summary")).toBeInTheDocument() + expect(screen.getByText("Test referral summary")).toBeInTheDocument() + expect(screen.getAllByText("Yes")).toHaveLength(3) + expect(screen.getAllByText("No")).toHaveLength(1) + }) + + it("should hide the digital common application when the disableCommonApplication is turned on", () => { + render( + + featureFlag === FeatureFlagEnum.disableCommonApplication, + }} + > + + + + + ) + + expect(screen.getByText("Online applications")).toBeInTheDocument() + expect(screen.queryByText("Common digital application")).not.toBeInTheDocument() + expect(screen.getByText("Custom online application URL")).toBeInTheDocument() + expect(screen.getByText("https://testexternallink.com")).toBeInTheDocument() + }) + + it("should update label when the enableReferralQuestionUnits is turned on", () => { + render( + + featureFlag === FeatureFlagEnum.enableReferralQuestionUnits, + }} + > + + + + + ) + + expect(screen.getByText("Referral only units")).toBeInTheDocument() + expect(screen.queryByText("Referral")).not.toBeInTheDocument() + }) +}) diff --git a/sites/partners/__tests__/components/listings/PaperListingDetails/sections/DetailListingData.test.tsx b/sites/partners/__tests__/components/listings/PaperListingDetails/sections/DetailListingData.test.tsx new file mode 100644 index 0000000000..76163bc8f1 --- /dev/null +++ b/sites/partners/__tests__/components/listings/PaperListingDetails/sections/DetailListingData.test.tsx @@ -0,0 +1,56 @@ +import React from "react" +import { render, screen } from "@testing-library/react" +import { listing } from "@bloom-housing/shared-helpers/__tests__/testHelpers" +import { ReviewOrderTypeEnum } from "@bloom-housing/shared-helpers/src/types/backend-swagger" +import { ListingContext } from "../../../../../src/components/listings/ListingContext" +import DetailListingData from "../../../../../src/components/listings/PaperListingDetails/sections/DetailListingData" + +describe("DetailListingData", () => { + it("should render all data", () => { + render( + + + + ) + expect(screen.getByRole("heading", { level: 2, name: "Listing data" })).toBeInTheDocument() + expect(screen.getByText("Date created")).toBeInTheDocument() + expect(screen.getByText("01/01/2023 at 10:00 AM")).toBeInTheDocument() + expect(screen.getByText("Jurisdiction")).toBeInTheDocument() + expect(screen.getByText("Bloomington")).toBeInTheDocument() + expect(screen.getByText("Listing ID")).toBeInTheDocument() + expect(screen.getByText("1234")).toBeInTheDocument() + }) + + it("should hide jurisdiction", () => { + render( + + + + ) + expect(screen.getByRole("heading", { level: 2, name: "Listing data" })).toBeInTheDocument() + expect(screen.getByText("Date created")).toBeInTheDocument() + expect(screen.getByText("01/01/2023 at 10:00 AM")).toBeInTheDocument() + expect(screen.queryByText("Jurisdiction")).not.toBeInTheDocument() + expect(screen.queryByText("Bloomington")).not.toBeInTheDocument() + expect(screen.getByText("Listing ID")).toBeInTheDocument() + expect(screen.getByText("1234")).toBeInTheDocument() + }) +}) diff --git a/sites/partners/__tests__/components/listings/PaperListingDetails/sections/DetailUnits.test.tsx b/sites/partners/__tests__/components/listings/PaperListingDetails/sections/DetailUnits.test.tsx index c498263f17..24ac2b87d0 100644 --- a/sites/partners/__tests__/components/listings/PaperListingDetails/sections/DetailUnits.test.tsx +++ b/sites/partners/__tests__/components/listings/PaperListingDetails/sections/DetailUnits.test.tsx @@ -4,9 +4,11 @@ import { DetailUnits } from "../../../../../src/components/listings/PaperListing import { ListingContext } from "../../../../../src/components/listings/ListingContext" import { listing, unit } from "@bloom-housing/shared-helpers/__tests__/testHelpers" import { + EnumListingListingType, EnumUnitGroupAmiLevelMonthlyRentDeterminationType, FeatureFlagEnum, HomeTypeEnum, + RentTypeEnum, ReviewOrderTypeEnum, UnitTypeEnum, } from "@bloom-housing/shared-helpers/src/types/backend-swagger" @@ -16,7 +18,8 @@ function mockJurisdictionsHaveFeatureFlagOn( featureFlag: string, enableHomeType = true, enableSection8Question = true, - enableUnitGroups = false + enableUnitGroups = false, + enableNonRegulatedListings = false ) { switch (featureFlag) { case FeatureFlagEnum.enableHomeType: @@ -25,6 +28,8 @@ function mockJurisdictionsHaveFeatureFlagOn( return enableSection8Question case FeatureFlagEnum.enableUnitGroups: return enableUnitGroups + case FeatureFlagEnum.enableNonRegulatedListings: + return enableNonRegulatedListings } } @@ -149,7 +154,7 @@ describe("DetailUnits", () => { expect(screen.getByText("None")).toBeInTheDocument() }) - it("should render the detail units with unit groups", () => { + it("should render the detail units with unit groups for regulated listing", () => { render( { expect(bath).toHaveTextContent("1 - 2") }) + it("should render the detail units with unit groups for non-regulated listing", () => { + render( + + mockJurisdictionsHaveFeatureFlagOn(featureFlag, false, true, true, true), + }} + > + + + + + ) + + // Above the table + expect(screen.getByRole("heading", { level: 2, name: /listing units/i })).toBeInTheDocument() + expect( + screen.queryByText("Do you want to show unit types or individual units?") + ).not.toBeInTheDocument() + expect(screen.queryByText("Individual units")).not.toBeInTheDocument() + expect(screen.queryByText("What is the listing availability?")).not.toBeInTheDocument() + expect(screen.queryByText("Open waitlist")).not.toBeInTheDocument() + expect(screen.queryAllByText("Home type")).toHaveLength(0) + + // Table + const table = screen.getByRole("table") + const headAndBody = within(table).getAllByRole("rowgroup") + expect(headAndBody).toHaveLength(2) + const [head, body] = headAndBody + + const columnHeaders = within(head).getAllByRole("columnheader") + expect(columnHeaders).toHaveLength(5) + + expect(columnHeaders[0]).toHaveTextContent("Unit type") + expect(columnHeaders[1]).toHaveTextContent("# of units") + expect(columnHeaders[2]).toHaveTextContent("Rent") + expect(columnHeaders[3]).toHaveTextContent("Occupancy") + expect(columnHeaders[4]).toHaveTextContent("Bath") + + const rows = within(body).getAllByRole("row") + expect(rows).toHaveLength(2) + // Validate first row + const [firstUnitType, firstUnitsNumber, firstRent, firstOccupancy, firstBath] = within( + rows[0] + ).getAllByRole("cell") + + expect(firstUnitType).toHaveTextContent("2 BR, SRO") + expect(firstUnitsNumber).toHaveTextContent("2") + expect(firstRent).toHaveTextContent("2000") + expect(firstOccupancy).toHaveTextContent("1 - 4") + expect(firstBath).toHaveTextContent("1 - 2") + + const [secondUnitType, secondUnitsNumber, secondRent, secondOccupancy, secondBath] = within( + rows[1] + ).getAllByRole("cell") + + expect(secondUnitType).toHaveTextContent("4 BR") + expect(secondUnitsNumber).toHaveTextContent("1") + expect(secondRent).toHaveTextContent("1250 - 1750") + expect(secondOccupancy).toHaveTextContent("2 - 5") + expect(secondBath).toHaveTextContent("2") + }) + describe("Listing availability text", () => { it("should render 'Open waitlist' for waitlistLottery review order type", () => { render( diff --git a/sites/partners/__tests__/components/listings/PaperListingForm/UnitGroupForm.test.tsx b/sites/partners/__tests__/components/listings/PaperListingForm/UnitGroupForm.test.tsx index bb26e4242d..180e898499 100644 --- a/sites/partners/__tests__/components/listings/PaperListingForm/UnitGroupForm.test.tsx +++ b/sites/partners/__tests__/components/listings/PaperListingForm/UnitGroupForm.test.tsx @@ -1,4 +1,3 @@ -import { render, screen, waitFor, within } from "@testing-library/react" import userEvent from "@testing-library/user-event" import { randomUUID } from "crypto" import { rest } from "msw" @@ -10,10 +9,17 @@ import { unitGroup, unitTypes, } from "@bloom-housing/shared-helpers/__tests__/testHelpers" -import { mockNextRouter } from "../../../testUtils" -import { FormProviderWrapper } from "../../applications/sections/helpers" +import { + render, + screen, + waitFor, + within, + mockNextRouter, + FormProviderWrapper, +} from "../../../testUtils" import { TempUnitGroup } from "../../../../src/lib/listings/formTypes" import UnitGroupForm from "../../../../src/components/listings/PaperListingForm/UnitGroupForm" +import { EnumListingListingType } from "@bloom-housing/shared-helpers/src/types/backend-swagger" const server = setupServer() @@ -84,7 +90,13 @@ describe("", () => { expect(screen.getByLabelText(/4\+ bedroom/i)).toBeInTheDocument() // Details Section - expect(screen.getByLabelText(/Affordable Unit Group Quantity/i)).toBeInTheDocument() + expect(screen.getByLabelText(/Unit Group Quantity/i)).toBeInTheDocument() + expect(screen.queryByRole("group", { name: /^rent type$/i })).not.toBeInTheDocument() + expect(screen.queryByRole("spinbutton", { name: /^monthly rent$/i })).not.toBeInTheDocument() + expect( + screen.queryByRole("spinbutton", { name: /^monthly rent from$/i }) + ).not.toBeInTheDocument() + expect(screen.queryByRole("spinbutton", { name: /^monthly rent to$/i })).not.toBeInTheDocument() expect(screen.getByLabelText(/Minimum occupancy/i)).toBeInTheDocument() expect(screen.getByLabelText(/Max occupancy/i)).toBeInTheDocument() expect(screen.getByLabelText(/Min square footage/i)).toBeInTheDocument() @@ -275,6 +287,102 @@ describe("", () => { ) }) + it("should render the unit group form for non-regulated listings", async () => { + render( + + + + + + ) + + expect(screen.getAllByRole("heading", { level: 2, name: /details/i })).toHaveLength(2) + + // Unit Types Section + expect(screen.getByText(/unit type/i)).toBeInTheDocument() + expect(await screen.findByRole("checkbox", { name: /studio/i })).toBeInTheDocument() + expect(screen.getByRole("checkbox", { name: /1 bedroom/i })).toBeInTheDocument() + expect(screen.getByRole("checkbox", { name: /2 bedroom/i })).toBeInTheDocument() + expect(screen.getByRole("checkbox", { name: /3 bedroom/i })).toBeInTheDocument() + expect(screen.getByRole("checkbox", { name: "4+ bedroom" })).toBeInTheDocument() + + // Details Section + expect(screen.getByRole("spinbutton", { name: /Unit Group Quantity/i })).toBeInTheDocument() + + expect(screen.getByRole("group", { name: /^rent type$/i })).toBeInTheDocument() + const fixedRentOption = screen.getByRole("radio", { name: /^fixed rent$/i }) + const rentRangeOption = screen.getByRole("radio", { name: /^rent range$/i }) + expect(fixedRentOption).toBeInTheDocument() + expect(fixedRentOption).not.toBeChecked() + expect(rentRangeOption).toBeInTheDocument() + expect(rentRangeOption).not.toBeChecked() + + expect(screen.queryByRole("spinbutton", { name: /^monthly rent$/i })).not.toBeInTheDocument() + expect( + screen.queryByRole("spinbutton", { name: /^monthly rent from$/i }) + ).not.toBeInTheDocument() + expect(screen.queryByRole("spinbutton", { name: /^monthly rent to$/i })).not.toBeInTheDocument() + + await userEvent.click(fixedRentOption) + expect(fixedRentOption).toBeChecked() + expect(rentRangeOption).not.toBeChecked() + + expect(screen.getByRole("spinbutton", { name: /^monthly rent$/i })).toBeInTheDocument() + expect( + screen.queryByRole("spinbutton", { name: /^monthly rent from$/i }) + ).not.toBeInTheDocument() + expect(screen.queryByRole("spinbutton", { name: /^monthly rent to$/i })).not.toBeInTheDocument() + + await userEvent.click(rentRangeOption) + expect(fixedRentOption).not.toBeChecked() + expect(rentRangeOption).toBeChecked() + + expect(screen.queryByRole("spinbutton", { name: /^monthly rent$/i })).not.toBeInTheDocument() + expect(screen.getByRole("spinbutton", { name: /^monthly rent from$/i })).toBeInTheDocument() + expect(screen.getByRole("spinbutton", { name: /^monthly rent to$/i })).toBeInTheDocument() + + expect(screen.getByRole("combobox", { name: /Minimum occupancy/i })).toBeInTheDocument() + expect(screen.getByRole("combobox", { name: /Max occupancy/i })).toBeInTheDocument() + expect( + screen.queryByRole("spinbutton", { name: /Min square footage/i }) + ).not.toBeInTheDocument() + expect( + screen.queryByRole("spinbutton", { name: /Max square footage/i }) + ).not.toBeInTheDocument() + expect(screen.queryByRole("combobox", { name: /Minimum floor/i })).not.toBeInTheDocument() + expect(screen.queryByRole("combobox", { name: /Maximum floor/i })).not.toBeInTheDocument() + expect(screen.getByRole("combobox", { name: /Min number of bathrooms/i })).toBeInTheDocument() + expect(screen.getByRole("combobox", { name: /Max number of bathrooms/i })).toBeInTheDocument() + + // Availability Section + expect(screen.getByRole("heading", { level: 2, name: /availability/i })).toBeInTheDocument() + + expect(screen.getByLabelText(/Unit group vacancies/i)).toBeInTheDocument() + expect(screen.queryByRole("group", { name: /Waitlist status/i })).not.toBeInTheDocument() + expect(screen.queryByLabelText(/^Open$/i)).not.toBeInTheDocument() + expect(screen.queryByLabelText(/Closed/i)).not.toBeInTheDocument() + + // Eligibility Section + expect(screen.queryByRole("heading", { level: 2, name: "Eligibility" })).not.toBeInTheDocument() + expect(screen.queryByRole("table")).not.toBeInTheDocument() + expect(screen.queryByRole("button", { name: "Add AMI level" })).not.toBeInTheDocument() + }) + describe("ami levels table delete functionality", () => { it("should remove ami chart on delete click", async () => { render( @@ -587,7 +695,7 @@ describe("", () => { await userEvent.click(studioButton) const quantityInput = screen.getByRole("spinbutton", { - name: /affordable unit group quantity/i, + name: /unit group quantity/i, }) await userEvent.type(quantityInput, "4") diff --git a/sites/partners/__tests__/components/listings/PaperListingForm/index.test.tsx b/sites/partners/__tests__/components/listings/PaperListingForm/index.test.tsx index 42b3d98b77..3ca3c5d568 100644 --- a/sites/partners/__tests__/components/listings/PaperListingForm/index.test.tsx +++ b/sites/partners/__tests__/components/listings/PaperListingForm/index.test.tsx @@ -7,7 +7,6 @@ import { jurisdiction, mockBaseJurisdiction, mockUser, - user, } from "@bloom-housing/shared-helpers/__tests__/testHelpers" import { FeatureFlag, @@ -30,7 +29,9 @@ const jurisdictions = [ { name: FeatureFlagEnum.enableRegions, active: true } as FeatureFlag, { name: FeatureFlagEnum.enableHomeType, active: true } as FeatureFlag, { name: FeatureFlagEnum.enableCompanyWebsite, active: true } as FeatureFlag, + { name: FeatureFlagEnum.enableWhatToExpectAdditionalField, active: true } as FeatureFlag, ], + whatToExpect: "Here's what you might expect from the process.", requiredListingFields: [ "listingsBuildingAddress", "name", @@ -126,16 +127,17 @@ describe("add listing", () => { return false } }, + getJurisdictionLanguages: () => [], }} > - + ) // Listing Details Tab expect(screen.getByRole("button", { name: "Listing details" })) expect(screen.getByRole("heading", { level: 2, name: "Listing intro" })) - expect(screen.getByRole("heading", { level: 2, name: "Listing photo" })) + expect(screen.getByRole("heading", { level: 2, name: "Listing photos" })) expect(screen.getByRole("heading", { level: 2, name: "Building details" })) expect(screen.getByRole("heading", { level: 2, name: "Listing units" })) expect(screen.getByRole("heading", { level: 2, name: "Housing preferences" })) @@ -181,29 +183,41 @@ describe("add listing", () => { return res(ctx.json([])) }) ) + + const mockRetrieve = jest.fn().mockResolvedValue({}) + render( [], - jurisdictionsService: new JurisdictionsService(), doJurisdictionsHaveFeatureFlagOn: (featureFlag: FeatureFlagEnum) => { switch (featureFlag) { - case FeatureFlagEnum.swapCommunityTypeWithPrograms: - return false + case FeatureFlagEnum.enableRegions: + return true + case FeatureFlagEnum.enableHomeType: + return true + case FeatureFlagEnum.enableCompanyWebsite: + return true case FeatureFlagEnum.enableWhatToExpectAdditionalField: return true default: return false } }, + getJurisdictionLanguages, + profile: { + ...mockUser, + listings: [], + jurisdictions: jurisdictions as Jurisdiction[], + userRoles: { + isAdmin: true, + }, + }, + jurisdictionsService: { + retrieve: mockRetrieve, + } as unknown as JurisdictionsService, }} > - + ) @@ -213,18 +227,11 @@ describe("add listing", () => { /tell the applicant what to expect from the process/i ) expect(whatToExpectEditorLabel).toBeInTheDocument() - const whatToExpectEditorWrapper = whatToExpectEditorLabel.parentElement.parentElement + const whatToExpectEditorWrapper = + whatToExpectEditorLabel.parentElement.parentElement.parentElement expect( - await within(whatToExpectEditorWrapper).findByText( - "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." - ) - ).toBeInTheDocument() - expect( - within(whatToExpectEditorWrapper).getByText("You have 451 characters remaining") - ).toBeInTheDocument() - expect( - within(whatToExpectEditorWrapper).getByRole("menuitem", { name: "Bold" }) + await within(whatToExpectEditorWrapper).findByRole("menuitem", { name: "Bold" }) ).toBeInTheDocument() expect( within(whatToExpectEditorWrapper).getByRole("menuitem", { name: "Bullet list" }) @@ -241,6 +248,15 @@ describe("add listing", () => { expect( within(whatToExpectEditorWrapper).getByRole("menuitem", { name: "Unlink" }) ).toBeInTheDocument() + + expect( + within(whatToExpectEditorWrapper).getByText("Here's what you might expect from the process.") + ).toBeInTheDocument() + + expect( + within(whatToExpectEditorWrapper).getByText("You have 954 characters remaining") + ).toBeInTheDocument() + // Query issue: https://github.com/ueberdosis/tiptap/discussions/4008#discussioncomment-7623655 const editor = screen.getByTestId("whatToExpect").firstElementChild.querySelector("p") act(() => { @@ -260,15 +276,7 @@ describe("add listing", () => { whatToExpectAdditonalTextEditorLabel.parentElement.parentElement expect( - await within(whatToExpectAdditonalTextEditorWrapper).findByText( - "Property staff should walk you through the process to get on their waitlist." - ) - ).toBeInTheDocument() - expect( - within(whatToExpectAdditonalTextEditorWrapper).getByText("You have 924 characters remaining") - ).toBeInTheDocument() - expect( - within(whatToExpectAdditonalTextEditorWrapper).getByRole("menuitem", { name: "Bold" }) + await within(whatToExpectAdditonalTextEditorWrapper).findByRole("menuitem", { name: "Bold" }) ).toBeInTheDocument() expect( within(whatToExpectAdditonalTextEditorWrapper).getByRole("menuitem", { name: "Bullet list" }) @@ -287,6 +295,17 @@ describe("add listing", () => { expect( within(whatToExpectAdditonalTextEditorWrapper).getByRole("menuitem", { name: "Unlink" }) ).toBeInTheDocument() + + expect( + within(whatToExpectAdditonalTextEditorWrapper).getByText( + "Property staff should walk you through the process to get on their waitlist." + ) + ).toBeInTheDocument() + + expect( + within(whatToExpectAdditonalTextEditorWrapper).getByText("You have 473 characters remaining") + ).toBeInTheDocument() + // Query issue: https://github.com/ueberdosis/tiptap/discussions/4008#discussioncomment-7623655 const whatToExpectAdditonalTextEditor = screen .getByTestId("whatToExpectAdditionalText") @@ -346,6 +365,8 @@ describe("add listing", () => { return true case FeatureFlagEnum.enableHomeType: return true + case FeatureFlagEnum.enableParkingFee: + return true case FeatureFlagEnum.enableCompanyWebsite: return true default: @@ -358,11 +379,11 @@ describe("add listing", () => { } as unknown as JurisdictionsService, }} > - + ) - const requiredFields = ["Listing name", "Jurisdiction"] + const requiredFields = ["Listing name"] const unrequiredFields = [ "Housing developer", @@ -377,14 +398,13 @@ describe("add listing", () => { "Reserved community description", "Units", "Application fee", - "Deposit min", - "Deposit max", "Deposit helper text", "Costs not included", "Property amenities", "Additional accessibility", "Unit amenities", "Smoking policy", + "Parking fee", "Pets policy", "Services offered", "Credit history", @@ -475,13 +495,12 @@ describe("add listing", () => { } as unknown as JurisdictionsService, }} > - + ) const possibleRequiredFields = [ "Listing name", - "Jurisdiction", "Housing developer", "Photos", "Street address", @@ -496,8 +515,6 @@ describe("add listing", () => { "Home type", "Units", "Application fee", - "Deposit min", - "Deposit max", "Deposit helper text", "Costs not included", "Property amenities", diff --git a/sites/partners/__tests__/components/listings/PaperListingForm/sections/AdditionalDetails.test.tsx b/sites/partners/__tests__/components/listings/PaperListingForm/sections/AdditionalDetails.test.tsx new file mode 100644 index 0000000000..4205a64447 --- /dev/null +++ b/sites/partners/__tests__/components/listings/PaperListingForm/sections/AdditionalDetails.test.tsx @@ -0,0 +1,170 @@ +import React from "react" +import { setupServer } from "msw/lib/node" +import { FormProviderWrapper, mockNextRouter } from "../../../../testUtils" +import { render, screen } from "@testing-library/react" +import { + EnumListingListingType, + FeatureFlagEnum, +} from "@bloom-housing/shared-helpers/src/types/backend-swagger" +import AdditionalDetails from "../../../../../src/components/listings/PaperListingForm/sections/AdditionalDetails" +import { AuthContext } from "@bloom-housing/shared-helpers" +import { jurisdiction, user } from "@bloom-housing/shared-helpers/__tests__/testHelpers" + +const server = setupServer() + +// Enable API mocking before tests. +beforeAll(() => { + server.listen() + mockNextRouter() +}) + +// Reset any runtime request handlers we may add during the tests. +afterEach(() => server.resetHandlers()) + +// Disable API mocking after the tests are done. +afterAll(() => server.close()) + +describe("AdditionalDetails", () => { + it("should render the AdditionalDetails section with default/regulated fields", async () => { + render( + + featureFlag === FeatureFlagEnum.enableNonRegulatedListings, + }} + > + + + + + ) + + // Check for the section heading + expect( + await screen.findByRole("heading", { level: 2, name: /additional details/i }) + ).toBeInTheDocument() + expect( + screen.getByText("Are there any other required documents and selection criteria?") + ).toBeInTheDocument() + + expect(screen.getByRole("textbox", { name: /^required documents$/i })).toBeInTheDocument() + expect(screen.getByRole("textbox", { name: /^important program rules$/i })).toBeInTheDocument() + expect(screen.getByRole("textbox", { name: /^required documents$/i })).toBeInTheDocument() + + expect( + screen.queryByRole("textbox", { name: /^required documents (additional info)$/i }) + ).not.toBeInTheDocument() + expect(screen.queryByRole("group", { name: /^required documents$/i })).not.toBeInTheDocument() + }) + + it("should render the AdditionalDetails section with non-regulated fields", async () => { + render( + + featureFlag === FeatureFlagEnum.enableNonRegulatedListings, + }} + > + + + + + ) + + // Check for the section heading + expect( + await screen.findByRole("heading", { level: 2, name: /additional details/i }) + ).toBeInTheDocument() + expect( + screen.getByText("Are there any other required documents and selection criteria?") + ).toBeInTheDocument() + + expect(screen.getByRole("group", { name: /^required documents$/i })).toBeInTheDocument() + + const socialSecurityCard = screen.getByRole("checkbox", { name: /^social security card$/i }) + const currentLandlordReference = screen.getByRole("checkbox", { + name: /^current landlord reference$/i, + }) + const birthCertificate = screen.getByRole("checkbox", { + name: /^birth certificate \(all household members 18\+\)$/i, + }) + const previousLandlordReference = screen.getByRole("checkbox", { + name: /^previous landlord reference$/i, + }) + const governmentIssuedId = screen.getByRole("checkbox", { + name: /^government-issued ID \(all household members 18\+\)$/i, + }) + const proofOfAssets = screen.getByRole("checkbox", { + name: /^proof of assets \(bank statements, etc.\)$/i, + }) + const proofOfIncome = screen.getByRole("checkbox", { + name: /^proof of household income \(check stubs, W-2, etc.\)$/i, + }) + const residencyDocuments = screen.getByRole("checkbox", { + name: /^immigration\/residency documents \(green card, etc.\)$/i, + }) + const proofOfCustody = screen.getByRole("checkbox", { + name: /^proof of custody\/guardianship$/i, + }) + + expect(socialSecurityCard).toBeInTheDocument() + expect(socialSecurityCard).toBeChecked() + expect(currentLandlordReference).toBeInTheDocument() + expect(currentLandlordReference).toBeChecked() + expect(birthCertificate).toBeInTheDocument() + expect(birthCertificate).toBeChecked() + expect(previousLandlordReference).toBeInTheDocument() + expect(previousLandlordReference).toBeChecked() + expect(governmentIssuedId).toBeInTheDocument() + expect(governmentIssuedId).not.toBeChecked() + expect(proofOfAssets).toBeInTheDocument() + expect(proofOfAssets).not.toBeChecked() + expect(proofOfIncome).toBeInTheDocument() + expect(proofOfIncome).not.toBeChecked() + expect(residencyDocuments).toBeInTheDocument() + expect(residencyDocuments).not.toBeChecked() + expect(proofOfCustody).toBeInTheDocument() + expect(proofOfCustody).not.toBeChecked() + + expect( + screen.getByRole("textbox", { name: /^required documents \(additional info\)$/i }) + ).toBeInTheDocument() + expect(screen.getByRole("textbox", { name: /^important program rules$/i })).toBeInTheDocument() + + expect(screen.queryByRole("textbox", { name: /^required documents$/i })).not.toBeInTheDocument() + }) +}) diff --git a/sites/partners/__tests__/components/listings/PaperListingForm/sections/AdditionalFees.test.tsx b/sites/partners/__tests__/components/listings/PaperListingForm/sections/AdditionalFees.test.tsx new file mode 100644 index 0000000000..ffb222096c --- /dev/null +++ b/sites/partners/__tests__/components/listings/PaperListingForm/sections/AdditionalFees.test.tsx @@ -0,0 +1,187 @@ +import React from "react" +import { setupServer } from "msw/lib/node" +import { FormProviderWrapper, mockNextRouter } from "../../../../testUtils" +import { render, screen } from "@testing-library/react" +import AdditionalFees from "../../../../../src/components/listings/PaperListingForm/sections/AdditionalFees" +import { AuthContext } from "@bloom-housing/shared-helpers" +import { jurisdiction, user } from "@bloom-housing/shared-helpers/__tests__/testHelpers" +import { + EnumListingListingType, + FeatureFlagEnum, +} from "@bloom-housing/shared-helpers/src/types/backend-swagger" +import userEvent from "@testing-library/user-event" +import "@testing-library/jest-dom" + +const server = setupServer() + +// Enable API mocking before tests. +beforeAll(() => { + server.listen() + mockNextRouter() +}) + +// Reset any runtime request handlers we may add during the tests. +afterEach(() => server.resetHandlers()) + +// Disable API mocking after the tests are done. +afterAll(() => server.close()) + +describe("AdditionalFees", () => { + it("should render the base AdditionalFees", async () => { + render( + false, + }} + > + + + + + ) + + expect( + await screen.findByRole("heading", { level: 2, name: /^additional fees$/i }) + ).toBeInTheDocument() + expect( + screen.getByText(/^tell us about any other fees required by the applicant.$/i) + ).toBeInTheDocument() + + expect(screen.getByRole("textbox", { name: /^application fee$/i })).toBeInTheDocument() + expect(screen.getByRole("textbox", { name: /^deposit helper text$/i })).toBeInTheDocument() + expect(screen.getByRole("textbox", { name: /^costs not included$/i })).toBeInTheDocument() + + expect(screen.queryByRole("group", { name: /^utilities included$/i })).not.toBeInTheDocument() + expect(screen.queryByRole("checkbox", { name: /^water$/i })).not.toBeInTheDocument() + expect(screen.queryByRole("checkbox", { name: /^gas$/i })).not.toBeInTheDocument() + expect(screen.queryByRole("checkbox", { name: /^trash$/i })).not.toBeInTheDocument() + expect(screen.queryByRole("checkbox", { name: /^sewer$/i })).not.toBeInTheDocument() + expect(screen.queryByRole("checkbox", { name: /^electricity$/i })).not.toBeInTheDocument() + expect(screen.queryByRole("checkbox", { name: /^cable$/i })).not.toBeInTheDocument() + expect(screen.queryByRole("checkbox", { name: /^phone$/i })).not.toBeInTheDocument() + expect(screen.queryByRole("checkbox", { name: /^internet$/i })).not.toBeInTheDocument() + }) + + it("should render the AdditionalFees section with utilities included", async () => { + render( + + featureFlag === FeatureFlagEnum.enableUtilitiesIncluded, + }} + > + + + + + ) + + expect( + await screen.findByRole("heading", { level: 2, name: /^additional fees$/i }) + ).toBeInTheDocument() + expect( + screen.getByText(/^tell us about any other fees required by the applicant.$/i) + ).toBeInTheDocument() + + expect(screen.getByRole("textbox", { name: /^application fee$/i })).toBeInTheDocument() + expect(screen.getByRole("textbox", { name: /^deposit helper text$/i })).toBeInTheDocument() + expect(screen.getByRole("textbox", { name: /^costs not included$/i })).toBeInTheDocument() + + expect(screen.getByRole("group", { name: /^utilities included$/i })).toBeInTheDocument() + const water = screen.getByRole("checkbox", { name: /^water$/i }) + const gas = screen.getByRole("checkbox", { name: /^gas$/i }) + const trash = screen.getByRole("checkbox", { name: /^trash$/i }) + const sewer = screen.getByRole("checkbox", { name: /^sewer$/i }) + const electricity = screen.getByRole("checkbox", { name: /^electricity$/i }) + const cable = screen.getByRole("checkbox", { name: /^cable$/i }) + const phone = screen.getByRole("checkbox", { name: /^phone$/i }) + const internet = screen.getByRole("checkbox", { name: /^internet$/i }) + expect(water).toBeInTheDocument() + expect(water).toBeChecked() + expect(gas).toBeInTheDocument() + expect(gas).toBeChecked() + expect(trash).toBeInTheDocument() + expect(trash).toBeChecked() + expect(sewer).toBeInTheDocument() + expect(sewer).toBeChecked() + expect(electricity).toBeInTheDocument() + expect(electricity).not.toBeChecked() + expect(cable).toBeInTheDocument() + expect(cable).not.toBeChecked() + expect(phone).toBeInTheDocument() + expect(phone).not.toBeChecked() + expect(internet).toBeInTheDocument() + expect(internet).not.toBeChecked() + }) + + it("should render the AdditionalFees section for non regulated fields", async () => { + render( + + featureFlag === FeatureFlagEnum.enableNonRegulatedListings, + }} + > + + + + + ) + + expect( + await screen.findByRole("heading", { level: 2, name: /^additional fees$/i }) + ).toBeInTheDocument() + expect( + screen.getByText(/^tell us about any other fees required by the applicant.$/i) + ).toBeInTheDocument() + + expect(screen.getByRole("group", { name: /^deposit type$/i })).toBeInTheDocument() + const fixedDepositOption = screen.getByRole("radio", { name: /^fixed deposit$/i }) + const depositRangeOption = screen.getByRole("radio", { name: /^deposit range$/i }) + expect(fixedDepositOption).toBeInTheDocument() + expect(fixedDepositOption).toBeChecked() + expect(depositRangeOption).toBeInTheDocument() + expect(depositRangeOption).not.toBeChecked() + + expect(screen.getByRole("spinbutton", { name: /^deposit$/i })).toBeInTheDocument() + expect(screen.queryByRole("spinbutton", { name: /^deposit min$/i })).not.toBeInTheDocument() + expect(screen.queryByRole("spinbutton", { name: /^deposit max$/i })).not.toBeInTheDocument() + + await userEvent.click(depositRangeOption) + + expect(fixedDepositOption).not.toBeChecked() + expect(depositRangeOption).toBeChecked() + + expect(screen.getByRole("spinbutton", { name: /^deposit min$/i })).toBeInTheDocument() + expect(screen.getByRole("spinbutton", { name: /^deposit max$/i })).toBeInTheDocument() + expect(screen.queryByRole("spinbutton", { name: /^deposit$/i })).not.toBeInTheDocument() + }) +}) diff --git a/sites/partners/__tests__/components/listings/PaperListingForm/sections/ApplicationDates.test.tsx b/sites/partners/__tests__/components/listings/PaperListingForm/sections/ApplicationDates.test.tsx index 85346e6a82..1e4e103102 100644 --- a/sites/partners/__tests__/components/listings/PaperListingForm/sections/ApplicationDates.test.tsx +++ b/sites/partners/__tests__/components/listings/PaperListingForm/sections/ApplicationDates.test.tsx @@ -1,22 +1,11 @@ import React from "react" -import { rest } from "msw" import { setupServer } from "msw/node" import { screen } from "@testing-library/react" -import { FormProvider, useForm } from "react-hook-form" -import { FeatureFlagEnum } from "@bloom-housing/shared-helpers/src/types/backend-swagger" -import { mockNextRouter, render } from "../../../../testUtils" -import { formDefaults, FormListing } from "../../../../../src/lib/listings/formTypes" +import { FormProviderWrapper, mockNextRouter, render } from "../../../../testUtils" +import { FormListing } from "../../../../../src/lib/listings/formTypes" import ApplicationDates from "../../../../../src/components/listings/PaperListingForm/sections/ApplicationDates" import userEvent from "@testing-library/user-event" -const FormComponent = ({ children, values }: { values?: FormListing; children }) => { - const formMethods = useForm({ - defaultValues: { ...formDefaults, ...values }, - shouldUnregister: false, - }) - return {children} -} - const server = setupServer() // Enable API mocking before tests. @@ -33,26 +22,12 @@ afterAll(() => server.close()) describe("ApplicationDates", () => { it("should render the ApplicationDates section with default fields", () => { - document.cookie = "access-token-available=True" - server.use( - rest.get("http://localhost/api/adapter/user", (_req, res, ctx) => { - return res( - ctx.json({ - jurisdictions: [ - { - id: "JurisdictionA", - name: "JurisdictionA", - featureFlags: [{ name: FeatureFlagEnum.enableMarketingStatus, active: false }], - }, - ], - }) - ) - }) - ) - render( - + { return }} /> - + ) expect(screen.getByRole("heading", { level: 2, name: "Application dates" })).toBeInTheDocument() expect( @@ -82,26 +57,12 @@ describe("ApplicationDates", () => { }) it("should mark due date as required", () => { - document.cookie = "access-token-available=True" - server.use( - rest.get("http://localhost/api/adapter/user", (_req, res, ctx) => { - return res( - ctx.json({ - jurisdictions: [ - { - id: "JurisdictionA", - name: "JurisdictionA", - featureFlags: [{ name: FeatureFlagEnum.enableMarketingStatus, active: false }], - }, - ], - }) - ) - }) - ) - render( - + { return }} /> - + ) expect(screen.getByRole("group", { name: "Application due date *" })).toBeInTheDocument() @@ -117,29 +78,12 @@ describe("ApplicationDates", () => { }) it("should show marketing status section with seasons", async () => { - document.cookie = "access-token-available=True" - server.use( - rest.get("http://localhost/api/adapter/user", (_req, res, ctx) => { - return res( - ctx.json({ - jurisdictions: [ - { - id: "JurisdictionA", - name: "JurisdictionA", - featureFlags: [ - { name: FeatureFlagEnum.enableMarketingStatus, active: true }, - { name: FeatureFlagEnum.enableMarketingStatusMonths, active: false }, - ], - }, - ], - }) - ) - }) - ) - render( - + { return }} /> - + ) await screen.findByRole("group", { name: "Marketing status" }) expect(screen.getByRole("group", { name: "Marketing status" })).toBeInTheDocument() @@ -168,29 +112,12 @@ describe("ApplicationDates", () => { }) it("should show marketing status section with months", async () => { - document.cookie = "access-token-available=True" - server.use( - rest.get("http://localhost/api/adapter/user", (_req, res, ctx) => { - return res( - ctx.json({ - jurisdictions: [ - { - id: "JurisdictionA", - name: "JurisdictionA", - featureFlags: [ - { name: FeatureFlagEnum.enableMarketingStatus, active: true }, - { name: FeatureFlagEnum.enableMarketingStatusMonths, active: true }, - ], - }, - ], - }) - ) - }) - ) - render( - + { return }} /> - + ) await screen.findByRole("group", { name: "Marketing status" }) expect(screen.getByRole("group", { name: "Marketing status" })).toBeInTheDocument() @@ -227,29 +154,12 @@ describe("ApplicationDates", () => { }) it("should not show marketing section unless both feature flags are on", () => { - document.cookie = "access-token-available=True" - server.use( - rest.get("http://localhost/api/adapter/user", (_req, res, ctx) => { - return res( - ctx.json({ - jurisdictions: [ - { - id: "JurisdictionA", - name: "JurisdictionA", - featureFlags: [ - { name: FeatureFlagEnum.enableMarketingStatus, active: false }, - { name: FeatureFlagEnum.enableMarketingStatusMonths, active: false }, - ], - }, - ], - }) - ) - }) - ) - render( - + { return }} /> - + ) expect(screen.queryByText("Marketing status")).not.toBeInTheDocument() }) diff --git a/sites/partners/__tests__/components/listings/PaperListingForm/sections/ApplicationTypes.test.tsx b/sites/partners/__tests__/components/listings/PaperListingForm/sections/ApplicationTypes.test.tsx new file mode 100644 index 0000000000..efa34d373a --- /dev/null +++ b/sites/partners/__tests__/components/listings/PaperListingForm/sections/ApplicationTypes.test.tsx @@ -0,0 +1,330 @@ +import React from "react" +import { setupServer } from "msw/node" +import { screen, within } from "@testing-library/react" +import { FormProviderWrapper, mockNextRouter, render } from "../../../../testUtils" +import userEvent from "@testing-library/user-event" +import ApplicationTypes from "../../../../../src/components/listings/PaperListingForm/sections/ApplicationTypes" +import { jurisdiction, listing, user } from "@bloom-housing/shared-helpers/__tests__/testHelpers" +import { AuthContext } from "@bloom-housing/shared-helpers" +import { + FeatureFlagEnum, + LanguagesEnum, +} from "@bloom-housing/shared-helpers/src/types/backend-swagger" + +const server = setupServer() + +// Enable API mocking before tests. +beforeAll(() => { + server.listen() + mockNextRouter() +}) + +// Reset any runtime request handlers we may add during the tests. +afterEach(() => server.resetHandlers()) + +// Disable API mocking after the tests are done. +afterAll(() => server.close()) + +describe("ApplicationTypes", () => { + it("should render the base section component", () => { + render( + + + + ) + + expect(screen.getByRole("heading", { level: 2, name: "Application types" })).toBeInTheDocument() + expect( + screen.getByText("Configure the online application and upload paper application forms.") + ).toBeInTheDocument() + expect( + screen.getByRole("group", { name: "Is there a digital application?" }) + ).toBeInTheDocument() + expect(screen.getByRole("group", { name: "Is there a paper application?" })).toBeInTheDocument() + expect( + screen.getByRole("group", { name: "Is there a referral opportunity?" }) + ).toBeInTheDocument() + + expect(screen.getAllByRole("radio", { name: /yes/i })).toHaveLength(3) + expect(screen.getAllByRole("radio", { name: /no/i })).toHaveLength(3) + }) + + it("should update the referral question label when enableReferralQuestionUnits flag is turned on", () => { + render( + + featureFlag === FeatureFlagEnum.enableReferralQuestionUnits, + getJurisdictionLanguages: () => [], + }} + > + + + + + ) + + expect( + screen.getByRole("group", { name: "Are there units set aside for referral only?" }) + ).toBeInTheDocument() + expect( + screen.queryByRole("group", { name: "Is there a referral opportunity?" }) + ).not.toBeInTheDocument() + }) + + it("should show add paper application button when paper application is enabled", async () => { + render( + + + + ) + + const paperApplicationFieldset = screen.getByRole("group", { + name: "Is there a paper application?", + }) + expect(paperApplicationFieldset).toBeInTheDocument() + + const yesButton = within(paperApplicationFieldset).getByRole("radio", { name: /yes/i }) + expect(yesButton).toBeInTheDocument() + await userEvent.click(yesButton) + + expect(await screen.findByRole("button", { name: "Add paper application" })).toBeInTheDocument() + }) + + it("should open drawer when add paper application button is clicked", async () => { + render( + + featureFlag === FeatureFlagEnum.enableReferralQuestionUnits, + getJurisdictionLanguages: () => Object.values(LanguagesEnum), + }} + > + + + + + ) + + const paperApplicationFieldset = screen.getByRole("group", { + name: "Is there a paper application?", + }) + expect(paperApplicationFieldset).toBeInTheDocument() + + const yesButton = within(paperApplicationFieldset).getByRole("radio", { name: /yes/i }) + expect(yesButton).toBeInTheDocument() + await userEvent.click(yesButton) + + const addButton = await screen.findByRole("button", { name: "Add paper application" }) + expect(addButton).toBeInTheDocument() + await userEvent.click(addButton) + + const addApplicationDialog = await screen.findByRole("dialog", { + name: "Add paper application", + }) + expect(addApplicationDialog).toBeInTheDocument() + + expect( + within(addApplicationDialog).getByRole("option", { name: "Select language" }) + ).toBeInTheDocument() + + expect( + within(addApplicationDialog).getByRole("option", { name: "English" }) + ).toBeInTheDocument() + + expect( + within(addApplicationDialog).getByRole("option", { name: "Español" }) + ).toBeInTheDocument() + + expect( + within(addApplicationDialog).getByRole("option", { name: "Tiếng Việt" }) + ).toBeInTheDocument() + + expect(within(addApplicationDialog).getByRole("option", { name: "中文" })).toBeInTheDocument() + + expect( + within(addApplicationDialog).getByRole("option", { name: "Filipino" }) + ).toBeInTheDocument() + + expect(within(addApplicationDialog).getByRole("option", { name: "বাংলা" })).toBeInTheDocument() + + expect(within(addApplicationDialog).getByRole("option", { name: "عربى" })).toBeInTheDocument() + + await userEvent.selectOptions(within(addApplicationDialog).getByRole("combobox"), [ + LanguagesEnum.en, + ]) + + expect(await within(addApplicationDialog).findByText("Upload file")).toBeInTheDocument() + expect(within(addApplicationDialog).getByText("Select PDF file")).toBeInTheDocument() + expect(within(addApplicationDialog).getByText("Drag files here or")).toBeInTheDocument() + expect(within(addApplicationDialog).getByText("choose from folder")).toBeInTheDocument() + + expect(within(addApplicationDialog).getByRole("button", { name: "Save" })).toBeInTheDocument() + expect(within(addApplicationDialog).getByRole("button", { name: "Save" })).toBeDisabled() + expect(within(addApplicationDialog).getByRole("button", { name: "Cancel" })).toBeInTheDocument() + }) + + it("should handle referral opportunity checkbox toggle", async () => { + render( + + + + ) + + const referralQuestionFieldset = screen.getByRole("group", { + name: "Is there a referral opportunity?", + }) + expect(referralQuestionFieldset).toBeInTheDocument() + + const referralYesRadio = within(referralQuestionFieldset).getByRole("radio", { name: /yes/i }) + await userEvent.click(referralYesRadio) + expect(referralYesRadio).toBeChecked() + + expect( + await screen.findByRole("textbox", { name: "Referral contact phone" }) + ).toBeInTheDocument() + expect(screen.getByRole("textbox", { name: "Referral contact phone" })).toHaveAttribute( + "placeholder", + "(555) 555-5555" + ) + expect(screen.getByRole("textbox", { name: "Referral summary" })).toBeInTheDocument() + }) + + it("should apply phone mask to referral phone number", async () => { + render( + + + + ) + + const referralQuestionFieldset = screen.getByRole("group", { + name: "Is there a referral opportunity?", + }) + expect(referralQuestionFieldset).toBeInTheDocument() + + const referralYesRadio = within(referralQuestionFieldset).getByRole("radio", { name: /yes/i }) + await userEvent.click(referralYesRadio) + expect(referralYesRadio).toBeChecked() + + const phoneInput = await screen.findByRole("textbox", { name: "Referral contact phone" }) + await userEvent.type(phoneInput, "1234567890") + expect(phoneInput).toHaveValue("(123) 456-7890") + }) + + it("should show common digital application option when enabled", async () => { + render( + + + + ) + + const digitalApplicationFieldset = screen.getByRole("group", { + name: "Is there a digital application?", + }) + expect(digitalApplicationFieldset).toBeInTheDocument() + + const digitalApplicationYesRadio = within(digitalApplicationFieldset).getByRole("radio", { + name: /yes/i, + }) + await userEvent.click(digitalApplicationYesRadio) + expect(digitalApplicationYesRadio).toBeChecked() + + const commonDigitalApplicationFieldset = await screen.findByRole("group", { + name: "Are you using the common digital application?", + }) + expect(commonDigitalApplicationFieldset).toBeInTheDocument() + + const commonDigitalApplicationNoRadio = within(commonDigitalApplicationFieldset).getByRole( + "radio", + { name: /no/i } + ) + await userEvent.click(commonDigitalApplicationNoRadio) + + const customApplicationInput = await screen.findByRole("textbox", { + name: "Custom online application URL", + }) + expect(customApplicationInput).toBeInTheDocument() + expect(customApplicationInput).toHaveAttribute("placeholder", "https://") + }) + + it("should disable common application option when disableCommonApplication is true", async () => { + render( + + + + ) + + const digitalApplicationFieldset = screen.getByRole("group", { + name: "Is there a digital application?", + }) + expect(digitalApplicationFieldset).toBeInTheDocument() + + const digitalApplicationYesRadio = within(digitalApplicationFieldset).getByRole("radio", { + name: /yes/i, + }) + await userEvent.click(digitalApplicationYesRadio) + expect(digitalApplicationYesRadio).toBeChecked() + + expect( + await screen.queryByRole("group", { + name: "Are you using the common digital application?", + }) + ).not.toBeInTheDocument() + + const customApplicationInput = await screen.findByRole("textbox", { + name: "Custom online application URL", + }) + expect(customApplicationInput).toBeInTheDocument() + expect(customApplicationInput).toHaveAttribute("placeholder", "https://") + }) +}) diff --git a/sites/partners/__tests__/components/listings/PaperListingForm/sections/CommunityType.test.tsx b/sites/partners/__tests__/components/listings/PaperListingForm/sections/CommunityType.test.tsx index 1a5ce38dfb..d5bedb2781 100644 --- a/sites/partners/__tests__/components/listings/PaperListingForm/sections/CommunityType.test.tsx +++ b/sites/partners/__tests__/components/listings/PaperListingForm/sections/CommunityType.test.tsx @@ -1,22 +1,12 @@ import React from "react" import { rest } from "msw" import { setupServer } from "msw/node" -import { FormProvider, useForm } from "react-hook-form" import { screen } from "@testing-library/react" import CommunityType from "../../../../../src/components/listings/PaperListingForm/sections/CommunityType" -import { formDefaults, FormListing } from "../../../../../src/lib/listings/formTypes" -import { mockNextRouter, render } from "../../../../testUtils" +import { FormProviderWrapper, mockNextRouter, render } from "../../../../testUtils" import { FeatureFlagEnum } from "@bloom-housing/shared-helpers/src/types/backend-swagger" import userEvent from "@testing-library/user-event" -const FormComponent = ({ children, values }: { values?: FormListing; children }) => { - const formMethods = useForm({ - defaultValues: { ...formDefaults, ...values }, - shouldUnregister: false, - }) - return {children} -} - const reservedCommunityTypes = [ { id: "rct1", @@ -87,9 +77,9 @@ describe("CommunityType", () => { ) render( - - - + + + ) // verify that the page has loaded as well as the community types @@ -134,9 +124,9 @@ describe("CommunityType", () => { ) render( - - - + + + ) // verify that the page has loaded as well as the community types @@ -173,9 +163,9 @@ describe("CommunityType", () => { document.cookie = "access-token-available=True" const results = render( - - - + + + ) expect(results.queryAllByRole("heading", { level: 2, name: "Community type" })).toHaveLength(0) diff --git a/sites/partners/__tests__/components/listings/PaperListingForm/sections/ListingData.test.tsx b/sites/partners/__tests__/components/listings/PaperListingForm/sections/ListingData.test.tsx new file mode 100644 index 0000000000..e9b7b26eb2 --- /dev/null +++ b/sites/partners/__tests__/components/listings/PaperListingForm/sections/ListingData.test.tsx @@ -0,0 +1,56 @@ +import React from "react" +import "@testing-library/jest-dom" +import { setupServer } from "msw/node" +import { screen } from "@testing-library/react" +import { FormProviderWrapper, mockNextRouter, render } from "../../../../testUtils" +import ListingData from "../../../../../src/components/listings/PaperListingForm/sections/ListingData" + +const server = setupServer() + +// Enable API mocking before tests. +beforeAll(() => { + server.listen() + mockNextRouter() +}) + +// Reset any runtime request handlers we may add during the tests. +afterEach(() => server.resetHandlers()) + +// Disable API mocking after the tests are done. +afterAll(() => server.close()) + +describe("ListingData", () => { + it("should render all data", () => { + render( + + + + ) + + expect(screen.getByRole("heading", { level: 2, name: "Listing data" })).toBeInTheDocument() + expect(screen.getByText("Date created")).toBeInTheDocument() + expect(screen.getByText("01/01/2023 at 10:00 AM")).toBeInTheDocument() + expect(screen.getByText("Jurisdiction")).toBeInTheDocument() + expect(screen.getByText("Bloomington")).toBeInTheDocument() + expect(screen.getByText("Listing ID")).toBeInTheDocument() + expect(screen.getByText("1234")).toBeInTheDocument() + }) + + it("should render nothing if no data", () => { + render( + + + + ) + expect( + screen.queryByRole("heading", { level: 2, name: "Listing data" }) + ).not.toBeInTheDocument() + expect(screen.queryByText("Date created")).not.toBeInTheDocument() + expect(screen.queryByText("Jurisdiction")).not.toBeInTheDocument() + expect(screen.queryByText("Listing ID")).not.toBeInTheDocument() + }) +}) diff --git a/sites/partners/__tests__/components/listings/PaperListingForm/sections/ListingIntro.test.tsx b/sites/partners/__tests__/components/listings/PaperListingForm/sections/ListingIntro.test.tsx index 8c35ecd61e..5aa92963d4 100644 --- a/sites/partners/__tests__/components/listings/PaperListingForm/sections/ListingIntro.test.tsx +++ b/sites/partners/__tests__/components/listings/PaperListingForm/sections/ListingIntro.test.tsx @@ -1,24 +1,10 @@ import React from "react" -import { rest } from "msw" +import "@testing-library/jest-dom" import { setupServer } from "msw/node" -import userEvent from "@testing-library/user-event" import { screen } from "@testing-library/react" -import { FormProvider, useForm } from "react-hook-form" -import { - FeatureFlagEnum, - Jurisdiction, -} from "@bloom-housing/shared-helpers/src/types/backend-swagger" -import { mockNextRouter, render } from "../../../../testUtils" -import { formDefaults, FormListing } from "../../../../../src/lib/listings/formTypes" +import { FormProviderWrapper, mockNextRouter, render } from "../../../../testUtils" import ListingIntro from "../../../../../src/components/listings/PaperListingForm/sections/ListingIntro" - -const FormComponent = ({ children, values }: { values?: FormListing; children }) => { - const formMethods = useForm({ - defaultValues: { ...formDefaults, ...values }, - shouldUnregister: false, - }) - return {children} -} +import { EnumListingListingType } from "@bloom-housing/shared-helpers/src/types/backend-swagger" const server = setupServer() @@ -35,119 +21,162 @@ afterEach(() => server.resetHandlers()) afterAll(() => server.close()) describe("ListingIntro", () => { - const adminUserWithJurisdictions = { - jurisdictions: [ - { - id: "jurisdiction1", - name: "jurisdictionWithJurisdictionAdmin", - featureFlags: [], - }, - ], - } - - it("should render the ListingIntro section with one jurisdiction", async () => { - document.cookie = "access-token-available=True" - server.use( - rest.get("http://localhost/api/adapter/user", (_req, res, ctx) => { - return res(ctx.json(adminUserWithJurisdictions)) - }) - ) - + it("should render the ListingIntro section with one jurisdiction", () => { render( - + - + ) - await screen.findByRole("heading", { level: 2, name: "Listing intro" }) expect( screen.getByText("Let's get started with some basic information about your listing.") ).toBeInTheDocument() expect(screen.getByRole("textbox", { name: "Listing name *" })).toBeInTheDocument() - expect(screen.queryByRole("combobox", { name: "Jurisdiction *" })).not.toBeInTheDocument() expect(screen.getByRole("textbox", { name: "Housing developer" })).toBeInTheDocument() + expect(screen.queryByRole("textbox", { name: "Listing file number" })).not.toBeInTheDocument() }) - it("should render the ListingIntro section with multiple jurisdictions and required developer", async () => { - document.cookie = "access-token-available=True" - server.use( - rest.get("http://localhost/api/adapter/user", (_req, res, ctx) => { - return res(ctx.json(adminUserWithJurisdictions)) - }) - ) - + it("should render the ListingIntro section with multiple jurisdictions and required developer", () => { render( - + - + ) expect(screen.getByRole("textbox", { name: "Listing name *" })).toBeInTheDocument() - expect(screen.getByRole("combobox", { name: "Jurisdiction *" })).toBeInTheDocument() expect(screen.getByRole("textbox", { name: "Housing developer *" })).toBeInTheDocument() - await userEvent.selectOptions( - screen.getByRole("combobox", { name: "Jurisdiction *" }), - screen.getByRole("option", { name: "JurisdictionA" }) + expect(screen.getByRole("textbox", { name: "Housing developer *" })).toBeInTheDocument() + expect(screen.queryByRole("textbox", { name: "Listing file number" })).not.toBeInTheDocument() + }) + + it("should render appropriate text when housing developer owner feature flag is on", () => { + render( + + + ) + expect(screen.getByRole("textbox", { name: "Housing developer / owner" })).toBeInTheDocument() + expect(screen.queryByRole("textbox", { name: "Housing developer" })).not.toBeInTheDocument() + }) - expect(screen.getByRole("textbox", { name: "Housing developer *" })).toBeInTheDocument() + it("should render listing file number field when feature flag is on", () => { + render( + + + + ) + + expect(screen.getByRole("textbox", { name: "Listing file number" })).toBeInTheDocument() }) - it("should render appropriate text when housing developer owner feature flag is on", async () => { - document.cookie = "access-token-available=True" - server.use( - rest.get("http://localhost/api/adapter/user", (_req, res, ctx) => { - return res( - ctx.json({ - jurisdictions: [ - { - id: "JurisdictionA", - name: "jurisdictionWithJurisdictionAdmin", - featureFlags: [{ name: FeatureFlagEnum.enableHousingDeveloperOwner, active: true }], - }, - ], - }) - ) - }) + it("should render the ListingIntro section with regulated fields when feature flag is off", () => { + render( + + + ) + expect(screen.queryAllByText("What kind of listing is this?")).toHaveLength(0) + expect(screen.getByRole("textbox", { name: /^housing developer$/i })).toBeInTheDocument() + expect( + screen.queryByRole("textbox", { name: /^property management account$/i }) + ).not.toBeInTheDocument() + }) + + it("should render the ListingIntro section with regulated fields when feature flag is on and listing is not non-regulated", () => { render( - + - + ) - await screen.findByRole("textbox", { name: "Housing developer / owner" }) - expect(screen.getByRole("textbox", { name: "Housing developer / owner" })).toBeInTheDocument() - expect(screen.queryByRole("textbox", { name: "Housing developer" })).not.toBeInTheDocument() + + expect(screen.getByRole("heading", { level: 2, name: "Listing intro" })).toBeInTheDocument() + + expect(screen.getByText("What kind of listing is this?")).toBeInTheDocument() + expect(screen.getByText("Regulated")).toBeInTheDocument() + + expect(screen.getByRole("textbox", { name: /^housing developer$/i })).toBeInTheDocument() + expect( + screen.queryByRole("textbox", { name: /^property management account$/i }) + ).not.toBeInTheDocument() + + expect( + screen.queryAllByRole("group", { + name: "Has this property received HUD EBLL clearance?", + }) + ).toHaveLength(0) + }) + + it("should render the ListingIntro section with non-regulated fields when feature flag is on and listing is non-regulated", () => { + render( + + + + ) + + expect(screen.getByRole("heading", { level: 2, name: "Listing intro" })).toBeInTheDocument() + + expect(screen.getByText("What kind of listing is this?")).toBeInTheDocument() + expect(screen.getByText("Non-regulated")).toBeInTheDocument() + + expect( + screen.getByRole("textbox", { name: /^property management account$/i }) + ).toBeInTheDocument() + + expect( + screen.getByRole("group", { + name: "Has this property received HUD EBLL clearance?", + }) + ).toBeInTheDocument() }) }) diff --git a/sites/partners/__tests__/components/listings/PaperListingForm/sections/ListingPhotos.test.tsx b/sites/partners/__tests__/components/listings/PaperListingForm/sections/ListingPhotos.test.tsx new file mode 100644 index 0000000000..5d3cf15497 --- /dev/null +++ b/sites/partners/__tests__/components/listings/PaperListingForm/sections/ListingPhotos.test.tsx @@ -0,0 +1,463 @@ +import React from "react" +import "@testing-library/jest-dom" +import { FormProvider, useForm } from "react-hook-form" +import { render, screen, within } from "@testing-library/react" +import { jurisdiction, listing } from "@bloom-housing/shared-helpers/__tests__/testHelpers" +import { Jurisdiction } from "@bloom-housing/shared-helpers/src/types/backend-swagger" +import { setupServer } from "msw/lib/node" +import { formDefaults, FormListing } from "../../../../../src/lib/listings/formTypes" +import ListingPhotos from "../../../../../src/components/listings/PaperListingForm/sections/ListingPhotos" +import { mockNextRouter } from "../../../../testUtils" +import userEvent from "@testing-library/user-event" +import * as helpers from "../../../../../src/lib/helpers" + +jest.mock("../../../../../src/lib/helpers", () => { + const actual = jest.requireActual("../../../../../src/lib/helpers") + return { + ...actual, + cloudinaryFileUploader: jest.fn(), + } +}) + +const FormComponent = ({ children, values }: { values?: FormListing; children }) => { + const formMethods = useForm({ + defaultValues: { ...formDefaults, ...values }, + shouldUnregister: false, + }) + return {children} +} + +const server = setupServer() + +// Enable API mocking before tests. +beforeAll(() => { + server.listen() + mockNextRouter() +}) + +// Reset any runtime request handlers we may add during the tests. +afterEach(() => server.resetHandlers()) + +// Disable API mocking after the tests are done. +afterAll(() => server.close()) + +describe("", () => { + describe("should render empty section when data is missing", () => { + it("listing images are not required", () => { + render( + + + + ) + + expect(screen.getByRole("heading", { level: 2, name: "Listing photos" })).toBeInTheDocument() + expect( + screen.getByText("Upload an image for the listing that will be used as a preview.") + ).toBeInTheDocument() + expect(screen.getByText("Photos")).toBeInTheDocument() + expect(screen.getByRole("button", { name: "Add photos" })).toBeInTheDocument() + + expect(screen.queryByRole("button", { name: "Edit photos" })).not.toBeInTheDocument() + expect(screen.queryByRole("table")).not.toBeInTheDocument() + }) + + it("at least one image is required", () => { + render( + + + + ) + + expect(screen.getByRole("heading", { level: 2, name: "Listing photos" })).toBeInTheDocument() + expect( + screen.getByText("Upload at least 1 image for the listing that will be used as a preview.") + ).toBeInTheDocument() + expect(screen.getByText("Photos")).toBeInTheDocument() + expect(screen.getByRole("button", { name: "Add photos" })).toBeInTheDocument() + + expect(screen.queryByRole("button", { name: "Edit photos" })).not.toBeInTheDocument() + expect(screen.queryByRole("table")).not.toBeInTheDocument() + }) + + it("more than one image is required", () => { + render( + + + + ) + + expect(screen.getByRole("heading", { level: 2, name: "Listing photos" })).toBeInTheDocument() + expect( + screen.getByText("Upload at least 3 images for the listing that will be used as a preview.") + ).toBeInTheDocument() + expect(screen.getByText("Photos")).toBeInTheDocument() + expect(screen.getByRole("button", { name: "Add photos" })).toBeInTheDocument() + + expect(screen.queryByRole("button", { name: "Edit photos" })).not.toBeInTheDocument() + expect(screen.queryByRole("table")).not.toBeInTheDocument() + }) + + it("update button label when images have already been added", () => { + render( + + + + ) + + const imagesTable = screen.getByRole("table") + expect(imagesTable).toBeInTheDocument() + + const [head, body] = within(imagesTable).getAllByRole("rowgroup") + + const headerColumns = within(head).getAllByRole("columnheader") + expect(headerColumns).toHaveLength(2) + const [previewHeader, actionsHeader] = headerColumns + expect(previewHeader).toHaveTextContent("Preview") + expect(actionsHeader).not.toHaveTextContent() + + const rows = within(body).getAllByRole("row") + expect(rows).toHaveLength(2) + + const [firstPreview, firstActions] = within(rows[0]).getAllByRole("cell") + expect(within(firstPreview).getByRole("presentation")).toBeInTheDocument() + expect(within(firstPreview).getByRole("presentation")).toHaveAttribute("src", "file_1_id") + expect(within(firstActions).getByRole("button", { name: "Delete" })).toBeInTheDocument() + + const [secondPreview, secondActions] = within(rows[1]).getAllByRole("cell") + expect(within(secondPreview).getByRole("presentation")).toBeInTheDocument() + expect(within(secondPreview).getByRole("presentation")).toHaveAttribute("src", "file_2_id") + expect(within(secondActions).getByRole("button", { name: "Delete" })).toBeInTheDocument() + + expect(screen.queryByRole("button", { name: "Add photos" })).not.toBeInTheDocument() + expect(screen.getByRole("button", { name: "Edit photos" })).toBeInTheDocument() + }) + }) + + describe("should open images drawer on Add Button click", () => { + it("should render table with proper description when images are not required", async () => { + render( + + + + ) + + const addButton = screen.getByRole("button", { name: "Add photos" }) + expect(addButton).toBeInTheDocument() + + await userEvent.click(addButton) + + const dialogDrawer = await screen.findByRole("dialog", { name: "Add photos" }) + expect(dialogDrawer).toBeInTheDocument() + + expect( + within(dialogDrawer).getByRole("heading", { level: 2, name: "Listing photos" }) + ).toBeInTheDocument() + expect( + within(dialogDrawer).getByText( + "Select JPEG or PNG file to upload. Please upload horizontal images only at approximately 1440px. Up to 10 uploaded images allowed." + ) + ).toBeInTheDocument() + + expect(within(dialogDrawer).getByRole("button", { name: "Save" })).toBeInTheDocument() + }) + + it("should render table with proper description when one image is required", async () => { + render( + + + + ) + + const addButton = screen.getByRole("button", { name: "Add photos" }) + expect(addButton).toBeInTheDocument() + + await userEvent.click(addButton) + + const dialogDrawer = await screen.findByRole("dialog", { name: "Add photos" }) + expect(dialogDrawer).toBeInTheDocument() + + expect( + within(dialogDrawer).getByRole("heading", { level: 2, name: "Listing photos" }) + ).toBeInTheDocument() + expect( + within(dialogDrawer).getByText( + "Select JPEG or PNG file to upload. Please upload horizontal images only at approximately 1440px. At least 1 image is required, and up to 10 images are allowed." + ) + ).toBeInTheDocument() + + expect(within(dialogDrawer).getByRole("button", { name: "Save" })).toBeInTheDocument() + }) + + it("should render table with proper description when more than one images is required", async () => { + render( + + + + ) + + const addButton = screen.getByRole("button", { name: "Add photos" }) + expect(addButton).toBeInTheDocument() + + await userEvent.click(addButton) + + const dialogDrawer = await screen.findByRole("dialog", { name: "Add photos" }) + expect(dialogDrawer).toBeInTheDocument() + + expect( + within(dialogDrawer).getByRole("heading", { level: 2, name: "Listing photos" }) + ).toBeInTheDocument() + expect( + within(dialogDrawer).getByText( + "Select JPEG or PNG file to upload. Please upload horizontal images only at approximately 1440px. At least 3 images are required, and up to 10 images are allowed." + ) + ).toBeInTheDocument() + + expect(within(dialogDrawer).getByRole("button", { name: "Save" })).toBeInTheDocument() + }) + }) + describe("enableListingImageAltText feature flag", () => { + const listingImages = [ + { + assets: { + createdAt: new Date(), + updatedAt: new Date(), + fileId: "file_1_id", + id: "asset_1_id", + label: "Asset 1 Label", + }, + ordinal: 0, + description: "Front view of the building", + }, + { + assets: { + createdAt: new Date(), + updatedAt: new Date(), + fileId: "file_2_id", + id: "asset_2_id", + label: "Asset 2 Label", + }, + ordinal: 1, + description: "Lobby interior view", + }, + ] + + it("shows description column and hides delete buttons when enabled", () => { + render( + + + + ) + + const imagesTable = screen.getByRole("table") + const [head, body] = within(imagesTable).getAllByRole("rowgroup") + + const headerTexts = within(head) + .getAllByRole("columnheader") + .map((col) => col.textContent) + expect(headerTexts).toEqual(["Preview", "Image description", "Actions"]) + + const rows = within(body).getAllByRole("row") + const [firstPreview, firstDescription] = within(rows[0]).getAllByRole("cell") + expect(within(firstPreview).getByRole("img")).toHaveAttribute("src", "file_1_id") + expect(firstDescription).toHaveTextContent("Front view of the building") + + expect(screen.queryByRole("button", { name: "Delete" })).not.toBeInTheDocument() + }) + + it("opens alt text editor from drawer when enabled", async () => { + render( + + + + ) + + const editPhotosButton = screen.getByRole("button", { name: "Edit photos" }) + await userEvent.click(editPhotosButton) + + const drawer = await screen.findByRole("dialog", { name: "Edit photos" }) + expect(within(drawer).getByTestId("drawer-photos-table")).toBeInTheDocument() + + const editButtons = within(drawer).getAllByRole("button", { name: "Edit" }) + expect(editButtons).toHaveLength(2) + + await userEvent.click(editButtons[0]) + + const altTextDrawer = await screen.findByRole("dialog", { name: "Add image description" }) + expect( + within(altTextDrawer).getByLabelText("Image description (alt text)") + ).toBeInTheDocument() + expect(within(altTextDrawer).getByRole("button", { name: "Save" })).toBeInTheDocument() + }) + + it("shows required error when alt text cleared after being present", async () => { + const jurisdictionWithAltTextRequirement = { + ...jurisdiction, + requiredListingFields: ["listingImages.description"], + } + + render( + + + + ) + + const editPhotosButton = screen.getByRole("button", { name: "Edit photos" }) + await userEvent.click(editPhotosButton) + + const drawer = await screen.findByRole("dialog", { name: "Edit photos" }) + await userEvent.click(within(drawer).getByRole("button", { name: "Edit" })) + + const altTextDrawer = await screen.findByRole("dialog", { name: "Add image description" }) + const altTextInput = within(altTextDrawer).getByLabelText(/Image description \(alt text\)/i, { + exact: false, + }) + await userEvent.clear(altTextInput) + await userEvent.click(within(altTextDrawer).getByRole("button", { name: "Save" })) + + expect(within(altTextDrawer).getByText("This field is required")).toBeInTheDocument() + }) + + it("opens alt text drawer after uploading a new photo when enabled", async () => { + const mockCloudinaryUploader = helpers.cloudinaryFileUploader as jest.MockedFunction< + typeof helpers.cloudinaryFileUploader + > + mockCloudinaryUploader.mockImplementation( + // eslint-disable-next-line @typescript-eslint/require-await + async ({ setCloudinaryData, setProgressValue }) => { + setProgressValue(100) + setCloudinaryData({ id: "new-file-id", url: "http://example.com/new-file" }) + } + ) + + render( + + + + ) + + const addPhotosButton = screen.getByRole("button", { name: "Add photos" }) + await userEvent.click(addPhotosButton) + + const addPhotosDrawer = await screen.findByRole("dialog", { name: "Add photos" }) + const dropzoneInput = within(addPhotosDrawer).getByTestId("dropzone-input") + + const newFile = new File(["dummy file"], "new-photo.jpg", { type: "image/jpeg" }) + await userEvent.upload(dropzoneInput, newFile) + + const altTextDrawer = await screen.findByRole("dialog", { name: "Add image description" }) + expect( + within(altTextDrawer).getByLabelText("Image description (alt text)") + ).toBeInTheDocument() + }) + }) +}) diff --git a/sites/partners/__tests__/components/listings/PaperListingForm/sections/LotteryResults.test.tsx b/sites/partners/__tests__/components/listings/PaperListingForm/sections/LotteryResults.test.tsx index aae6e2459f..49968a92d6 100644 --- a/sites/partners/__tests__/components/listings/PaperListingForm/sections/LotteryResults.test.tsx +++ b/sites/partners/__tests__/components/listings/PaperListingForm/sections/LotteryResults.test.tsx @@ -4,20 +4,12 @@ import userEvent from "@testing-library/user-event" import { rest } from "msw" import { setupServer } from "msw/node" import LotteryResults from "../../../../../src/components/listings/PaperListingForm/sections/LotteryResults" -import { FormProvider, useForm } from "react-hook-form" -import { formDefaults, FormListing } from "../../../../../src/lib/listings/formTypes" +import { formDefaults } from "../../../../../src/lib/listings/formTypes" import { ListingEvent, ListingEventsTypeEnum, } from "@bloom-housing/shared-helpers/src/types/backend-swagger" - -const FormComponent = ({ children, values }: { values?: FormListing; children }) => { - const formMethods = useForm({ - defaultValues: { ...formDefaults, ...values }, - shouldUnregister: false, - }) - return {children} -} +import { FormProviderWrapper } from "../../../../testUtils" const server = setupServer() @@ -37,9 +29,9 @@ describe("LotteryResults", () => { const submitFn = jest.fn() const showDrawerFn = jest.fn() const results = render( - + - + ) expect(results.container.innerHTML).toEqual("") @@ -49,9 +41,9 @@ describe("LotteryResults", () => { const submitFn = jest.fn() const showDrawerFn = jest.fn() const results = render( - + - + ) expect(results.getByText("Add results")).toBeTruthy() @@ -67,9 +59,9 @@ describe("LotteryResults", () => { const submitFn = jest.fn() const showDrawerFn = jest.fn() const results = render( - + - + ) expect(results.getByText("Add results")).toBeTruthy() @@ -89,9 +81,9 @@ describe("LotteryResults", () => { const submitFn = jest.fn() const showDrawerFn = jest.fn() const results = render( - + - + ) expect(results.getByText("Edit results")).toBeTruthy() @@ -118,9 +110,9 @@ describe("LotteryResults", () => { const submitFn = jest.fn() const showDrawerFn = jest.fn() const results = render( - + - + ) expect(results.getByText("Add results")).toBeTruthy() diff --git a/sites/partners/__tests__/components/listings/PaperListingForm/sections/NeighborhoodAmenities.test.tsx b/sites/partners/__tests__/components/listings/PaperListingForm/sections/NeighborhoodAmenities.test.tsx index dc411c0c12..681eb284eb 100644 --- a/sites/partners/__tests__/components/listings/PaperListingForm/sections/NeighborhoodAmenities.test.tsx +++ b/sites/partners/__tests__/components/listings/PaperListingForm/sections/NeighborhoodAmenities.test.tsx @@ -1,26 +1,10 @@ import React from "react" import { screen, waitFor, within } from "@testing-library/react" -import { FormProvider, useForm } from "react-hook-form" -import { AuthContext } from "@bloom-housing/shared-helpers" -import { - FeatureFlagEnum, - Jurisdiction, - JurisdictionsService, - NeighborhoodAmenitiesEnum, -} from "@bloom-housing/shared-helpers/src/types/backend-swagger" +import { NeighborhoodAmenitiesEnum } from "@bloom-housing/shared-helpers/src/types/backend-swagger" import { t } from "@bloom-housing/ui-components" -import { mockNextRouter, render } from "../../../../testUtils" -import { formDefaults, FormListing } from "../../../../../src/lib/listings/formTypes" +import { FormProviderWrapper, mockNextRouter, render } from "../../../../testUtils" import NeighborhoodAmenities from "../../../../../src/components/listings/PaperListingForm/sections/NeighborhoodAmenities" -const FormComponent = ({ children, values }: { values?: FormListing; children }) => { - const formMethods = useForm({ - defaultValues: { ...formDefaults, ...values }, - shouldUnregister: false, - }) - return {children} -} - beforeAll(() => { mockNextRouter() }) @@ -36,6 +20,12 @@ describe("NeighborhoodAmenities", () => { NeighborhoodAmenitiesEnum.parksAndCommunityCenters, NeighborhoodAmenitiesEnum.pharmacies, NeighborhoodAmenitiesEnum.healthCareResources, + NeighborhoodAmenitiesEnum.shoppingVenues, + NeighborhoodAmenitiesEnum.hospitals, + NeighborhoodAmenitiesEnum.seniorCenters, + NeighborhoodAmenitiesEnum.recreationalFacilities, + NeighborhoodAmenitiesEnum.playgrounds, + NeighborhoodAmenitiesEnum.busStops, ], } @@ -49,67 +39,32 @@ describe("NeighborhoodAmenities", () => { } it("should not render when feature flag is disabled", () => { - const doJurisdictionsHaveFeatureFlagOn = () => false - const { container } = render( - - - - - - ) - - expect(container.firstChild).toBeNull() - }) - - it("should not render when no jurisdiction is selected", () => { - const doJurisdictionsHaveFeatureFlagOn = () => true - - const { container } = render( - - - - - + + + ) expect(container.firstChild).toBeNull() }) it("should render all neighborhood amenities as textareas when dropdown is disabled", async () => { - const mockRetrieve = jest.fn().mockResolvedValue(mockJurisdictionWithAllAmenities) - - const doJurisdictionsHaveFeatureFlagOn = (flag: FeatureFlagEnum) => { - if (flag === FeatureFlagEnum.enableNeighborhoodAmenities) return true - if (flag === FeatureFlagEnum.enableNeighborhoodAmenitiesDropdown) return false - return false - } - render( - - - - - + + + ) await screen.findByRole("heading", { name: "Neighborhood amenities" }) @@ -129,32 +84,16 @@ describe("NeighborhoodAmenities", () => { }) it("should render neighborhood amenities with dropdowns when dropdown is enabled", async () => { - const mockRetrieve = jest.fn().mockResolvedValue(mockJurisdictionWithAllAmenities) - - const doJurisdictionsHaveFeatureFlagOn = (flag: FeatureFlagEnum) => { - if (flag === FeatureFlagEnum.enableNeighborhoodAmenities) return true - if (flag === FeatureFlagEnum.enableNeighborhoodAmenitiesDropdown) return true - return false - } - render( - - - - - + + + ) await waitFor(() => { @@ -174,32 +113,16 @@ describe("NeighborhoodAmenities", () => { }) it("should only render visible amenities from jurisdiction configuration", async () => { - const mockRetrieve = jest.fn().mockResolvedValue(mockJurisdictionWithLimitedAmenities) - - const doJurisdictionsHaveFeatureFlagOn = (flag: FeatureFlagEnum) => { - if (flag === FeatureFlagEnum.enableNeighborhoodAmenities) return true - if (flag === FeatureFlagEnum.enableNeighborhoodAmenitiesDropdown) return false - return false - } - render( - - - - - + + + ) await screen.findByRole("heading", { name: "Neighborhood amenities" }) @@ -216,29 +139,16 @@ describe("NeighborhoodAmenities", () => { }) it("should include distance options in dropdown when enabled", async () => { - const mockRetrieve = jest.fn().mockResolvedValue(mockJurisdictionWithLimitedAmenities) - - const doJurisdictionsHaveFeatureFlagOn = (flag: FeatureFlagEnum) => { - if (flag === FeatureFlagEnum.enableNeighborhoodAmenities) return true - if (flag === FeatureFlagEnum.enableNeighborhoodAmenitiesDropdown) return true - return false - } - render( - - - - - + + + ) const select = await screen.findByRole("combobox", { name: "Grocery stores" }) @@ -268,28 +178,16 @@ describe("NeighborhoodAmenities", () => { }) it("should render partial amenities in 1 row when there are less or equal to 2 amenities", async () => { - const mockRetrieve = jest.fn().mockResolvedValue(mockJurisdictionWithLimitedAmenities) - - const doJurisdictionsHaveFeatureFlagOn = (flag: FeatureFlagEnum) => { - if (flag === FeatureFlagEnum.enableNeighborhoodAmenities) return true - return false - } - const { container } = render( - - - - - + + + ) await screen.findByRole("heading", { name: "Neighborhood amenities" }) diff --git a/sites/partners/__tests__/components/listings/PaperListingForm/sections/PreferencesAndPrograms.test.tsx b/sites/partners/__tests__/components/listings/PaperListingForm/sections/PreferencesAndPrograms.test.tsx index 225aa57730..b54d2523e5 100644 --- a/sites/partners/__tests__/components/listings/PaperListingForm/sections/PreferencesAndPrograms.test.tsx +++ b/sites/partners/__tests__/components/listings/PaperListingForm/sections/PreferencesAndPrograms.test.tsx @@ -1,23 +1,16 @@ import React from "react" import { setupServer } from "msw/node" -import { FormProvider, useForm } from "react-hook-form" import { AuthContext } from "@bloom-housing/shared-helpers" import { MultiselectQuestion, MultiselectQuestionsApplicationSectionEnum, + MultiselectQuestionsStatusEnum, ValidationMethodEnum, } from "@bloom-housing/shared-helpers/src/types/backend-swagger" import { render, screen, within } from "@testing-library/react" import PreferencesAndPrograms from "../../../../../src/components/listings/PaperListingForm/sections/PreferencesAndPrograms" -import { formDefaults, FormListing } from "../../../../../src/lib/listings/formTypes" - -const FormComponent = ({ children, values }: { values?: FormListing; children }) => { - const formMethods = useForm({ - defaultValues: { ...formDefaults, ...values }, - shouldUnregister: false, - }) - return {children} -} +import { formDefaults } from "../../../../../src/lib/listings/formTypes" +import { FormProviderWrapper } from "../../../../testUtils" const server = setupServer() @@ -38,22 +31,17 @@ describe("PreferencesAndPrograms", () => { const setFn = jest.fn() render( - { - return false - }, - }} - > - - - - + + + ) expect(screen.getByRole("heading", { level: 2, name: /preferences/i })).toBeInTheDocument() @@ -75,6 +63,7 @@ describe("PreferencesAndPrograms", () => { jurisdictions: [], hideFromListing: false, applicationSection: MultiselectQuestionsApplicationSectionEnum.preferences, + status: MultiselectQuestionsStatusEnum.active, }, { id: "preference_id_2", @@ -102,6 +91,7 @@ describe("PreferencesAndPrograms", () => { ], hideFromListing: false, applicationSection: MultiselectQuestionsApplicationSectionEnum.preferences, + status: MultiselectQuestionsStatusEnum.active, }, ] const setFn = jest.fn() @@ -114,14 +104,17 @@ describe("PreferencesAndPrograms", () => { }, }} > - + - + ) @@ -137,15 +130,14 @@ describe("PreferencesAndPrograms", () => { expect(tableHeaders[0]).toHaveTextContent(/order/i) expect(tableHeaders[1]).toHaveTextContent(/name/i) expect(tableHeaders[2]).toHaveTextContent(/additional fields/i) - expect(tableHeaders[3]).not.toHaveTextContent() - + expect(tableHeaders[3]).toHaveTextContent(/actions/i) const tableRows = within(body).getAllByRole("row") expect(tableRows).toHaveLength(2) const firstRowCells = within(tableRows[0]).getAllByRole("cell") expect(firstRowCells[0]).toHaveTextContent("1") expect(firstRowCells[1]).toHaveTextContent(/city employees/i) - expect(firstRowCells[2]).not.toHaveTextContent() + expect(firstRowCells[2]).toHaveTextContent("") expect(within(firstRowCells[3]).getByRole("button", { name: /delete/i })).toBeInTheDocument() const secondRowCells = within(tableRows[1]).getAllByRole("cell") @@ -172,14 +164,17 @@ describe("PreferencesAndPrograms", () => { }, }} > - + - + ) @@ -216,6 +211,7 @@ describe("PreferencesAndPrograms", () => { text: "Families", applicationSection: MultiselectQuestionsApplicationSectionEnum.programs, jurisdictions: undefined, + status: MultiselectQuestionsStatusEnum.active, }, ] const setFn = jest.fn() @@ -227,14 +223,17 @@ describe("PreferencesAndPrograms", () => { }, }} > - + - + ) @@ -259,6 +258,7 @@ describe("PreferencesAndPrograms", () => { text: "Community 1", applicationSection: MultiselectQuestionsApplicationSectionEnum.programs, jurisdictions: undefined, + status: MultiselectQuestionsStatusEnum.active, }, ] const setFn = jest.fn() @@ -270,14 +270,17 @@ describe("PreferencesAndPrograms", () => { }, }} > - + - + ) diff --git a/sites/partners/__tests__/components/listings/PaperListingForm/sections/RankingsAndResults.test.tsx b/sites/partners/__tests__/components/listings/PaperListingForm/sections/RankingsAndResults.test.tsx index 5117e41c4f..2c8984f2b0 100644 --- a/sites/partners/__tests__/components/listings/PaperListingForm/sections/RankingsAndResults.test.tsx +++ b/sites/partners/__tests__/components/listings/PaperListingForm/sections/RankingsAndResults.test.tsx @@ -1,28 +1,16 @@ import React from "react" -import { rest } from "msw" import { setupServer } from "msw/node" -import { FormProvider, useForm } from "react-hook-form" import { screen } from "@testing-library/react" import RankingsAndResults from "../../../../../src/components/listings/PaperListingForm/sections/RankingsAndResults" -import { formDefaults, FormListing } from "../../../../../src/lib/listings/formTypes" -import { mockNextRouter, mockTipTapEditor, render } from "../../../../testUtils" -import { FeatureFlagEnum } from "@bloom-housing/shared-helpers/src/types/backend-swagger" +import { formDefaults } from "../../../../../src/lib/listings/formTypes" +import { + FormProviderWrapper, + mockNextRouter, + mockTipTapEditor, + render, +} from "../../../../testUtils" import userEvent from "@testing-library/user-event" -const FormComponent = ({ - children, - values, -}: { - values?: FormListing - children: React.ReactNode -}) => { - const formMethods = useForm({ - defaultValues: { ...formDefaults, ...values }, - shouldUnregister: false, - }) - return {children} -} - const server = setupServer() beforeAll(() => { server.listen() @@ -34,40 +22,10 @@ describe("RankingsAndResults", () => { describe("RankingsAndResults enableWaitlistLottery", () => { afterEach(() => server.resetHandlers()) afterAll(() => server.close()) - const userWithWaitlistLotteryFlag = { - jurisdictions: [ - { - id: "jurisdiction1", - name: "jurisdictionWithWaitlistLottery", - featureFlags: [ - { - name: FeatureFlagEnum.enableWaitlistLottery, - active: true, - }, - ], - }, - ], - } - const userWithoutWaitlistLotteryFlag = { - jurisdictions: [ - { - id: "jurisdiction1", - name: "jurisdictionWithoutWaitlistLottery", - featureFlags: [], - }, - ], - } it("should not show lottery fields when enableWaitlistLottery is false and waitlist is open", async () => { - document.cookie = "access-token-available=True" - server.use( - rest.get("http://localhost/api/adapter/user", (_req, res, ctx) => { - return res(ctx.json(userWithoutWaitlistLotteryFlag)) - }) - ) - render( - { requiredFields={[]} whatToExpectEditor={null} whatToExpectAdditionalTextEditor={null} + enableUnitGroups={false} + enableWaitlistAdditionalFields={false} + enableWaitlistLottery={false} + enableWhatToExpectAdditionalField={false} /> - + ) await screen.findByText("Rankings & results") @@ -97,15 +59,8 @@ describe("RankingsAndResults", () => { }) it("should show review order options when waitlist is open and feature flag is enabled", async () => { - document.cookie = "access-token-available=True" - server.use( - rest.get("http://localhost/api/adapter/user", (_req, res, ctx) => { - return res(ctx.json(userWithWaitlistLotteryFlag)) - }) - ) - render( - { requiredFields={[]} whatToExpectEditor={null} whatToExpectAdditionalTextEditor={null} + enableUnitGroups={false} + enableWaitlistAdditionalFields={false} + enableWaitlistLottery={true} + enableWhatToExpectAdditionalField={false} /> - + ) screen.getByRole("heading", { name: "Rankings & results" }) @@ -132,15 +91,8 @@ describe("RankingsAndResults", () => { }) it("should show review order options when availabilityQuestion is availableUnits and enableWaitlistLottery is false", () => { - document.cookie = "access-token-available=True" - server.use( - rest.get("http://localhost/api/adapter/user", (_req, res, ctx) => { - return res(ctx.json(userWithoutWaitlistLotteryFlag)) - }) - ) - render( - { requiredFields={[]} whatToExpectEditor={null} whatToExpectAdditionalTextEditor={null} + enableUnitGroups={false} + enableWaitlistAdditionalFields={false} + enableWaitlistLottery={false} + enableWhatToExpectAdditionalField={false} /> - + ) screen.getByRole("heading", { name: "Rankings & results" }) @@ -168,13 +124,17 @@ describe("RankingsAndResults", () => { it("should show proper message when selecting lottery as a non admin user", async () => { process.env.showLottery = "true" render( - + - + ) screen.getByRole("heading", { name: "Rankings & results" }) @@ -189,15 +149,19 @@ describe("RankingsAndResults", () => { it("should show proper message when selecting lottery as an admin user", async () => { process.env.showLottery = "true" render( - + - + ) screen.getByRole("heading", { name: "Rankings & results" }) diff --git a/sites/partners/__tests__/forgot-password.test.tsx b/sites/partners/__tests__/forgot-password.test.tsx index f540ff2016..cdbcd3cc93 100644 --- a/sites/partners/__tests__/forgot-password.test.tsx +++ b/sites/partners/__tests__/forgot-password.test.tsx @@ -26,7 +26,13 @@ describe("forgot-password", () => { const { getByText, getByLabelText } = render() - expect(getByText("Send email", { selector: "h1" })) + expect(getByText("Enter your email to get a password reset link", { selector: "h1" })) + expect( + getByText( + "Please enter your email address so we can send you a password reset link. If you don’t receive an email, you may not have an account.", + { selector: "p" } + ) + ) expect(getByLabelText("Email")) expect(getByText("Send email", { selector: "button" })) expect(getByText("Cancel")) diff --git a/sites/partners/__tests__/pages/application/index.test.tsx b/sites/partners/__tests__/pages/application/index.test.tsx index ae5a1822ec..fae2866731 100644 --- a/sites/partners/__tests__/pages/application/index.test.tsx +++ b/sites/partners/__tests__/pages/application/index.test.tsx @@ -11,15 +11,11 @@ import { UnitTypeEnum, YesNoEnum, } from "@bloom-housing/shared-helpers/src/types/backend-swagger" -import { AuthContext } from "@bloom-housing/shared-helpers" import { ApplicationContext } from "../../../src/components/applications/ApplicationContext" -import DetailsApplicationData from "../../../src/components/applications/PaperApplicationDetails/sections/DetailsApplicationData" -import DetailsPrimaryApplicant from "../../../src/components/applications/PaperApplicationDetails/sections/DetailsPrimaryApplicant" import DetailsAlternateContact from "../../../src/components/applications/PaperApplicationDetails/sections/DetailsAlternateContact" import DetailsHouseholdDetails from "../../../src/components/applications/PaperApplicationDetails/sections/DetailsHouseholdDetails" import DetailsHouseholdIncome from "../../../src/components/applications/PaperApplicationDetails/sections/DetailsHouseholdIncome" import DetailsTerms from "../../../src/components/applications/PaperApplicationDetails/sections/DetailsTerms" -import DetailsHouseholdMembers from "../../../src/components/applications/PaperApplicationDetails/sections/DetailsHouseholdMembers" const server = setupServer() @@ -80,98 +76,6 @@ describe("partners_application_index", () => { expect(submissionStatus).toBeInTheDocument() }) - it("should display Application Data section info", () => { - const { getByText } = render( - - - - - - ) - - expect(getByText("Application data")).toBeInTheDocument() - expect(getByText("Confirmation code")).toBeInTheDocument() - expect(getByText("ABCD1234")).toBeInTheDocument() - expect(getByText("Application submission type")).toBeInTheDocument() - expect(getByText("Electronic")).toBeInTheDocument() - expect(getByText("Application submitted date")).toBeInTheDocument() - expect(getByText("1/28/2025")).toBeInTheDocument() - expect(getByText("Application submitted time")).toBeInTheDocument() - expect(getByText("1:09:00 PM UTC")).toBeInTheDocument() - expect(getByText("Application language")).toBeInTheDocument() - expect(getByText("Español")).toBeInTheDocument() - expect(getByText("Total household size")).toBeInTheDocument() - expect(getByText("2")).toBeInTheDocument() - expect(getByText("Submitted by")).toBeInTheDocument() - expect(getByText("Applicant First Applicant Last")).toBeInTheDocument() - }) - - it("should display Primary Applicant section info", () => { - const { getByText, getAllByText } = render( - - - - ) - - expect(getByText("Primary applicant")).toBeInTheDocument() - expect(getByText("First name")).toBeInTheDocument() - expect(getByText("Middle name")).toBeInTheDocument() - expect(getByText("Last name")).toBeInTheDocument() - expect(getByText("Date of birth")).toBeInTheDocument() - expect(getByText("Email")).toBeInTheDocument() - expect(getByText("Phone")).toBeInTheDocument() - expect(getByText("Second phone")).toBeInTheDocument() - expect(getByText("Preferred contact")).toBeInTheDocument() - expect(getByText("Work in region")).toBeInTheDocument() - expect(getByText("Residence address")).toBeInTheDocument() - expect(getByText("Mailing address")).toBeInTheDocument() - expect(getByText("Work address")).toBeInTheDocument() - expect(getAllByText("Street address")).toHaveLength(3) - expect(getAllByText("Apt or unit #")).toHaveLength(3) - expect(getAllByText("City")).toHaveLength(3) - expect(getAllByText("State")).toHaveLength(3) - expect(getAllByText("Zip code")).toHaveLength(3) - }) - - it("should display Primary Applicant section info with full time student question", () => { - const { getByText, getAllByText } = render( - - - - ) - - expect(getByText("Primary applicant")).toBeInTheDocument() - expect(getByText("First name")).toBeInTheDocument() - expect(getByText("Middle name")).toBeInTheDocument() - expect(getByText("Last name")).toBeInTheDocument() - expect(getByText("Date of birth")).toBeInTheDocument() - expect(getByText("Email")).toBeInTheDocument() - expect(getByText("Phone")).toBeInTheDocument() - expect(getByText("Second phone")).toBeInTheDocument() - expect(getByText("Preferred contact")).toBeInTheDocument() - expect(getByText("Work in region")).toBeInTheDocument() - expect(getByText("Residence address")).toBeInTheDocument() - expect(getByText("Mailing address")).toBeInTheDocument() - expect(getByText("Work address")).toBeInTheDocument() - expect(getAllByText("Street address")).toHaveLength(3) - expect(getAllByText("Apt or unit #")).toHaveLength(3) - expect(getAllByText("City")).toHaveLength(3) - expect(getAllByText("State")).toHaveLength(3) - expect(getAllByText("Zip code")).toHaveLength(3) - expect(getByText("Full-time student")).toBeInTheDocument() - expect(getByText("No")).toBeInTheDocument() - }) - it("should display no contact Alternate Contact section info", () => { const { getByText, queryByText, getAllByText } = render( { expect(getByText("Zip code")).toBeInTheDocument() }) - it("should display Houshold Members section table", () => { - const { getByRole, queryByText } = render( - - {/* eslint-disable-next-line @typescript-eslint/no-empty-function */} - {}} /> - - ) - - // Check the section header - expect(getByRole("heading", { name: "Household members" })).toBeInTheDocument() - - // Get the table and check headers - const table = getByRole("table") - const tableHeaders = within(table).getAllByRole("columnheader") - expect(tableHeaders).toHaveLength(6) - - const [name, dob, relationship, residence, work, actions] = tableHeaders - expect(name).toHaveTextContent(/name/i) - expect(dob).toHaveTextContent(/date of birth/i) - expect(relationship).toHaveTextContent(/relationship/i) - expect(residence).toHaveTextContent(/same residence/i) - expect(work).toHaveTextContent(/work in region/i) - expect(actions).toHaveTextContent(/actions/i) - - // Check table body rows - const tableBodyRows = within(table).getAllByRole("row") - expect(tableBodyRows).toHaveLength(2) // 1 for the header row + 1 for the Household member row - - const [nameVal, dobVal, relationshipVal, residenceVal, workVal, actionsVal] = within( - tableBodyRows[1] - ).getAllByRole("cell") - - expect(nameVal).toHaveTextContent("Household First Household Last") - expect(dobVal).toHaveTextContent("11/25/1966") - expect(relationshipVal).toHaveTextContent("Friend") - expect(residenceVal).toHaveTextContent("No") - expect(workVal).toHaveTextContent("Yes") - expect(within(actionsVal).getByText("View")).toBeInTheDocument() - - expect(queryByText("Full-time student")).not.toBeInTheDocument() - }) - - it("should display Houshold Members section table with full time student question", () => { - const { getByRole } = render( - - {/* eslint-disable-next-line @typescript-eslint/no-empty-function */} - {}} enableFullTimeStudentQuestion={true} /> - - ) - - // Check the section header - expect(getByRole("heading", { name: "Household members" })).toBeInTheDocument() - - // Get the table and check headers - const table = getByRole("table") - const tableHeaders = within(table).getAllByRole("columnheader") - expect(tableHeaders).toHaveLength(7) - - const [name, dob, relationship, residence, work, student, actions] = tableHeaders - expect(name).toHaveTextContent(/name/i) - expect(dob).toHaveTextContent(/date of birth/i) - expect(relationship).toHaveTextContent(/relationship/i) - expect(residence).toHaveTextContent(/same residence/i) - expect(work).toHaveTextContent(/work in region/i) - expect(student).toHaveTextContent("Full-time student") - expect(actions).toHaveTextContent(/actions/i) - - // Check table body rows - const tableBodyRows = within(table).getAllByRole("row") - expect(tableBodyRows).toHaveLength(2) // 1 for the header row + 1 for the Household member row - - const [nameVal, dobVal, relationshipVal, residenceVal, workVal, studentVal, actionsVal] = - within(tableBodyRows[1]).getAllByRole("cell") - - expect(nameVal).toHaveTextContent("Household First Household Last") - expect(dobVal).toHaveTextContent("11/25/1966") - expect(relationshipVal).toHaveTextContent("Friend") - expect(residenceVal).toHaveTextContent("No") - expect(workVal).toHaveTextContent("Yes") - expect(studentVal).toHaveTextContent("No") - expect(within(actionsVal).getByText("View")).toBeInTheDocument() - }) - it("should display Houshold Details info", () => { const { getByText } = render( ({ @@ -43,7 +49,12 @@ describe("ApplicationTypes", () => { it("should render application types section", () => { render( - + ) @@ -75,7 +86,12 @@ describe("ApplicationTypes", () => { it("should render referral opportunity section", async () => { render( - + ) @@ -111,7 +127,12 @@ describe("ApplicationTypes", () => { it("should open and close the paper application drawer", async () => { render( - + ) @@ -136,7 +157,12 @@ describe("ApplicationTypes", () => { it("should disable save button and hide dropzone when no language is selected", async () => { render( - + ) @@ -158,7 +184,12 @@ describe("ApplicationTypes", () => { render( - + ) @@ -192,7 +223,12 @@ describe("ApplicationTypes", () => { render( - + ) diff --git a/sites/partners/__tests__/pages/listings/PaperListingForm/sections/MarketingFlyer.test.tsx b/sites/partners/__tests__/pages/listings/PaperListingForm/sections/MarketingFlyer.test.tsx new file mode 100644 index 0000000000..31da977346 --- /dev/null +++ b/sites/partners/__tests__/pages/listings/PaperListingForm/sections/MarketingFlyer.test.tsx @@ -0,0 +1,286 @@ +import React from "react" +import userEvent from "@testing-library/user-event" +import MarketingFlyer from "../../../../../src/components/listings/PaperListingForm/sections/MarketingFlyer" +import { mockNextRouter, render, screen, waitFor, within } from "../../../../testUtils" +import * as helpers from "../../../../../src/lib/helpers" + +jest.mock("../../../../../src/lib/helpers", () => ({ + ...jest.requireActual("../../../../../src/lib/helpers"), + cloudinaryFileUploader: jest.fn(), +})) + +beforeAll(() => { + mockNextRouter() +}) + +describe("MarketingFlyer", () => { + const mockOnSubmit = jest.fn() + + beforeEach(() => { + mockOnSubmit.mockClear() + }) + + it("should render marketing flyer section with add button when no data exists", () => { + render() + + expect(screen.getByRole("heading", { level: 3, name: "Marketing flyer" })).toBeInTheDocument() + expect(screen.getByRole("button", { name: "Add marketing flyer" })).toBeInTheDocument() + }) + + it("should display both marketing and accessible flyer file entries", () => { + render( + + ) + + expect(screen.getByRole("heading", { level: 3, name: "Marketing flyer" })).toBeInTheDocument() + expect(screen.getByText("test_file_id")).toBeInTheDocument() + expect(screen.getByText("accessible_file_id")).toBeInTheDocument() + expect(screen.getByRole("button", { name: "Edit marketing flyer" })).toBeInTheDocument() + expect(screen.getAllByRole("button", { name: "Delete" })).toHaveLength(2) + }) + + it("should display both marketing and accessible flyer URL entries", () => { + render( + + ) + + expect(screen.getByRole("heading", { level: 3, name: "Marketing flyer" })).toBeInTheDocument() + expect(screen.getByText("http://test.url.com")).toBeInTheDocument() + expect(screen.getByText("http://accessible.url.com")).toBeInTheDocument() + expect(screen.getByRole("button", { name: "Edit marketing flyer" })).toBeInTheDocument() + expect(screen.getAllByRole("button", { name: "Delete" })).toHaveLength(2) + }) + + it("should render file and URL values with mixed data", () => { + render( + + ) + + const table = screen.getByRole("table") + const tableWithin = within(table) + + const marketingLabel = tableWithin.getByText("Marketing flyer") + + expect(marketingLabel).toBeInTheDocument() + expect(tableWithin.getByText("file_name.pdf")).toBeInTheDocument() + expect(tableWithin.getByText("Accessible marketing flyer")).toBeInTheDocument() + expect(tableWithin.getByText("http://accessible.url.com")).toBeInTheDocument() + }) + + it("should handle file upload and url save", async () => { + const mockCloudinaryUploader = helpers.cloudinaryFileUploader as jest.MockedFunction< + typeof helpers.cloudinaryFileUploader + > + // eslint-disable-next-line @typescript-eslint/require-await + mockCloudinaryUploader.mockImplementation(async ({ setCloudinaryData, setProgressValue }) => { + setProgressValue(100) + setCloudinaryData({ + id: "test-cloudinary-id/test-file", + url: "https://test.cloudinary.com/test-file.pdf", + }) + }) + + render() + + const addButton = screen.getByRole("button", { name: "Add marketing flyer" }) + await userEvent.click(addButton) + + const uploadRadio = screen.getByRole("radio", { name: "Upload PDF" }) + await userEvent.click(uploadRadio) + + const file = new File(["mocked flyer pdf content"], "flyer.pdf", { + type: "application/pdf", + }) + const dropzone = screen.getByLabelText("Upload file") + + await userEvent.upload(dropzone, file) + + await waitFor(() => { + expect(screen.getByText("test-file")).toBeInTheDocument() + }) + + const accessibleUrlRadio = screen.getByRole("radio", { + name: "Webpage URL to an accessible version", + }) + await userEvent.click(accessibleUrlRadio) + + const accessibleUrlInput = screen.getByLabelText("Informational webpage URL", { + selector: "#accessibleMarketingFlyerURL", + }) + await userEvent.type(accessibleUrlInput, "https://accessible.example.com") + + const saveButton = screen.getByRole("button", { name: "Save" }) + await userEvent.click(saveButton) + + expect(mockOnSubmit).toHaveBeenCalledWith({ + marketingFlyer: "", + listingsMarketingFlyerFile: { + fileId: "test-cloudinary-id/test-file", + label: "cloudinaryPDF", + }, + accessibleMarketingFlyer: "https://accessible.example.com", + listingsAccessibleMarketingFlyerFile: { + fileId: "", + label: "", + }, + }) + + expect(screen.queryByRole("heading", { name: "Add marketing flyer" })).not.toBeInTheDocument() + }) + + it("should edit existing entries by switching marketing to URL and accessible to file", async () => { + const mockCloudinaryUploader = helpers.cloudinaryFileUploader as jest.MockedFunction< + typeof helpers.cloudinaryFileUploader + > + // eslint-disable-next-line @typescript-eslint/require-await + mockCloudinaryUploader.mockImplementation(async ({ setCloudinaryData, setProgressValue }) => { + setProgressValue(100) + setCloudinaryData({ + id: "accessible-upload-id/new-accessible-file", + url: "https://test.cloudinary.com/new-accessible-file.pdf", + }) + }) + + render( + + ) + + const editButton = screen.getByRole("button", { name: "Edit marketing flyer" }) + await userEvent.click(editButton) + + const existingMarketingFiles = screen.getAllByText("original.pdf") + //listing table + table from modal drawer + expect(existingMarketingFiles.length).toEqual(2) + expect(screen.getByRole("radio", { name: "Upload PDF" })).toBeChecked() + + const accessibleUrlRadio = screen.getByRole("radio", { + name: "Webpage URL to an accessible version", + }) + expect(accessibleUrlRadio).toBeChecked() + + const accessibleUrlInput = screen.getByRole("textbox", { name: "Informational webpage URL" }) + expect(accessibleUrlInput).toHaveValue("https://existing-accessible-url.com") + + const marketingUrlRadio = screen.getByRole("radio", { name: "Webpage URL" }) + await userEvent.click(marketingUrlRadio) + + const marketingUrlInput = screen.getByLabelText("Informational webpage URL", { + selector: "#marketingFlyerURL", + }) + await userEvent.clear(marketingUrlInput) + await userEvent.type(marketingUrlInput, "https://new-marketing-url.com") + + const accessibleUploadRadio = screen.getByRole("radio", { + name: "Upload accessible PDF", + }) + await userEvent.click(accessibleUploadRadio) + + const accessibleDropzone = screen.getByLabelText("Upload file") + const accessibleFile = new File(["new accessible content"], "new-accessible.pdf", { + type: "application/pdf", + }) + await userEvent.upload(accessibleDropzone, accessibleFile) + + await waitFor(() => { + expect(screen.getByText("new-accessible-file")).toBeInTheDocument() + }) + + const saveButton = screen.getByRole("button", { name: "Save" }) + await userEvent.click(saveButton) + + expect(mockOnSubmit).toHaveBeenCalledWith({ + marketingFlyer: "https://new-marketing-url.com", + listingsMarketingFlyerFile: { + fileId: "", + label: "", + }, + accessibleMarketingFlyer: "", + listingsAccessibleMarketingFlyerFile: { + fileId: "accessible-upload-id/new-accessible-file", + label: "cloudinaryPDF", + }, + }) + }) + + it("should handle delete action for individual flyers", async () => { + render( + + ) + + const deleteButtons = screen.getAllByRole("button", { name: "Delete" }) + await userEvent.click(deleteButtons[0]) + + expect(mockOnSubmit).toHaveBeenCalledWith({ + marketingFlyer: "", + listingsMarketingFlyerFile: { + fileId: "", + label: "", + }, + accessibleMarketingFlyer: "http://accessible.url.com", + listingsAccessibleMarketingFlyerFile: undefined, + }) + + mockOnSubmit.mockClear() + await userEvent.click(deleteButtons[1]) + + expect(mockOnSubmit).toHaveBeenCalledWith({ + marketingFlyer: undefined, + listingsMarketingFlyerFile: { + fileId: "test_file_id", + label: "test_file", + }, + accessibleMarketingFlyer: "", + listingsAccessibleMarketingFlyerFile: { + fileId: "", + label: "", + }, + }) + }) +}) diff --git a/sites/partners/__tests__/pages/listings/[id]/index.test.tsx b/sites/partners/__tests__/pages/listings/[id]/index.test.tsx index 5c701454cc..4510782d3b 100644 --- a/sites/partners/__tests__/pages/listings/[id]/index.test.tsx +++ b/sites/partners/__tests__/pages/listings/[id]/index.test.tsx @@ -1,7 +1,7 @@ /* eslint-disable import/no-named-as-default */ import React from "react" import { setupServer } from "msw/lib/node" -import { fireEvent, mockNextRouter, render, within } from "../../../testUtils" +import { fireEvent, mockNextRouter, queryByText, render, screen, within } from "../../../testUtils" import { ListingContext } from "../../../../src/components/listings/ListingContext" import { jurisdiction, listing, user } from "@bloom-housing/shared-helpers/__tests__/testHelpers" import DetailListingData from "../../../../src/components/listings/PaperListingDetails/sections/DetailListingData" @@ -13,11 +13,13 @@ import DetailPreferences from "../../../../src/components/listings/PaperListingD import { ApplicationAddressTypeEnum, ApplicationMethodsTypeEnum, + EnumListingListingType, FeatureFlagEnum, LanguagesEnum, ListingEventsTypeEnum, ListingsStatusEnum, MultiselectQuestionsApplicationSectionEnum, + MultiselectQuestionsStatusEnum, RegionEnum, ReviewOrderTypeEnum, } from "@bloom-housing/shared-helpers/src/types/backend-swagger" @@ -76,44 +78,77 @@ afterAll(() => { function mockJurisdictionsHaveFeatureFlagOn( featureFlag: string, - enableHomeType = true, - enableSection8Question = true, - enableUnitGroups = false, - enableIsVerified = true + overrides?: { + enableHomeType?: boolean + enableSection8Question?: boolean + enableUnitGroups?: boolean + enableIsVerified?: boolean + enableMarketingStatus?: boolean + enableAccessibilityFeatures?: boolean + enableRegions?: boolean + } ) { switch (featureFlag) { case FeatureFlagEnum.enableHomeType: - return enableHomeType + return overrides?.enableHomeType ?? true case FeatureFlagEnum.enableSection8Question: - return enableSection8Question + return overrides?.enableSection8Question ?? true case FeatureFlagEnum.enableUnitGroups: - return enableUnitGroups + return overrides?.enableUnitGroups ?? false case FeatureFlagEnum.enableIsVerified: - return enableIsVerified + return overrides?.enableIsVerified ?? true + case FeatureFlagEnum.enableMarketingStatus: + return overrides?.enableMarketingStatus ?? false + case FeatureFlagEnum.enableAccessibilityFeatures: + return overrides?.enableAccessibilityFeatures ?? true + case FeatureFlagEnum.enableRegions: + return overrides?.enableRegions ?? true default: - return true + return false } } describe("listing data", () => { describe("should display all listing data", () => { it("should display Listing Data section", () => { - const { getByText } = render( + render( + + + + ) + + expect(screen.getByText("Listing data")).toBeInTheDocument() + expect(screen.getByText("Listing ID")).toBeInTheDocument() + expect(screen.getByText("Uvbk5qurpB2WI9V6WnNdH")).toBeInTheDocument() + expect(screen.getByText("Date created")).toBeInTheDocument() + expect(screen.getByText("02/03/2025 at 10:13 AM")).toBeInTheDocument() + expect(screen.getByText("Jurisdiction")).toBeInTheDocument() + expect(screen.getByText("Bloomington")).toBeInTheDocument() + }) + + it("should display Listing Data section but no jurisdiction", () => { + render( - + ) - expect(getByText("Listing data")).toBeInTheDocument() - expect(getByText("Listing ID")).toBeInTheDocument() - expect(getByText("Uvbk5qurpB2WI9V6WnNdH")).toBeInTheDocument() - expect(getByText("Date created")).toBeInTheDocument() - expect(getByText("02/03/2025 at 10:13 AM")).toBeInTheDocument() + expect(screen.getByText("Listing data")).toBeInTheDocument() + expect(screen.getByText("Listing ID")).toBeInTheDocument() + expect(screen.getByText("Uvbk5qurpB2WI9V6WnNdH")).toBeInTheDocument() + expect(screen.getByText("Date created")).toBeInTheDocument() + expect(screen.getByText("02/03/2025 at 10:13 AM")).toBeInTheDocument() + expect(screen.queryByText("Jurisdiction")).not.toBeInTheDocument() }) describe("should display Listing Notes section", () => { @@ -122,7 +157,7 @@ describe("listing data", () => { ) it.each(STATUS_OPTIONS)("should hide section for %s status", (status) => { - const { queryByText } = render( + render( { ) - expect(queryByText("Listing notes")).not.toBeInTheDocument() - expect(queryByText("Change request summary")).not.toBeInTheDocument() - expect(queryByText("Test changes")).not.toBeInTheDocument() - expect(queryByText("Request date")).not.toBeInTheDocument() - expect(queryByText("01/10/2025")).not.toBeInTheDocument() - expect(queryByText("Requested by")).not.toBeInTheDocument() - expect(queryByText("John Test")).not.toBeInTheDocument() + expect(screen.queryByText("Listing notes")).not.toBeInTheDocument() + expect(screen.queryByText("Change request summary")).not.toBeInTheDocument() + expect(screen.queryByText("Test changes")).not.toBeInTheDocument() + expect(screen.queryByText("Request date")).not.toBeInTheDocument() + expect(screen.queryByText("01/10/2025")).not.toBeInTheDocument() + expect(screen.queryByText("Requested by")).not.toBeInTheDocument() + expect(screen.queryByText("John Test")).not.toBeInTheDocument() }) it("should show Listing Notes section data - no user defined", () => { - const { getByText, queryByText } = render( + render( { ) - expect(getByText("Listing notes")).toBeInTheDocument() - expect(getByText("Change request summary")).toBeInTheDocument() - expect(getByText("Test changes")).toBeInTheDocument() - expect(getByText("Request date")).toBeInTheDocument() - expect(getByText("01/10/2025")).toBeInTheDocument() - expect(queryByText("Requested by")).not.toBeInTheDocument() - expect(queryByText("John Test")).not.toBeInTheDocument() + expect(screen.getByText("Listing notes")).toBeInTheDocument() + expect(screen.getByText("Change request summary")).toBeInTheDocument() + expect(screen.getByText("Test changes")).toBeInTheDocument() + expect(screen.getByText("Request date")).toBeInTheDocument() + expect(screen.getByText("01/10/2025")).toBeInTheDocument() + expect(screen.queryByText("Requested by")).not.toBeInTheDocument() + expect(screen.queryByText("John Test")).not.toBeInTheDocument() }) it("should show Listing Notes section data - with user defined", () => { - const { getByText } = render( + render( { ) - expect(getByText("Listing notes")).toBeInTheDocument() - expect(getByText("Change request summary")).toBeInTheDocument() - expect(getByText("Test changes")).toBeInTheDocument() - expect(getByText("Request date")).toBeInTheDocument() - expect(getByText("01/10/2025")).toBeInTheDocument() - expect(getByText("Requested by")).toBeInTheDocument() - expect(getByText("John Test")).toBeInTheDocument() + expect(screen.getByText("Listing notes")).toBeInTheDocument() + expect(screen.getByText("Change request summary")).toBeInTheDocument() + expect(screen.getByText("Test changes")).toBeInTheDocument() + expect(screen.getByText("Request date")).toBeInTheDocument() + expect(screen.getByText("01/10/2025")).toBeInTheDocument() + expect(screen.getByText("Requested by")).toBeInTheDocument() + expect(screen.getByText("John Test")).toBeInTheDocument() }) }) - it("should display Listing Intro section", () => { - const { getByText } = render( - - - - ) + describe("should display Listing Intro section", () => { + it("should display Listing Intro section without listing type selection", () => { + render( + + + + ) + + expect(screen.getByText("Listing intro")).toBeInTheDocument() + expect(screen.getByText("Listing name")).toBeInTheDocument() + expect(screen.getByText("Archer Studios")).toBeInTheDocument() + expect(screen.getByText("Housing developer")).toBeInTheDocument() + expect(screen.getByText("Charities Housing")).toBeInTheDocument() + + expect(screen.queryByText("What kind of listing is this?")).not.toBeInTheDocument() + expect(screen.queryByText("Regulated")).not.toBeInTheDocument() + expect(screen.queryByText("Non-regulated")).not.toBeInTheDocument() + expect( + screen.queryByText("Has this property received HUD EBLL clearance?") + ).not.toBeInTheDocument() + }) + + it("should display Listing Intro for regulated listing", () => { + render( + + featureFlag === FeatureFlagEnum.enableNonRegulatedListings, + }} + > + + + + + ) + + expect(screen.getByText("What kind of listing is this?")).toBeInTheDocument() + expect(screen.getByText("Regulated")).toBeInTheDocument() + expect(screen.queryByText("Non-regulated")).not.toBeInTheDocument() + expect( + screen.queryByText("Has this property received HUD EBLL clearance?") + ).not.toBeInTheDocument() + }) - expect(getByText("Listing intro")).toBeInTheDocument() - expect(getByText("Listing name")).toBeInTheDocument() - expect(getByText("Archer Studios")).toBeInTheDocument() - expect(getByText("Jurisdiction")).toBeInTheDocument() - expect(getByText("San Jose")).toBeInTheDocument() - expect(getByText("Housing developer")).toBeInTheDocument() - expect(getByText("Charities Housing")).toBeInTheDocument() + it("should display Listing Intro for non-regulated listing", () => { + render( + + featureFlag === FeatureFlagEnum.enableNonRegulatedListings, + }} + > + + + + + ) + + expect(screen.getByText("What kind of listing is this?")).toBeInTheDocument() + expect(screen.queryByText("Regulated")).not.toBeInTheDocument() + expect(screen.getByText("Non-regulated")).toBeInTheDocument() + expect( + screen.getByText("Has this property received HUD EBLL clearance?") + ).toBeInTheDocument() + }) }) - describe("should display Lisiting Photo section", () => { + describe("should display Listing Photos section", () => { it("should display section with missing data", () => { - const { getByText, queryByText, queryByRole } = render( + render( { ) - expect(getByText("Listing photo")).toBeInTheDocument() - expect(getByText("None")).toBeInTheDocument() - expect(queryByText("Preview")).not.toBeInTheDocument() - expect(queryByText("Primary")).not.toBeInTheDocument() - expect(queryByText("Primary photo")).not.toBeInTheDocument() - expect(queryByRole("img")).not.toBeInTheDocument() + expect(screen.getByText("Listing photos")).toBeInTheDocument() + expect(screen.getByText("None")).toBeInTheDocument() + expect(screen.queryByText("Preview")).not.toBeInTheDocument() + expect(screen.queryByText("Primary")).not.toBeInTheDocument() + expect(screen.queryByText("Primary photo")).not.toBeInTheDocument() + expect(screen.queryByRole("img")).not.toBeInTheDocument() }) it("should display Lisiting Photo section data", () => { - const { getByText, getAllByRole } = render( + render( { ) - expect(getByText("Listing photo", { selector: "h2" })).toBeInTheDocument() - expect(getByText("Preview")).toBeInTheDocument() - expect(getByText("Primary")).toBeInTheDocument() - expect(getByText("Primary photo")).toBeInTheDocument() - const listingImages = getAllByRole("img") + expect(screen.getByText("Listing photos", { selector: "h2" })).toBeInTheDocument() + expect(screen.getByText("Preview")).toBeInTheDocument() + const listingImages = screen.getAllByRole("img") expect(listingImages).toHaveLength(2) listingImages.forEach((imageElement) => { expect(imageElement).toHaveAttribute("src", "asset_file_id") - expect(imageElement).toHaveAttribute("alt", "Listing photo") + expect(imageElement).toHaveAttribute("alt", "Listing photos") }) }) }) - it("should display Building Details section - without region", () => { - const { getByText, queryByText } = render( - - - - ) - - expect(getByText("Building details")).toBeInTheDocument() - expect(getByText("Building address")).toBeInTheDocument() - expect(getByText("Street address")).toBeInTheDocument() - expect(getByText("98 Archer Street")).toBeInTheDocument() - expect(getByText("City")).toBeInTheDocument() - expect(getByText("San Jose")).toBeInTheDocument() - expect(getByText("Longitude")).toBeInTheDocument() - expect(getByText("-121.91071")).toBeInTheDocument() - expect(getByText("State")).toBeInTheDocument() - expect(getByText("CA")).toBeInTheDocument() - expect(getByText("Latitude")).toBeInTheDocument() - expect(getByText("37.36537")).toBeInTheDocument() - expect(getByText("Zip code")).toBeInTheDocument() - expect(getByText("95112")).toBeInTheDocument() - expect(getByText("Neighborhood")).toBeInTheDocument() - expect(getByText("Rosemary Gardens Park")).toBeInTheDocument() - expect(getByText("Year built")).toBeInTheDocument() - expect(getByText("2012")).toBeInTheDocument() - expect(queryByText("Region")).not.toBeInTheDocument() - expect(queryByText("Southwest")).not.toBeInTheDocument() - }) - - it("should display Building Details section - with region", () => { - const { getByText } = render( - - mockJurisdictionsHaveFeatureFlagOn(featureFlag), - }} - > + describe("should display Building Details section", () => { + it("should display Building Details section - without region", () => { + render( - - ) + ) + + expect(screen.getByText("Building details")).toBeInTheDocument() + expect(screen.getByText("Building address")).toBeInTheDocument() + expect(screen.getByText("Street address")).toBeInTheDocument() + expect(screen.getByText("98 Archer Street")).toBeInTheDocument() + expect(screen.getByText("City")).toBeInTheDocument() + expect(screen.getByText("San Jose")).toBeInTheDocument() + expect(screen.getByText("Longitude")).toBeInTheDocument() + expect(screen.getByText("-121.91071")).toBeInTheDocument() + expect(screen.getByText("State")).toBeInTheDocument() + expect(screen.getByText("CA")).toBeInTheDocument() + expect(screen.getByText("Latitude")).toBeInTheDocument() + expect(screen.getByText("37.36537")).toBeInTheDocument() + expect(screen.getByText("Zip code")).toBeInTheDocument() + expect(screen.getByText("95112")).toBeInTheDocument() + expect(screen.getByText("Neighborhood")).toBeInTheDocument() + expect(screen.getByText("Rosemary Gardens Park")).toBeInTheDocument() + expect(screen.getByText("Year built")).toBeInTheDocument() + expect(screen.getByText("2012")).toBeInTheDocument() + expect(screen.queryByText("Region")).not.toBeInTheDocument() + expect(screen.queryByText("Southwest")).not.toBeInTheDocument() + }) + + it("should display Building Details section - with region", () => { + render( + + mockJurisdictionsHaveFeatureFlagOn(featureFlag), + }} + > + + + + + ) - expect(getByText("Building details")).toBeInTheDocument() - expect(getByText("Building address")).toBeInTheDocument() - expect(getByText("Street address")).toBeInTheDocument() - expect(getByText("98 Archer Street")).toBeInTheDocument() - expect(getByText("City")).toBeInTheDocument() - expect(getByText("San Jose")).toBeInTheDocument() - expect(getByText("Longitude")).toBeInTheDocument() - expect(getByText("-121.91071")).toBeInTheDocument() - expect(getByText("State")).toBeInTheDocument() - expect(getByText("CA")).toBeInTheDocument() - expect(getByText("Latitude")).toBeInTheDocument() - expect(getByText("37.36537")).toBeInTheDocument() - expect(getByText("Zip code")).toBeInTheDocument() - expect(getByText("95112")).toBeInTheDocument() - expect(getByText("Neighborhood")).toBeInTheDocument() - expect(getByText("Rosemary Gardens Park")).toBeInTheDocument() - expect(getByText("Year built")).toBeInTheDocument() - expect(getByText("2012")).toBeInTheDocument() - expect(getByText("Region")).toBeInTheDocument() - expect(getByText("Southwest")).toBeInTheDocument() + expect(screen.getByText("Building details")).toBeInTheDocument() + expect(screen.getByText("Building address")).toBeInTheDocument() + expect(screen.getByText("Street address")).toBeInTheDocument() + expect(screen.getByText("98 Archer Street")).toBeInTheDocument() + expect(screen.getByText("City")).toBeInTheDocument() + expect(screen.getByText("San Jose")).toBeInTheDocument() + expect(screen.getByText("Longitude")).toBeInTheDocument() + expect(screen.getByText("-121.91071")).toBeInTheDocument() + expect(screen.getByText("State")).toBeInTheDocument() + expect(screen.getByText("CA")).toBeInTheDocument() + expect(screen.getByText("Latitude")).toBeInTheDocument() + expect(screen.getByText("37.36537")).toBeInTheDocument() + expect(screen.getByText("Zip code")).toBeInTheDocument() + expect(screen.getByText("95112")).toBeInTheDocument() + expect(screen.getByText("Neighborhood")).toBeInTheDocument() + expect(screen.getByText("Rosemary Gardens Park")).toBeInTheDocument() + expect(screen.getByText("Year built")).toBeInTheDocument() + expect(screen.getByText("2012")).toBeInTheDocument() + expect(screen.getByText("Region")).toBeInTheDocument() + expect(screen.getByText("Southwest")).toBeInTheDocument() + }) }) describe("should display Community Type section", () => { it("should display all section data - without disclaimer", () => { - const { getByText } = render( + render( { ) - expect(getByText("Community type")).toBeInTheDocument() - expect(getByText("Reserved community type")).toBeInTheDocument() - expect(getByText("Farmworker housing")).toBeInTheDocument() - expect(getByText("Reserved community description")).toBeInTheDocument() - expect(getByText("Test community description")).toBeInTheDocument() + expect(screen.getByText("Community type")).toBeInTheDocument() + expect(screen.getByText("Reserved community type")).toBeInTheDocument() + expect(screen.getByText("Farmworker housing")).toBeInTheDocument() + expect(screen.getByText("Reserved community description")).toBeInTheDocument() + expect(screen.getByText("Test community description")).toBeInTheDocument() expect( - getByText( + screen.getByText( "Do you want to include a community type disclaimer as the first page of the application?" ) ).toBeInTheDocument() - expect(getByText("No")).toBeInTheDocument() + expect(screen.getByText("No")).toBeInTheDocument() }) it("should display all section data - with disclaimer", () => { - const { getByText } = render( + render( { ) - expect(getByText("Community type")).toBeInTheDocument() - expect(getByText("Reserved community type")).toBeInTheDocument() - expect(getByText("Farmworker housing")).toBeInTheDocument() - expect(getByText("Reserved community description")).toBeInTheDocument() - expect(getByText("Test community description")).toBeInTheDocument() + expect(screen.getByText("Community type")).toBeInTheDocument() + expect(screen.getByText("Reserved community type")).toBeInTheDocument() + expect(screen.getByText("Farmworker housing")).toBeInTheDocument() + expect(screen.getByText("Reserved community description")).toBeInTheDocument() + expect(screen.getByText("Test community description")).toBeInTheDocument() expect( - getByText( + screen.getByText( "Do you want to include a community type disclaimer as the first page of the application?" ) ).toBeInTheDocument() - expect(getByText("Yes")).toBeInTheDocument() - expect(getByText("Test Disclaimer Title")).toBeInTheDocument() - expect(getByText("Test Disclaimer Description")).toBeInTheDocument() + expect(screen.getByText("Yes")).toBeInTheDocument() + expect(screen.getByText("Test Disclaimer Title")).toBeInTheDocument() + expect(screen.getByText("Test Disclaimer Description")).toBeInTheDocument() }) const COMMUNITY_TYPES = [ @@ -452,7 +550,7 @@ describe("listing data", () => { it.each(COMMUNITY_TYPES)(`Should display %s type`, (item) => { const { typeString, dtoField } = item - const { getByText } = render( + render( { ) - expect(getByText(typeString)) + expect(screen.getByText(typeString)) }) }) it("should display missing Listing Units section", () => { - const { getByText, queryByText } = render( + render( { ) - expect(getByText("Do you want to show unit types or individual units?")).toBeInTheDocument() - expect(getByText("Individual units")).toBeInTheDocument() - expect(getByText("What is the listing availability?")).toBeInTheDocument() - expect(getByText("Available units")).toBeInTheDocument() - expect(getByText("None")).toBeInTheDocument() - - expect(queryByText("Unit #")).not.toBeInTheDocument() - expect(queryByText("Unit type")).not.toBeInTheDocument() - expect(queryByText("AMI")).not.toBeInTheDocument() - expect(queryByText("Rent")).not.toBeInTheDocument() - expect(queryByText("SQ FT")).not.toBeInTheDocument() - expect(queryByText("ADA")).not.toBeInTheDocument() expect( - queryByText("Do you accept Section 8 Housing Choice Vouchers?") + screen.getByText("Do you want to show unit types or individual units?") + ).toBeInTheDocument() + expect(screen.getByText("Individual units")).toBeInTheDocument() + expect(screen.getByText("What is the listing availability?")).toBeInTheDocument() + expect(screen.getByText("Available units")).toBeInTheDocument() + expect(screen.getByText("None")).toBeInTheDocument() + + expect(screen.queryByText("Unit #")).not.toBeInTheDocument() + expect(screen.queryByText("Unit type")).not.toBeInTheDocument() + expect(screen.queryByText("AMI")).not.toBeInTheDocument() + expect(screen.queryByText("Rent")).not.toBeInTheDocument() + expect(screen.queryByText("SQ FT")).not.toBeInTheDocument() + expect(screen.queryByText("ADA")).not.toBeInTheDocument() + expect( + screen.queryByText("Do you accept Section 8 Housing Choice Vouchers?") ).not.toBeInTheDocument() - expect(queryByText("No")).not.toBeInTheDocument() + expect(screen.queryByText("No")).not.toBeInTheDocument() }) it("should display Listing Units section", () => { - const { getByText, getAllByText } = render( + render( { ) - expect(getByText("Do you want to show unit types or individual units?")).toBeInTheDocument() - expect(getByText("Individual units")).toBeInTheDocument() - expect(getByText("What is the listing availability?")).toBeInTheDocument() - expect(getByText("Available units")).toBeInTheDocument() - - expect(getByText("Unit #")).toBeInTheDocument() - expect(getByText("Unit type")).toBeInTheDocument() - expect(getByText("AMI")).toBeInTheDocument() - expect(getByText("Rent")).toBeInTheDocument() - expect(getByText("SQ FT")).toBeInTheDocument() - expect(getByText("ADA")).toBeInTheDocument() - - expect(getAllByText(/#[1-9]/i)).toHaveLength(6) - expect(getAllByText("Studio")).toHaveLength(6) - expect(getAllByText("45.0")).toHaveLength(6) - expect(getAllByText("1104.0")).toHaveLength(6) - expect(getAllByText("285")).toHaveLength(6) - expect(getAllByText(/Test ADA_\d{1}/)).toHaveLength(6) - expect(getAllByText("View")).toHaveLength(6) - - expect(getByText("Do you accept Section 8 Housing Choice Vouchers?")).toBeInTheDocument() - expect(getByText("Yes")).toBeInTheDocument() + expect( + screen.getByText("Do you want to show unit types or individual units?") + ).toBeInTheDocument() + expect(screen.getByText("Individual units")).toBeInTheDocument() + expect(screen.getByText("What is the listing availability?")).toBeInTheDocument() + expect(screen.getByText("Available units")).toBeInTheDocument() + + expect(screen.getByText("Unit #")).toBeInTheDocument() + expect(screen.getByText("Unit type")).toBeInTheDocument() + expect(screen.getByText("AMI")).toBeInTheDocument() + expect(screen.getByText("Rent")).toBeInTheDocument() + expect(screen.getByText("SQ FT")).toBeInTheDocument() + expect(screen.getByText("ADA")).toBeInTheDocument() + + expect(screen.getAllByText(/#[1-9]/i)).toHaveLength(6) + expect(screen.getAllByText("Studio")).toHaveLength(6) + expect(screen.getAllByText("45.0")).toHaveLength(6) + expect(screen.getAllByText("1104.0")).toHaveLength(6) + expect(screen.getAllByText("285")).toHaveLength(6) + expect(screen.getAllByText(/Test ADA_\d{1}/)).toHaveLength(6) + expect(screen.getAllByText("View")).toHaveLength(6) + + expect( + screen.getByText("Do you accept Section 8 Housing Choice Vouchers?") + ).toBeInTheDocument() + expect(screen.getByText("Yes")).toBeInTheDocument() }) it("should display missing Housing Preferences section", () => { - const { getByText, queryByText } = render( + render( ) - expect(getByText("Housing preferences")).toBeInTheDocument() - expect(getByText("Active preferences")).toBeInTheDocument() - expect(getByText("None")).toBeInTheDocument() - expect(queryByText("Order")).not.toBeInTheDocument() - expect(queryByText("Name")).not.toBeInTheDocument() - expect(queryByText("Description")).not.toBeInTheDocument() + expect(screen.getByText("Housing preferences")).toBeInTheDocument() + expect(screen.getByText("Active preferences")).toBeInTheDocument() + expect(screen.getByText("None")).toBeInTheDocument() + expect(screen.queryByText("Order")).not.toBeInTheDocument() + expect(screen.queryByText("Name")).not.toBeInTheDocument() + expect(screen.queryByText("Description")).not.toBeInTheDocument() }) it("should display Housing Preferences section", () => { - const { getByText, getAllByText } = render( + render( { text: "Test Name_1", description: "Test Description_1", applicationSection: MultiselectQuestionsApplicationSectionEnum.preferences, + status: MultiselectQuestionsStatusEnum.active, }, }, { @@ -596,6 +701,7 @@ describe("listing data", () => { text: "Test Name_2", description: "Test Description_2", applicationSection: MultiselectQuestionsApplicationSectionEnum.preferences, + status: MultiselectQuestionsStatusEnum.active, }, }, ], @@ -605,19 +711,19 @@ describe("listing data", () => { ) - expect(getByText("Housing preferences")).toBeInTheDocument() - expect(getByText("Active preferences")).toBeInTheDocument() - expect(getByText("Order")).toBeInTheDocument() - expect(getByText("1")).toBeInTheDocument() - expect(getByText("2")).toBeInTheDocument() - expect(getByText("Name")).toBeInTheDocument() - expect(getAllByText(/Test Name_\d{1}/)).toHaveLength(2) - expect(getByText("Description")).toBeInTheDocument() - expect(getAllByText(/Test Description_\d{1}/)).toHaveLength(2) + expect(screen.getByText("Housing preferences")).toBeInTheDocument() + expect(screen.getByText("Active preferences")).toBeInTheDocument() + expect(screen.getByText("Order")).toBeInTheDocument() + expect(screen.getByText("1")).toBeInTheDocument() + expect(screen.getByText("2")).toBeInTheDocument() + expect(screen.getByText("Name")).toBeInTheDocument() + expect(screen.getAllByText(/Test Name_\d{1}/)).toHaveLength(2) + expect(screen.getByText("Description")).toBeInTheDocument() + expect(screen.getAllByText(/Test Description_\d{1}/)).toHaveLength(2) }) it("should display Housing Programs section", () => { - const { getByText, getAllByText } = render( + render( { text: "Test Program Name_1", description: "Test Program Description_1", applicationSection: MultiselectQuestionsApplicationSectionEnum.programs, + status: MultiselectQuestionsStatusEnum.draft, }, }, { @@ -642,6 +749,7 @@ describe("listing data", () => { text: "Test Program Name_2", description: "Test Program Description_2", applicationSection: MultiselectQuestionsApplicationSectionEnum.programs, + status: MultiselectQuestionsStatusEnum.draft, }, }, ], @@ -651,19 +759,19 @@ describe("listing data", () => { ) - expect(getByText("Housing programs")).toBeInTheDocument() - expect(getByText("Active programs")).toBeInTheDocument() - expect(getByText("Order")).toBeInTheDocument() - expect(getByText("1")).toBeInTheDocument() - expect(getByText("2")).toBeInTheDocument() - expect(getByText("Name")).toBeInTheDocument() - expect(getAllByText(/Test Program Name_\d{1}/)).toHaveLength(2) - expect(getByText("Description")).toBeInTheDocument() - expect(getAllByText(/Test Program Description_\d{1}/)).toHaveLength(2) + expect(screen.getByText("Housing programs")).toBeInTheDocument() + expect(screen.getByText("Active programs")).toBeInTheDocument() + expect(screen.getByText("Order")).toBeInTheDocument() + expect(screen.getByText("1")).toBeInTheDocument() + expect(screen.getByText("2")).toBeInTheDocument() + expect(screen.getByText("Name")).toBeInTheDocument() + expect(screen.getAllByText(/Test Program Name_\d{1}/)).toHaveLength(2) + expect(screen.getByText("Description")).toBeInTheDocument() + expect(screen.getAllByText(/Test Program Description_\d{1}/)).toHaveLength(2) }) it("should display Additional Fees section", () => { - const { getByText } = render( + render( { ) - expect(getByText("Additional fees")).toBeInTheDocument() - expect(getByText("Application fee")).toBeInTheDocument() - expect(getByText("30.0")).toBeInTheDocument() - expect(getByText("Deposit max")).toBeInTheDocument() - expect(getByText("1000")).toBeInTheDocument() - expect(getByText("Deposit helper text")).toBeInTheDocument() - expect(getByText("Test Deposit Helper Text")).toBeInTheDocument() - expect(getByText("Deposit min")).toBeInTheDocument() - expect(getByText("1140.0")).toBeInTheDocument() - expect(getByText("Costs not included")).toBeInTheDocument() + expect(screen.getByText("Additional fees")).toBeInTheDocument() + expect(screen.getByText("Application fee")).toBeInTheDocument() + expect(screen.getByText("30.0")).toBeInTheDocument() + expect(screen.getByText("Deposit helper text")).toBeInTheDocument() + expect(screen.getByText("Test Deposit Helper Text")).toBeInTheDocument() + expect(screen.getByText("Costs not included")).toBeInTheDocument() expect( - getByText( + screen.getByText( "Resident responsible for PG&E, internet and phone. Owner pays for water, trash, and sewage." ) ).toBeInTheDocument() @@ -696,7 +800,7 @@ describe("listing data", () => { describe("should display Building Features section", () => { it("should display data with no accessibility features", () => { - const { getByText } = render( + render( { ) - expect(getByText("Building features")).toBeInTheDocument() - expect(getByText("Property amenities")).toBeInTheDocument() + expect(screen.getByText("Building features")).toBeInTheDocument() + expect(screen.getByText("Property amenities")).toBeInTheDocument() expect( - getByText( + screen.getByText( "Community Room, Laundry Room, Assigned Parking, Bike Storage, Roof Top Garden, Part-time Resident Service Coordinator" ) ).toBeInTheDocument() - expect(getByText("Unit amenities")).toBeInTheDocument() - expect(getByText("Dishwasher")).toBeInTheDocument() - expect(getByText("Additional accessibility")).toBeInTheDocument() + expect(screen.getByText("Unit amenities")).toBeInTheDocument() + expect(screen.getByText("Dishwasher")).toBeInTheDocument() + expect(screen.getByText("Additional accessibility")).toBeInTheDocument() expect( - getByText( + screen.getByText( "There is a total of 5 ADA units in the complex, all others are adaptable. Exterior Wheelchair ramp (front entry)" ) ).toBeInTheDocument() - expect(getByText("Smoking policy")).toBeInTheDocument() - expect(getByText("Non-smoking building")).toBeInTheDocument() - expect(getByText("Pets policy")).toBeInTheDocument() + expect(screen.getByText("Smoking policy")).toBeInTheDocument() + expect(screen.getByText("Non-smoking building")).toBeInTheDocument() + expect(screen.getByText("Pets policy")).toBeInTheDocument() expect( - getByText( + screen.getByText( "No pets allowed. Accommodation animals may be granted to persons with disabilities via a reasonable accommodation request." ) ).toBeInTheDocument() - expect(getByText("Services offered")).toBeInTheDocument() - expect(getByText("Professional Help")).toBeInTheDocument() + expect(screen.getByText("Services offered")).toBeInTheDocument() + expect(screen.getByText("Services offered")).toBeInTheDocument() + expect(screen.getByText("Professional Help")).toBeInTheDocument() }) it("should display accessibility features", () => { @@ -742,7 +847,7 @@ describe("listing data", () => { }) ) - const { getByText } = render( + render( { ) - expect(getByText("Elevator")).toBeInTheDocument() - expect(getByText("Wheelchair ramp")).toBeInTheDocument() - expect(getByText("Service animals allowed")).toBeInTheDocument() - expect(getByText("Accessible parking spots")).toBeInTheDocument() - expect(getByText("Parking on site")).toBeInTheDocument() - expect(getByText("In-unit washer/dryer")).toBeInTheDocument() - expect(getByText("Laundry in building")).toBeInTheDocument() - expect(getByText("Barrier-free (no-step) property entrance")).toBeInTheDocument() - expect(getByText("Roll-in showers")).toBeInTheDocument() - expect(getByText("Grab bars in bathrooms")).toBeInTheDocument() - expect(getByText("Heating in unit")).toBeInTheDocument() - expect(getByText("AC in unit")).toBeInTheDocument() - expect(getByText("Units for those with hearing disabilities")).toBeInTheDocument() - expect(getByText("Units for those with visual disabilities")).toBeInTheDocument() - expect(getByText("Units for those with mobility disabilities")).toBeInTheDocument() - expect(getByText("Lowered cabinets and countertops")).toBeInTheDocument() - expect(getByText("Lowered light switches")).toBeInTheDocument() - expect(getByText("Wide unit doorways for wheelchairs")).toBeInTheDocument() - expect(getByText("Barrier-free bathrooms")).toBeInTheDocument() - expect(getByText("Barrier-free (no-step) unit entrances")) + expect(screen.getByText("Elevator")).toBeInTheDocument() + expect(screen.getByText("Wheelchair ramp")).toBeInTheDocument() + expect(screen.getByText("Service animals allowed")).toBeInTheDocument() + expect(screen.getByText("Accessible parking spots")).toBeInTheDocument() + expect(screen.getByText("Parking on site")).toBeInTheDocument() + expect(screen.getByText("In-unit washer/dryer")).toBeInTheDocument() + expect(screen.getByText("Laundry in building")).toBeInTheDocument() + expect(screen.getByText("Barrier-free (no-step) property entrance")).toBeInTheDocument() + expect(screen.getByText("Roll-in showers")).toBeInTheDocument() + expect(screen.getByText("Grab bars in bathrooms")).toBeInTheDocument() + expect(screen.getByText("Heating in unit")).toBeInTheDocument() + expect(screen.getByText("AC in unit")).toBeInTheDocument() + expect(screen.getByText("Units for those with hearing disabilities")).toBeInTheDocument() + expect(screen.getByText("Units for those with visual disabilities")).toBeInTheDocument() + expect(screen.getByText("Units for those with mobility disabilities")).toBeInTheDocument() + expect(screen.getByText("Lowered cabinets and countertops")).toBeInTheDocument() + expect(screen.getByText("Lowered light switches")).toBeInTheDocument() + expect(screen.getByText("Wide unit doorways for wheelchairs")).toBeInTheDocument() + expect(screen.getByText("Barrier-free bathrooms")).toBeInTheDocument() + expect(screen.getByText("Barrier-free (no-step) unit entrances")) }) }) describe("should display Additional Eligibility Rules section", () => { it("should display data with selection criteria", () => { - const { getByText, queryByText } = render( + render( { ) - expect(getByText("Additional eligibility rules")).toBeInTheDocument() - expect(getByText("Credit history")).toBeInTheDocument() + expect(screen.getByText("Additional eligibility rules")).toBeInTheDocument() + expect(screen.getByText("Credit history")).toBeInTheDocument() expect( // Look only for part of the text to verify that content rendered properly - getByText( + screen.getByText( /Applications will be rated on a score system for housing. An applicant's score may be impacted by negative tenant peformance information provided to the credit reporting agency./ ) ).toBeInTheDocument() - expect(getByText("Rental history")).toBeInTheDocument() + expect(screen.getByText("Rental history")).toBeInTheDocument() expect( // Look only for part of the text to verify that content rendered properly - getByText(/Two years of rental history will be verified with all applicable landlords./) + screen.getByText( + /Two years of rental history will be verified with all applicable landlords./ + ) ).toBeInTheDocument() - expect(getByText("Criminal background")).toBeInTheDocument() + expect(screen.getByText("Criminal background")).toBeInTheDocument() expect( // Look only for part of the text to verify that content rendered properly - getByText(/A criminal background investigation will be obtained on each applicant./) + screen.getByText( + /A criminal background investigation will be obtained on each applicant./ + ) ).toBeInTheDocument() - expect(getByText("Rental assistance")).toBeInTheDocument() - expect(getByText("Custom rental assistance")).toBeInTheDocument() - expect(getByText("Building selection criteria")).toBeInTheDocument() - expect(getByText("URL")).toBeInTheDocument() + expect(screen.getByText("Rental assistance")).toBeInTheDocument() + expect(screen.getByText("Custom rental assistance")).toBeInTheDocument() + expect(screen.getByText("Building selection criteria")).toBeInTheDocument() + expect(screen.getByText("URL")).toBeInTheDocument() expect( - getByText("Tenant Selection Criteria will be available to all applicants upon request.") + screen.getByText( + "Tenant Selection Criteria will be available to all applicants upon request." + ) ).toBeInTheDocument() - expect(queryByText("Preview")).not.toBeInTheDocument() - expect(queryByText("File name")).not.toBeInTheDocument() + expect(screen.queryByText("Preview")).not.toBeInTheDocument() + expect(screen.queryByText("File name")).not.toBeInTheDocument() }) it("should display selection criteria file", async () => { - const { getByText, findByRole } = render( + render( { ) - expect(getByText("Preview")).toBeInTheDocument() - expect(getByText("File name")).toBeInTheDocument() - expect(getByText("example_file.pdf")).toBeInTheDocument() - expect(getByText("Preview")).toBeInTheDocument() + expect(screen.getByText("Preview")).toBeInTheDocument() + expect(screen.getByText("File name")).toBeInTheDocument() + expect(screen.getByText("example_file.pdf")).toBeInTheDocument() + expect(screen.getByText("Preview")).toBeInTheDocument() - const previewImage = await findByRole("img") + const previewImage = await screen.findByRole("img") expect(previewImage).toBeInTheDocument() expect(previewImage).toHaveAttribute( "src", @@ -883,32 +994,188 @@ describe("listing data", () => { }) }) - it("should display Additional Details section", () => { - const { getByText } = render( - - - - ) + describe("should display Additional Details section", () => { + it("should display Additional Details section for regulated listings", () => { + render( + + + + ) - expect(getByText("Required documents")).toBeInTheDocument() - expect(getByText("Completed application and government issued IDs")).toBeInTheDocument() - expect(getByText("Important program rules")).toBeInTheDocument() - expect( - getByText( - "Applicants must adhere to minimum & maximum income limits. Tenant Selection Criteria applies." + expect(screen.getByText("Required documents")).toBeInTheDocument() + expect( + screen.getByText("Completed application and government issued IDs") + ).toBeInTheDocument() + expect(screen.getByText("Important program rules")).toBeInTheDocument() + expect( + screen.getByText( + "Applicants must adhere to minimum & maximum income limits. Tenant Selection Criteria applies." + ) + ).toBeInTheDocument() + expect(screen.getByText("Special notes")).toBeInTheDocument() + expect(screen.getByText("Special notes description")).toBeInTheDocument() + }) + + it("shoudld display Additional Details section for non-regulated listings - show all documents options", () => { + render( + + featureFlag === FeatureFlagEnum.enableNonRegulatedListings, + }} + > + + + + ) - ).toBeInTheDocument() - expect(getByText("Special notes")).toBeInTheDocument() - expect(getByText("Special notes description")).toBeInTheDocument() + + const requiredDocumentsListTitle = screen.getByText("Required documents") + expect(requiredDocumentsListTitle).toBeInTheDocument() + const requiredDocumentsListContainer = requiredDocumentsListTitle.parentElement + + expect( + within(requiredDocumentsListContainer).getByText("Social Security card") + ).toBeInTheDocument() + expect( + within(requiredDocumentsListContainer).getByText("Current landlord reference") + ).toBeInTheDocument() + expect( + within(requiredDocumentsListContainer).getByText( + "Birth Certificate (all household members 18+)" + ) + ).toBeInTheDocument() + expect( + within(requiredDocumentsListContainer).getByText("Previous landlord reference") + ).toBeInTheDocument() + expect( + within(requiredDocumentsListContainer).getByText( + "Government-issued ID (all household members 18+)" + ) + ).toBeInTheDocument() + expect( + within(requiredDocumentsListContainer).getByText( + "Proof of Assets (bank statements, etc.)" + ) + ).toBeInTheDocument() + expect( + within(requiredDocumentsListContainer).getByText( + "Proof of household income (check stubs, W-2, etc.)" + ) + ).toBeInTheDocument() + expect( + within(requiredDocumentsListContainer).getByText( + "Immigration/Residency documents (green card, etc.)" + ) + ).toBeInTheDocument() + expect( + within(requiredDocumentsListContainer).getByText("Proof of Custody/Guardianship") + ).toBeInTheDocument() + + expect(screen.getByText("Required documents (Additional Info)")).toBeInTheDocument() + }) + + it("shoudld display Additional Details section for non-regulated listings - show partial documents options", () => { + render( + + featureFlag === FeatureFlagEnum.enableNonRegulatedListings, + }} + > + + + + + ) + + const requiredDocumentsListTitle = screen.getByText("Required documents") + expect(requiredDocumentsListTitle).toBeInTheDocument() + const requiredDocumentsListContainer = requiredDocumentsListTitle.parentElement + + expect( + within(requiredDocumentsListContainer).getByText("Social Security card") + ).toBeInTheDocument() + expect( + within(requiredDocumentsListContainer).getByText("Current landlord reference") + ).toBeInTheDocument() + expect( + within(requiredDocumentsListContainer).getByText( + "Birth Certificate (all household members 18+)" + ) + ).toBeInTheDocument() + expect( + within(requiredDocumentsListContainer).getByText("Previous landlord reference") + ).toBeInTheDocument() + expect( + within(requiredDocumentsListContainer).queryByText( + "Government-issued ID (all household members 18+)" + ) + ).not.toBeInTheDocument() + expect( + within(requiredDocumentsListContainer).queryByText( + "Proof of Assets (bank statements, etc.)" + ) + ).not.toBeInTheDocument() + expect( + within(requiredDocumentsListContainer).queryByText( + "Proof of household income (check stubs, W-2, etc.)" + ) + ).not.toBeInTheDocument() + expect( + within(requiredDocumentsListContainer).queryByText( + "Immigration/Residency documents (green card, etc.)" + ) + ).not.toBeInTheDocument() + expect( + within(requiredDocumentsListContainer).queryByText("Proof of Custody/Guardianship") + ).not.toBeInTheDocument() + + expect(screen.getByText("Required documents (Additional Info)")).toBeInTheDocument() + }) }) describe("should display Rankings & Results section", () => { it("should display data for waitlist review order typy without lottery event", () => { - const { getByText, queryByText } = render( + render( { ) - expect(getByText("Rankings & results")).toBeInTheDocument() - expect(getByText("Do you want to show a waitlist size?")).toBeInTheDocument() - expect(getByText("Yes")).toBeInTheDocument() - expect(getByText("Number of openings")).toBeInTheDocument() - expect(getByText("Tell the applicant what to expect from the process")).toBeInTheDocument() + expect(screen.getByText("Rankings & results")).toBeInTheDocument() + expect(screen.getByText("Do you want to show a waitlist size?")).toBeInTheDocument() + expect(screen.getByText("Yes")).toBeInTheDocument() + expect(screen.getByText("Number of openings")).toBeInTheDocument() expect( - getByText( + screen.getByText("Tell the applicant what to expect from the process") + ).toBeInTheDocument() + expect( + screen.getByText( "Applicant will be contacted. All info will be verified. Be prepared if chosen." ) ).toBeInTheDocument() expect( - queryByText("How is the application review order determined?") + screen.queryByText("How is the application review order determined?") ).not.toBeInTheDocument() - expect(queryByText("Lottery")).not.toBeInTheDocument() - expect(queryByText("First come first serve")).not.toBeInTheDocument() + expect(screen.queryByText("Lottery")).not.toBeInTheDocument() + expect(screen.queryByText("First come first serve")).not.toBeInTheDocument() expect( - queryByText("Will the lottery be run in the partner portal?") + screen.queryByText("Will the lottery be run in the partner portal?") ).not.toBeInTheDocument() - expect(queryByText("When will the lottery be run?")).not.toBeInTheDocument() - expect(queryByText("Lottery start time")).not.toBeInTheDocument() - expect(queryByText("Lottery end time")).not.toBeInTheDocument() - expect(queryByText("Lottery date notes")).not.toBeInTheDocument() + expect(screen.queryByText("When will the lottery be run?")).not.toBeInTheDocument() + expect(screen.queryByText("Lottery start time")).not.toBeInTheDocument() + expect(screen.queryByText("Lottery end time")).not.toBeInTheDocument() + expect(screen.queryByText("Lottery date notes")).not.toBeInTheDocument() }) it("should display data for first come first serve review order typy without lottery event", () => { - const { getByText, queryByText } = render( + render( { ) - expect(getByText("Rankings & results")).toBeInTheDocument() - expect(getByText("How is the application review order determined?")).toBeInTheDocument() - expect(getByText("First come first serve")).toBeInTheDocument() - expect(getByText("Tell the applicant what to expect from the process")).toBeInTheDocument() + expect(screen.getByText("Rankings & results")).toBeInTheDocument() expect( - getByText( + screen.getByText("How is the application review order determined?") + ).toBeInTheDocument() + expect(screen.getByText("First come first serve")).toBeInTheDocument() + expect( + screen.getByText("Tell the applicant what to expect from the process") + ).toBeInTheDocument() + expect( + screen.getByText( "Applicant will be contacted. All info will be verified. Be prepared if chosen." ) ).toBeInTheDocument() - expect(queryByText("Do you want to show a waitlist size?")).not.toBeInTheDocument() - expect(queryByText("Yes")).not.toBeInTheDocument() - expect(queryByText("Number of Openings")).not.toBeInTheDocument() - expect(queryByText("Lottery")).not.toBeInTheDocument() + expect(screen.queryByText("Do you want to show a waitlist size?")).not.toBeInTheDocument() + expect(screen.queryByText("Yes")).not.toBeInTheDocument() + expect(screen.queryByText("Number of Openings")).not.toBeInTheDocument() + expect(screen.queryByText("Lottery")).not.toBeInTheDocument() expect( - queryByText("Will the lottery be run in the partner portal?") + screen.queryByText("Will the lottery be run in the partner portal?") ).not.toBeInTheDocument() - expect(queryByText("When will the lottery be run?")).not.toBeInTheDocument() - expect(queryByText("Lottery start time")).not.toBeInTheDocument() - expect(queryByText("Lottery end time")).not.toBeInTheDocument() - expect(queryByText("Lottery date notes")).not.toBeInTheDocument() + expect(screen.queryByText("When will the lottery be run?")).not.toBeInTheDocument() + expect(screen.queryByText("Lottery start time")).not.toBeInTheDocument() + expect(screen.queryByText("Lottery end time")).not.toBeInTheDocument() + expect(screen.queryByText("Lottery date notes")).not.toBeInTheDocument() }) it("should display data for lottery serve review order typy with lottery event", () => { process.env.showLottery = "true" - const { getByText, queryByText } = render( + render( { ) - expect(getByText("Rankings & results")).toBeInTheDocument() - expect(getByText("How is the application review order determined?")).toBeInTheDocument() - expect(getByText("Lottery")).toBeInTheDocument() - expect(getByText("Will the lottery be run in the partner portal?")).toBeInTheDocument() - expect(getByText("No")).toBeInTheDocument() - expect(getByText("When will the lottery be run?")).toBeInTheDocument() - expect(getByText("02/18/2024")).toBeInTheDocument() - expect(getByText("Lottery start time")).toBeInTheDocument() - expect(getByText("10:30 AM")).toBeInTheDocument() - expect(getByText("Lottery end time")).toBeInTheDocument() - expect(getByText("12:15 PM")).toBeInTheDocument() - expect(getByText("Lottery date notes")).toBeInTheDocument() - expect(getByText("Test lottery note")).toBeInTheDocument() - expect(getByText("Tell the applicant what to expect from the process")).toBeInTheDocument() + expect(screen.getByText("Rankings & results")).toBeInTheDocument() + expect( + screen.getByText("How is the application review order determined?") + ).toBeInTheDocument() + expect(screen.getByText("Lottery")).toBeInTheDocument() + expect( + screen.getByText("Will the lottery be run in the partner portal?") + ).toBeInTheDocument() + expect(screen.getByText("No")).toBeInTheDocument() + expect(screen.getByText("When will the lottery be run?")).toBeInTheDocument() + expect(screen.getByText("02/18/2024")).toBeInTheDocument() + expect(screen.getByText("Lottery start time")).toBeInTheDocument() + expect(screen.getByText("10:30 AM")).toBeInTheDocument() + expect(screen.getByText("Lottery end time")).toBeInTheDocument() + expect(screen.getByText("12:15 PM")).toBeInTheDocument() + expect(screen.getByText("Lottery date notes")).toBeInTheDocument() + expect(screen.getByText("Test lottery note")).toBeInTheDocument() + expect( + screen.getByText("Tell the applicant what to expect from the process") + ).toBeInTheDocument() expect( - getByText( + screen.getByText( "Applicant will be contacted. All info will be verified. Be prepared if chosen." ) ).toBeInTheDocument() - expect(queryByText("Do you want to show a waitlist size?")).not.toBeInTheDocument() - expect(queryByText("Yes")).not.toBeInTheDocument() - expect(queryByText("Number of openings")).not.toBeInTheDocument() - expect(queryByText("First come first serve")).not.toBeInTheDocument() + expect(screen.queryByText("Do you want to show a waitlist size?")).not.toBeInTheDocument() + expect(screen.queryByText("Yes")).not.toBeInTheDocument() + expect(screen.queryByText("Number of openings")).not.toBeInTheDocument() + expect(screen.queryByText("First come first serve")).not.toBeInTheDocument() }) }) it("should display Leasing Agent section", () => { - const { getByText } = render( + render( { ) - expect(getByText("Leasing agent")).toBeInTheDocument() - expect(getByText("Leasing agent name")).toBeInTheDocument() - expect(getByText("Marisela Baca")).toBeInTheDocument() - expect(getByText("Email")).toBeInTheDocument() - expect(getByText("mbaca@charitieshousing.org")).toBeInTheDocument() - expect(getByText("Phone")).toBeInTheDocument() - expect(getByText("(408) 217-8562")).toBeInTheDocument() - expect(getByText("Leasing agent title")).toBeInTheDocument() - expect(getByText("Pro Agent")).toBeInTheDocument() - expect(getByText("Office hours")).toBeInTheDocument() - expect(getByText("Monday, Tuesday & Friday, 9:00AM - 5:00PM")).toBeInTheDocument() - expect(getByText("Leasing agent address")).toBeInTheDocument() - expect(getByText("Street address or PO box")).toBeInTheDocument() - expect(getByText("98 Archer Street")).toBeInTheDocument() - expect(getByText("Apt or unit #")).toBeInTheDocument() - expect(getByText("#12")).toBeInTheDocument() - expect(getByText("City")).toBeInTheDocument() - expect(getByText("San Jose")).toBeInTheDocument() - expect(getByText("State")).toBeInTheDocument() - expect(getByText("CA")).toBeInTheDocument() - expect(getByText("Zip code")).toBeInTheDocument() - expect(getByText("95112")).toBeInTheDocument() + expect(screen.getByText("Leasing agent")).toBeInTheDocument() + expect(screen.getByText("Leasing agent name")).toBeInTheDocument() + expect(screen.getByText("Marisela Baca")).toBeInTheDocument() + expect(screen.getByText("Email")).toBeInTheDocument() + expect(screen.getByText("mbaca@charitieshousing.org")).toBeInTheDocument() + expect(screen.getByText("Phone")).toBeInTheDocument() + expect(screen.getByText("(408) 217-8562")).toBeInTheDocument() + expect(screen.getByText("Leasing agent title")).toBeInTheDocument() + expect(screen.getByText("Pro Agent")).toBeInTheDocument() + expect(screen.getByText("Office hours")).toBeInTheDocument() + expect(screen.getByText("Monday, Tuesday & Friday, 9:00AM - 5:00PM")).toBeInTheDocument() + expect(screen.getByText("Leasing agent address")).toBeInTheDocument() + expect(screen.getByText("Street address or PO box")).toBeInTheDocument() + expect(screen.getByText("98 Archer Street")).toBeInTheDocument() + expect(screen.getByText("Apt or unit #")).toBeInTheDocument() + expect(screen.getByText("#12")).toBeInTheDocument() + expect(screen.getByText("City")).toBeInTheDocument() + expect(screen.getByText("San Jose")).toBeInTheDocument() + expect(screen.getByText("State")).toBeInTheDocument() + expect(screen.getByText("CA")).toBeInTheDocument() + expect(screen.getByText("Zip code")).toBeInTheDocument() + expect(screen.getByText("95112")).toBeInTheDocument() }) describe("should display Application Types section", () => { it("should display section with missing data", () => { - const { getByText, getAllByText, queryByText } = render( + render( { ) - expect(getByText("Application types")).toBeInTheDocument() - expect(getByText("Online applications")).toBeInTheDocument() - expect(getByText("Paper applications")).toBeInTheDocument() - expect(getByText("Referral")).toBeInTheDocument() - expect(getAllByText("n/a")).toHaveLength(3) - expect(queryByText("Common digital application")).not.toBeInTheDocument() - expect(queryByText("Referral contact phone")).not.toBeInTheDocument() - expect(queryByText("Referral summary")).not.toBeInTheDocument() - expect(queryByText("Custom online application URL")).not.toBeInTheDocument() - expect(queryByText("File name")).not.toBeInTheDocument() - expect(queryByText("Language")).not.toBeInTheDocument() + expect(screen.getByText("Application types")).toBeInTheDocument() + expect(screen.getByText("Online applications")).toBeInTheDocument() + expect(screen.getByText("Paper applications")).toBeInTheDocument() + expect(screen.getByText("Referral")).toBeInTheDocument() + expect(screen.getAllByText("n/a")).toHaveLength(3) + expect(screen.queryByText("Common digital application")).not.toBeInTheDocument() + expect(screen.queryByText("Referral contact phone")).not.toBeInTheDocument() + expect(screen.queryByText("Referral summary")).not.toBeInTheDocument() + expect(screen.queryByText("Custom online application URL")).not.toBeInTheDocument() + expect(screen.queryByText("File name")).not.toBeInTheDocument() + expect(screen.queryByText("Language")).not.toBeInTheDocument() }) it("should display section data - for internal application", () => { - const { getByText, getAllByText, queryByText } = render( + render( { ) - expect(getByText("Application types")).toBeInTheDocument() - expect(getByText("Online applications")).toBeInTheDocument() - expect(getByText("Common digital application")).toBeInTheDocument() - expect(getByText("Paper applications")).toBeInTheDocument() - expect(getByText("Referral")).toBeInTheDocument() - expect(getAllByText("Yes")).toHaveLength(4) - expect(queryByText("Referral contact phone")).not.toBeInTheDocument() - expect(queryByText("Referral summary")).not.toBeInTheDocument() - expect(queryByText("Custom online application URL")).not.toBeInTheDocument() - expect(queryByText("File name")).not.toBeInTheDocument() - expect(queryByText("Language")).not.toBeInTheDocument() + expect(screen.getByText("Application types")).toBeInTheDocument() + expect(screen.getByText("Online applications")).toBeInTheDocument() + expect(screen.getByText("Common digital application")).toBeInTheDocument() + expect(screen.getByText("Paper applications")).toBeInTheDocument() + expect(screen.getByText("Referral")).toBeInTheDocument() + expect(screen.getAllByText("Yes")).toHaveLength(4) + expect(screen.queryByText("Referral contact phone")).not.toBeInTheDocument() + expect(screen.queryByText("Referral summary")).not.toBeInTheDocument() + expect(screen.queryByText("Custom online application URL")).not.toBeInTheDocument() + expect(screen.queryByText("File name")).not.toBeInTheDocument() + expect(screen.queryByText("Language")).not.toBeInTheDocument() }) it("should display section data - for external application", () => { - const { getByText, getAllByText, queryByText } = render( + render( { ) - expect(getByText("Application types")).toBeInTheDocument() - expect(getByText("Online applications")).toBeInTheDocument() - expect(getByText("Common digital application")).toBeInTheDocument() - expect(getByText("Paper applications")).toBeInTheDocument() - expect(getByText("Custom online application URL")).toBeInTheDocument() - expect(getByText("Test reference")).toBeInTheDocument() - expect(getByText("Referral")).toBeInTheDocument() - expect(getAllByText("No")).toHaveLength(4) - expect(queryByText("Referral contact phone")).not.toBeInTheDocument() - expect(queryByText("Referral summary")).not.toBeInTheDocument() - expect(queryByText("File name")).not.toBeInTheDocument() - expect(queryByText("Language")).not.toBeInTheDocument() + expect(screen.getByText("Application types")).toBeInTheDocument() + expect(screen.getByText("Online applications")).toBeInTheDocument() + expect(screen.getByText("Common digital application")).toBeInTheDocument() + expect(screen.getByText("Paper applications")).toBeInTheDocument() + expect(screen.getByText("Custom online application URL")).toBeInTheDocument() + expect(screen.getByText("Test reference")).toBeInTheDocument() + expect(screen.getByText("Referral")).toBeInTheDocument() + expect(screen.getAllByText("No")).toHaveLength(4) + expect(screen.queryByText("Referral contact phone")).not.toBeInTheDocument() + expect(screen.queryByText("Referral summary")).not.toBeInTheDocument() + expect(screen.queryByText("File name")).not.toBeInTheDocument() + expect(screen.queryByText("Language")).not.toBeInTheDocument() }) it("should display section data - for referral application", () => { - const { getByText, getAllByText, queryByText } = render( + render( { ) - expect(getByText("Application types")).toBeInTheDocument() - expect(getByText("Online applications")).toBeInTheDocument() - expect(getByText("Paper applications")).toBeInTheDocument() - expect(getByText("Referral")).toBeInTheDocument() - expect(getAllByText("No")).toHaveLength(3) - expect(getByText("Referral contact phone")).toBeInTheDocument() - expect(getByText("(509) 786-4500")).toBeInTheDocument() - expect(getByText("Referral summary")).toBeInTheDocument() - expect(getByText("Test Referral Summary")).toBeInTheDocument() - expect(queryByText("Common digital application")).not.toBeInTheDocument() - expect(queryByText("Custom online application URL")).not.toBeInTheDocument() - expect(queryByText("Test Reference")).not.toBeInTheDocument() - expect(queryByText("File name")).not.toBeInTheDocument() - expect(queryByText("Language")).not.toBeInTheDocument() + expect(screen.getByText("Application types")).toBeInTheDocument() + expect(screen.getByText("Online applications")).toBeInTheDocument() + expect(screen.getByText("Paper applications")).toBeInTheDocument() + expect(screen.getByText("Referral")).toBeInTheDocument() + expect(screen.getAllByText("No")).toHaveLength(3) + expect(screen.getByText("Referral contact phone")).toBeInTheDocument() + expect(screen.getByText("(509) 786-4500")).toBeInTheDocument() + expect(screen.getByText("Referral summary")).toBeInTheDocument() + expect(screen.getByText("Test Referral Summary")).toBeInTheDocument() + expect(screen.queryByText("Common digital application")).not.toBeInTheDocument() + expect(screen.queryByText("Custom online application URL")).not.toBeInTheDocument() + expect(screen.queryByText("Test Reference")).not.toBeInTheDocument() + expect(screen.queryByText("File name")).not.toBeInTheDocument() + expect(screen.queryByText("Language")).not.toBeInTheDocument() }) it("should display section data - for paper application", () => { - const { getByText, getAllByText, queryByText } = render( + render( { ) - expect(getByText("Application types")).toBeInTheDocument() - expect(getByText("Online applications")).toBeInTheDocument() - expect(getAllByText("Paper applications")).toHaveLength(2) - expect(getByText("Referral")).toBeInTheDocument() - expect(getAllByText("No")).toHaveLength(3) - expect(getByText("File name")).toBeInTheDocument() - expect(getByText("Language")).toBeInTheDocument() - expect(getByText("English")).toBeInTheDocument() - expect(getByText("Español")).toBeInTheDocument() - expect(getAllByText(/asset_\d_file_id.pdf/)).toHaveLength(2) - - expect(queryByText("Referral contact phone")).not.toBeInTheDocument() - expect(queryByText("Referral summary")).not.toBeInTheDocument() - expect(queryByText("Common digital application")).not.toBeInTheDocument() - expect(queryByText("Custom online application URL")).not.toBeInTheDocument() - expect(queryByText("Test Reference")).not.toBeInTheDocument() + expect(screen.getByText("Application types")).toBeInTheDocument() + expect(screen.getByText("Online applications")).toBeInTheDocument() + expect(screen.getAllByText("Paper applications")).toHaveLength(2) + expect(screen.getByText("Referral")).toBeInTheDocument() + expect(screen.getAllByText("No")).toHaveLength(3) + expect(screen.getByText("File name")).toBeInTheDocument() + expect(screen.getByText("Language")).toBeInTheDocument() + expect(screen.getByText("English")).toBeInTheDocument() + expect(screen.getByText("Español")).toBeInTheDocument() + expect(screen.getAllByText(/asset_\d_file_id.pdf/)).toHaveLength(2) + + expect(screen.queryByText("Referral contact phone")).not.toBeInTheDocument() + expect(screen.queryByText("Referral summary")).not.toBeInTheDocument() + expect(screen.queryByText("Common digital application")).not.toBeInTheDocument() + expect(screen.queryByText("Custom online application URL")).not.toBeInTheDocument() + expect(screen.queryByText("Test Reference")).not.toBeInTheDocument() }) it("should hide digital application choice when disable flag is on", () => { - const { getByText, getAllByText, queryByText } = render( + render( true, + doJurisdictionsHaveFeatureFlagOn: (featureFlag: FeatureFlagEnum) => + featureFlag !== FeatureFlagEnum.enableReferralQuestionUnits, }} > { ) - expect(getByText("Application types")).toBeInTheDocument() - expect(getByText("Online applications")).toBeInTheDocument() - expect(getByText("Paper applications")).toBeInTheDocument() - expect(getByText("Custom online application URL")).toBeInTheDocument() - expect(getByText("https://example.com/application")).toBeInTheDocument() - expect(getByText("Referral")).toBeInTheDocument() - expect(getAllByText("No")).toHaveLength(3) - expect(queryByText("Common digital application")).not.toBeInTheDocument() - expect(queryByText("Referral contact phone")).not.toBeInTheDocument() - expect(queryByText("Referral summary")).not.toBeInTheDocument() - expect(queryByText("File name")).not.toBeInTheDocument() - expect(queryByText("Language")).not.toBeInTheDocument() + expect(screen.getByText("Application types")).toBeInTheDocument() + expect(screen.getByText("Online applications")).toBeInTheDocument() + expect(screen.getByText("Paper applications")).toBeInTheDocument() + expect(screen.getByText("Custom online application URL")).toBeInTheDocument() + expect(screen.getByText("https://example.com/application")).toBeInTheDocument() + expect(screen.getByText("Referral")).toBeInTheDocument() + expect(screen.getAllByText("No")).toHaveLength(3) + expect(screen.queryByText("Common digital application")).not.toBeInTheDocument() + expect(screen.queryByText("Referral contact phone")).not.toBeInTheDocument() + expect(screen.queryByText("Referral summary")).not.toBeInTheDocument() + expect(screen.queryByText("File name")).not.toBeInTheDocument() + expect(screen.queryByText("Language")).not.toBeInTheDocument() }) }) describe("should display Application Address section", () => { it("should display section with mising data", () => { - const { getByText, getAllByText, queryByText } = render( + render( { ) - expect(getByText("Application address")).toBeInTheDocument() - expect(getByText("Can applications be mailed in?")).toBeInTheDocument() - expect(getByText("Can applications be picked up?")).toBeInTheDocument() - expect(getByText("Can applications be dropped off?")).toBeInTheDocument() - expect(getByText("Are postmarks considered?")).toBeInTheDocument() - expect(getByText("Additional application submission notes")).toBeInTheDocument() - expect(getAllByText("No")).toHaveLength(4) - expect(getAllByText("None")).toHaveLength(1) - - expect(queryByText("Where can applications be mailed in?")).not.toBeInTheDocument() - expect(queryByText("Leasing agent address")).not.toBeInTheDocument() - expect(queryByText("Mailing address")).not.toBeInTheDocument() - expect(queryByText("Where are applications picked up?")).not.toBeInTheDocument() - expect(queryByText("Pickup address")).not.toBeInTheDocument() - expect(queryByText("Office hours")).not.toBeInTheDocument() - expect(queryByText("Where are applications dropped off?")).not.toBeInTheDocument() - expect(queryByText("Drop off address")).not.toBeInTheDocument() - expect(queryByText("Received by date")).not.toBeInTheDocument() - expect(queryByText("Received by time")).not.toBeInTheDocument() + expect(screen.getByText("Application address")).toBeInTheDocument() + expect(screen.getByText("Can applications be mailed in?")).toBeInTheDocument() + expect(screen.getByText("Can applications be picked up?")).toBeInTheDocument() + expect(screen.getByText("Can applications be dropped off?")).toBeInTheDocument() + expect(screen.getByText("Are postmarks considered?")).toBeInTheDocument() + expect(screen.getByText("Additional application submission notes")).toBeInTheDocument() + expect(screen.getAllByText("No")).toHaveLength(4) + expect(screen.getAllByText("None")).toHaveLength(1) + + expect(screen.queryByText("Where can applications be mailed in?")).not.toBeInTheDocument() + expect(screen.queryByText("Leasing agent address")).not.toBeInTheDocument() + expect(screen.queryByText("Mailing address")).not.toBeInTheDocument() + expect(screen.queryByText("Where are applications picked up?")).not.toBeInTheDocument() + expect(screen.queryByText("Pickup address")).not.toBeInTheDocument() + expect(screen.queryByText("Office hours")).not.toBeInTheDocument() + expect(screen.queryByText("Where are applications dropped off?")).not.toBeInTheDocument() + expect(screen.queryByText("Drop off address")).not.toBeInTheDocument() + expect(screen.queryByText("Received by date")).not.toBeInTheDocument() + expect(screen.queryByText("Received by time")).not.toBeInTheDocument() }) it("should display all the Application Address data", () => { - const { getByText, getAllByText } = render( + render( { ) - expect(getByText("Application address")).toBeInTheDocument() - expect(getByText("Can applications be mailed in?")).toBeInTheDocument() - expect(getByText("Where can applications be mailed in?")).toBeInTheDocument() - expect(getByText("Mailing address")).toBeInTheDocument() - expect(getByText("Can applications be picked up?")).toBeInTheDocument() - expect(getByText("Where are applications picked up?")).toBeInTheDocument() - expect(getByText("Pickup address")).toBeInTheDocument() - expect(getByText("Can applications be dropped off?")).toBeInTheDocument() - expect(getByText("Where are applications dropped off?")).toBeInTheDocument() - expect(getByText("Drop off address")).toBeInTheDocument() - expect(getByText("Are postmarks considered?")).toBeInTheDocument() - expect(getByText("Received by date")).toBeInTheDocument() - expect(getByText("03/14/2025")).toBeInTheDocument() - expect(getByText("Received by time")).toBeInTheDocument() - expect(getByText("08:15 AM")).toBeInTheDocument() - expect(getByText("Additional application submission notes")).toBeInTheDocument() - expect(getByText("Test Submission note")).toBeInTheDocument() - expect(getAllByText("Street address or PO box")).toHaveLength(3) - expect(getAllByText("Apt or unit #")).toHaveLength(3) - expect(getAllByText("City")).toHaveLength(3) - expect(getAllByText("State")).toHaveLength(3) - expect(getAllByText("Zip code")).toHaveLength(3) - expect(getAllByText("Office hours")).toHaveLength(2) - expect(getAllByText("Yes")).toHaveLength(4) - expect(getAllByText("Leasing agent address")).toHaveLength(3) - expect(getByText("1598 Peaceful Lane")).toBeInTheDocument() - expect(getByText("None")).toBeInTheDocument() - expect(getByText("Warrensville Heights")).toBeInTheDocument() - expect(getByText("Ohio")).toBeInTheDocument() - expect(getByText("44128")).toBeInTheDocument() - expect(getByText("2560 Barnes Street")).toBeInTheDocument() - expect(getByText("#13")).toBeInTheDocument() - expect(getByText("Doral")).toBeInTheDocument() - expect(getByText("Florida")).toBeInTheDocument() - expect(getByText("33166")).toBeInTheDocument() - expect(getByText("3897 Benson Street")).toBeInTheDocument() - expect(getByText("#29")).toBeInTheDocument() - expect(getByText("Zurich")).toBeInTheDocument() - expect(getByText("Montana")).toBeInTheDocument() - expect(getByText("59547")).toBeInTheDocument() + expect(screen.getByText("Application address")).toBeInTheDocument() + expect(screen.getByText("Can applications be mailed in?")).toBeInTheDocument() + expect(screen.getByText("Where can applications be mailed in?")).toBeInTheDocument() + expect(screen.getByText("Mailing address")).toBeInTheDocument() + expect(screen.getByText("Can applications be picked up?")).toBeInTheDocument() + expect(screen.getByText("Where are applications picked up?")).toBeInTheDocument() + expect(screen.getByText("Pickup address")).toBeInTheDocument() + expect(screen.getByText("Can applications be dropped off?")).toBeInTheDocument() + expect(screen.getByText("Where are applications dropped off?")).toBeInTheDocument() + expect(screen.getByText("Drop off address")).toBeInTheDocument() + expect(screen.getByText("Are postmarks considered?")).toBeInTheDocument() + expect(screen.getByText("Received by date")).toBeInTheDocument() + expect(screen.getByText("03/14/2025")).toBeInTheDocument() + expect(screen.getByText("Received by time")).toBeInTheDocument() + expect(screen.getByText("08:15 AM")).toBeInTheDocument() + expect(screen.getByText("Additional application submission notes")).toBeInTheDocument() + expect(screen.getByText("Test Submission note")).toBeInTheDocument() + expect(screen.getAllByText("Street address or PO box")).toHaveLength(3) + expect(screen.getAllByText("Apt or unit #")).toHaveLength(3) + expect(screen.getAllByText("City")).toHaveLength(3) + expect(screen.getAllByText("State")).toHaveLength(3) + expect(screen.getAllByText("Zip code")).toHaveLength(3) + expect(screen.getAllByText("Office hours")).toHaveLength(2) + expect(screen.getAllByText("Yes")).toHaveLength(4) + expect(screen.getAllByText("Leasing agent address")).toHaveLength(3) + expect(screen.getByText("1598 Peaceful Lane")).toBeInTheDocument() + expect(screen.getByText("None")).toBeInTheDocument() + expect(screen.getByText("Warrensville Heights")).toBeInTheDocument() + expect(screen.getByText("Ohio")).toBeInTheDocument() + expect(screen.getByText("44128")).toBeInTheDocument() + expect(screen.getByText("2560 Barnes Street")).toBeInTheDocument() + expect(screen.getByText("#13")).toBeInTheDocument() + expect(screen.getByText("Doral")).toBeInTheDocument() + expect(screen.getByText("Florida")).toBeInTheDocument() + expect(screen.getByText("33166")).toBeInTheDocument() + expect(screen.getByText("3897 Benson Street")).toBeInTheDocument() + expect(screen.getByText("#29")).toBeInTheDocument() + expect(screen.getByText("Zurich")).toBeInTheDocument() + expect(screen.getByText("Montana")).toBeInTheDocument() + expect(screen.getByText("59547")).toBeInTheDocument() }) }) describe("should display Application Dates section", () => { it("should display section with mising data", () => { - const { getByText, getAllByText, queryByText } = render( - + featureFlag === FeatureFlagEnum.enableMarketingFlyer, }} > - - + + + + ) - expect(getByText("Application dates")).toBeInTheDocument() - expect(getByText("Application due date")).toBeInTheDocument() - expect(getByText("Application due time")).toBeInTheDocument() - expect(getAllByText("None")).toHaveLength(2) - expect(queryByText("Open houses")).not.toBeInTheDocument() - expect(queryByText("Open house")).not.toBeInTheDocument() - expect(queryByText("Date")).not.toBeInTheDocument() - expect(queryByText("Start time")).not.toBeInTheDocument() - expect(queryByText("End time")).not.toBeInTheDocument() - expect(queryByText("URL")).not.toBeInTheDocument() - expect(queryByText("Open house notes")).not.toBeInTheDocument() - expect(queryByText("Done")).not.toBeInTheDocument() + expect(screen.getByText("Application dates")).toBeInTheDocument() + expect(screen.getByText("Application due date")).toBeInTheDocument() + expect(screen.getByText("Application due time")).toBeInTheDocument() + expect(screen.getAllByText("None")).toHaveLength(2) + expect(screen.queryByText("Open houses")).not.toBeInTheDocument() + expect(screen.queryByText("Open house")).not.toBeInTheDocument() + expect(screen.queryByText("Date")).not.toBeInTheDocument() + expect(screen.queryByText("Start time")).not.toBeInTheDocument() + expect(screen.queryByText("End time")).not.toBeInTheDocument() + expect(screen.queryByText("URL")).not.toBeInTheDocument() + expect(screen.queryByText("Open house notes")).not.toBeInTheDocument() + expect(screen.queryByText("Done")).not.toBeInTheDocument() + expect(screen.queryByText("Marketing flyer")).not.toBeInTheDocument() + expect(screen.queryByText("Preview")).not.toBeInTheDocument() + expect(screen.queryByText("File name")).not.toBeInTheDocument() + expect(screen.queryByText("Accessible marketing flyer")).not.toBeInTheDocument() }) it("should display all the Application Dates data", () => { - const { getByText } = render( - + featureFlag === FeatureFlagEnum.enableMarketingFlyer, + }} + > + - - + accessibleMarketingFlyer: "http://test.url.com", + listingsAccessibleMarketingFlyerFile: { + id: "file_id_2", + createdAt: new Date(), + updatedAt: new Date(), + fileId: "file_id_2", + label: "test_file_2", + }, + }} + > + + + ) - expect(getByText("Application dates")).toBeInTheDocument() - expect(getByText("Application due date")).toBeInTheDocument() - expect(getByText("12/20/2024")).toBeInTheDocument() - expect(getByText("Application due time")).toBeInTheDocument() - expect(getByText("03:30 PM")).toBeInTheDocument() - expect(getByText("Open houses")).toBeInTheDocument() - expect(getByText("Date")).toBeInTheDocument() - expect(getByText("02/18/2024")).toBeInTheDocument() - expect(getByText("Start time")).toBeInTheDocument() - expect(getByText("10:30 AM")).toBeInTheDocument() - expect(getByText("End time")).toBeInTheDocument() - expect(getByText("12:15 PM")).toBeInTheDocument() - expect(getByText("Link")).toBeInTheDocument() - - const urlButton = getByText("URL", { selector: "a" }) + expect(screen.getByText("Application dates")).toBeInTheDocument() + expect(screen.getByText("Application due date")).toBeInTheDocument() + expect(screen.getByText("12/20/2024")).toBeInTheDocument() + expect(screen.getByText("Application due time")).toBeInTheDocument() + expect(screen.getByText("03:30 PM")).toBeInTheDocument() + expect(screen.getByText("Open houses")).toBeInTheDocument() + expect(screen.getByText("Date")).toBeInTheDocument() + expect(screen.getByText("02/18/2024")).toBeInTheDocument() + expect(screen.getByText("Start time")).toBeInTheDocument() + expect(screen.getByText("10:30 AM")).toBeInTheDocument() + expect(screen.getByText("End time")).toBeInTheDocument() + expect(screen.getByText("12:15 PM")).toBeInTheDocument() + expect(screen.getByText("Link")).toBeInTheDocument() + + const urlButton = screen.getByText("URL", { selector: "a" }) expect(urlButton).toBeInTheDocument() expect(urlButton).toHaveAttribute("href", "http://test.url.com") - expect(getByText("View")).toBeInTheDocument() + expect(screen.getByText("View")).toBeInTheDocument() + expect(screen.getByText("Marketing flyer")).toBeInTheDocument() + expect(screen.getAllByText("Preview")).toHaveLength(2) + expect(screen.getAllByText("File name")).toHaveLength(2) + expect(screen.getByText("file_id.pdf")).toBeInTheDocument() + expect(screen.getByText("Accessible marketing flyer")).toBeInTheDocument() + expect(screen.getByText("file_id_2.pdf")).toBeInTheDocument() }) }) describe("should display Verification section", () => { it("section should be hiden when jurisdiction flag is not set", () => { - const { queryByText } = render( + render( - mockJurisdictionsHaveFeatureFlagOn(featureFlag, true, true, false, false), + mockJurisdictionsHaveFeatureFlagOn(featureFlag, { + enableHomeType: true, + enableSection8Question: true, + enableUnitGroups: false, + enableIsVerified: false, + }), }} > { ) - expect(queryByText("Verification")).not.toBeInTheDocument() - expect(queryByText("I verify that this listing data is valid")).not.toBeInTheDocument() - expect(queryByText("Yes")).not.toBeInTheDocument() + expect(screen.queryByText("Verification")).not.toBeInTheDocument() + expect( + screen.queryByText("I verify that this listing data is valid") + ).not.toBeInTheDocument() + expect(screen.queryByText("Yes")).not.toBeInTheDocument() }) it("should render section when jurisdiction flag is set", () => { - const { getByText } = render( + render( { ) - expect(getByText("Verification")).toBeInTheDocument() - expect(getByText("I verify that this listing data is valid")).toBeInTheDocument() - expect(getByText("Yes")).toBeInTheDocument() + expect(screen.getByText("Verification")).toBeInTheDocument() + expect(screen.getByText("I verify that this listing data is valid")).toBeInTheDocument() + expect(screen.getByText("Yes")).toBeInTheDocument() }) }) }) @@ -1641,7 +1970,7 @@ describe("listing data", () => { const result = await getServerSideProps(MOCK_CONTEXT) - const { findByText } = render( + render( { ) - const statusTag = await findByText(status.tagString) + const statusTag = await screen.findByText(status.tagString) expect(statusTag).toBeInTheDocument() } ) @@ -1683,7 +2012,7 @@ describe("listing data", () => { const result = await getServerSideProps(MOCK_CONTEXT) - const { getByText } = render( + render( { ) - const editButton = getByText("Edit") + const editButton = screen.getByText("Edit") expect(editButton).toBeInTheDocument() expect(editButton).toHaveAttribute("href", "/listings/Uvbk5qurpB2WI9V6WnNdH/edit") }) @@ -1719,7 +2048,7 @@ describe("listing data", () => { const result = await getServerSideProps(MOCK_CONTEXT) - const { getByText } = render( + render( { ) - const copyButton = getByText("Copy", { selector: "button" }) + const copyButton = screen.getByText("Copy", { selector: "button" }) expect(copyButton).toBeInTheDocument() fireEvent.click(copyButton) - const copyDialogHeader = getByText("Copy listing", { selector: "h1" }) + const copyDialogHeader = screen.getByText("Copy listing", { selector: "h1" }) expect(copyDialogHeader).toBeInTheDocument() const copyDialogForm = copyDialogHeader.parentElement.parentElement @@ -1778,7 +2107,7 @@ describe("listing data", () => { const result = await getServerSideProps(MOCK_CONTEXT) - const { getByText, queryByText } = render( + render( { ) - const copyButton = getByText("Copy", { selector: "button" }) + const copyButton = screen.getByText("Copy", { selector: "button" }) expect(copyButton).toBeInTheDocument() fireEvent.click(copyButton) - let copyDialogHeader = getByText("Copy listing", { selector: "h1" }) + let copyDialogHeader = screen.getByText("Copy listing", { selector: "h1" }) expect(copyDialogHeader).toBeInTheDocument() const copyDialogForm = copyDialogHeader.parentElement.parentElement @@ -1810,7 +2139,7 @@ describe("listing data", () => { expect(cancelDialogButton).toBeInTheDocument() fireEvent.click(cancelDialogButton) - copyDialogHeader = queryByText("Copy listing", { selector: "h1" }) + copyDialogHeader = screen.queryByText("Copy listing", { selector: "h1" }) expect(copyDialogHeader).not.toBeInTheDocument() }) }) @@ -1829,7 +2158,7 @@ describe("listing data", () => { const result = await getServerSideProps(MOCK_CONTEXT) - const { getByText } = render( + render( { ) - const previewButton = getByText("Preview") + const previewButton = screen.getByText("Preview") expect(previewButton).toBeInTheDocument() expect(previewButton).toHaveAttribute("href", "/preview/listings/Uvbk5qurpB2WI9V6WnNdH") }) @@ -1865,7 +2194,7 @@ describe("listing data", () => { const result = await getServerSideProps(MOCK_CONTEXT) - const { getByText, queryByText } = render( + render( { ) - const unitSectionHeader = getByText("Listing units", { selector: "h2" }) + const unitSectionHeader = screen.getByText("Listing units", { selector: "h2" }) expect(unitSectionHeader).toBeInTheDocument() const unitSection = unitSectionHeader.parentElement expect(unitSection).toBeInTheDocument() @@ -1907,7 +2236,7 @@ describe("listing data", () => { fireEvent.click(unitViewButton) - let unitDrawerHeader = getByText("Unit", { selector: "h1" }) + let unitDrawerHeader = screen.getByText("Unit", { selector: "h1" }) expect(unitDrawerHeader).toBeInTheDocument() const unitDrawer = unitDrawerHeader.parentElement.parentElement @@ -1930,7 +2259,9 @@ describe("listing data", () => { expect(within(detailsSection).getAllByText("2")).toHaveLength(2) // Eligibility section - const eligibilitySectionHeader = within(unitDrawer).getByText("Eligibility", { selector: "h2" }) + const eligibilitySectionHeader = within(unitDrawer).getByText("Eligibility", { + selector: "h2", + }) expect(eligibilitySectionHeader).toBeInTheDocument() const eligibilitySection = eligibilitySectionHeader.parentElement expect(within(eligibilitySection).getByText("AMI chart")).toBeInTheDocument() @@ -1960,7 +2291,74 @@ describe("listing data", () => { expect(doneButton).toBeInTheDocument() fireEvent.click(doneButton) - unitDrawerHeader = queryByText("Unit", { selector: "h1" }) + unitDrawerHeader = screen.queryByText("Unit", { selector: "h1" }) expect(unitDrawerHeader).not.toBeInTheDocument() }) + + it("should display correct nav links on non-lottery", async () => { + window.URL.createObjectURL = jest.fn() + document.cookie = "access-token-available=True" + render( + + mockJurisdictionsHaveFeatureFlagOn(featureFlag), + }} + > + + + ) + + const secondaryNavigation = await screen.findByRole("navigation", { + name: "Secondary navigation", + }) + expect(secondaryNavigation).toBeInTheDocument() + expect(within(secondaryNavigation).getByRole("link", { name: "Listing" })).toBeInTheDocument() + expect( + within(secondaryNavigation).getByRole("link", { name: "Applications" }) + ).toBeInTheDocument() + expect(within(secondaryNavigation).queryAllByRole("link", { name: "Lottery" })).toHaveLength(0) + }) + + it("should display correct nav links for lottery", async () => { + window.URL.createObjectURL = jest.fn() + document.cookie = "access-token-available=True" + render( + + mockJurisdictionsHaveFeatureFlagOn(featureFlag), + }} + > + + + ) + + const secondaryNavigation = await screen.findByRole("navigation", { + name: "Secondary navigation", + }) + expect(secondaryNavigation).toBeInTheDocument() + expect(within(secondaryNavigation).getByRole("link", { name: "Listing" })).toBeInTheDocument() + expect( + within(secondaryNavigation).getByRole("link", { name: "Applications" }) + ).toBeInTheDocument() + expect(within(secondaryNavigation).getByRole("link", { name: "Lottery" })).toBeInTheDocument() + }) }) diff --git a/sites/partners/__tests__/pages/listings/index.test.tsx b/sites/partners/__tests__/pages/listings/index.test.tsx index 9321a716c4..817d5914dd 100644 --- a/sites/partners/__tests__/pages/listings/index.test.tsx +++ b/sites/partners/__tests__/pages/listings/index.test.tsx @@ -1,6 +1,7 @@ import React from "react" import { AuthContext, MessageProvider } from "@bloom-housing/shared-helpers" -import { fireEvent, screen } from "@testing-library/react" +import { fireEvent, screen, waitFor, within } from "@testing-library/react" +import userEvent from "@testing-library/user-event" import { act } from "react-dom/test-utils" import { rest } from "msw" import { setupServer } from "msw/node" @@ -503,4 +504,282 @@ describe("listings", () => { const success = await findByText("The file has been exported") expect(success).toBeInTheDocument() }) + + it("should open add listing modal if user has access to multiple jurisdictions", async () => { + window.URL.createObjectURL = jest.fn() + document.cookie = "access-token-available=True" + const { pushMock } = mockNextRouter() + server.use( + rest.get("http://localhost:3100/listings", (_req, res, ctx) => { + return res(ctx.json({ items: [listing], meta: { totalItems: 1, totalPages: 1 } })) + }), + rest.get("http://localhost/api/adapter/listings", (_req, res, ctx) => { + return res(ctx.json({ items: [listing], meta: { totalItems: 1, totalPages: 1 } })) + }), + rest.get("http://localhost/api/adapter/user", (_req, res, ctx) => { + return res( + ctx.json({ + id: "user1", + userRoles: { id: "user1", isAdmin: true, isPartner: false }, + jurisdictions: [ + { + id: "id1", + name: "JurisdictionA", + featureFlags: [], + } as Jurisdiction, + { + id: "id2", + name: "JurisdictionB", + featureFlags: [], + } as Jurisdiction, + ], + }) + ) + }), + rest.post("http://localhost:3100/auth/token", (_req, res, ctx) => { + return res(ctx.json("")) + }) + ) + + render() + + const addListingButton = await screen.findByRole("button", { name: "Add listing" }) + expect(addListingButton).toBeInTheDocument() + await userEvent.click(addListingButton) + + expect( + screen.getByRole("heading", { level: 1, name: "Select jurisdiction" }) + ).toBeInTheDocument() + expect( + screen.getByText("Once you create this listing, this selection cannot be changed.") + ).toBeInTheDocument() + + expect(screen.getByRole("option", { name: "JurisdictionA" })).toBeInTheDocument() + expect(screen.getByRole("option", { name: "JurisdictionB" })).toBeInTheDocument() + + await userEvent.selectOptions(screen.getByLabelText("Jurisdiction"), "JurisdictionA") + + await userEvent.click(screen.getByRole("button", { name: "Get started" })) + await waitFor(() => { + expect(pushMock).toHaveBeenCalledWith({ + pathname: "/listings/add", + query: { jurisdictionId: "id1" }, + }) + }) + }) + + it("should open add listing modal if user has access to one jurisdiction and enableNonRegulatedListings", async () => { + window.URL.createObjectURL = jest.fn() + document.cookie = "access-token-available=True" + const { pushMock } = mockNextRouter() + server.use( + rest.get("http://localhost:3100/listings", (_req, res, ctx) => { + return res(ctx.json({ items: [listing], meta: { totalItems: 1, totalPages: 1 } })) + }), + rest.get("http://localhost/api/adapter/listings", (_req, res, ctx) => { + return res(ctx.json({ items: [listing], meta: { totalItems: 1, totalPages: 1 } })) + }), + rest.get("http://localhost/api/adapter/user", (_req, res, ctx) => { + return res( + ctx.json({ + id: "user1", + userRoles: { id: "user1", isAdmin: true, isPartner: false }, + jurisdictions: [ + { + id: "id1", + name: "JurisdictionA", + featureFlags: [ + { + id: "id_1", + name: FeatureFlagEnum.enableNonRegulatedListings, + active: true, + }, + ], + } as Jurisdiction, + ], + }) + ) + }), + rest.post("http://localhost:3100/auth/token", (_req, res, ctx) => { + return res(ctx.json("")) + }) + ) + + render() + + const addListingButton = await screen.findByRole("button", { name: "Add listing" }) + expect(addListingButton).toBeInTheDocument() + await userEvent.click(addListingButton) + + expect( + screen.getByRole("heading", { level: 1, name: "Select Listing Type" }) + ).toBeInTheDocument() + expect( + screen.getByText("Once you create this listing, this selection cannot be changed.") + ).toBeInTheDocument() + + const listingTypeRadioGroup = screen.getByRole("group", { + name: "What kind of listing is this?", + }) + expect(listingTypeRadioGroup).toBeInTheDocument() + expect( + within(listingTypeRadioGroup).getByRole("radio", { name: "Regulated" }) + ).toBeInTheDocument() + expect( + within(listingTypeRadioGroup).getByRole("radio", { name: "Non-regulated" }) + ).toBeInTheDocument() + + await userEvent.click(screen.getByRole("radio", { name: "Non-regulated" })) + + await userEvent.click(screen.getByRole("button", { name: "Get started" })) + await waitFor(() => { + expect(pushMock).toHaveBeenCalledWith({ + pathname: "/listings/add", + query: { jurisdictionId: "id1", nonRegulated: true }, + }) + }) + }) + + it("should open add listing modal if user has access to multiple jurisdictions and enableNonRegulatedListings", async () => { + window.URL.createObjectURL = jest.fn() + document.cookie = "access-token-available=True" + const { pushMock } = mockNextRouter() + server.use( + rest.get("http://localhost:3100/listings", (_req, res, ctx) => { + return res(ctx.json({ items: [listing], meta: { totalItems: 1, totalPages: 1 } })) + }), + rest.get("http://localhost/api/adapter/listings", (_req, res, ctx) => { + return res(ctx.json({ items: [listing], meta: { totalItems: 1, totalPages: 1 } })) + }), + rest.get("http://localhost/api/adapter/user", (_req, res, ctx) => { + return res( + ctx.json({ + id: "user1", + userRoles: { id: "user1", isAdmin: true, isPartner: false }, + jurisdictions: [ + { + id: "id1", + name: "JurisdictionA", + featureFlags: [], + } as Jurisdiction, + { + id: "id2", + name: "JurisdictionB", + featureFlags: [ + { + id: "id_1", + name: FeatureFlagEnum.enableNonRegulatedListings, + active: true, + }, + ], + } as Jurisdiction, + ], + }) + ) + }), + rest.post("http://localhost:3100/auth/token", (_req, res, ctx) => { + return res(ctx.json("")) + }) + ) + + render() + + const addListingButton = await screen.findByRole("button", { name: "Add listing" }) + expect(addListingButton).toBeInTheDocument() + await userEvent.click(addListingButton) + + expect( + screen.getByRole("heading", { level: 1, name: "Select jurisdiction" }) + ).toBeInTheDocument() + expect( + screen.getByText("Once you create this listing, this selection cannot be changed.") + ).toBeInTheDocument() + + // Listing type question not there without a jurisdiction selected + expect( + screen.queryAllByRole("group", { + name: "What kind of listing is this?", + }) + ).toHaveLength(0) + + // select the jurisdiction without the enableNonRegulatedListings and question shouldn't exist + await userEvent.selectOptions(screen.getByLabelText("Jurisdiction"), "JurisdictionA") + expect( + screen.queryAllByRole("group", { + name: "What kind of listing is this?", + }) + ).toHaveLength(0) + + // select the jurisdiction with the enableNonRegulatedListings and question should exist + await userEvent.selectOptions(screen.getByLabelText("Jurisdiction"), "JurisdictionB") + const listingTypeRadioGroup = screen.getByRole("group", { + name: "What kind of listing is this?", + }) + expect( + within(listingTypeRadioGroup).getByRole("radio", { name: "Regulated" }) + ).toBeInTheDocument() + expect( + within(listingTypeRadioGroup).getByRole("radio", { name: "Non-regulated" }) + ).toBeInTheDocument() + + await userEvent.click(screen.getByRole("button", { name: "Get started" })) + // Since Regulated is selected by default the nonRegulated flag is not passed to the next page + await waitFor(() => { + expect(pushMock).toHaveBeenCalledWith({ + pathname: "/listings/add", + query: { jurisdictionId: "id2" }, + }) + }) + }) + + it("should not open add listing modal if user has access to only one jurisdiction and no enableNonRegulatedListings", async () => { + window.URL.createObjectURL = jest.fn() + document.cookie = "access-token-available=True" + const { pushMock } = mockNextRouter() + server.use( + rest.get("http://localhost:3100/listings", (_req, res, ctx) => { + return res(ctx.json({ items: [listing], meta: { totalItems: 1, totalPages: 1 } })) + }), + rest.get("http://localhost/api/adapter/listings", (_req, res, ctx) => { + return res(ctx.json({ items: [listing], meta: { totalItems: 1, totalPages: 1 } })) + }), + rest.get("http://localhost/api/adapter/user", (_req, res, ctx) => { + return res( + ctx.json({ + id: "user1", + userRoles: { id: "user1", isAdmin: true, isPartner: false }, + jurisdictions: [ + { + id: "id1", + name: "JurisdictionA", + featureFlags: [], + } as Jurisdiction, + ], + }) + ) + }), + rest.post("http://localhost:3100/auth/token", (_req, res, ctx) => { + return res(ctx.json("")) + }) + ) + + render() + + const addListingButton = await screen.findByRole("button", { name: "Add listing" }) + expect(addListingButton).toBeInTheDocument() + await userEvent.click(addListingButton) + + expect( + screen.queryByRole("heading", { level: 1, name: "Select jurisdiction" }) + ).not.toBeInTheDocument() + + expect(screen.queryByRole("option", { name: "JurisdictionA" })).not.toBeInTheDocument() + + await waitFor(() => { + expect(pushMock).toHaveBeenCalledWith({ + pathname: "/listings/add", + query: { jurisdictionId: "id1" }, + }) + }) + }) }) diff --git a/sites/partners/__tests__/pages/settings/index.test.tsx b/sites/partners/__tests__/pages/settings/preferences.test.tsx similarity index 69% rename from sites/partners/__tests__/pages/settings/index.test.tsx rename to sites/partners/__tests__/pages/settings/preferences.test.tsx index 914cc16a3e..9ef3496fa3 100644 --- a/sites/partners/__tests__/pages/settings/index.test.tsx +++ b/sites/partners/__tests__/pages/settings/preferences.test.tsx @@ -1,15 +1,16 @@ import React from "react" import { setupServer } from "msw/lib/node" -import { fireEvent, within } from "@testing-library/react" -import Settings from "../../../src/pages/settings" +import { fireEvent, screen, within } from "@testing-library/react" import { rest } from "msw" +import { MessageContext, MessageProvider } from "@bloom-housing/shared-helpers" +import { Toast } from "@bloom-housing/ui-seeds" import { listing, multiselectQuestionPreference, } from "@bloom-housing/shared-helpers/__tests__/testHelpers" +import { FeatureFlagEnum } from "@bloom-housing/shared-helpers/src/types/backend-swagger" import { mockNextRouter, render } from "../../testUtils" -import { MessageContext, MessageProvider } from "@bloom-housing/shared-helpers" -import { Toast } from "@bloom-housing/ui-seeds" +import SettingsPreferences from "../../../src/pages/settings/preferences" const server = setupServer() @@ -51,7 +52,7 @@ describe("settings", () => { ) ) - const { getByText, findByText } = render() + const { getByText, findByText } = render() expect(getByText("Settings")).toBeInTheDocument() expect(getByText("Preferences")).toBeInTheDocument() @@ -60,28 +61,138 @@ describe("settings", () => { expect(getByText("None")).toBeInTheDocument() }) - it("should render the preference table", async () => { + it("should render tabs if multiple settings are on", async () => { + window.URL.createObjectURL = jest.fn() + document.cookie = "access-token-available=True" server.use( rest.get("http://localhost:3100/multiselectQuestions", (_req, res, ctx) => { return res(ctx.json([multiselectQuestionPreference])) }), + rest.get("http://localhost/api/adapter/multiselectQuestions", (_req, res, ctx) => { + return res(ctx.json([multiselectQuestionPreference])) + }), + rest.get( + "http://localhost/api/adapter/multiselectQuestions/listings/id1", + (_req, res, ctx) => { + return res(ctx.json([listing])) + } + ), + rest.get("http://localhost/api/adapter/user", (_req, res, ctx) => { + return res( + ctx.json({ + id: "user1", + roles: { id: "user1", isAdmin: false, isPartner: true }, + jurisdictions: [ + { + id: "jurisdiction1", + name: "jurisdictionWithJurisdictionAdmin", + featureFlags: [{ name: FeatureFlagEnum.enableProperties, active: true }], + }, + { + id: "jurisdiction2", + name: "jurisdictionWithJurisdictionAdmin2", + featureFlags: [], + }, + ], + }) + ) + }) + ) + + render() + expect(screen.getByText("Settings")).toBeInTheDocument() + expect(await screen.findByRole("tablist")).toBeInTheDocument() + expect(screen.getByRole("heading", { level: 2, name: "Preferences" })).toBeInTheDocument() + expect(screen.getByText("Properties")).toBeInTheDocument() + }) + + it("should not render tabs if only preferences is on", async () => { + window.URL.createObjectURL = jest.fn() + document.cookie = "access-token-available=True" + server.use( rest.get("http://localhost:3100/multiselectQuestions", (_req, res, ctx) => { return res(ctx.json([multiselectQuestionPreference])) }), + rest.get("http://localhost/api/adapter/multiselectQuestions", (_req, res, ctx) => { + return res(ctx.json([multiselectQuestionPreference])) + }), rest.get( "http://localhost/api/adapter/multiselectQuestions/listings/id1", (_req, res, ctx) => { return res(ctx.json([listing])) } - ) + ), + rest.get("http://localhost/api/adapter/user", (_req, res, ctx) => { + return res( + ctx.json({ + id: "user1", + roles: { id: "user1", isAdmin: false, isPartner: true }, + jurisdictions: [ + { + id: "jurisdiction1", + name: "jurisdictionWithJurisdictionAdmin", + featureFlags: [ + { name: FeatureFlagEnum.enableProperties, active: false }, + { name: FeatureFlagEnum.disableListingPreferences, active: false }, + ], + }, + ], + }) + ) + }) + ) + + render() + expect(await screen.findByText("Settings")).toBeInTheDocument() + expect( + await screen.findByRole("heading", { level: 2, name: "Preferences" }) + ).toBeInTheDocument() + expect(screen.queryByRole("tablist")).not.toBeInTheDocument() + expect(screen.queryByText("Properties")).not.toBeInTheDocument() + }) + + it("should render the preference table", async () => { + window.URL.createObjectURL = jest.fn() + document.cookie = "access-token-available=True" + server.use( + rest.get("http://localhost:3100/multiselectQuestions", (_req, res, ctx) => { + return res(ctx.json([multiselectQuestionPreference])) + }), + rest.get("http://localhost/api/adapter/multiselectQuestions", (_req, res, ctx) => { + return res(ctx.json([multiselectQuestionPreference])) + }), + rest.get( + "http://localhost/api/adapter/multiselectQuestions/listings/id1", + (_req, res, ctx) => { + return res(ctx.json([listing])) + } + ), + rest.get("http://localhost/api/adapter/user", (_req, res, ctx) => { + return res( + ctx.json({ + id: "user1", + roles: { id: "user1", isAdmin: false, isPartner: true }, + jurisdictions: [ + { + id: "jurisdiction1", + name: "jurisdictionWithJurisdictionAdmin", + featureFlags: [ + { name: FeatureFlagEnum.enableProperties, active: false }, + { name: FeatureFlagEnum.disableListingPreferences, active: false }, + ], + }, + ], + }) + ) + }) ) - const { getByText, findByText, getByRole } = render() + const { getByText, findByText, findByRole } = render() expect(getByText("Settings")).toBeInTheDocument() expect(getByText("Preferences")).toBeInTheDocument() await findByText("Name") - const table = getByRole("table") + const table = await findByRole("table") const headAndBody = within(table).getAllByRole("rowgroup") expect(headAndBody).toHaveLength(2) const [head, body] = headAndBody @@ -125,9 +236,6 @@ describe("settings", () => { }), rest.delete("http://localhost/api/adapter/multiselectQuestions", (_req, res, ctx) => { return res(ctx.json({})) - }), - rest.options("http://localhost/api/adapter/multiselectQuestions", (_req, res, ctx) => { - return res(ctx.json({})) }) ) @@ -147,7 +255,7 @@ describe("settings", () => { const { findByText, getByTestId, findByRole, queryAllByText } = render( - + ) @@ -199,7 +307,7 @@ describe("settings", () => { ) const { findByText, getByTestId, findByRole, queryAllByText, getByText } = render( - + ) await findByText(multiselectQuestionPreference.text) @@ -250,7 +358,7 @@ describe("settings", () => { ) ) - const { findByText, getByTestId, queryAllByText, getByText } = render() + const { findByText, getByTestId, queryAllByText, getByText } = render() await findByText(multiselectQuestionPreference.text) @@ -295,7 +403,7 @@ describe("settings", () => { }) ) - const { findByText, getByTestId, queryAllByText, getByText } = render() + const { findByText, getByTestId, queryAllByText, getByText } = render() await findByText(multiselectQuestionPreference.text) diff --git a/sites/partners/__tests__/pages/sign-in.test.tsx b/sites/partners/__tests__/pages/sign-in.test.tsx new file mode 100644 index 0000000000..718990e907 --- /dev/null +++ b/sites/partners/__tests__/pages/sign-in.test.tsx @@ -0,0 +1,733 @@ +import React from "react" +import { render, fireEvent, waitFor } from "@testing-library/react" +import { useRouter } from "next/router" +import { MessageContext, AuthContext } from "@bloom-housing/shared-helpers" +import { UserService, MfaType } from "@bloom-housing/shared-helpers/src/types/backend-swagger" +import SignIn from "../../src/pages/sign-in" + +const TOAST_MESSAGE = { + toastMessagesRef: { current: [] }, + addToast: jest.fn(), +} + +const mockDoJurisdictionsHaveFeatureFlagOn = jest.fn().mockReturnValue(true) + +jest.mock("next/router", () => ({ + useRouter: jest.fn(), +})) + +jest.mock("react-google-recaptcha-v3", () => ({ + GoogleReCaptcha: () => null, +})) + +beforeAll(() => { + window.scrollTo = jest.fn() + process.env.showSmsMfa = "TRUE" +}) + +describe("Partners Sign In Page", () => { + beforeEach(() => { + jest.clearAllMocks() + ;(useRouter as jest.Mock).mockReturnValue({ + query: {}, + push: jest.fn(), + }) + }) + + describe("Form rendering tests", () => { + it("renders email and password form with all elements", () => { + const { getByLabelText, getByRole, getByText } = render( + + + + + + ) + + expect(getByText("Sign in", { selector: "h1" })).toBeInTheDocument() + expect(getByLabelText("Email")).toBeInTheDocument() + expect(getByLabelText("Password")).toBeInTheDocument() + expect(getByRole("link", { name: /forgot password/i })).toBeInTheDocument() + expect(getByRole("button", { name: /sign in/i })).toBeInTheDocument() + }) + + it("should not show validation errors on initial render", () => { + const { queryByText } = render( + + + + + + ) + + expect( + queryByText("There are errors you'll need to resolve before moving on.") + ).not.toBeInTheDocument() + expect(queryByText("Please enter your login email")).not.toBeInTheDocument() + expect(queryByText("Please enter your login password")).not.toBeInTheDocument() + }) + }) + + describe("Email and password login", () => { + it("successfully logs in with valid email and password", async () => { + const mockLogin = jest.fn().mockResolvedValue({ firstName: "Partner", id: "user-123" }) + const mockRouter = { query: {}, push: jest.fn() } + ;(useRouter as jest.Mock).mockReturnValue(mockRouter) + + const { getByLabelText, getByRole } = render( + + + + + + ) + + fireEvent.change(getByLabelText("Email"), { target: { value: "partner@example.com" } }) + fireEvent.change(getByLabelText("Password"), { target: { value: "password123" } }) + fireEvent.click(getByRole("button", { name: /sign in/i })) + + await waitFor(() => { + expect(mockLogin).toHaveBeenCalledWith( + "partner@example.com", + "password123", + undefined, + undefined, + true, + undefined + ) + expect(mockRouter.push).toHaveBeenCalledWith("/") + }) + }) + + it("shows error when email is missing", async () => { + const { getByLabelText, getByRole, findByText } = render( + + + + + + ) + + fireEvent.change(getByLabelText("Password"), { target: { value: "password123" } }) + fireEvent.click(getByRole("button", { name: /sign in/i })) + + expect( + await findByText("There are errors you'll need to resolve before moving on.") + ).toBeInTheDocument() + expect(await findByText("Please enter your login email")).toBeInTheDocument() + }) + + it("shows error when password is missing", async () => { + const { getByLabelText, getByRole, findByText } = render( + + + + + + ) + + fireEvent.change(getByLabelText("Email"), { target: { value: "partner@example.com" } }) + fireEvent.click(getByRole("button", { name: /sign in/i })) + + expect( + await findByText("There are errors you'll need to resolve before moving on.") + ).toBeInTheDocument() + expect(await findByText("Please enter your login password")).toBeInTheDocument() + }) + + it("shows error when both email and password are missing", async () => { + const { getByRole, findByText } = render( + + + + + + ) + + fireEvent.click(getByRole("button", { name: /sign in/i })) + + expect( + await findByText("There are errors you'll need to resolve before moving on.") + ).toBeInTheDocument() + expect(await findByText("Please enter your login email")).toBeInTheDocument() + expect(await findByText("Please enter your login password")).toBeInTheDocument() + }) + }) + + describe("MFA flow tests", () => { + it("progresses to MFA type selection when mfaCodeIsMissing error is returned", async () => { + const mockError = { + response: { + status: 401, + data: { + name: "mfaCodeIsMissing", + message: "MFA code is missing", + }, + }, + } + const mockLogin = jest.fn().mockRejectedValue(mockError) + + const { getByLabelText, getByRole, findByText } = render( + + + + + + ) + + fireEvent.change(getByLabelText("Email"), { target: { value: "partner@example.com" } }) + fireEvent.change(getByLabelText("Password"), { target: { value: "password123" } }) + fireEvent.click(getByRole("button", { name: /sign in/i })) + + expect( + await findByText(/how would you like us to verify that it's you?/i) + ).toBeInTheDocument() + expect(await findByText(/verify with email/i)).toBeInTheDocument() + }) + + it("completes MFA flow with email verification", async () => { + const mockError = { + response: { + status: 401, + data: { + name: "mfaCodeIsMissing", + message: "MFA code is missing", + }, + }, + } + const mockLogin = jest + .fn() + .mockRejectedValueOnce(mockError) + .mockResolvedValueOnce({ firstName: "Partner" }) + const mockRequestMfaCode = jest.fn().mockResolvedValue({ phoneNumberVerified: true }) + const mockRouter = { query: {}, push: jest.fn() } + ;(useRouter as jest.Mock).mockReturnValue(mockRouter) + + const { getByLabelText, getByRole, findByText, getByTestId } = render( + + + + + + ) + + fireEvent.change(getByLabelText("Email"), { target: { value: "partner@example.com" } }) + fireEvent.change(getByLabelText("Password"), { target: { value: "password123" } }) + fireEvent.click(getByRole("button", { name: /sign in/i })) + + expect( + await findByText(/how would you like us to verify that it's you?/i) + ).toBeInTheDocument() + fireEvent.click(getByRole("button", { name: /verify with email/i })) + + await waitFor(() => { + expect(mockRequestMfaCode).toHaveBeenCalledWith( + "partner@example.com", + "password123", + MfaType.email + ) + }) + + expect(await findByText(/we sent a code to your email/i)).toBeInTheDocument() + fireEvent.change(getByTestId("sign-in-mfa-code-field"), { target: { value: "123456" } }) + fireEvent.click(getByRole("button", { name: /sign in/i })) + + await waitFor(() => { + expect(mockLogin).toHaveBeenCalledWith( + "partner@example.com", + "password123", + "123456", + MfaType.email, + true + ) + expect(mockRouter.push).toHaveBeenCalledWith("/") + }) + }) + }) + + describe("Phone number addition flow tests", () => { + it("prompts to add phone number when phoneNumberMissing error is returned", async () => { + const mockError = { + response: { + status: 401, + data: { + name: "mfaCodeIsMissing", + message: "MFA code is missing", + }, + }, + } + const mockLogin = jest.fn().mockRejectedValue(mockError) + const mockRequestMfaCodeError = { + response: { + status: 400, + data: { + name: "phoneNumberMissing", + message: "Phone number is missing", + }, + }, + } + const mockRequestMfaCode = jest.fn().mockRejectedValue(mockRequestMfaCodeError) + + const { getByLabelText, getByRole, findByText } = render( + + + + + + ) + + fireEvent.change(getByLabelText("Email"), { target: { value: "partner@example.com" } }) + fireEvent.change(getByLabelText("Password"), { target: { value: "password123" } }) + fireEvent.click(getByRole("button", { name: /sign in/i })) + + expect( + await findByText(/how would you like us to verify that it's you?/i) + ).toBeInTheDocument() + + const smsButton = getByRole("button", { name: /verify with phone number/i }) + if (smsButton) { + fireEvent.click(smsButton) + + expect(await findByText(/add a phone number/i)).toBeInTheDocument() + } + }) + + it("shows phone number prompt when phone number is missing for SMS MFA", async () => { + const mockError = { + response: { + status: 401, + data: { + name: "mfaCodeIsMissing", + message: "MFA code is missing", + }, + }, + } + const mockLogin = jest.fn().mockRejectedValue(mockError) + const mockRequestMfaCodeError = { + response: { + status: 400, + data: { + name: "phoneNumberMissing", + message: "Phone number is missing", + }, + }, + } + const mockRequestMfaCode = jest.fn().mockRejectedValue(mockRequestMfaCodeError) + + const { getByLabelText, getByRole, findByText } = render( + + + + + + ) + + fireEvent.change(getByLabelText("Email"), { target: { value: "partner@example.com" } }) + fireEvent.change(getByLabelText("Password"), { target: { value: "password123" } }) + fireEvent.click(getByRole("button", { name: /sign in/i })) + + expect( + await findByText(/how would you like us to verify that it's you?/i) + ).toBeInTheDocument() + + const smsButton = getByRole("button", { name: /verify with phone number/i }) + if (smsButton) { + fireEvent.click(smsButton) + + expect(await findByText(/add a phone number/i)).toBeInTheDocument() + expect( + await findByText(/enter your phone number and we'll send you a code/i) + ).toBeInTheDocument() + } + }) + + it("allows editing phone number when phone number is not verified", async () => { + const mockError = { + response: { + status: 401, + data: { + name: "mfaCodeIsMissing", + message: "MFA code is missing", + }, + }, + } + const mockLogin = jest.fn().mockRejectedValue(mockError) + const mockRequestMfaCode = jest.fn().mockResolvedValue({ + phoneNumberVerified: false, + phoneNumber: "+14155559999", + }) + + const { getByLabelText, getByRole, findByText } = render( + + + + + + ) + + fireEvent.change(getByLabelText("Email"), { target: { value: "partner@example.com" } }) + fireEvent.change(getByLabelText("Password"), { target: { value: "password123" } }) + fireEvent.click(getByRole("button", { name: /sign in/i })) + + expect( + await findByText(/how would you like us to verify that it's you?/i) + ).toBeInTheDocument() + + const smsButton = getByRole("button", { name: /verify with phone number/i }) + if (smsButton) { + fireEvent.click(smsButton) + + await waitFor(() => { + expect(mockRequestMfaCode).toHaveBeenCalled() + }) + + expect(await findByText(/sent to/i)).toBeInTheDocument() + expect(await findByText(/edit phone number/i)).toBeInTheDocument() + } + }) + }) + + describe("Error flows tests", () => { + it("shows error message when login fails with invalid credentials", async () => { + const mockError = { + response: { + status: 401, + data: { + name: "unauthorized", + message: "Invalid credentials", + }, + }, + } + const mockLogin = jest.fn().mockRejectedValue(mockError) + + const { getByLabelText, getByRole } = render( + + + + + + ) + + fireEvent.change(getByLabelText("Email"), { target: { value: "partner@example.com" } }) + fireEvent.change(getByLabelText("Password"), { target: { value: "wrongpassword" } }) + fireEvent.click(getByRole("button", { name: /sign in/i })) + + await waitFor(() => { + expect(mockLogin).toHaveBeenCalled() + }) + }) + + it("shows error when MFA code is invalid", async () => { + const mockError = { + response: { + status: 401, + data: { + name: "mfaCodeIsMissing", + message: "MFA code is missing", + }, + }, + } + const mockMfaError = { + response: { + status: 401, + data: { + name: "mfaCodeInvalid", + message: "MFA code is invalid", + }, + }, + } + const mockLogin = jest + .fn() + .mockRejectedValueOnce(mockError) + .mockRejectedValueOnce(mockMfaError) + const mockRequestMfaCode = jest.fn().mockResolvedValue({ phoneNumberVerified: true }) + + const { getByLabelText, getByRole, findByText, getByTestId } = render( + + + + + + ) + + fireEvent.change(getByLabelText("Email"), { target: { value: "partner@example.com" } }) + fireEvent.change(getByLabelText("Password"), { target: { value: "password123" } }) + fireEvent.click(getByRole("button", { name: /sign in/i })) + + expect( + await findByText(/how would you like us to verify that it's you?/i) + ).toBeInTheDocument() + fireEvent.click(getByRole("button", { name: /verify with email/i })) + + expect(await findByText(/we sent a code to your email/i)).toBeInTheDocument() + fireEvent.change(getByTestId("sign-in-mfa-code-field"), { target: { value: "000000" } }) + fireEvent.click(getByRole("button", { name: /sign in/i })) + + await waitFor(() => { + expect(mockLogin).toHaveBeenCalledTimes(2) + }) + }) + + it("Shows error when MFA code field is empty", async () => { + const mockError = { + response: { + status: 401, + data: { + name: "mfaCodeIsMissing", + message: "MFA code is missing", + }, + }, + } + const mockLogin = jest.fn().mockRejectedValue(mockError) + const mockRequestMfaCode = jest.fn().mockResolvedValue({ phoneNumberVerified: true }) + + const { getByLabelText, getByRole, findByText } = render( + + + + + + ) + + fireEvent.change(getByLabelText("Email"), { target: { value: "partner@example.com" } }) + fireEvent.change(getByLabelText("Password"), { target: { value: "password123" } }) + fireEvent.click(getByRole("button", { name: /sign in/i })) + + expect( + await findByText(/how would you like us to verify that it's you?/i) + ).toBeInTheDocument() + fireEvent.click(getByRole("button", { name: /verify with email/i })) + + expect(await findByText(/we sent a code to your email/i)).toBeInTheDocument() + fireEvent.click(getByRole("button", { name: /sign in/i })) + + expect(await findByText(/please enter your code/i)).toBeInTheDocument() + }) + }) + + describe("Resend confirmation modal tests", () => { + it("shows resend confirmation modal when account is not confirmed", async () => { + const mockError = { + response: { + status: 401, + data: { + message: "accountConfirmed", + }, + }, + } + const mockLogin = jest.fn().mockRejectedValue(mockError) + + const { getByLabelText, getByRole, findByText } = render( + + + + + + ) + + fireEvent.change(getByLabelText("Email"), { target: { value: "partner@example.com" } }) + fireEvent.change(getByLabelText("Password"), { target: { value: "password123" } }) + fireEvent.click(getByRole("button", { name: /sign in/i })) + + expect(await findByText(/your account is not confirmed/i)).toBeInTheDocument() + expect(await findByText(/resend the email/i)).toBeInTheDocument() + }) + + it("successfully resends confirmation email", async () => { + const mockError = { + response: { + status: 401, + data: { + message: "accountConfirmed", + }, + }, + } + const mockLogin = jest.fn().mockRejectedValue(mockError) + const mockResendPartnerConfirmation = jest.fn().mockResolvedValue({}) + + const { getByLabelText, getByRole, findByText } = render( + + + + + + ) + + fireEvent.change(getByLabelText("Email"), { target: { value: "partner@example.com" } }) + fireEvent.change(getByLabelText("Password"), { target: { value: "password123" } }) + fireEvent.click(getByRole("button", { name: /sign in/i })) + + expect(await findByText(/your account is not confirmed/i)).toBeInTheDocument() + + const resendButton = await findByText(/resend the email/i) + fireEvent.click(resendButton) + + await waitFor(() => { + expect(mockResendPartnerConfirmation).toHaveBeenCalledWith({ + body: expect.objectContaining({ + email: "partner@example.com", + appUrl: window.location.origin, + }), + }) + }) + + expect(await findByText(/confirmation email has been sent/i)).toBeInTheDocument() + }) + + it("closes resend confirmation modal when cancel button is clicked", async () => { + const mockError = { + response: { + status: 401, + data: { + message: "accountConfirmed", + }, + }, + } + const mockLogin = jest.fn().mockRejectedValue(mockError) + + const { getByLabelText, getByRole, findByText, queryByText } = render( + + + + + + ) + + fireEvent.change(getByLabelText("Email"), { target: { value: "partner@example.com" } }) + fireEvent.change(getByLabelText("Password"), { target: { value: "password123" } }) + fireEvent.click(getByRole("button", { name: /sign in/i })) + + expect(await findByText(/your account is not confirmed/i)).toBeInTheDocument() + + const cancelButton = getByRole("button", { name: /cancel/i }) + fireEvent.click(cancelButton) + + await waitFor(() => { + expect(queryByText(/your account is not confirmed/i)).not.toBeInTheDocument() + }) + }) + }) +}) diff --git a/sites/partners/__tests__/testUtils.tsx b/sites/partners/__tests__/testUtils.tsx index cb3aa7d386..2f8b984d69 100644 --- a/sites/partners/__tests__/testUtils.tsx +++ b/sites/partners/__tests__/testUtils.tsx @@ -2,6 +2,8 @@ import React, { FC, ReactElement } from "react" import { render, RenderOptions } from "@testing-library/react" import { SWRConfig } from "swr" import { AuthProvider, ConfigProvider } from "@bloom-housing/shared-helpers" +import { formDefaults, FormListing } from "../src/lib/listings/formTypes" +import { FormProvider, useForm } from "react-hook-form" const AllTheProviders: FC<{ children: React.ReactNode }> = ({ children }) => { return ( @@ -68,3 +70,14 @@ export const mockTipTapEditor = () => { Range.prototype.getBoundingClientRect = getBoundingClientRect Range.prototype.getClientRects = (): DOMRectList => new FakeDOMRectList() } + +export const FormProviderWrapper = ({ + children, + values, +}: React.PropsWithChildren<{ values?: Partial }>) => { + const formMethods = useForm({ + defaultValues: { ...formDefaults, ...values }, + shouldUnregister: false, + }) + return {children} +} diff --git a/sites/partners/cypress/e2e/default/03-listing.spec.ts b/sites/partners/cypress/e2e/default/03-listing.spec.ts index 49e3b2f256..dace1d27a9 100644 --- a/sites/partners/cypress/e2e/default/03-listing.spec.ts +++ b/sites/partners/cypress/e2e/default/03-listing.spec.ts @@ -10,16 +10,15 @@ describe("Listing Management Tests", () => { it("error messaging & save dialogs", () => { // Test to check that the appropriate error messages happen on submit cy.visit("/") - cy.get("a").contains("Add listing").click() + cy.get("button").contains("Add listing").click() + cy.getByID("jurisdiction").select("Bloomington") + cy.get("button").contains("Get started").click() cy.contains("New listing") // Save an empty listing as a draft and should show errors for appropriate fields cy.getByID("saveDraftButton").contains("Save as draft").click() cy.contains("Please resolve any errors before saving or publishing your listing.") cy.getByID("name-error").contains("This field is required") - cy.getByID("jurisdictions.id-error").contains("This field is required") // Fill out minimum fields and errors get removed - cy.getByID("jurisdictions.id").select("Bloomington") - cy.getByID("jurisdictions.id-error").should("not.include.text", "This field is required") cy.getByID("name").type("Test - error messaging") cy.getByID("name-error").should("to.be.empty") cy.getByID("saveDraftButton").contains("Save as draft").click() @@ -33,7 +32,7 @@ describe("Listing Management Tests", () => { cy.getByID("publishButtonConfirm").contains("Publish").click() cy.contains("Please resolve any errors before saving or publishing your listing.") cy.getByID("developer-error").contains("This field is required") - cy.getByID("photos-error").contains("This field is required") + cy.getByID("photos-error").contains("At least 1 image is required") cy.getByID("listingsBuildingAddress.street-error").contains("Cannot enter a partial address") cy.getByID("listingsBuildingAddress.city-error").contains("Cannot enter a partial address") cy.getByID("listingsBuildingAddress.state-error").contains("Cannot enter a partial address") @@ -77,9 +76,10 @@ describe("Listing Management Tests", () => { it("error messaging publish with minimal fields", () => { cy.visit("/") - cy.get("a").contains("Add listing").click() + cy.get("button").contains("Add listing").click() + cy.getByID("jurisdiction").select("Lakeview") + cy.get("button").contains("Get started").click() cy.contains("New listing") - cy.getByID("jurisdictions.id").select("Lakeview") // Try to publish a listing and should show errors for appropriate fields cy.getByID("publishButton").contains("Publish").click() cy.getByID("publishButtonConfirm").contains("Publish").click() @@ -108,9 +108,12 @@ describe("Listing Management Tests", () => { it("full listing publish", () => { cy.visit("/") - cy.get("a").contains("Add listing").click() - cy.contains("New listing") + cy.fixture("listing").then((listing) => { + cy.get("button").contains("Add listing").click() + cy.getByID("jurisdiction").select(listing["jurisdiction.id"]) + cy.get("button").contains("Get started").click() + cy.contains("New listing") fillOutListing(cy, listing) verifyDetails(cy, listing) verifyAutofill(cy, listing) @@ -131,7 +134,6 @@ describe("Listing Management Tests", () => { fixture: "cypress-automated-image-upload-071e2ab9-5a52-4f34-85f0-e41f696f4b96.jpeg", } ) - cy.getByID("jurisdictions.id").select(listing["jurisdiction.id"]) cy.getByID("name").type(listing["name"]) cy.getByID("developer").type(listing["developer"]) // Test photo upload @@ -190,7 +192,7 @@ describe("Listing Management Tests", () => { cy.getByID("listingsBuildingAddress.state").select(listing["buildingAddress.state"]) cy.getByID("listingsBuildingAddress.zipCode").type(listing["buildingAddress.zipCode"]) cy.getByID("yearBuilt").type(listing["yearBuilt"]) - cy.get(".addressPopup").contains(listing["buildingAddress.street"]) + cy.getByID("map-address-popup").contains(listing["buildingAddress.street"]) cy.getByID("reservedCommunityTypes.id").select(listing["reservedCommunityType.id"], { force: true, }) @@ -216,6 +218,7 @@ describe("Listing Management Tests", () => { cy.getByID("monthlyRent").type(listing["monthlyRent"]) cy.getByID("unitAccessibilityPriorityTypes.id").select(listing["priorityType.id"]) cy.get("button").contains("Save & exit").click() + cy.getByID("amiChart.id").find("option").should("have.length", 3) cy.getByID("amiChart.id").select(1).trigger("change") cy.getByID("amiPercentage").select(1) cy.get("button").contains("Save & exit").click() @@ -461,9 +464,6 @@ describe("Listing Management Tests", () => { function verifyAutofill(cy: Cypress.cy, listing: any): void { cy.findAndOpenListing(listing["name"]) cy.getByID("listingEditButton").contains("Edit").click() - cy.getByID("jurisdictions.id") - .find("option:selected") - .should("have.text", listing["jurisdiction.id"]) cy.getByID("name").should("have.value", listing["name"]) cy.getByID("developer").should("have.value", listing["developer"]) diff --git a/sites/partners/cypress/support/commands.js b/sites/partners/cypress/support/commands.js index dbc8f1c572..ebfa406856 100644 --- a/sites/partners/cypress/support/commands.js +++ b/sites/partners/cypress/support/commands.js @@ -408,12 +408,12 @@ Cypress.Commands.add("addMinimalListing", (listingName, isLottery, isApproval, j // Create and publish minimal FCFS or Lottery listing // TODO: test Open Waitlist, though maybe with integration test instead cy.getByID("addListingButton").contains("Add listing").click() + if (jurisdiction) { + cy.getByID("jurisdiction").select("Bloomington") + cy.get("button").contains("Get started").click() + } cy.contains("New listing") cy.fixture("minimalListing").then((listing) => { - if (jurisdiction) { - cy.getByID("jurisdictions.id").select("Bloomington") - cy.getByID("jurisdictions.id-error").should("not.include.text", "This field is required") - } cy.getByID("name").type(listingName) cy.getByID("developer").type(listing["developer"]) cy.getByID("add-photos-button").contains("Add photo").click() diff --git a/sites/partners/netlify.toml b/sites/partners/netlify.toml index f5eeba131f..1b9175ac1d 100644 --- a/sites/partners/netlify.toml +++ b/sites/partners/netlify.toml @@ -1,13 +1,20 @@ [build] command = "yarn run build" -ignore = "/bin/false" + +# From https://docs.netlify.com/build/configure-builds/ignore-builds/: +# +# > An exit code of 1 indicates the contents have changed and the build process continues as +# > usual. An exit code of 0 indicates that there are no changes and the build should stop. +# +# Ignore builds for pull requsets with only infra changes. +ignore = "if [[ $PULL_REQUEST == 'false' ]]; then exit 1; else git diff --quiet $CACHED_COMMIT_REF $COMMIT_REF -- ':(exclude)infra/'; fi" [build.environment] -NODE_VERSION = "18.17.0" -YARN_VERSION = "1.22.4" +NODE_VERSION = "22.21.1" +YARN_VERSION = "1.22.22" NEXT_TELEMETRY_DISABLED = "1" NODE_OPTIONS = "--max_old_space_size=4096" diff --git a/sites/partners/next-env.d.ts b/sites/partners/next-env.d.ts index a4a7b3f5cf..254b73c165 100644 --- a/sites/partners/next-env.d.ts +++ b/sites/partners/next-env.d.ts @@ -1,5 +1,6 @@ /// /// +/// // NOTE: This file should not be edited -// see https://nextjs.org/docs/pages/building-your-application/configuring/typescript for more information. +// see https://nextjs.org/docs/pages/api-reference/config/typescript for more information. diff --git a/sites/partners/next.config.js b/sites/partners/next.config.js index f1ae903368..4e59dbc474 100644 --- a/sites/partners/next.config.js +++ b/sites/partners/next.config.js @@ -60,6 +60,7 @@ module.exports = withBundleAnalyzer( }, sassOptions: { additionalData: tailwindVars, + silenceDeprecations: ["import", "legacy-js-api", "global-builtin"], // TODO: this is hiding tech debt! }, transpilePackages: [ "@bloom-housing/ui-seeds", diff --git a/sites/partners/package.json b/sites/partners/package.json index 76e1bf0a8e..2b06a21904 100644 --- a/sites/partners/package.json +++ b/sites/partners/package.json @@ -30,8 +30,8 @@ }, "dependencies": { "@bloom-housing/shared-helpers": "^7.7.1", - "@bloom-housing/ui-components": "12.7.7", - "@bloom-housing/ui-seeds": "2.0.3", + "@bloom-housing/ui-components": "13.0.3", + "@bloom-housing/ui-seeds": "3.1.4", "@heroicons/react": "^2.2.0", "@mapbox/mapbox-sdk": "^0.13.0", "@tiptap/core": "^2.24.0", @@ -47,14 +47,16 @@ "clone-deep": "^4.0.1", "dayjs": "^1.10.7", "dotenv": "^17.2.3", - "markdown-to-jsx": "^7.7.12", + "mapbox-gl": "^3.16.0", + "markdown-to-jsx": "^7.7.16", "nanoid": "^3.1.12", - "next": "^14.2.28", + "next": "^15.5.7", "qs": "^6.10.1", - "react": "18.2.0", - "react-dom": "18.2.0", + "react": "19.2.3", + "react-dom": "19.2.3", "react-google-recaptcha-v3": "^1.10.1", "react-hook-form": "^6.15.5", + "react-map-gl": "^8.1.0", "swr": "^2.1.2", "tailwindcss": "2.2.10" }, @@ -72,7 +74,7 @@ "@testing-library/user-event": "^14.4.3", "@types/mapbox__mapbox-sdk": "^0.13.2", "@types/node": "^12.12.67", - "@types/react": "^18.0.33", + "@types/react": "^19.2.7", "babel-loader": "^9.1.2", "concurrently": "^5.3.0", "cypress": "^14.5.0", @@ -85,9 +87,9 @@ "nyc": "^15.1.0", "postcss": "^8.5.3", "postcss-custom-media": "^10.0.0", - "sass": "1.52.1", - "sass-loader": "^10.0.3", - "typescript": "4.6.4", + "sass": "^1.93.3", + "sass-loader": "^16.0.6", + "typescript": "4.9.5", "webpack": "^5.98.0" }, "prettier": { diff --git a/sites/partners/page_content/locale_overrides/general.json b/sites/partners/page_content/locale_overrides/general.json index a58b5df98b..7ad615f61b 100644 --- a/sites/partners/page_content/locale_overrides/general.json +++ b/sites/partners/page_content/locale_overrides/general.json @@ -31,9 +31,14 @@ "application.details.agency": "Agency if applicable", "application.details.annualIncome": "Annual income", "application.details.applicationData": "Application data", + "application.details.applicationStatus": "Status", + "application.details.applicationStatus.declined": "Declined", "application.details.applicationStatus.draft": "Draft", + "application.details.applicationStatus.receivedUnit": "Received a unit", "application.details.applicationStatus.removed": "Removed", "application.details.applicationStatus.submitted": "Submitted", + "application.details.applicationStatus.waitlist": "Wait list", + "application.details.applicationStatus.waitlistDeclined": "Wait list - Declined", "application.details.communityTypes": "Community types", "application.details.fullTimeStudent": "Full-time student", "application.details.householdIncome": "Declared household income", @@ -133,6 +138,8 @@ "authentication.createAccount.lastName": "Last name", "errors.alert.emailConflict": "This email is already in use. Please contact your housing department if you're still experiencing issues", "errors.copy.listingNameError": "Create a unique listing name", + "errors.maxLessThanFlatRentValueFrom": "Flat rent to value must be greater than flat rent from value", + "errors.minGreaterThanFlatRentValueTo": "Flat rent from value must be less than flat rent to value", "errors.maxLessThanMinBathroomsError": "Max number of bathrooms must be greater than or equal to minimum number of bathrooms", "errors.maxLessThanMinFloorError": "Max floor must be greater than or equal to minimum floor", "errors.maxLessThanMinFootageError": "Max square footage must be greater than or equal to minimum square footage", @@ -164,6 +171,7 @@ "leasingAgent.managementWebsite": "Company website", "leasingAgent.managementWebsitePlaceholder": "https://www.google.com", "leasingAgent.name": "Leasing agent name", + "leasingAgent.ManagerPropName": "Leasing agent or property manager name", "leasingAgent.namePlaceholder": "Full name", "leasingAgent.officeHoursPlaceholder": "ex: 9:00am - 5:00pm, Monday to Friday", "leasingAgent.title": "Leasing agent title", @@ -186,7 +194,7 @@ "listings.additionalApplicationSubmissionNotes": "Additional application submission notes", "listings.addListing": "Add listing", "listings.addPaperApplication": "Add paper application", - "listings.addPhoto": "Add photo", + "listings.addPhotos": "Add photos", "listings.addPreference": "Add preference", "listings.addPreferences": "Add preferences", "listings.addProgram": "Add program", @@ -194,8 +202,6 @@ "listings.amiOverrideTitle": "Override for household size of %{householdSize}", "listings.appearsAsFirstPage": "Appears as first page of application", "listings.appearsInListing": "Appears in listing", - "listings.applicationAddress.mailApplication": "Can applications be mailed in?", - "listings.applicationAddress.mailApplicationType": "Where can applications be mailed in?", "listings.applicationDropOffQuestion": "Can applications be dropped off?", "listings.applicationDueTime": "Application due time", "listings.applicationDueDate": "Due date", @@ -205,6 +211,9 @@ "listings.applicationType.onlineApplication": "Online applications", "listings.applicationType.paperApplication": "Paper applications", "listings.applicationType.referral": "Referral", + "listings.applicationType.referralUnit": "Referral only units", + "listings.applicationAddress.mailApplication": "Can applications be mailed in?", + "listings.applicationAddress.mailApplicationType": "Where can applications be mailed in?", "listings.approval.approveAndPublish": "Approve & publish", "listings.approval.changeRequestSummary": "Change request summary", "listings.approval.listingClosed": "Listing closed", @@ -229,6 +238,10 @@ "listings.copyListing": "Copy listing", "listings.createdDate": "Created date", "listings.customOnlineApplicationUrl": "Custom online application URL", + "listings.depositTitle": "Deposit type", + "listings.depositFixed": "Fixed deposit", + "listings.depositRange": "Deposit range", + "listings.depositValue": "Deposit", "listings.depositMax": "Deposit max", "listings.depositMin": "Deposit min", "listings.details.createdDate": "Date created", @@ -240,6 +253,7 @@ "listings.details.updatedDate": "Date updated", "listings.details.you": "You", "listings.developer": "Housing developer", + "listings.propertyManager": "Property Management Account", "listings.dropOffAddress": "Drop off address", "listings.dueDateQuestion": "Is there an application due date?", "listings.editCommunities": "Edit communities", @@ -251,17 +265,25 @@ "listings.events.openHouseNotes": "Open house notes", "listings.fieldError": "Please resolve any errors before saving or publishing your listing.", "listings.firstComeFirstServe": "First come first serve", + "listings.getStarted": "Get started", "listings.housingDeveloperOwner": "Housing developer / owner", "listings.includeCommunityDisclaimer": "Do you want to include a community type disclaimer as the first page of the application?", "listings.isDigitalApplication": "Is there a digital application?", "listings.isPaperApplication": "Is there a paper application?", "listings.isReferralOpportunity": "Is there a referral opportunity?", + "listings.areReferralOnlyUnits": "Are there units set aside for referral only?", "listings.latitude": "Latitude", "listings.leasingAgentAddress": "Leasing agent address", + "listings.leasingAgentAddressManagerProp": "Leasing agent or property manager address", "listings.listingAvailabilityQuestion": "What is the listing availability?", "listings.listingIsAlreadyLive": "This listing is already live. Updates will affect the applicant experience on the housing portal.", + "listings.listingTypeTitle": "What kind of listing is this?", + "listings.hasEbllClearanceTitle": "Has this property received HUD EBLL clearance?", + "listings.regulated": "Regulated", + "listings.nonRegulated": "Non-regulated", "listings.listingName": "Listing name", - "listings.listingPhoto": "Listing photo", + "listings.listingFileNumber": "Listing file number", + "listings.listingPhoto": "Listing photos", "listings.listingStatus.active": "Open", "listings.listingStatus.changesRequested": "Changes requested", "listings.listingStatus.closed": "Closed", @@ -269,6 +291,9 @@ "listings.listingStatus.pendingReview": "Pending review", "listings.listingStatusText": "Listing status", "listings.listingSubmitted": "Listing submitted", + "listings.listingType": "Listing Type", + "listings.listingType.regulated.description": "Covered by an ordinance or funding source that requires tenants to income-qualify to live in these units, e.g. inclusionary units, LIHTC, or other deed-restricted properties", + "listings.listingType.nonRegulated.description": "Tenants do not not need to income qualify to live here, and there is no funding source or ordinance that regulates rent levels or other tenant qualifications.", "listings.longitude": "Longitude", "listings.lottery.dataExpiryDescription": "Lottery data has expired for this listing and is no longer available for export.", "listings.lottery.dataExpiryMessage": "Lottery data for this listing will expire on %{date}. Please export data prior to this date.", @@ -372,6 +397,7 @@ "listings.reservedCommunityDescription": "Reserved community description", "listings.reservedCommunityDisclaimer": "Reserved community disclaimer", "listings.reservedCommunityDisclaimerTitle": "Reserved community disclaimer title", + "listings.seniorsOnly": "Is this unit for seniors only?", "listings.reviewOrderQuestion": "How is the application review order determined?", "listings.section8Title": "Do you accept Section 8 Housing Choice Vouchers?", "listings.sections.additionalDetails": "Additional details", @@ -394,13 +420,17 @@ "listings.sections.communityType": "Community type", "listings.sections.communityTypeSubtitle": "Are there any requirements that applicants need to meet?", "listings.sections.costsNotIncluded": "Costs not included", + "listings.sections.creditScreeningFee": "Credit screening fee", "listings.sections.depositHelperText": "Deposit helper text", "listings.sections.housingPreferencesSubtext": "Tell us about any preferences that will be used to rank qualifying applicants.", "listings.sections.housingProgramsSubtext": "Tell us about any additional housing programs related to this listing.", "listings.sections.introSubtitle": "Let's get started with some basic information about your listing.", "listings.sections.introTitle": "Listing intro", "listings.sections.leasingAgentSubtitle": "Provide details about the leasing agent who will be managing the application process.", + "listings.sections.leasingAgentManagerPropSubtitle": "Provide details about the leasing agent or property manager who will be managing the application process.", "listings.sections.leasingAgentTitle": "Leasing agent", + "listings.sections.leasingAgentManagerPropTitle": "Leasing agent or property manager title", + "listings.sections.leasingAgentManagerPropSectionTitle": "Leasing agent or property manager", "listings.sections.lotteryResultsAdd": "Add results", "listings.sections.lotteryResultsEdit": "Edit results", "listings.sections.lotteryResultsHelperText": "Upload results", @@ -410,24 +440,53 @@ "listings.sections.neighborhoodPlaceholder": "Select neighborhood", "listings.sections.openHouse": "Open house", "listings.sections.photo.helperText": "Select JPEG or PNG file to upload. Please upload horizontal images only at approximately 1440px. Up to 10 uploaded images allowed.", + "listings.sections.photo.helperTextBase": "Select JPEG or PNG file to upload. Please upload horizontal images only at approximately 1440px.", + "listings.sections.photo.helperTextLimit": "Up to 10 uploaded images allowed.", + "listings.sections.photo.helperTextLimits": "At least %{smart_count} image is required, and up to 10 images are allowed. |||| At least %{smart_count} images are required, and up to 10 images are allowed.", + "listings.sections.photo.addImageDescription": "Add image description", + "listings.sections.photo.altTextHelper": "Write a concise description of the image. Example: “Exterior view of a nine-story apartment building.”", + "listings.sections.photo.editPhoto": "Edit photo", + "listings.sections.photo.imageDescription": "Image description", + "listings.sections.photo.imageDescriptionAltText": "Image description (alt text)", + "listings.sections.photo.imageDescriptionForListingPhoto": "Image description for listing photo", "listings.sections.photo.maximumUpload": "Note: the maximum of 10 images have been uploaded.", "listings.sections.photo.primaryPhoto": "Primary photo", "listings.sections.photoSubtitle": "Upload an image for the listing that will be used as a preview.", - "listings.sections.photoTitle": "Listing photo", + "listings.sections.photosSubtitle": "Upload at least %{smart_count} image for the listing that will be used as a preview. |||| Upload at least %{smart_count} images for the listing that will be used as a preview.", + "listings.sections.photoTitle": "Listing photos", + "listings.sections.photoError": "At least %{smart_count} image is required |||| At least %{smart_count} images are required", "listings.sections.rankingsResultsSubtitle": "Provide details about what happens to applications once they are submitted.", "listings.sections.rankingsResultsTitle": "Rankings & results", "listings.sections.regionPlaceholder": "Select region", "listings.selectCommunityTypes": "Select community types", "listings.selectJurisdiction": "You must first select a jurisdiction", + "listings.selectJurisdictionTitle": "Select jurisdiction", + "listings.selectJurisdictionContent": "Once you create this listing, this selection cannot be changed.", + "listings.selectListingType": "Select Listing Type", "listings.selectPreferences": "Select preferences", "listings.selectPrograms": "Select programs", + "listings.smokingPolicyOptions.noSmokingAllowed": "No smoking allowed", + "listings.smokingPolicyOptions.smokingAllowed": "Smoking allowed", + "listings.smokingPolicyOptions.unknown": "Policy unknown", "listings.streetAddressOrPOBox": "Street address or PO box", "listings.totalListings": "Total listings", + "listings.unit.ami": "AMI", + "listings.marketing": "Marketing", + "listings.marketingFlyer.accessibleTitle": "Accessible marketing flyer", + "listings.marketingFlyer.accessibleWebpageUrl": "Webpage URL to an accessible version", + "listings.marketingFlyer.add": "Add marketing flyer", + "listings.marketingFlyer.edit": "Edit marketing flyer", + "listings.marketingFlyer.shareAccessibleQuestion": "How will you share your accessible marketing flyer?", + "listings.marketingFlyer.shareQuestion": "How will you share your marketing flyer?", + "listings.marketingFlyer.title": "Marketing flyer", + "listings.marketingFlyer.uploadAccessiblePdf": "Upload accessible PDF", + "listings.marketingFlyer.uploadPdf": "Upload PDF", + "listings.marketingFlyer.webpageUrl": "Webpage URL", + "listings.marketingFlyer.informationalWebpageUrl": "Informational webpage URL", "listings.unit.%incomeRent": "Percentage of income rent", "listings.unit.accessibilityPriorityType": "Accessibility priority type", "listings.unit.add": "Add unit", - "listings.unit.affordableGroupQuantity": "Affordable unit group quantity", - "listings.unit.ami": "AMI", + "listings.unit.affordableGroupQuantity": "Unit Group Quantity", "listings.unit.amiAdd": "Add AMI level", "listings.unit.amiChart": "AMI chart", "listings.unit.amiDelete": "Delete this AMI level", @@ -483,6 +542,11 @@ "listings.unit.unitTypes": "Unit types", "listings.unit.waitlistStatus": "Waitlist status", "listings.unitGroup.add": "Add unit group", + "listings.unitGroup.rentType": "Rent Type", + "listings.unitGroup.fixedRent": "Fixed Rent", + "listings.unitGroup.rentRange": "Rent Range", + "listings.unitGroup.flatRentValueFrom": "Monthly Rent From", + "listings.unitGroup.flatRentValueTo": "Monthly Rent To", "listings.unitGroup.delete": "Delete this unit group", "listings.unitGroup.deleteConf": "Do you really want to delete this unit group?", "listings.unitGroup.typeOptions.fourBdrm": "4+ bedroom", @@ -525,6 +589,7 @@ "settings.createCopy": "Make a copy", "settings.createCopyDescription": "Create a copy of your preference.", "settings.preference": "Preference", + "settings.preferences": "Preferences", "settings.preferenceAdd": "Add preference", "settings.preferenceAdditionalFields": "Additional fields", "settings.preferenceAddOption": "Add option", @@ -560,6 +625,7 @@ "settings.preferenceValidatingAddress.selectMapLayer": "Select a map layer", "settings.preferenceValidatingAddress.selectMapLayerDescription": "Select your map layer based on your district. If you don't see your map contact us", "settings.preferenceValidatingAddress": "Do you need help validating the address?", + "settings.properties": "Properties", "t.add": "Add", "t.addItem": "Add item", "t.addItemsToEdit": "Add items to edit", @@ -637,6 +703,7 @@ "t.submitNew": "Submit & new", "t.title": "Title", "t.updated": "Updated", + "t.uploadFiles": "Upload files", "t.url": "URL", "t.verified": "Verified", "t.view": "View", diff --git a/sites/partners/src/components/applications/ApplicationsColDefs.ts b/sites/partners/src/components/applications/ApplicationsColDefs.tsx similarity index 97% rename from sites/partners/src/components/applications/ApplicationsColDefs.ts rename to sites/partners/src/components/applications/ApplicationsColDefs.tsx index 44128db748..513117280f 100644 --- a/sites/partners/src/components/applications/ApplicationsColDefs.ts +++ b/sites/partners/src/components/applications/ApplicationsColDefs.tsx @@ -41,7 +41,8 @@ function compareStrings(a, b, node, nextNode, isInverted) { export function getColDefs( maxHouseholdSize: number, enableFullTimeStudentQuestion?: boolean, - disableWorkInRegion?: boolean + disableWorkInRegion?: boolean, + enableApplicationStatus?: boolean ): ColDef[] { const defs: ColDef[] = [ { @@ -84,6 +85,22 @@ export function getColDefs( valueFormatter: ({ value }) => t(`application.details.submissionType.${value}`), comparator: compareStrings, }, + ...(enableApplicationStatus + ? [ + { + headerName: t("application.details.applicationStatus"), + field: "status", + sortable: false, + filter: false, + width: 160, + minWidth: 50, + valueFormatter: ({ data }) => { + if (!data.status) return "" + return t(`application.details.applicationStatus.${data.status}`) + }, + }, + ] + : []), { headerName: t("application.name.firstName"), field: "applicant.firstName", diff --git a/sites/partners/src/components/applications/PaperApplicationDetails/DetailsAddressColumns.tsx b/sites/partners/src/components/applications/PaperApplicationDetails/DetailsAddressColumns.tsx index 99f59e869c..5f96379948 100644 --- a/sites/partners/src/components/applications/PaperApplicationDetails/DetailsAddressColumns.tsx +++ b/sites/partners/src/components/applications/PaperApplicationDetails/DetailsAddressColumns.tsx @@ -49,7 +49,7 @@ const DetailsAddressColumns = ({ if (type === AddressColsType.mailing) { if (application.sendMailToMailingAddress) { - address[item] = application.applicationsMailingAddress[item] + address[item] = application.applicationsMailingAddress[item] || t("t.n/a") } else { address[item] = application.applicant.applicantAddress[item] || t("t.n/a") } diff --git a/sites/partners/src/components/applications/PaperApplicationDetails/sections/DetailsHouseholdMembers.tsx b/sites/partners/src/components/applications/PaperApplicationDetails/sections/DetailsHouseholdMembers.tsx index 43e3693d37..e43d825d3e 100644 --- a/sites/partners/src/components/applications/PaperApplicationDetails/sections/DetailsHouseholdMembers.tsx +++ b/sites/partners/src/components/applications/PaperApplicationDetails/sections/DetailsHouseholdMembers.tsx @@ -47,7 +47,12 @@ const DetailsHouseholdMembers = ({ (a, b) => a.orderId - b.orderId ) return orderedHouseholdMembers?.map((item) => ({ - name: { content: `${item.firstName} ${item.middleName || ""} ${item.lastName}` }, + name: { + content: + item.firstName || item.middleName || item.lastName + ? `${item.firstName || ""} ${item.middleName || ""} ${item.lastName || ""}` + : t("t.n/a"), + }, birth: { content: item.birthMonth && item.birthDay && item.birthYear diff --git a/sites/partners/src/components/applications/PaperApplicationForm/MultiselectQuestionsMap.tsx b/sites/partners/src/components/applications/PaperApplicationForm/MultiselectQuestionsMap.tsx index e97b724f45..fa7a9af44d 100644 --- a/sites/partners/src/components/applications/PaperApplicationForm/MultiselectQuestionsMap.tsx +++ b/sites/partners/src/components/applications/PaperApplicationForm/MultiselectQuestionsMap.tsx @@ -1,5 +1,6 @@ import React, { useEffect, useState } from "react" -import { FieldGroup, LatitudeLongitude, ListingMap, t } from "@bloom-housing/ui-components" +import { FieldGroup, t } from "@bloom-housing/ui-components" +import { Map, LatitudeLongitude } from "@bloom-housing/shared-helpers" import { FieldValue, Grid } from "@bloom-housing/ui-seeds" import { useFormContext, useWatch } from "react-hook-form" import { GeocodeService as GeocodeServiceType } from "@mapbox/mapbox-sdk/services/geocoding" @@ -132,7 +133,7 @@ const MultiselectQuestionsMap = ({ geocodingClient, dataKey }: MultiselectQuesti {displayMapPreview() ? ( - <> - - {application?.status - ? t(`application.details.applicationStatus.${application.status}`) - : t(`application.details.applicationStatus.draft`)} - + @@ -246,7 +243,7 @@ const ApplicationForm = ({ listingId, editMode, application }: ApplicationFormPr
- + { +type FormApplicationDataProps = { + enableApplicationStatus: boolean +} + +const FormApplicationData = ({ enableApplicationStatus }: FormApplicationDataProps) => { const formMethods = useFormContext() // eslint-disable-next-line @typescript-eslint/unbound-method @@ -18,6 +25,7 @@ const FormApplicationData = () => { const isDateRequired = dateSubmittedValue?.day || dateSubmittedValue?.month || dateSubmittedValue?.year + const applicationStatusOptions = Array.from(Object.values(ApplicationStatusEnum)) return ( @@ -65,6 +73,21 @@ const FormApplicationData = () => { /> + {enableApplicationStatus && ( + + + - - - - - - - - - - - - - + {!isNonRegulated && ( + <> + + + { + void trigger("sqFeetMin") + void trigger("sqFeetMax") + }} + /> + + + { + void trigger("sqFeetMin") + void trigger("sqFeetMax") + }} + /> + + + + + { + void trigger("floorMin") + void trigger("floorMax") + }, + }} + /> + + + + )}