From 8031c9018558873cd939c6d27446025de5d497da Mon Sep 17 00:00:00 2001 From: dominikx96 Date: Fri, 19 Aug 2022 03:25:59 +0200 Subject: [PATCH] feat: add duplicates v2 feature MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: create duplicates pages * refactor: update applications link * refactor: refactor application routes * refactor: update active path detection * fix: export SideNav component and add className * refactor: cleanup & update table cols * refactor: refactor application export and pages structure * feat: create reusable sidenav component * feat: update header active paths * feat: integrate side nav * feat: adding backend for applicaiton sidenav * feat: changes applicaiton seeeds * fix: updates per morgan * fix: update the "all applications" translation * refactor: create helper file and move shared part * feat: integrate resolved apps view * refactor: cleanup * feat: hides pending subcats and footer with scan * style: childrenItems comment * feat: adds includeDemographics param to useApplicationsExport Co-authored-by: Yazeed Loonat Co-authored-by: Sean Albert refactor: create duplicates pages fix: remove export button from duplicate pages, fix table widths (#3006) 2835/async duplicates processing (#2961) * feat(backend): add bull job scheduler for afs processing * feat(backend): move AFS processing logic to async consumer * feat(backend): improve afs async processing * test: updates imports * perf: select fields for listings afs * fix: undo app repo select * feat: sets rule_key for existing afs * Fix code style issues with Prettier * feat: adds afs process controller * fix: updates seed and afs duplicate procesing call * perf: clean up afs migration Co-authored-by: Michal Plebanski Co-authored-by: Lint Action fix: add-afs migration update build: debug migration build: debug migration feat: allow marking status for application flagged sets (#3020) Syncs dev into duplicates branch (via rebase) (#3093) * fix: add a11y linting tools and fix errors (#2974) * chore(release): version - @bloom-housing/shared-helpers@5.1.1-alpha.15 - @bloom-housing/partners@5.1.1-alpha.17 - @bloom-housing/public@5.1.1-alpha.15 - @bloom-housing/ui-components@5.1.1-alpha.9 * 2513/email markup fixes from qa (#2909) * fix: address QA testing issues for confirmation email template * fix: use the right keys for the translation update Co-authored-by: Sean Albert * chore(release): version - @bloom-housing/backend-core@5.1.1-alpha.7 - @bloom-housing/shared-helpers@5.1.1-alpha.16 - @bloom-housing/partners@5.1.1-alpha.18 - @bloom-housing/public@5.1.1-alpha.16 * 2918/add button group (#2958) * feat: add ButtonGroup component * feat: add ButtonGroup component Also add a title tooltip to swatch in docs Update 2nd Generation badge to primary blue instead of red * Fix code style issues with Prettier * fix: clean up button group styles, add react key * test: ButtonGroup, add to exports Co-authored-by: Lint Action * chore(release): version - @bloom-housing/shared-helpers@5.1.1-alpha.17 - @bloom-housing/partners@5.1.1-alpha.19 - @bloom-housing/public@5.1.1-alpha.17 - @bloom-housing/ui-components@5.1.1-alpha.10 * refactor!: preferences & programs data model merged (#2904) BREAKING CHANGE: The preference and program entities have been merged into a single entity called MultiselectQuestion * chore(release): version - @bloom-housing/backend-core@5.1.1-alpha.8 - @bloom-housing/shared-helpers@5.1.1-alpha.18 - @bloom-housing/partners@5.1.1-alpha.20 - @bloom-housing/public@5.1.1-alpha.18 - @bloom-housing/ui-components@5.1.1-alpha.11 * fix: update to get migration to run on dev (#2987) * chore(release): version - @bloom-housing/backend-core@5.1.1-alpha.9 - @bloom-housing/shared-helpers@5.1.1-alpha.19 - @bloom-housing/partners@5.1.1-alpha.21 - @bloom-housing/public@5.1.1-alpha.19 * fix: Migration hotfix 2 (#2990) * fix: update to get migration to run on dev * fix: updates to migrations * fix: updates to migrations * chore(release): version - @bloom-housing/backend-core@5.1.1-alpha.10 - @bloom-housing/shared-helpers@5.1.1-alpha.20 - @bloom-housing/partners@5.1.1-alpha.22 - @bloom-housing/public@5.1.1-alpha.20 * feat: show preference details on listing form (#2989) * chore(release): version - @bloom-housing/backend-core@5.1.1-alpha.11 - @bloom-housing/shared-helpers@5.1.1-alpha.21 - @bloom-housing/partners@5.1.1-alpha.23 - @bloom-housing/public@5.1.1-alpha.21 - @bloom-housing/ui-components@5.1.1-alpha.12 * fix: update to fix some lingering migration issues (#2991) * fix: update to fix some lingering migration issues * test: updates enablePartnerSettings seed * test: update to tests Co-authored-by: Sean Albert * chore(release): version - @bloom-housing/backend-core@5.1.1-alpha.12 - @bloom-housing/shared-helpers@5.1.1-alpha.22 - @bloom-housing/partners@5.1.1-alpha.24 - @bloom-housing/public@5.1.1-alpha.22 * feat: adds the ability to manage preference for partner admins (#2985) * chore(release): version - @bloom-housing/backend-core@5.1.1-alpha.13 - @bloom-housing/shared-helpers@5.1.1-alpha.23 - @bloom-housing/partners@5.1.1-alpha.25 - @bloom-housing/public@5.1.1-alpha.23 * fix: styling precedence issues in preferences (#2994) * chore(release): version - @bloom-housing/backend-core@5.1.1-alpha.14 - @bloom-housing/shared-helpers@5.1.1-alpha.24 - @bloom-housing/partners@5.1.1-alpha.26 - @bloom-housing/public@5.1.1-alpha.24 * fix: updates translation migration files (#2995) * chore(release): version - @bloom-housing/backend-core@5.1.1-alpha.15 - @bloom-housing/shared-helpers@5.1.1-alpha.25 - @bloom-housing/partners@5.1.1-alpha.27 - @bloom-housing/public@5.1.1-alpha.25 * fix: add margin in contact section (#2999) * chore(release): version - @bloom-housing/shared-helpers@5.1.1-alpha.26 - @bloom-housing/partners@5.1.1-alpha.28 - @bloom-housing/public@5.1.1-alpha.26 - @bloom-housing/ui-components@5.1.1-alpha.13 * 2919/progress bar update (#2950) * fix: added a11y labeling * fix: base view for bar progress * fix: support for different colors * fix: style passed as prop * fix: wip conversion to vanilla css * fix: completed conversion to vanilla css * fix: testing different mobile views * fix: drop labels on mobile * fix: removing remaining tailwind * fix: cleaner css class construction * fix: added css variables * fix: align label font color * fix: refining css theming * fix: added documentation * fix: added missing css var * fix: align default sizing * fix: stricter typing for nav style * fix: removed empty linking * fix: remove clickable ux * fix: removed unused css * fix: style updates per Jesse * fix: integrating a11y updates * fix: removed unneccessary tabbing * fix: bar label css updates * fix: sr a11y for progress states * fix: gen 2 flagging + removed unused strings * chore(release): version - @bloom-housing/shared-helpers@5.1.1-alpha.27 - @bloom-housing/partners@5.1.1-alpha.29 - @bloom-housing/public@5.1.1-alpha.27 - @bloom-housing/ui-components@5.1.1-alpha.14 * 2940/media modal sync (#2968) * fix: media card sync * fix: modal padding to prevent overlap * fix: added media card documentation * fix: minor documentation change * fix: replace a11y css * fix: consistent css spacing * chore(release): version - @bloom-housing/shared-helpers@5.1.1-alpha.28 - @bloom-housing/partners@5.1.1-alpha.30 - @bloom-housing/public@5.1.1-alpha.28 - @bloom-housing/ui-components@5.1.1-alpha.15 * refactor: code cleanliness improvements (#2784) * chore(release): version - @bloom-housing/backend-core@5.1.1-alpha.16 - @bloom-housing/shared-helpers@5.1.1-alpha.29 - @bloom-housing/partners@5.1.1-alpha.31 - @bloom-housing/public@5.1.1-alpha.29 - @bloom-housing/ui-components@5.1.1-alpha.16 * fix(login): too many login attempts fix (#2988) * chore(release): version - @bloom-housing/backend-core@5.1.1-alpha.17 - @bloom-housing/shared-helpers@5.1.1-alpha.30 - @bloom-housing/partners@5.1.1-alpha.32 - @bloom-housing/public@5.1.1-alpha.30 - @bloom-housing/ui-components@5.1.1-alpha.17 * fix: null app fee showing null string (#3016) * chore(release): version - @bloom-housing/shared-helpers@5.1.1-alpha.31 - @bloom-housing/partners@5.1.1-alpha.33 - @bloom-housing/public@5.1.1-alpha.31 - @bloom-housing/ui-components@5.1.1-alpha.18 * 2891/add sign up to beginning of application flow (#3018) * feat: add sign-up to beginning of application flow * refactor: change login panel in beginning of application flow to use ActionBlock * feat: test create account should redirect to /create-account * feat: design improvements for choose language panel * test: remove redundant test * chore(release): version - @bloom-housing/public@5.1.1-alpha.32 * fix: remove select text from multiselect application questions (#3017) * chore(release): version - @bloom-housing/public@5.1.1-alpha.33 * fix: table columns should take up full width (#3005) * chore(release): version - @bloom-housing/shared-helpers@5.1.1-alpha.32 - @bloom-housing/partners@5.1.1-alpha.34 - @bloom-housing/public@5.1.1-alpha.34 - @bloom-housing/ui-components@5.1.1-alpha.19 * refactor: allow custom strings in all components (#3012) * chore(release): version - @bloom-housing/shared-helpers@5.1.1-alpha.33 - @bloom-housing/partners@5.1.1-alpha.35 - @bloom-housing/public@5.1.1-alpha.35 - @bloom-housing/ui-components@5.1.1-alpha.20 * refactor: alert box v2 styling refactor (#3014) * chore(release): version - @bloom-housing/shared-helpers@5.1.1-alpha.34 - @bloom-housing/partners@5.1.1-alpha.36 - @bloom-housing/public@5.1.1-alpha.36 - @bloom-housing/ui-components@5.1.1-alpha.21 * chore: axe core a11y dynamic linting (#3000) * chore(release): version - @bloom-housing/shared-helpers@5.1.1-alpha.35 - @bloom-housing/partners@5.1.1-alpha.37 - @bloom-housing/public@5.1.1-alpha.37 - @bloom-housing/ui-components@5.1.1-alpha.22 * fix(application): remove programs from autofill (#3021) * chore(release): version - @bloom-housing/public@5.1.1-alpha.38 * chore: remove all dependecies of moment (#3027) * chore(release): version - @bloom-housing/backend-core@5.1.1-alpha.18 - @bloom-housing/shared-helpers@5.1.1-alpha.36 - @bloom-housing/partners@5.1.1-alpha.38 - @bloom-housing/public@5.1.1-alpha.39 * fix: migration for application export clean up (#3036) * chore(release): version - @bloom-housing/backend-core@5.1.1-alpha.19 - @bloom-housing/shared-helpers@5.1.1-alpha.37 - @bloom-housing/partners@5.1.1-alpha.39 - @bloom-housing/public@5.1.1-alpha.40 * fix: add unit tests to partners (#3023) * fix: add unit tests to partners * Fix code style issues with Prettier Co-authored-by: Lint Action * chore(release): version - @bloom-housing/shared-helpers@5.1.1-alpha.38 - @bloom-housing/partners@5.1.1-alpha.40 - @bloom-housing/public@5.1.1-alpha.41 - @bloom-housing/ui-components@5.1.1-alpha.23 * fix: return N/A string if rent and income is NaN (#3040) * chore(release): version - @bloom-housing/backend-core@5.1.1-alpha.20 - @bloom-housing/shared-helpers@5.1.1-alpha.39 - @bloom-housing/partners@5.1.1-alpha.41 - @bloom-housing/public@5.1.1-alpha.42 * 2920/bordered field option (#3054) * fix: border css * fix: removed unused lines * fix: dynamic background * fix: corrected class position * fix: color correction * fix: reformatted css approach * fix: improved storybook testing * fix: removed duplicate css * fix: minimize tailwind variables * fix: css theming * fix: box color correction * fix: css variable typo * chore(release): version - @bloom-housing/shared-helpers@5.1.1-alpha.40 - @bloom-housing/partners@5.1.1-alpha.42 - @bloom-housing/public@5.1.1-alpha.43 - @bloom-housing/ui-components@5.1.1-alpha.24 * feat: allow alerts to be sticky under page header (#3050) * chore(release): version - @bloom-housing/shared-helpers@5.1.1-alpha.41 - @bloom-housing/partners@5.1.1-alpha.43 - @bloom-housing/public@5.1.1-alpha.44 - @bloom-housing/ui-components@5.1.1-alpha.25 * fix: updated css class naming (#483) (#3073) Co-authored-by: ColinBuyck <53269332+ColinBuyck@users.noreply.github.com> * chore(release): version - @bloom-housing/shared-helpers@5.1.1-alpha.42 - @bloom-housing/partners@5.1.1-alpha.44 - @bloom-housing/public@5.1.1-alpha.45 - @bloom-housing/ui-components@5.1.1-alpha.26 * Update dropzone for single upload (#3070) * fix: default dropzone upload to single file, add maxFiles * fix: simplify boolean expression * fix: sync latest style updates from Detroit * chore(release): version - @bloom-housing/shared-helpers@5.1.1-alpha.43 - @bloom-housing/partners@5.1.1-alpha.45 - @bloom-housing/public@5.1.1-alpha.46 - @bloom-housing/ui-components@5.1.1-alpha.27 * feat: add confirm modal when copying a preference (#3041) * chore(release): version - @bloom-housing/shared-helpers@5.1.1-alpha.44 - @bloom-housing/partners@5.1.1-alpha.46 - @bloom-housing/public@5.1.1-alpha.47 - @bloom-housing/ui-components@5.1.1-alpha.28 * fix: complete app section while navigating * chore(release): version - @bloom-housing/public@5.1.1-alpha.48 * fix: update email confirmation what to expect copy (#3061) * chore(release): version - @bloom-housing/backend-core@5.1.1-alpha.21 - @bloom-housing/shared-helpers@5.1.1-alpha.45 - @bloom-housing/partners@5.1.1-alpha.47 - @bloom-housing/public@5.1.1-alpha.49 * chore: add tests back to eslint (#3077) * chore(release): version - @bloom-housing/shared-helpers@5.1.1-alpha.46 - @bloom-housing/partners@5.1.1-alpha.48 - @bloom-housing/public@5.1.1-alpha.50 - @bloom-housing/ui-components@5.1.1-alpha.29 * 3030/what to expect visual updates (#3063) * feat: change available units to vacant units * feat: show vacant units, open waitlist as pill tags in listings * feat: change units to vacant units in summary table * feat: update translations to use new key that refers to vacant units * feat: use customClass as last prop in getHeader function * refactor: change style to styleType * chore(release): version - @bloom-housing/shared-helpers@5.1.1-alpha.47 - @bloom-housing/partners@5.1.1-alpha.49 - @bloom-housing/public@5.1.1-alpha.51 - @bloom-housing/ui-components@5.1.1-alpha.30 * fix: append copy text to copied preferences (#3084) * chore(release): version - @bloom-housing/partners@5.1.1-alpha.50 * fix: make pill style on listing card optional (#3088) * chore(release): version - @bloom-housing/shared-helpers@5.1.1-alpha.48 - @bloom-housing/partners@5.1.1-alpha.51 - @bloom-housing/public@5.1.1-alpha.52 - @bloom-housing/ui-components@5.1.1-alpha.31 * 2853/duplicates pages (#2892) * refactor: create duplicates pages * refactor: update applications link * refactor: refactor application routes * refactor: update active path detection * fix: export SideNav component and add className * refactor: cleanup & update table cols * refactor: refactor application export and pages structure * feat: create reusable sidenav component * feat: update header active paths * feat: integrate side nav * feat: adding backend for applicaiton sidenav * feat: changes applicaiton seeeds * fix: updates per morgan * fix: update the "all applications" translation * refactor: create helper file and move shared part * feat: integrate resolved apps view * refactor: cleanup * feat: hides pending subcats and footer with scan * style: childrenItems comment * feat: adds includeDemographics param to useApplicationsExport Co-authored-by: Yazeed Loonat Co-authored-by: Sean Albert * refactor: create duplicates pages * fix: remove export button from duplicate pages, fix table widths (#3006) * 2835/async duplicates processing (#2961) * feat(backend): add bull job scheduler for afs processing * feat(backend): move AFS processing logic to async consumer * feat(backend): improve afs async processing * test: updates imports * perf: select fields for listings afs * fix: undo app repo select * feat: sets rule_key for existing afs * Fix code style issues with Prettier * feat: adds afs process controller * fix: updates seed and afs duplicate procesing call * perf: clean up afs migration Co-authored-by: Michal Plebanski Co-authored-by: Lint Action * fix: add-afs migration update * build: debug migration * build: debug migration * feat: allow marking status for application flagged sets (#3020) * chore: merge cleanup * style: fix linter issue with duplicate export * test: fix email test Co-authored-by: Emily Jablonski <65367387+emilyjablonski@users.noreply.github.com> Co-authored-by: github.context.workflow Co-authored-by: Jared White Co-authored-by: Lint Action Co-authored-by: Yazeed Loonat Co-authored-by: ColinBuyck <53269332+ColinBuyck@users.noreply.github.com> Co-authored-by: ludtkemorgan <42942267+ludtkemorgan@users.noreply.github.com> Co-authored-by: Krzysztof Zięcina Co-authored-by: Krzysztof Zięcina Co-authored-by: dominikx96 Co-authored-by: Michal Plebanski Co-authored-by: Emily Jablonski chore: standardize the typescript version (#3086) feat: adds setsAfsLastRunAt1664300247901 migration (#3102) fix: duplicates ux issues (#3101) fix: updates csv export (#3104) 3099/AFS Pagination (#3105) * fix: afs list pagination * fix: resolved afs pagination * style: linter issue with afs limit --- backend/core/package.json | 4 + .../core/src/ami-charts/dto/ami-chart.dto.ts | 12 - backend/core/src/app.module.ts | 3 + ...n-flagged-sets-cronjob-boostrap.service.ts | 22 + ...plication-flagged-sets-cronjob-consumer.ts | 300 +++++++++++ .../application-flagged-sets.controller.ts | 35 +- .../application-flagged-sets.module.ts | 41 +- .../application-flagged-sets.service.ts | 506 ++++++++---------- .../applications-flagged-sets-constants.ts | 3 + .../dto/application-flagged-set-meta.dto.ts | 29 + .../application-flagged-set-resolve.dto.ts | 7 +- .../application-flagged-set.entity.ts | 26 +- ...ed-application-flagged-set-query-params.ts | 13 +- .../types/flagged-set-status-enum.ts | 1 + .../types/view-enum.ts | 6 + .../dto/application-method.dto.ts | 14 +- .../src/applications/applications.module.ts | 2 - .../dto/application-update.dto.ts | 12 - .../entities/application.entity.ts | 7 + .../application-csv-exporter.service.ts | 7 +- .../services/applications.service.ts | 32 +- .../types/application-review-status-enum.ts | 6 + backend/core/src/assets/dto/asset.dto.ts | 16 +- backend/core/src/auth/dto/user-update.dto.ts | 13 - .../dto/jurisdiction-update.dto.ts | 16 +- .../src/listings/dto/listing-create.dto.ts | 1 + .../src/listings/dto/listing-event.dto.ts | 14 +- .../src/listings/dto/listing-update.dto.ts | 22 +- .../src/listings/entities/listing.entity.ts | 14 + backend/core/src/listings/listings.module.ts | 2 + backend/core/src/listings/listings.service.ts | 8 +- .../listings/tests/listings.service.spec.ts | 5 + ...2-add-afs-related-properties-to-listing.ts | 106 ++++ .../1661788864862-adding-review-status.ts | 38 ++ ...86580-add-app-flag-notification-boolean.ts | 17 + .../1664300247901-setsAfsLastRunAt.ts | 15 + .../dto/paper-application.dto.ts | 14 +- backend/core/src/seeder/seed.ts | 10 +- backend/core/src/seeder/seeds/applications.ts | 89 ++- backend/core/src/shared/shared.module.ts | 1 + .../src/translations/dto/translation.dto.ts | 14 +- .../dto/unit-ami-chart-override-update.dto.ts | 16 +- backend/core/src/units/dto/unit-update.dto.ts | 14 +- backend/core/test/afs/afs.e2e-spec.ts | 197 +------ backend/core/test/lib/get-test-app-body.ts | 2 + backend/core/types/src/backend-swagger.ts | 216 +++++--- shared-helpers/index.ts | 1 + shared-helpers/src/DateFormat.ts | 11 + .../integration/04-application.spec.ts | 2 +- sites/partners/lib/hooks.ts | 104 +++- .../locale_overrides/general.json | 43 +- .../application/[id]/applicationsCols.tsx | 110 ++++ .../pages/application/[id]/review.tsx | 285 ++++++++++ .../listings/[id]/applications/index.tsx | 152 +++--- .../[id]/applications/pending/index.tsx | 203 +++++++ .../[id]/applications/resolved/index.tsx | 154 ++++++ .../listings/[id]/flags/[flagId]/index.tsx | 213 -------- .../pages/listings/[id]/flags/index.tsx | 107 ---- sites/partners/pages/listings/[id]/index.tsx | 9 - .../src/applications/ApplicationsColDefs.ts | 5 - .../src/applications/ApplicationsSideNav.tsx | 65 +++ .../PaperApplicationForm/FormTypes.ts | 3 +- .../PaperApplicationForm.tsx | 7 +- sites/partners/src/applications/helpers.ts | 54 ++ sites/partners/src/flags/applicationsCols.tsx | 32 +- sites/partners/src/flags/flagSetCols.tsx | 2 +- .../pages/applications/review/terms.tsx | 2 + ui-components/index.ts | 1 + ui-components/src/global/vendor/ag_grid.scss | 52 +- ui-components/src/headers/PageHeader.scss | 3 +- ui-components/src/navigation/Breadcrumbs.scss | 1 + ui-components/src/navigation/SideNav.scss | 56 ++ ui-components/src/navigation/SideNav.tsx | 4 +- ui-components/src/navigation/Tabs.scss | 31 +- ui-components/src/navigation/Tabs.stories.tsx | 10 +- .../ApplicationStatus.stories.tsx | 8 +- .../src/page_components/NavigationHeader.tsx | 20 +- ui-components/src/tables/AgTable.tsx | 68 ++- yarn.lock | 157 +++++- 79 files changed, 2670 insertions(+), 1253 deletions(-) create mode 100644 backend/core/src/application-flagged-sets/application-flagged-sets-cronjob-boostrap.service.ts create mode 100644 backend/core/src/application-flagged-sets/application-flagged-sets-cronjob-consumer.ts create mode 100644 backend/core/src/application-flagged-sets/constants/applications-flagged-sets-constants.ts create mode 100644 backend/core/src/application-flagged-sets/dto/application-flagged-set-meta.dto.ts create mode 100644 backend/core/src/application-flagged-sets/types/view-enum.ts create mode 100644 backend/core/src/applications/types/application-review-status-enum.ts create mode 100644 backend/core/src/migration/1658992843452-add-afs-related-properties-to-listing.ts create mode 100644 backend/core/src/migration/1661788864862-adding-review-status.ts create mode 100644 backend/core/src/migration/1661810986580-add-app-flag-notification-boolean.ts create mode 100644 backend/core/src/migration/1664300247901-setsAfsLastRunAt.ts create mode 100644 shared-helpers/src/DateFormat.ts create mode 100644 sites/partners/pages/application/[id]/applicationsCols.tsx create mode 100644 sites/partners/pages/application/[id]/review.tsx create mode 100644 sites/partners/pages/listings/[id]/applications/pending/index.tsx create mode 100644 sites/partners/pages/listings/[id]/applications/resolved/index.tsx delete mode 100644 sites/partners/pages/listings/[id]/flags/[flagId]/index.tsx delete mode 100644 sites/partners/pages/listings/[id]/flags/index.tsx create mode 100644 sites/partners/src/applications/ApplicationsSideNav.tsx create mode 100644 sites/partners/src/applications/helpers.ts diff --git a/backend/core/package.json b/backend/core/package.json index 75e0385592..44d382b1a6 100644 --- a/backend/core/package.json +++ b/backend/core/package.json @@ -40,6 +40,7 @@ "dependencies": { "@anchan828/nest-sendgrid": "^0.3.25", "@google-cloud/translate": "^6.2.6", + "@nestjs/bull": "^0.5.0", "@nestjs/cli": "^8.2.1", "@nestjs/common": "^8.3.1", "@nestjs/config": "^1.2.0", @@ -54,6 +55,7 @@ "@types/cache-manager": "^3.4.0", "async-retry": "^1.3.1", "axios": "0.21.2", + "bull": "^4.8.4", "cache-manager": "^3.4.0", "cache-manager-redis-store": "^2.0.0", "casbin": "5.13.0", @@ -96,6 +98,8 @@ "@nestjs/schematics": "^8.0.7", "@nestjs/testing": "^8.3.1", "@types/axios": "^0.14.0", + "@types/bull": "^3.15.8", + "@types/cron": "^1.7.3", "@types/express": "^4.17.8", "@types/node": "^12.12.67", "@types/passport-jwt": "^3.0.3", diff --git a/backend/core/src/ami-charts/dto/ami-chart.dto.ts b/backend/core/src/ami-charts/dto/ami-chart.dto.ts index 6766070747..6c5d321f86 100644 --- a/backend/core/src/ami-charts/dto/ami-chart.dto.ts +++ b/backend/core/src/ami-charts/dto/ami-chart.dto.ts @@ -53,16 +53,4 @@ export class AmiChartUpdateDto extends AmiChartCreateDto { @IsOptional({ groups: [ValidationsGroupsEnum.default] }) @IsUUID(4, { groups: [ValidationsGroupsEnum.default] }) id?: string - - @Expose() - @IsOptional({ groups: [ValidationsGroupsEnum.default] }) - @IsDate({ groups: [ValidationsGroupsEnum.default] }) - @Type(() => Date) - createdAt?: Date - - @Expose() - @IsOptional({ groups: [ValidationsGroupsEnum.default] }) - @IsDate({ groups: [ValidationsGroupsEnum.default] }) - @Type(() => Date) - updatedAt?: Date } diff --git a/backend/core/src/app.module.ts b/backend/core/src/app.module.ts index c3dc067597..ab5cc6aa7e 100644 --- a/backend/core/src/app.module.ts +++ b/backend/core/src/app.module.ts @@ -1,4 +1,6 @@ // dotenv is a dev dependency, so conditionally import it (don't need it in Prod). +import { BullModule } from "@nestjs/bull" + try { // eslint-disable-next-line @typescript-eslint/no-var-requires require("dotenv").config() @@ -38,6 +40,7 @@ import { PaperApplicationsModule } from "./paper-applications/paper-applications import { ActivityLogModule } from "./activity-log/activity-log.module" import { logger } from "./shared/middlewares/logger.middleware" import { CatchAllFilter } from "./shared/filters/catch-all-filter" +import { AFSProcessingQueueNames } from "./application-flagged-sets/constants/applications-flagged-sets-constants" export function applicationSetup(app: INestApplication) { const { httpAdapter } = app.get(HttpAdapterHost) diff --git a/backend/core/src/application-flagged-sets/application-flagged-sets-cronjob-boostrap.service.ts b/backend/core/src/application-flagged-sets/application-flagged-sets-cronjob-boostrap.service.ts new file mode 100644 index 0000000000..544803ba25 --- /dev/null +++ b/backend/core/src/application-flagged-sets/application-flagged-sets-cronjob-boostrap.service.ts @@ -0,0 +1,22 @@ +import { InjectQueue } from "@nestjs/bull" +import { Injectable } from "@nestjs/common" +import { Queue } from "bull" +import { AFSProcessingQueueNames } from "./constants/applications-flagged-sets-constants" +import { ConfigService } from "@nestjs/config" + +@Injectable() +export class ApplicationFlaggedSetsCronjobBoostrapService { + constructor( + @InjectQueue(AFSProcessingQueueNames.afsProcessing) private afsProcessingQueue: Queue, + private readonly config: ConfigService + ) { + void this.afsProcessingQueue.add(null, { + repeat: { + cron: config.get("AFS_PROCESSING_CRON_STRING"), + }, + // NOTE: This is not unique on purpose because Bull will not add a job twice with an ID + // which already exists. + id: "afs-process", + }) + } +} diff --git a/backend/core/src/application-flagged-sets/application-flagged-sets-cronjob-consumer.ts b/backend/core/src/application-flagged-sets/application-flagged-sets-cronjob-consumer.ts new file mode 100644 index 0000000000..4d56a13fdd --- /dev/null +++ b/backend/core/src/application-flagged-sets/application-flagged-sets-cronjob-consumer.ts @@ -0,0 +1,300 @@ +import { Process, Processor } from "@nestjs/bull" +import { AFSProcessingQueueNames } from "./constants/applications-flagged-sets-constants" +import { Brackets, LessThan, MoreThanOrEqual, Repository, SelectQueryBuilder } from "typeorm" +import { Application } from "../applications/entities/application.entity" +import { Rule } from "./types/rule-enum" +import { InjectRepository } from "@nestjs/typeorm" +import { ListingRepository } from "../listings/db/listing.repository" +import { Listing } from "../listings/entities/listing.entity" +import { ApplicationFlaggedSet } from "./entities/application-flagged-set.entity" +import { FlaggedSetStatus } from "./types/flagged-set-status-enum" +import { getView } from "../applications/views/view" + +@Processor(AFSProcessingQueueNames.afsProcessing) +export class ApplicationFlaggedSetsCronjobConsumer { + constructor( + @InjectRepository(ListingRepository) private readonly listingRepository: ListingRepository, + @InjectRepository(ApplicationFlaggedSet) + private readonly afsRepository: Repository, + @InjectRepository(Application) private readonly applicationRepository: Repository + ) {} + + @Process({ concurrency: 1 }) + async process() { + const outOfDateListings = await this.listingRepository + .createQueryBuilder("listings") + .select(["listings.id", "listings.afsLastRunAt"]) + .where("listings.lastApplicationUpdateAt IS NOT NULL") + .andWhere( + new Brackets((qb) => { + qb.where("listings.afsLastRunAt IS NULL").orWhere( + "listings.afsLastRunAt <= listings.lastApplicationUpdateAt" + ) + }) + ) + .getMany() + + for (const outOfDateListing of outOfDateListings) { + try { + await this.generateAFSesForListingRules(outOfDateListing) + outOfDateListing.afsLastRunAt = new Date() + await this.listingRepository.save(outOfDateListing) + } catch (e) { + console.error(e) + } + } + } + + private async generateAFSesForListingRules(listing: Pick) { + const qbView = getView(this.applicationRepository.createQueryBuilder("application")) + const qb = qbView.getViewQb(true) + qb.where("application.listing_id = :id", { id: listing.id }) + qb.andWhere("application.updatedAt >= :afsLastRunAt", { + afsLastRunAt: listing.afsLastRunAt, + }) + const newApplications = await qb.getMany() + + for (const newApplication of newApplications) { + await this.addApplication(newApplication) + } + + const existingApplications = await this.applicationRepository.find({ + where: { + listing: { + id: listing.id, + }, + createdAt: LessThan(listing.afsLastRunAt), + updatedAt: MoreThanOrEqual(listing.afsLastRunAt), + }, + }) + + for (const existingApplication of existingApplications) { + await this.updateApplication(existingApplication) + } + } + + async updateApplication(application: Application) { + application.markedAsDuplicate = false + await this.applicationRepository.save(application) + + let afses = await this.afsRepository + .createQueryBuilder("afs") + .leftJoin("afs.applications", "applications") + .select(["afs", "applications.id"]) + .where(`afs.listing_id = :listingId`, { listingId: application.listingId }) + .getMany() + + afses = afses.filter((afs) => afs.applications.map((app) => app.id).includes(application.id)) + + const afsesToBeSaved: Array = [] + const afsesToBeRemoved: Array = [] + + for (const afs of afses) { + afs.status = FlaggedSetStatus.pending + afs.resolvedTime = null + afs.resolvingUser = null + + const applicationIndex = afs.applications.findIndex((app) => app.id === application.id) + + if (applicationIndex == -1) { + continue + } + + afs.applications.splice(applicationIndex, 1) + + if (afs.applications.length > 1) { + afsesToBeSaved.push(afs) + } else { + afsesToBeRemoved.push(afs) + } + } + if (afsesToBeSaved.length) { + await this.afsRepository.save(afsesToBeSaved) + } + if (afsesToBeRemoved.length) { + await this.afsRepository.remove(afsesToBeRemoved) + } + + await this.addApplication(application) + } + + /** + * + * This method checks if the new application matches others based on the rules. + * If there are applications that match, this application is added to the AFS set (creating a new one or updating an existing set) + */ + async addApplication(newApplication: Application): Promise { + const rules = [Rule.email, Rule.nameAndDOB] + + for (const rule of rules) { + const applicationsMatchingRule = await this.fetchDuplicatesMatchingRule(newApplication, rule) + if (applicationsMatchingRule.length === 0) { + // continue to the next rule + continue + } + + const afses = await this.afsRepository + .createQueryBuilder("afs") + .leftJoin("afs.applications", "applications") + .select(["afs", "applications.id"]) + .where(`afs.ruleKey = :ruleKey`, { ruleKey: this.getRuleKeyForRule(newApplication, rule) }) + .getMany() + + if (afses.length === 0) { + await this.afsRepository.save({ + rule: rule, + ruleKey: this.getRuleKeyForRule(newApplication, rule), + resolvedTime: null, + resolvingUser: null, + status: FlaggedSetStatus.pending, + applications: [newApplication, ...applicationsMatchingRule], + listing: { id: newApplication.listingId }, + }) + } else if (afses.length === 1) { + const afs = afses[0] + + if (!afs.applications.map((app) => app.id).includes(newApplication.id)) { + afs.applications.push(newApplication) + await this.afsRepository.save(afs) + } + } else { + console.error( + "There should be up to one AFS matching a rule for given application, " + + "probably a logic error when creating AFSes" + ) + } + // there was a match so we don't need to check the next rule + break + } + } + + private async fetchDuplicatesMatchingRule(application: Application, rule: Rule) { + switch (rule) { + case Rule.nameAndDOB: + return await this.fetchDuplicatesMatchingNameAndDOBRule(application) + case Rule.email: + return await this.fetchDuplicatesMatchingEmailRule(application) + } + } + + private async fetchDuplicatesMatchingEmailRule(newApplication: Application) { + return await this.applicationRepository.find({ + select: ["id"], + where: (qb: SelectQueryBuilder) => { + qb.where("Application.id != :id", { + id: newApplication.id, + }) + .andWhere("Application.listing.id = :listingId", { + listingId: newApplication.listingId, + }) + .andWhere("Application__applicant.emailAddress = :emailAddress", { + emailAddress: newApplication.applicant.emailAddress, + }) + .andWhere("Application.status = :status", { status: "submitted" }) + }, + }) + } + + private getRuleKeyForRule(newApplication: Application, rule: Rule): string { + if (rule == Rule.email) { + return `${newApplication.listingId}-email-${newApplication.applicant.emailAddress}` + } else if (rule == Rule.nameAndDOB) { + return ( + `${newApplication.listingId}-nameAndDOB-${newApplication.applicant.firstName}-${newApplication.applicant.lastName}-${newApplication.applicant.birthMonth}-` + + `${newApplication.applicant.birthDay}-${newApplication.applicant.birthYear}` + ) + } else { + throw new Error("Invalid rule") + } + } + + private async fetchDuplicatesMatchingNameAndDOBRule(newApplication: Application) { + const firstNames = [ + newApplication.applicant.firstName, + ...newApplication.householdMembers.map((householdMember) => householdMember.firstName), + ] + + const lastNames = [ + newApplication.applicant.lastName, + ...newApplication.householdMembers.map((householdMember) => householdMember.lastName), + ] + + const birthMonths = [ + newApplication.applicant.birthMonth, + ...newApplication.householdMembers.map((householdMember) => householdMember.birthMonth), + ] + + const birthDays = [ + newApplication.applicant.birthDay, + ...newApplication.householdMembers.map((householdMember) => householdMember.birthDay), + ] + + const birthYears = [ + newApplication.applicant.birthYear, + ...newApplication.householdMembers.map((householdMember) => householdMember.birthYear), + ] + + return await this.applicationRepository.find({ + select: ["id"], + where: (qb: SelectQueryBuilder) => { + qb.where("Application.id != :id", { + id: newApplication.id, + }) + .andWhere("Application.listing.id = :listingId", { + listingId: newApplication.listingId, + }) + .andWhere("Application.status = :status", { status: "submitted" }) + .andWhere( + new Brackets((subQb) => { + subQb.where("Application__householdMembers.firstName IN (:...firstNames)", { + firstNames: firstNames, + }) + subQb.orWhere("Application__applicant.firstName IN (:...firstNames)", { + firstNames: firstNames, + }) + }) + ) + .andWhere( + new Brackets((subQb) => { + subQb.where("Application__householdMembers.lastName IN (:...lastNames)", { + lastNames: lastNames, + }) + subQb.orWhere("Application__applicant.lastName IN (:...lastNames)", { + lastNames: lastNames, + }) + }) + ) + .andWhere( + new Brackets((subQb) => { + subQb.where("Application__householdMembers.birthMonth IN (:...birthMonths)", { + birthMonths: birthMonths, + }) + subQb.orWhere("Application__applicant.birthMonth IN (:...birthMonths)", { + birthMonths: birthMonths, + }) + }) + ) + .andWhere( + new Brackets((subQb) => { + subQb.where("Application__householdMembers.birthDay IN (:...birthDays)", { + birthDays: birthDays, + }) + subQb.orWhere("Application__applicant.birthDay IN (:...birthDays)", { + birthDays: birthDays, + }) + }) + ) + .andWhere( + new Brackets((subQb) => { + subQb.where("Application__householdMembers.birthYear IN (:...birthYears)", { + birthYears: birthYears, + }) + subQb.orWhere("Application__applicant.birthYear IN (:...birthYears)", { + birthYears: birthYears, + }) + }) + ) + }, + }) + } +} diff --git a/backend/core/src/application-flagged-sets/application-flagged-sets.controller.ts b/backend/core/src/application-flagged-sets/application-flagged-sets.controller.ts index 08a63fd51c..6ac24dd140 100644 --- a/backend/core/src/application-flagged-sets/application-flagged-sets.controller.ts +++ b/backend/core/src/application-flagged-sets/application-flagged-sets.controller.ts @@ -4,6 +4,7 @@ import { Get, Param, Post, + Put, Query, Request, UseGuards, @@ -17,11 +18,15 @@ import { OptionalAuthGuard } from "../auth/guards/optional-auth.guard" import { AuthzGuard } from "../auth/guards/authz.guard" import { defaultValidationPipeOptions } from "../shared/default-validation-pipe-options" import { mapTo } from "../shared/mapTo" +import { StatusDto } from "../shared/dto/status.dto" import { ApplicationFlaggedSetsService } from "./application-flagged-sets.service" import { ApplicationFlaggedSetDto } from "./dto/application-flagged-set.dto" import { PaginatedApplicationFlaggedSetDto } from "./dto/paginated-application-flagged-set.dto" import { ApplicationFlaggedSetResolveDto } from "./dto/application-flagged-set-resolve.dto" import { PaginatedApplicationFlaggedSetQueryParams } from "./paginated-application-flagged-set-query-params" +import { ApplicationFlaggedSetsCronjobConsumer } from "./application-flagged-sets-cronjob-consumer" +import { ApplicationFlaggedSetMeta } from "./dto/application-flagged-set-meta.dto" +import { IdDto } from "../shared/dto/id.dto" @Controller("/applicationFlaggedSets") @ApiTags("applicationFlaggedSets") @@ -34,7 +39,18 @@ import { PaginatedApplicationFlaggedSetQueryParams } from "./paginated-applicati }) ) export class ApplicationFlaggedSetsController { - constructor(private readonly applicationFlaggedSetsService: ApplicationFlaggedSetsService) {} + constructor( + private readonly applicationFlaggedSetsService: ApplicationFlaggedSetsService, + private readonly afsProcessingService: ApplicationFlaggedSetsCronjobConsumer + ) {} + + @Get("meta") + @ApiOperation({ summary: "Meta information for application flagged sets", operationId: "meta" }) + async meta( + @Query() queryParams: PaginatedApplicationFlaggedSetQueryParams + ): Promise { + return await this.applicationFlaggedSetsService.meta(queryParams) + } @Get() @ApiOperation({ summary: "List application flagged sets", operationId: "list" }) @@ -64,4 +80,21 @@ export class ApplicationFlaggedSetsController { ): Promise { return mapTo(ApplicationFlaggedSetDto, await this.applicationFlaggedSetsService.resolve(dto)) } + + @Put(":id") + @ApiOperation({ + summary: "Reset flagged set confirmation alert", + operationId: "resetConfirmationAlert", + }) + async resetConfirmationAlert(@Body() dto: IdDto): Promise { + await this.applicationFlaggedSetsService.resetConfirmationAlert(dto.id) + return mapTo(StatusDto, { status: "ok" }) + } + + @Post("process") + @ApiOperation({ summary: "Trigger the duplicate check process", operationId: "process" }) + async process(): Promise { + await this.afsProcessingService.process() + return "success" + } } diff --git a/backend/core/src/application-flagged-sets/application-flagged-sets.module.ts b/backend/core/src/application-flagged-sets/application-flagged-sets.module.ts index 2f2c960d31..605ad6254f 100644 --- a/backend/core/src/application-flagged-sets/application-flagged-sets.module.ts +++ b/backend/core/src/application-flagged-sets/application-flagged-sets.module.ts @@ -5,11 +5,46 @@ import { TypeOrmModule } from "@nestjs/typeorm" import { AuthModule } from "../auth/auth.module" import { ApplicationFlaggedSet } from "./entities/application-flagged-set.entity" import { Application } from "../applications/entities/application.entity" +import { ApplicationFlaggedSetsCronjobBoostrapService } from "./application-flagged-sets-cronjob-boostrap.service" +import { ApplicationFlaggedSetsCronjobConsumer } from "./application-flagged-sets-cronjob-consumer" +import { BullModule } from "@nestjs/bull" +import { AFSProcessingQueueNames } from "./constants/applications-flagged-sets-constants" +import { ConfigService } from "@nestjs/config" +import { SharedModule } from "../shared/shared.module" +import { ListingRepository } from "../listings/db/listing.repository" @Module({ - imports: [TypeOrmModule.forFeature([ApplicationFlaggedSet, Application]), AuthModule], + imports: [ + TypeOrmModule.forFeature([ApplicationFlaggedSet, Application, ListingRepository]), + AuthModule, + BullModule.forRootAsync({ + imports: [SharedModule], + inject: [ConfigService], + useFactory: (configService: ConfigService) => { + const redisUrl = new URL(configService.get("REDIS_TLS_URL")) + return { + redis: { + host: redisUrl.hostname, + port: +redisUrl.port, + }, + } + }, + }), + BullModule.registerQueue({ + name: AFSProcessingQueueNames.afsProcessing, + }), + SharedModule, + ], controllers: [ApplicationFlaggedSetsController], - providers: [ApplicationFlaggedSetsService], - exports: [ApplicationFlaggedSetsService], + providers: [ + ApplicationFlaggedSetsService, + ApplicationFlaggedSetsCronjobBoostrapService, + ApplicationFlaggedSetsCronjobConsumer, + ], + exports: [ + ApplicationFlaggedSetsService, + ApplicationFlaggedSetsCronjobBoostrapService, + ApplicationFlaggedSetsCronjobConsumer, + ], }) export class ApplicationFlaggedSetsModule {} diff --git a/backend/core/src/application-flagged-sets/application-flagged-sets.service.ts b/backend/core/src/application-flagged-sets/application-flagged-sets.service.ts index cb03675133..0450246313 100644 --- a/backend/core/src/application-flagged-sets/application-flagged-sets.service.ts +++ b/backend/core/src/application-flagged-sets/application-flagged-sets.service.ts @@ -1,27 +1,24 @@ import { BadRequestException, Inject, Injectable, NotFoundException, Scope } from "@nestjs/common" +import { InjectQueue } from "@nestjs/bull" +import { Queue } from "bull" import { AuthzService } from "../auth/services/authz.service" import { ApplicationFlaggedSet } from "./entities/application-flagged-set.entity" import { InjectRepository } from "@nestjs/typeorm" -import { - Brackets, - DeepPartial, - EntityManager, - getManager, - getMetadataArgsStorage, - In, - QueryRunner, - Repository, - SelectQueryBuilder, -} from "typeorm" +import { getManager, Repository, SelectQueryBuilder } from "typeorm" import { Application } from "../applications/entities/application.entity" import { REQUEST } from "@nestjs/core" import { Request as ExpressRequest } from "express" -import { User } from "../auth/entities/user.entity" import { FlaggedSetStatus } from "./types/flagged-set-status-enum" -import { Rule } from "./types/rule-enum" import { ApplicationFlaggedSetResolveDto } from "./dto/application-flagged-set-resolve.dto" +import { ApplicationFlaggedSetMeta } from "./dto/application-flagged-set-meta.dto" import { PaginatedApplicationFlaggedSetQueryParams } from "./paginated-application-flagged-set-query-params" import { ListingStatus } from "../listings/types/listing-status-enum" +import { View } from "./types/view-enum" +import { AFSProcessingQueueNames } from "./constants/applications-flagged-sets-constants" +import { Rule } from "./types/rule-enum" +import { IdDto } from "../../src/shared/dto/id.dto" +import { assignDefined } from "../../src/shared/utils/assign-defined" +import { ApplicationReviewStatus } from "../applications/types/application-review-status-enum" @Injectable({ scope: Scope.REQUEST }) export class ApplicationFlaggedSetsService { @@ -31,7 +28,8 @@ export class ApplicationFlaggedSetsService { @InjectRepository(Application) private readonly applicationsRepository: Repository, @InjectRepository(ApplicationFlaggedSet) - private readonly afsRepository: Repository + private readonly afsRepository: Repository, + @InjectQueue(AFSProcessingQueueNames.afsProcessing) private afsProcessingQueue: Queue ) {} async listPaginated(queryParams: PaginatedApplicationFlaggedSetQueryParams) { const innerQuery = this.afsRepository @@ -42,12 +40,35 @@ export class ApplicationFlaggedSetsService { .offset((queryParams.page - 1) * queryParams.limit) .limit(queryParams.limit) + if (queryParams.view) { + if (queryParams.view === View.pending) { + innerQuery.andWhere("afs.status = :status", { + status: FlaggedSetStatus.pending, + }) + } else if (queryParams.view === View.pendingNameAndDoB) { + innerQuery.andWhere("afs.status = :status", { + status: FlaggedSetStatus.pending, + }) + innerQuery.andWhere("rule = :rule", { rule: Rule.nameAndDOB }) + } else if (queryParams.view === View.pendingEmail) { + innerQuery.andWhere("afs.status = :status", { + status: FlaggedSetStatus.pending, + }) + innerQuery.andWhere("rule = :rule", { rule: Rule.email }) + } else if (queryParams.view === View.resolved) { + innerQuery.andWhere("afs.status = :status", { + status: FlaggedSetStatus.resolved, + }) + } + } + // status const outerQuery = this.afsRepository .createQueryBuilder("afs") .select([ "afs.id", "afs.rule", "afs.status", + "applications.reviewStatus", "afs.listingId", "listing.id", "applications.id", @@ -72,7 +93,9 @@ export class ApplicationFlaggedSetsService { totalPages: Math.ceil(count / queryParams.limit), } - innerQuery.andWhere("afs.status = :status", { status: FlaggedSetStatus.flagged }) + innerQuery.andWhere("afs.status = :status", { + status: FlaggedSetStatus.pending, + }) const countTotalFlagged = await innerQuery.getCount() return { @@ -84,308 +107,229 @@ export class ApplicationFlaggedSetsService { } } - async findOneById(afsId: string) { - return await this.afsRepository.findOneOrFail({ - relations: ["listing", "applications"], + async findOneById(afsId: string, applicationIdList?: IdDto[]) { + const qb = this.afsRepository + .createQueryBuilder("afs") + .select([ + "afs.id", + "afs.rule", + "afs.status", + "afs.showConfirmationAlert", + "applications.id", + "applications.submissionType", + "applications.confirmationCode", + "applications.reviewStatus", + "applications.submissionDate", + "applicant.firstName", + "applicant.lastName", + "applicant.birthDay", + "applicant.birthMonth", + "applicant.birthYear", + "applicant.emailAddress", + "listing.id", + "listing.status", + ]) + .leftJoin("afs.applications", "applications") + .leftJoin("applications.applicant", "applicant") + .leftJoin("afs.listing", "listing") + .orderBy("applications.confirmationCode", "DESC") + .where("afs.id = :id", { id: afsId }) + if (applicationIdList?.length) { + qb.andWhere("applications.id IN (:...applicationIdList)", { + applicationIdList: applicationIdList.map((elem) => elem.id), + }) + } + + return await qb.getOneOrFail() + } + + async resetConfirmationAlert(id: string) { + const obj = await this.afsRepository.findOne({ where: { - id: afsId, + id, }, }) + if (!obj) { + throw new NotFoundException() + } + assignDefined(obj, { ...obj, showConfirmationAlert: false }) + await this.afsRepository.save(obj) } async resolve(dto: ApplicationFlaggedSetResolveDto) { return await getManager().transaction("SERIALIZABLE", async (transactionalEntityManager) => { const transAfsRepository = transactionalEntityManager.getRepository(ApplicationFlaggedSet) const transApplicationsRepository = transactionalEntityManager.getRepository(Application) - const afs = await transAfsRepository.findOne({ - where: { id: dto.afsId }, - relations: ["applications", "listing"], - }) - if (!afs) { - throw new NotFoundException() - } + const afs = await this.findOneById(dto.afsId, dto.applications) if (afs.listing.status !== ListingStatus.closed) { throw new BadRequestException("Listing must be closed before resolving any duplicates.") } - // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion - afs.resolvingUser = this.request.user as User - afs.resolvedTime = new Date() - afs.status = FlaggedSetStatus.resolved - const appsToBeResolved = afs.applications.filter((afsApp) => - dto.applications.map((appIdDto) => appIdDto.id).includes(afsApp.id) - ) + const selectedApps = dto.applications.length ? afs.applications.map((app) => app.id) : [] + + if (dto.status === FlaggedSetStatus.pending) { + // mark selected as pendingAndValid + if (selectedApps.length) { + await transApplicationsRepository + .createQueryBuilder() + .update(Application) + .set({ reviewStatus: ApplicationReviewStatus.pendingAndValid }) + .where("id IN (:...selectedApps)", { + selectedApps, + }) + .execute() + } - const appsNotToBeResolved = afs.applications.filter( - (afsApp) => !dto.applications.map((appIdDto) => appIdDto.id).includes(afsApp.id) - ) + // mark those that were not selected as duplicate + const qb = transApplicationsRepository + .createQueryBuilder() + .update(Application) + .set({ reviewStatus: ApplicationReviewStatus.pending }) + .where( + "exists (SELECT 1 FROM application_flagged_set_applications_applications WHERE applications_id = id AND application_flagged_set_id = :afsId)", + { afsId: dto.afsId } + ) - for (const appToBeResolved of appsToBeResolved) { - appToBeResolved.markedAsDuplicate = true - } + if (selectedApps.length) { + qb.andWhere("id NOT IN (:...selectedApps)", { + selectedApps, + }) + } + await qb.execute() + + // mark the flagged set as pending + await transAfsRepository + .createQueryBuilder() + .update(ApplicationFlaggedSet) + .set({ + resolvedTime: new Date(), + status: FlaggedSetStatus.pending, + resolvingUser: this.request.user, + showConfirmationAlert: false, + }) + .where("id = :afsId", { afsId: dto.afsId }) + .execute() + } else if (dto.status === FlaggedSetStatus.resolved) { + // mark selected a valid + if (selectedApps.length) { + await transApplicationsRepository + .createQueryBuilder() + .update(Application) + .set({ reviewStatus: ApplicationReviewStatus.valid }) + .where("id IN (:...selectedApps)", { + selectedApps, + }) + .execute() + } - for (const appNotToBeResolved of appsNotToBeResolved) { - appNotToBeResolved.markedAsDuplicate = false + // mark those that were not selected as duplicate + const qb = transApplicationsRepository + .createQueryBuilder() + .update(Application) + .set({ reviewStatus: ApplicationReviewStatus.duplicate }) + .where( + "exists (SELECT 1 FROM application_flagged_set_applications_applications WHERE applications_id = id AND application_flagged_set_id = :afsId)", + { afsId: dto.afsId } + ) + if (selectedApps.length) { + qb.andWhere("id NOT IN (:...selectedApps)", { + selectedApps, + }) + } + await qb.execute() + + // mark the flagged set as resolved + await transAfsRepository + .createQueryBuilder() + .update(ApplicationFlaggedSet) + .set({ + resolvedTime: new Date(), + status: FlaggedSetStatus.resolved, + resolvingUser: this.request.user, + showConfirmationAlert: true, + }) + .where("id = :afsId", { afsId: dto.afsId }) + .execute() } - - await transApplicationsRepository.save([...appsToBeResolved, ...appsNotToBeResolved]) - - appsToBeResolved.forEach((app) => (app.markedAsDuplicate = true)) - await transAfsRepository.save(afs) - return afs }) } - async onApplicationSave(newApplication: Application, transactionalEntityManager: EntityManager) { - for (const rule of [Rule.email, Rule.nameAndDOB]) { - await this.updateApplicationFlaggedSetsForRule( - transactionalEntityManager, - newApplication, - rule - ) - } - } - - private async _getAfsesContainingApplicationId( - queryRunnery: QueryRunner, - applicationId: string - ): Promise> { - const metadataArgsStorage = getMetadataArgsStorage().findJoinTable( - ApplicationFlaggedSet, - "applications" - ) - const applicationsJunctionTableName = metadataArgsStorage.name - const query = ` - SELECT DISTINCT application_flagged_set_id FROM ${applicationsJunctionTableName} - WHERE applications_id = $1 - ` - return await queryRunnery.query(query, [applicationId]) + public async scheduleAfsProcessing() { + return this.afsProcessingQueue.add(null, {}) } - async onApplicationUpdate( - newApplication: Application, - transactionalEntityManager: EntityManager - ) { - const transApplicationsRepository = transactionalEntityManager.getRepository(Application) - newApplication.markedAsDuplicate = false - await transApplicationsRepository.save(newApplication) - - const transAfsRepository = transactionalEntityManager.getRepository(ApplicationFlaggedSet) - - const afsIds = await this._getAfsesContainingApplicationId( - transAfsRepository.queryRunner, - newApplication.id - ) - const afses = await transAfsRepository.find({ - where: { id: In(afsIds.map((afs) => afs.application_flagged_set_id)) }, - relations: ["applications"], - }) - const afsesToBeSaved: Array = [] - const afsesToBeRemoved: Array = [] - for (const afs of afses) { - afs.status = FlaggedSetStatus.flagged - afs.resolvedTime = null - afs.resolvingUser = null - const applicationIndex = afs.applications.findIndex( - (application) => application.id === newApplication.id - ) - afs.applications.splice(applicationIndex, 1) - if (afs.applications.length > 1) { - afsesToBeSaved.push(afs) - } else { - afsesToBeRemoved.push(afs) + async meta(queryParams: PaginatedApplicationFlaggedSetQueryParams) { + const constructQuery = (params: { + listingId: string + status?: FlaggedSetStatus + rule?: Rule + }): SelectQueryBuilder => { + const innerQuery = this.afsRepository.createQueryBuilder("afs").select("afs.id") + innerQuery.where("afs.listing_id = :listingId", { listingId: params.listingId }) + if (params.status) { + innerQuery.andWhere("afs.status = :status", { status: params.status }) + } + if (params.rule) { + innerQuery.andWhere("afs.rule = :rule", { rule: params.rule }) } - } - await transAfsRepository.save(afsesToBeSaved) - await transAfsRepository.remove(afsesToBeRemoved) - await this.onApplicationSave(newApplication, transactionalEntityManager) - } + const outerQuery = this.afsRepository + .createQueryBuilder("afs") + .select("SUM(1) as value") + .where(`afs.id IN (` + innerQuery.getQuery() + ")") + .setParameters(innerQuery.getParameters()) - async fetchDuplicatesMatchingRule( - transactionalEntityManager: EntityManager, - application: Application, - rule: Rule - ) { - switch (rule) { - case Rule.nameAndDOB: - return await this.fetchDuplicatesMatchingNameAndDOBRule( - transactionalEntityManager, - application - ) - case Rule.email: - return await this.fetchDuplicatesMatchingEmailRule(transactionalEntityManager, application) + return outerQuery } - } - async updateApplicationFlaggedSetsForRule( - transactionalEntityManager: EntityManager, - newApplication: Application, - rule: Rule - ) { - const applicationsMatchingRule = await this.fetchDuplicatesMatchingRule( - transactionalEntityManager, - newApplication, - rule - ) - const transAfsRepository = transactionalEntityManager.getRepository(ApplicationFlaggedSet) - const visitedAfses = [] - const afses = await transAfsRepository - .createQueryBuilder("afs") - .leftJoin("afs.applications", "applications") - .select(["afs", "applications.id"]) - .where(`afs.listing_id = :listingId`, { listingId: newApplication.listing.id }) - .andWhere(`rule = :rule`, { rule }) - .getMany() - - for (const matchedApplication of applicationsMatchingRule) { - const afsesMatchingRule = afses.filter((afs) => - afs.applications.map((app) => app.id).includes(matchedApplication.id) - ) - - if (afsesMatchingRule.length === 0) { - const newAfs: DeepPartial = { - rule: rule, - resolvedTime: null, - resolvingUser: null, - status: FlaggedSetStatus.flagged, - applications: [newApplication, matchedApplication], - listing: newApplication.listing, - } - await transAfsRepository.save(newAfs) - } else if (afsesMatchingRule.length === 1) { - for (const afs of afsesMatchingRule) { - if (visitedAfses.includes(afs.id)) { - return - } - visitedAfses.push(afs.id) - afs.applications.push(newApplication) - await transAfsRepository.save(afs) - } - } else { - console.error( - "There should be up to one AFS matching a rule for given application, " + - "probably a logic error when creating AFSes" - ) - } - } - } + const allQB = this.applicationsRepository.createQueryBuilder("afs") + allQB.select("SUM(1) as value") + allQB.where("afs.listing_id = :listingId", { listingId: queryParams.listingId }) - private async fetchDuplicatesMatchingEmailRule( - transactionalEntityManager: EntityManager, - newApplication: Application - ) { - const transApplicationsRepository = transactionalEntityManager.getRepository(Application) - return await transApplicationsRepository.find({ - select: ["id"], - where: (qb: SelectQueryBuilder) => { - qb.where("Application.id != :id", { - id: newApplication.id, - }) - .andWhere("Application.listing.id = :listingId", { - listingId: newApplication.listing.id, - }) - .andWhere("Application__applicant.emailAddress = :emailAddress", { - emailAddress: newApplication.applicant.emailAddress, - }) - .andWhere("Application.status = :status", { status: "submitted" }) - }, + const resolvedQB = constructQuery({ + listingId: queryParams.listingId, + status: FlaggedSetStatus.resolved, }) - } - private async fetchDuplicatesMatchingNameAndDOBRule( - transactionalEntityManager: EntityManager, - newApplication: Application - ) { - const transApplicationsRepository = transactionalEntityManager.getRepository(Application) - const firstNames = [ - newApplication.applicant.firstName, - ...newApplication.householdMembers.map((householdMember) => householdMember.firstName), - ] + const pendingQB = constructQuery({ + listingId: queryParams.listingId, + status: FlaggedSetStatus.pending, + }) - const lastNames = [ - newApplication.applicant.lastName, - ...newApplication.householdMembers.map((householdMember) => householdMember.lastName), - ] + const pendingNameQB = constructQuery({ + listingId: queryParams.listingId, + status: FlaggedSetStatus.pending, + rule: Rule.nameAndDOB, + }) - const birthMonths = [ - newApplication.applicant.birthMonth, - ...newApplication.householdMembers.map((householdMember) => householdMember.birthMonth), - ] + const pendingEmailQB = constructQuery({ + listingId: queryParams.listingId, + status: FlaggedSetStatus.pending, + rule: Rule.email, + }) - const birthDays = [ - newApplication.applicant.birthDay, - ...newApplication.householdMembers.map((householdMember) => householdMember.birthDay), - ] + const [ + totalCount, + totalResolvedCount, + totalPendingCount, + totalNamePendingCount, + totalEmailPendingCount, + ] = await Promise.all( + [allQB, resolvedQB, pendingQB, pendingNameQB, pendingEmailQB].map( + async (query) => await query.getRawOne() + ) + ) - const birthYears = [ - newApplication.applicant.birthYear, - ...newApplication.householdMembers.map((householdMember) => householdMember.birthYear), - ] + const res: ApplicationFlaggedSetMeta = { + totalCount: totalCount.value, + totalResolvedCount: totalResolvedCount.value, + totalPendingCount: totalPendingCount.value, + totalNamePendingCount: totalNamePendingCount.value, + totalEmailPendingCount: totalEmailPendingCount.value, + } - return await transApplicationsRepository.find({ - select: ["id"], - where: (qb: SelectQueryBuilder) => { - qb.where("Application.id != :id", { - id: newApplication.id, - }) - .andWhere("Application.listing.id = :listingId", { - listingId: newApplication.listing.id, - }) - .andWhere("Application.status = :status", { status: "submitted" }) - .andWhere( - new Brackets((subQb) => { - subQb.where("Application__householdMembers.firstName IN (:...firstNames)", { - firstNames: firstNames, - }) - subQb.orWhere("Application__applicant.firstName IN (:...firstNames)", { - firstNames: firstNames, - }) - }) - ) - .andWhere( - new Brackets((subQb) => { - subQb.where("Application__householdMembers.lastName IN (:...lastNames)", { - lastNames: lastNames, - }) - subQb.orWhere("Application__applicant.lastName IN (:...lastNames)", { - lastNames: lastNames, - }) - }) - ) - .andWhere( - new Brackets((subQb) => { - subQb.where("Application__householdMembers.birthMonth IN (:...birthMonths)", { - birthMonths: birthMonths, - }) - subQb.orWhere("Application__applicant.birthMonth IN (:...birthMonths)", { - birthMonths: birthMonths, - }) - }) - ) - .andWhere( - new Brackets((subQb) => { - subQb.where("Application__householdMembers.birthDay IN (:...birthDays)", { - birthDays: birthDays, - }) - subQb.orWhere("Application__applicant.birthDay IN (:...birthDays)", { - birthDays: birthDays, - }) - }) - ) - .andWhere( - new Brackets((subQb) => { - subQb.where("Application__householdMembers.birthYear IN (:...birthYears)", { - birthYears: birthYears, - }) - subQb.orWhere("Application__applicant.birthYear IN (:...birthYears)", { - birthYears: birthYears, - }) - }) - ) - }, - }) + return res } } diff --git a/backend/core/src/application-flagged-sets/constants/applications-flagged-sets-constants.ts b/backend/core/src/application-flagged-sets/constants/applications-flagged-sets-constants.ts new file mode 100644 index 0000000000..d61758cacb --- /dev/null +++ b/backend/core/src/application-flagged-sets/constants/applications-flagged-sets-constants.ts @@ -0,0 +1,3 @@ +export enum AFSProcessingQueueNames { + afsProcessing = "afs-processing", +} diff --git a/backend/core/src/application-flagged-sets/dto/application-flagged-set-meta.dto.ts b/backend/core/src/application-flagged-sets/dto/application-flagged-set-meta.dto.ts new file mode 100644 index 0000000000..bd652a0027 --- /dev/null +++ b/backend/core/src/application-flagged-sets/dto/application-flagged-set-meta.dto.ts @@ -0,0 +1,29 @@ +import { Expose } from "class-transformer" +import { IsNumber, IsOptional } from "class-validator" + +export class ApplicationFlaggedSetMeta { + @Expose() + @IsNumber() + @IsOptional() + totalCount?: number + + @Expose() + @IsNumber() + @IsOptional() + totalResolvedCount?: number + + @Expose() + @IsNumber() + @IsOptional() + totalPendingCount?: number + + @Expose() + @IsNumber() + @IsOptional() + totalNamePendingCount?: number + + @Expose() + @IsNumber() + @IsOptional() + totalEmailPendingCount?: number +} diff --git a/backend/core/src/application-flagged-sets/dto/application-flagged-set-resolve.dto.ts b/backend/core/src/application-flagged-sets/dto/application-flagged-set-resolve.dto.ts index 7cfeae050c..e951ba0dfe 100644 --- a/backend/core/src/application-flagged-sets/dto/application-flagged-set-resolve.dto.ts +++ b/backend/core/src/application-flagged-sets/dto/application-flagged-set-resolve.dto.ts @@ -1,7 +1,8 @@ import { Expose, Type } from "class-transformer" -import { ArrayMaxSize, IsArray, IsDefined, IsUUID, ValidateNested } from "class-validator" +import { ArrayMaxSize, IsArray, IsDefined, IsEnum, IsUUID, ValidateNested } from "class-validator" import { ValidationsGroupsEnum } from "../../shared/types/validations-groups-enum" import { IdDto } from "../../shared/dto/id.dto" +import { FlaggedSetStatus } from "../types/flagged-set-status-enum" export class ApplicationFlaggedSetResolveDto { @Expose() @@ -15,4 +16,8 @@ export class ApplicationFlaggedSetResolveDto { @ValidateNested({ groups: [ValidationsGroupsEnum.default], each: true }) @Type(() => IdDto) applications: IdDto[] + + @Expose() + @IsEnum(FlaggedSetStatus, { groups: [ValidationsGroupsEnum.default] }) + status: FlaggedSetStatus } diff --git a/backend/core/src/application-flagged-sets/entities/application-flagged-set.entity.ts b/backend/core/src/application-flagged-sets/entities/application-flagged-set.entity.ts index fa69f0706f..62f41112a8 100644 --- a/backend/core/src/application-flagged-sets/entities/application-flagged-set.entity.ts +++ b/backend/core/src/application-flagged-sets/entities/application-flagged-set.entity.ts @@ -1,13 +1,13 @@ import { Column, Entity, Index, JoinColumn, JoinTable, ManyToMany, ManyToOne } from "typeorm" import { AbstractEntity } from "../../shared/entities/abstract.entity" -import { IsDate, IsEnum, IsOptional, IsString, ValidateNested } from "class-validator" +import { IsDate, IsEnum, IsOptional, IsString, ValidateNested, IsBoolean } from "class-validator" import { Expose, Type } from "class-transformer" import { ValidationsGroupsEnum } from "../../shared/types/validations-groups-enum" import { Application } from "../../applications/entities/application.entity" import { User } from "../../auth/entities/user.entity" import { Listing } from "../../listings/entities/listing.entity" -import { FlaggedSetStatus } from "../types/flagged-set-status-enum" import { Rule } from "../types/rule-enum" +import { FlaggedSetStatus } from "../types/flagged-set-status-enum" @Entity() @Index(["listing"]) @@ -16,7 +16,12 @@ export class ApplicationFlaggedSet extends AbstractEntity { @Expose() @IsEnum(Rule, { groups: [ValidationsGroupsEnum.default] }) @IsString({ groups: [ValidationsGroupsEnum.default] }) - rule: string + rule: Rule + + @Column({ nullable: false, unique: true }) + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + ruleKey: string @Column({ type: "timestamptz", nullable: true }) @Expose() @@ -32,11 +37,6 @@ export class ApplicationFlaggedSet extends AbstractEntity { @Type(() => User) resolvingUser: User - @Column({ enum: FlaggedSetStatus, nullable: false, default: FlaggedSetStatus.flagged }) - @Expose() - @IsEnum(FlaggedSetStatus, { groups: [ValidationsGroupsEnum.default] }) - status: FlaggedSetStatus - @ManyToMany(() => Application) @JoinTable({ name: "application_flagged_set_applications_applications" }) @Expose() @@ -48,4 +48,14 @@ export class ApplicationFlaggedSet extends AbstractEntity { @Column() listingId: string + + @Column({ type: "bool", nullable: false, default: false }) + @Expose() + @IsBoolean({ groups: [ValidationsGroupsEnum.default] }) + showConfirmationAlert: boolean + + @Column({ enum: FlaggedSetStatus, nullable: false, default: FlaggedSetStatus.pending }) + @Expose() + @IsEnum(FlaggedSetStatus, { groups: [ValidationsGroupsEnum.default] }) + status: FlaggedSetStatus } diff --git a/backend/core/src/application-flagged-sets/paginated-application-flagged-set-query-params.ts b/backend/core/src/application-flagged-sets/paginated-application-flagged-set-query-params.ts index 8068126294..4c66f498eb 100644 --- a/backend/core/src/application-flagged-sets/paginated-application-flagged-set-query-params.ts +++ b/backend/core/src/application-flagged-sets/paginated-application-flagged-set-query-params.ts @@ -1,8 +1,9 @@ import { PaginationQueryParams } from "../shared/dto/pagination.dto" import { Expose } from "class-transformer" import { ApiProperty } from "@nestjs/swagger" -import { IsUUID } from "class-validator" +import { IsEnum, IsOptional, IsUUID } from "class-validator" import { ValidationsGroupsEnum } from "../shared/types/validations-groups-enum" +import { View } from "./types/view-enum" export class PaginatedApplicationFlaggedSetQueryParams extends PaginationQueryParams { @Expose() @@ -13,4 +14,14 @@ export class PaginatedApplicationFlaggedSetQueryParams extends PaginationQueryPa }) @IsUUID(4, { groups: [ValidationsGroupsEnum.default] }) listingId: string + + @Expose() + @ApiProperty({ + enum: Object.keys(View), + example: "active", + required: false, + }) + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsEnum(View, { groups: [ValidationsGroupsEnum.default] }) + view?: View } diff --git a/backend/core/src/application-flagged-sets/types/flagged-set-status-enum.ts b/backend/core/src/application-flagged-sets/types/flagged-set-status-enum.ts index e8a1e34e6d..af51080114 100644 --- a/backend/core/src/application-flagged-sets/types/flagged-set-status-enum.ts +++ b/backend/core/src/application-flagged-sets/types/flagged-set-status-enum.ts @@ -1,4 +1,5 @@ export enum FlaggedSetStatus { flagged = "flagged", + pending = "pending", resolved = "resolved", } diff --git a/backend/core/src/application-flagged-sets/types/view-enum.ts b/backend/core/src/application-flagged-sets/types/view-enum.ts new file mode 100644 index 0000000000..10d10c94fc --- /dev/null +++ b/backend/core/src/application-flagged-sets/types/view-enum.ts @@ -0,0 +1,6 @@ +export enum View { + pending = "pending", + pendingNameAndDoB = "pendingNameAndDoB", + pendingEmail = "pendingEmail", + resolved = "resolved", +} diff --git a/backend/core/src/application-methods/dto/application-method.dto.ts b/backend/core/src/application-methods/dto/application-method.dto.ts index c7f0dcfa04..8e40c09600 100644 --- a/backend/core/src/application-methods/dto/application-method.dto.ts +++ b/backend/core/src/application-methods/dto/application-method.dto.ts @@ -1,5 +1,5 @@ import { Expose, Type } from "class-transformer" -import { IsDate, IsOptional, IsUUID, ValidateNested } from "class-validator" +import { IsOptional, IsUUID, ValidateNested } from "class-validator" import { OmitType } from "@nestjs/swagger" import { IdDto } from "../../shared/dto/id.dto" import { ValidationsGroupsEnum } from "../../shared/types/validations-groups-enum" @@ -52,18 +52,6 @@ export class ApplicationMethodUpdateDto extends OmitType(ApplicationMethodDto, [ @IsUUID(4, { groups: [ValidationsGroupsEnum.default] }) id?: string - @Expose() - @IsOptional({ groups: [ValidationsGroupsEnum.default] }) - @IsDate({ groups: [ValidationsGroupsEnum.default] }) - @Type(() => Date) - createdAt?: Date - - @Expose() - @IsOptional({ groups: [ValidationsGroupsEnum.default] }) - @IsDate({ groups: [ValidationsGroupsEnum.default] }) - @Type(() => Date) - updatedAt?: Date - @Expose() @IsOptional({ groups: [ValidationsGroupsEnum.default] }) @ValidateNested({ groups: [ValidationsGroupsEnum.default], each: true }) diff --git a/backend/core/src/applications/applications.module.ts b/backend/core/src/applications/applications.module.ts index 6694a82dae..d2eb244429 100644 --- a/backend/core/src/applications/applications.module.ts +++ b/backend/core/src/applications/applications.module.ts @@ -8,7 +8,6 @@ import { ListingsModule } from "../listings/listings.module" import { Address } from "../shared/entities/address.entity" import { Applicant } from "./entities/applicant.entity" import { ApplicationsSubmissionController } from "./applications-submission.controller" -import { ApplicationFlaggedSetsModule } from "../application-flagged-sets/application-flagged-sets.module" import { TranslationsModule } from "../translations/translations.module" import { Listing } from "../listings/entities/listing.entity" import { ScheduleModule } from "@nestjs/schedule" @@ -26,7 +25,6 @@ import { ListingRepository } from "../listings/db/listing.repository" ActivityLogModule, SharedModule, ListingsModule, - ApplicationFlaggedSetsModule, TranslationsModule, EmailModule, ScheduleModule.forRoot(), diff --git a/backend/core/src/applications/dto/application-update.dto.ts b/backend/core/src/applications/dto/application-update.dto.ts index 99268080f6..d50222190b 100644 --- a/backend/core/src/applications/dto/application-update.dto.ts +++ b/backend/core/src/applications/dto/application-update.dto.ts @@ -41,18 +41,6 @@ export class ApplicationUpdateDto extends OmitType(ApplicationDto, [ @IsUUID(4, { groups: [ValidationsGroupsEnum.default] }) id?: string - @Expose() - @IsOptional({ groups: [ValidationsGroupsEnum.default] }) - @IsDate({ groups: [ValidationsGroupsEnum.default] }) - @Type(() => Date) - createdAt?: Date - - @Expose() - @IsOptional({ groups: [ValidationsGroupsEnum.default] }) - @IsDate({ groups: [ValidationsGroupsEnum.default] }) - @Type(() => Date) - updatedAt?: Date - @Expose() @IsOptional({ groups: [ValidationsGroupsEnum.default] }) @IsDate({ groups: [ValidationsGroupsEnum.default] }) diff --git a/backend/core/src/applications/entities/application.entity.ts b/backend/core/src/applications/entities/application.entity.ts index 8d236b4585..3efc6db074 100644 --- a/backend/core/src/applications/entities/application.entity.ts +++ b/backend/core/src/applications/entities/application.entity.ts @@ -42,6 +42,7 @@ import { ApplicationStatus } from "../types/application-status-enum" import { ApplicationSubmissionType } from "../types/application-submission-type-enum" import { IncomePeriod } from "../types/income-period-enum" import { UnitType } from "../../unit-types/entities/unit-type.entity" +import { ApplicationReviewStatus } from "../types/application-review-status-enum" @Entity({ name: "applications" }) @Unique(["listing", "confirmationCode"]) @@ -278,4 +279,10 @@ export class Application extends AbstractEntity { @Expose() @IsString({ groups: [ValidationsGroupsEnum.default] }) confirmationCode: string + + @Column({ enum: ApplicationReviewStatus, default: ApplicationReviewStatus.valid }) + @Expose() + @IsEnum(ApplicationReviewStatus, { groups: [ValidationsGroupsEnum.default] }) + @ApiProperty({ enum: ApplicationReviewStatus, enumName: "ApplicationReviewStatus" }) + reviewStatus?: ApplicationReviewStatus | null } diff --git a/backend/core/src/applications/services/application-csv-exporter.service.ts b/backend/core/src/applications/services/application-csv-exporter.service.ts index 93e4dbab13..bf467ecb97 100644 --- a/backend/core/src/applications/services/application-csv-exporter.service.ts +++ b/backend/core/src/applications/services/application-csv-exporter.service.ts @@ -5,6 +5,7 @@ import { getBirthday } from "../../shared/utils/get-birthday" import { formatBoolean } from "../../shared/utils/format-boolean" import { ApplicationMultiselectQuestion } from "../entities/application-multiselect-question.entity" import { AddressCreateDto } from "../../shared/dto/address.dto" +import { ApplicationReviewStatus } from "../types/application-review-status-enum" @Injectable({ scope: Scope.REQUEST }) export class ApplicationCsvExporterService { @@ -203,8 +204,10 @@ export class ApplicationCsvExporterService { "Household Members": { [app.householdMembers_id]: this.mapHouseholdMembers(app), }, - "Marked As Duplicate": formatBoolean(app.application_marked_as_duplicate), - "Flagged As Duplicate": formatBoolean(app.flagged), + "Marked As Duplicate": formatBoolean( + app.application_review_status === ApplicationReviewStatus.duplicate + ), // if "duplicate" then "marked as duplicate" is true else false + "Flagged As Duplicate": formatBoolean(app.flagged), // if in "flagged" set then "flagged as duplicate" is true ...demographics, } /** diff --git a/backend/core/src/applications/services/applications.service.ts b/backend/core/src/applications/services/applications.service.ts index f38578e7fd..f63efbb57a 100644 --- a/backend/core/src/applications/services/applications.service.ts +++ b/backend/core/src/applications/services/applications.service.ts @@ -14,7 +14,6 @@ import { Request as ExpressRequest } from "express" import { REQUEST } from "@nestjs/core" import retry from "async-retry" import crypto from "crypto" -import { ApplicationFlaggedSetsService } from "../../application-flagged-sets/application-flagged-sets.service" import { AuthzService } from "../../auth/services/authz.service" import { ListingsService } from "../../listings/listings.service" import { Application } from "../entities/application.entity" @@ -27,12 +26,12 @@ import { ApplicationCreateDto } from "../dto/application-create.dto" import { ApplicationUpdateDto } from "../dto/application-update.dto" import { ApplicationsCsvListQueryParams } from "../dto/applications-csv-list-query-params" import { ListingRepository } from "../../listings/db/listing.repository" +import { Listing } from "../../listings/entities/listing.entity" @Injectable({ scope: Scope.REQUEST }) export class ApplicationsService { constructor( @Inject(REQUEST) private req: ExpressRequest, - private readonly applicationFlaggedSetsService: ApplicationFlaggedSetsService, private readonly authzService: AuthzService, private readonly listingsService: ListingsService, private readonly emailService: EmailService, @@ -208,21 +207,22 @@ export class ApplicationsService { id: application.id, }) - return await this.repository.manager.transaction( + const app = await this.repository.manager.transaction( "SERIALIZABLE", async (transactionalEntityManager) => { const applicationsRepository = transactionalEntityManager.getRepository(Application) const newApplication = await applicationsRepository.save(application) - await this.applicationFlaggedSetsService.onApplicationUpdate( - application, - transactionalEntityManager + await this.updateListingApplicationEditTimestamp( + newApplication.listingId, + transactionalEntityManager.getRepository(Listing) ) return await applicationsRepository.findOne({ id: newApplication.id }) } ) + return app } async delete(applicationId: string) { @@ -239,6 +239,8 @@ export class ApplicationsService { authzActions.delete ) + await this.updateListingApplicationEditTimestamp(application.listingId) + return await this.repository.softRemove({ id: applicationId }) } @@ -286,14 +288,17 @@ export class ApplicationsService { "SERIALIZABLE", async (transactionalEntityManager) => { const applicationsRepository = transactionalEntityManager.getRepository(Application) + const application = await applicationsRepository.save({ ...applicationCreateDto, confirmationCode: ApplicationsService.generateConfirmationCode(), }) - await this.applicationFlaggedSetsService.onApplicationSave( - application, - transactionalEntityManager + + await this.updateListingApplicationEditTimestamp( + application.listingId, + transactionalEntityManager.getRepository(Listing) ) + return await applicationsRepository.findOne({ id: application.id }) } ) @@ -394,4 +399,13 @@ export class ApplicationsService { public static generateConfirmationCode(): string { return crypto.randomBytes(4).toString("hex").toUpperCase() } + + private async updateListingApplicationEditTimestamp( + listingId: string, + repository: Repository = this.listingsRepository + ) { + const listing = await repository.findOne({ where: { id: listingId } }) + listing.lastApplicationUpdateAt = new Date() + await repository.save(listing) + } } diff --git a/backend/core/src/applications/types/application-review-status-enum.ts b/backend/core/src/applications/types/application-review-status-enum.ts new file mode 100644 index 0000000000..9cef6022f3 --- /dev/null +++ b/backend/core/src/applications/types/application-review-status-enum.ts @@ -0,0 +1,6 @@ +export enum ApplicationReviewStatus { + pending = "pending", + pendingAndValid = "pendingAndValid", + valid = "valid", + duplicate = "duplicate", +} diff --git a/backend/core/src/assets/dto/asset.dto.ts b/backend/core/src/assets/dto/asset.dto.ts index 68302a7751..d09c5733b2 100644 --- a/backend/core/src/assets/dto/asset.dto.ts +++ b/backend/core/src/assets/dto/asset.dto.ts @@ -1,7 +1,7 @@ import { OmitType } from "@nestjs/swagger" import { Asset } from "../entities/asset.entity" -import { Expose, Type } from "class-transformer" -import { IsDate, IsDefined, IsOptional, IsUUID } from "class-validator" +import { Expose } from "class-transformer" +import { IsDefined, IsOptional, IsUUID } from "class-validator" import { ValidationsGroupsEnum } from "../../shared/types/validations-groups-enum" export class AssetDto extends OmitType(Asset, [] as const) {} @@ -12,18 +12,6 @@ export class AssetUpdateDto extends OmitType(AssetDto, ["id", "createdAt", "upda @IsOptional({ groups: [ValidationsGroupsEnum.default] }) @IsUUID(4, { groups: [ValidationsGroupsEnum.default] }) id?: string - - @Expose() - @IsOptional({ groups: [ValidationsGroupsEnum.default] }) - @IsDate({ groups: [ValidationsGroupsEnum.default] }) - @Type(() => Date) - createdAt?: Date - - @Expose() - @IsOptional({ groups: [ValidationsGroupsEnum.default] }) - @IsDate({ groups: [ValidationsGroupsEnum.default] }) - @Type(() => Date) - updatedAt?: Date } export class CreatePresignedUploadMetadataDto { diff --git a/backend/core/src/auth/dto/user-update.dto.ts b/backend/core/src/auth/dto/user-update.dto.ts index de4f4341ce..982e2dbb66 100644 --- a/backend/core/src/auth/dto/user-update.dto.ts +++ b/backend/core/src/auth/dto/user-update.dto.ts @@ -3,7 +3,6 @@ import { Expose, Type } from "class-transformer" import { ArrayMinSize, IsArray, - IsDate, IsDefined, IsEmail, IsNotEmpty, @@ -47,18 +46,6 @@ export class UserUpdateDto extends OmitType(UserDto, [ @EnforceLowerCase() email?: string - @Expose() - @IsOptional({ groups: [ValidationsGroupsEnum.default] }) - @IsDate({ groups: [ValidationsGroupsEnum.default] }) - @Type(() => Date) - createdAt?: Date - - @Expose() - @IsOptional({ groups: [ValidationsGroupsEnum.default] }) - @IsDate({ groups: [ValidationsGroupsEnum.default] }) - @Type(() => Date) - updatedAt?: Date - @Expose() @IsOptional({ groups: [ValidationsGroupsEnum.default] }) @IsString({ groups: [ValidationsGroupsEnum.default] }) diff --git a/backend/core/src/jurisdictions/dto/jurisdiction-update.dto.ts b/backend/core/src/jurisdictions/dto/jurisdiction-update.dto.ts index 33cabae2c3..e129f22e25 100644 --- a/backend/core/src/jurisdictions/dto/jurisdiction-update.dto.ts +++ b/backend/core/src/jurisdictions/dto/jurisdiction-update.dto.ts @@ -1,6 +1,6 @@ import { OmitType } from "@nestjs/swagger" -import { Expose, Type } from "class-transformer" -import { IsDate, IsOptional, IsUUID } from "class-validator" +import { Expose } from "class-transformer" +import { IsOptional, IsUUID } from "class-validator" import { ValidationsGroupsEnum } from "../../shared/types/validations-groups-enum" import { JurisdictionDto } from "./jurisdiction.dto" @@ -13,16 +13,4 @@ export class JurisdictionUpdateDto extends OmitType(JurisdictionDto, [ @IsOptional({ groups: [ValidationsGroupsEnum.default] }) @IsUUID(4, { groups: [ValidationsGroupsEnum.default] }) id?: string - - @Expose() - @IsOptional({ groups: [ValidationsGroupsEnum.default] }) - @IsDate({ groups: [ValidationsGroupsEnum.default] }) - @Type(() => Date) - createdAt?: Date - - @Expose() - @IsOptional({ groups: [ValidationsGroupsEnum.default] }) - @IsDate({ groups: [ValidationsGroupsEnum.default] }) - @Type(() => Date) - updatedAt?: Date } diff --git a/backend/core/src/listings/dto/listing-create.dto.ts b/backend/core/src/listings/dto/listing-create.dto.ts index f3901f3f4c..8581222c79 100644 --- a/backend/core/src/listings/dto/listing-create.dto.ts +++ b/backend/core/src/listings/dto/listing-create.dto.ts @@ -39,6 +39,7 @@ export class ListingCreateDto extends OmitType(ListingDto, [ "listingMultiselectQuestions", "publishedAt", "closedAt", + "afsLastRunAt", ] as const) { @Expose() @IsDefined({ groups: [ValidationsGroupsEnum.default] }) diff --git a/backend/core/src/listings/dto/listing-event.dto.ts b/backend/core/src/listings/dto/listing-event.dto.ts index 91d2e347cd..825f02dd3c 100644 --- a/backend/core/src/listings/dto/listing-event.dto.ts +++ b/backend/core/src/listings/dto/listing-event.dto.ts @@ -1,7 +1,7 @@ import { OmitType } from "@nestjs/swagger" import { ListingEvent } from "../entities/listing-event.entity" import { Expose, Type } from "class-transformer" -import { IsDate, IsOptional, IsUUID, ValidateNested } from "class-validator" +import { IsOptional, IsUUID, ValidateNested } from "class-validator" import { ValidationsGroupsEnum } from "../../shared/types/validations-groups-enum" import { AssetCreateDto, AssetUpdateDto } from "../../assets/dto/asset.dto" @@ -30,18 +30,6 @@ export class ListingEventUpdateDto extends OmitType(ListingEventDto, [ @IsUUID(4, { groups: [ValidationsGroupsEnum.default] }) id?: string - @Expose() - @IsOptional({ groups: [ValidationsGroupsEnum.default] }) - @IsDate({ groups: [ValidationsGroupsEnum.default] }) - @Type(() => Date) - createdAt?: Date - - @Expose() - @IsOptional({ groups: [ValidationsGroupsEnum.default] }) - @IsDate({ groups: [ValidationsGroupsEnum.default] }) - @Type(() => Date) - updatedAt?: Date - @Expose() @IsOptional({ groups: [ValidationsGroupsEnum.default] }) @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) diff --git a/backend/core/src/listings/dto/listing-update.dto.ts b/backend/core/src/listings/dto/listing-update.dto.ts index bfff84d61f..98932a8470 100644 --- a/backend/core/src/listings/dto/listing-update.dto.ts +++ b/backend/core/src/listings/dto/listing-update.dto.ts @@ -1,13 +1,6 @@ import { OmitType } from "@nestjs/swagger" import { Expose, Type } from "class-transformer" -import { - ArrayMaxSize, - IsDate, - IsDefined, - IsOptional, - IsUUID, - ValidateNested, -} from "class-validator" +import { ArrayMaxSize, IsDefined, IsOptional, IsUUID, ValidateNested } from "class-validator" import { ValidationsGroupsEnum } from "../../shared/types/validations-groups-enum" import { IdDto } from "../../shared/dto/id.dto" import { AddressUpdateDto } from "../../shared/dto/address.dto" @@ -46,24 +39,13 @@ export class ListingUpdateDto extends OmitType(ListingDto, [ "listingMultiselectQuestions", "publishedAt", "closedAt", + "afsLastRunAt", ] as const) { @Expose() @IsOptional({ groups: [ValidationsGroupsEnum.default] }) @IsUUID(4, { groups: [ValidationsGroupsEnum.default] }) id?: string - @Expose() - @IsOptional({ groups: [ValidationsGroupsEnum.default] }) - @IsDate({ groups: [ValidationsGroupsEnum.default] }) - @Type(() => Date) - createdAt?: Date - - @Expose() - @IsOptional({ groups: [ValidationsGroupsEnum.default] }) - @IsDate({ groups: [ValidationsGroupsEnum.default] }) - @Type(() => Date) - updatedAt?: Date - @Expose() @IsDefined({ groups: [ValidationsGroupsEnum.default] }) @ValidateNested({ groups: [ValidationsGroupsEnum.default], each: true }) diff --git a/backend/core/src/listings/entities/listing.entity.ts b/backend/core/src/listings/entities/listing.entity.ts index 3974de0972..b707addeff 100644 --- a/backend/core/src/listings/entities/listing.entity.ts +++ b/backend/core/src/listings/entities/listing.entity.ts @@ -661,6 +661,20 @@ class Listing extends BaseEntity { @ValidateNested({ groups: [ValidationsGroupsEnum.default], each: true }) @Type(() => ListingUtilities) utilities?: ListingUtilities + + @Column({ type: "timestamptz", nullable: true, default: "1970-01-01" }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsDate({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => Date) + afsLastRunAt?: Date | null + + @Column({ type: "timestamptz", nullable: true, default: "1970-01-01" }) + @Expose() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsDate({ groups: [ValidationsGroupsEnum.default] }) + @Type(() => Date) + lastApplicationUpdateAt?: Date | null } export { Listing as default, Listing } diff --git a/backend/core/src/listings/listings.module.ts b/backend/core/src/listings/listings.module.ts index fc553018c0..8718f85824 100644 --- a/backend/core/src/listings/listings.module.ts +++ b/backend/core/src/listings/listings.module.ts @@ -13,6 +13,7 @@ import { ListingFeatures } from "./entities/listing-features.entity" import { ActivityLogModule } from "../activity-log/activity-log.module" import { ListingRepository } from "./db/listing.repository" import { ListingUtilities } from "./entities/listing-utilities.entity" +import { ApplicationFlaggedSetsModule } from "../application-flagged-sets/application-flagged-sets.module" @Module({ imports: [ @@ -29,6 +30,7 @@ import { ListingUtilities } from "./entities/listing-utilities.entity" AuthModule, TranslationsModule, ActivityLogModule, + ApplicationFlaggedSetsModule, ], providers: [ListingsService], exports: [ListingsService], diff --git a/backend/core/src/listings/listings.service.ts b/backend/core/src/listings/listings.service.ts index 8fa8cd9290..92824065ea 100644 --- a/backend/core/src/listings/listings.service.ts +++ b/backend/core/src/listings/listings.service.ts @@ -19,6 +19,7 @@ import { AuthzService } from "../auth/services/authz.service" import { Request as ExpressRequest } from "express" import { REQUEST } from "@nestjs/core" import { User } from "../auth/entities/user.entity" +import { ApplicationFlaggedSetsService } from "../application-flagged-sets/application-flagged-sets.service" @Injectable({ scope: Scope.REQUEST }) export class ListingsService { @@ -27,7 +28,8 @@ export class ListingsService { @InjectRepository(AmiChart) private readonly amiChartsRepository: Repository, private readonly translationService: TranslationsService, private readonly authzService: AuthzService, - @Inject(REQUEST) private req: ExpressRequest + @Inject(REQUEST) private req: ExpressRequest, + private readonly afsService: ApplicationFlaggedSetsService ) {} private getFullyJoinedQueryBuilder() { @@ -119,6 +121,10 @@ export class ListingsService { }) listingDto.unitsAvailable = availableUnits + if (listing.status == ListingStatus.active && listingDto.status === ListingStatus.closed) { + await this.afsService.scheduleAfsProcessing() + } + Object.assign(listing, { ...listingDto, publishedAt: diff --git a/backend/core/src/listings/tests/listings.service.spec.ts b/backend/core/src/listings/tests/listings.service.spec.ts index c44936bd25..e0e34d302d 100644 --- a/backend/core/src/listings/tests/listings.service.spec.ts +++ b/backend/core/src/listings/tests/listings.service.spec.ts @@ -10,6 +10,7 @@ import { ListingFilterParams } from "../dto/listing-filter-params" import { OrderByFieldsEnum } from "../types/listing-orderby-enum" import { OrderParam } from "../../applications/types/order-param" import { AuthzService } from "../../auth/services/authz.service" +import { ApplicationFlaggedSetsService } from "../../application-flagged-sets/application-flagged-sets.service" import { ListingRepository } from "../db/listing.repository" import { ListingsQueryBuilder } from "../db/listing-query-builder" import { UserRepository } from "../../auth/repositories/user-repository" @@ -131,6 +132,10 @@ describe("ListingsService", () => { const module: TestingModule = await Test.createTestingModule({ providers: [ ListingsService, + { + provide: ApplicationFlaggedSetsService, + useValue: { scheduleAfsProcessing: jest.fn() }, + }, AuthzService, UserRepository, { diff --git a/backend/core/src/migration/1658992843452-add-afs-related-properties-to-listing.ts b/backend/core/src/migration/1658992843452-add-afs-related-properties-to-listing.ts new file mode 100644 index 0000000000..d0766b11f7 --- /dev/null +++ b/backend/core/src/migration/1658992843452-add-afs-related-properties-to-listing.ts @@ -0,0 +1,106 @@ +import { MigrationInterface, QueryRunner } from "typeorm" +import { Rule } from "../application-flagged-sets/types/rule-enum" + +export class addAfsRelatedPropertiesToListing1658992843452 implements MigrationInterface { + name = "addAfsRelatedPropertiesToListing1658992843452" + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "listings" ADD "afs_last_run_at" TIMESTAMP WITH TIME ZONE DEFAULT '1970-01-01'` + ) + await queryRunner.query( + `ALTER TABLE "listings" ADD "last_application_update_at" TIMESTAMP WITH TIME ZONE DEFAULT '1970-01-01'` + ) + await queryRunner.query( + `ALTER TABLE "application_flagged_set" ADD "rule_key" character varying` + ) + await queryRunner.query( + `ALTER TABLE "application_flagged_set" ADD CONSTRAINT "UQ_2983d3205a16bfae28323d021ea" UNIQUE ("rule_key")` + ) + + // set rule_key for existing afses + const afsas = await queryRunner.query(` + SELECT afsas.application_flagged_set_id, afsas.applications_id, afs.listing_id, afs.rule, afs.rule_key + FROM application_flagged_set_applications_applications afsas + INNER JOIN application_flagged_set afs ON afs.id = afsas.application_flagged_set_id + WHERE afs.rule_key IS NULL + `) + + const ruleKeyMap: { [key: string]: number } = {} + + for (const afsa of afsas) { + const [applicant] = await queryRunner.query( + ` + SELECT applicant.email_address, applicant.first_name, applicant.last_name, applicant.birth_month, applicant.birth_day, applicant.birth_year + FROM applicant + INNER JOIN applications on applications.applicant_id = applicant.id + WHERE applications.id = $1 + `, + [afsa.applications_id] + ) + + let ruleKey: string | null = null + + // get application info needed for key + if (afsa.rule === Rule.email) { + ruleKey = `${afsa.listing_id}-email-${applicant.email_address}` + } else if (afsa.rule === Rule.nameAndDOB) { + ruleKey = + `${afsa.listing_id}-nameAndDOB-${applicant.first_name}-${applicant.last_name}-${applicant.birth_month}-` + + `${applicant.birth_day}-${applicant.birth_year}` + } + + // check if rule_key already exists, because their are existing duplicates + const existingSets = await queryRunner.query( + ` + SELECT id, rule_key + FROM application_flagged_set + WHERE rule_key = $1 + `, + [ruleKey] + ) + + // update and delete the current set if the application_flagged_set ids are different + if (existingSets.length && existingSets[0].id !== afsa.application_flagged_set_id) { + const update = await queryRunner.query( + ` + UPDATE application_flagged_set_applications_applications + SET application_flagged_set_id = $1 + WHERE application_flagged_set_id = $2 + AND applications_id = $3 + `, + [existingSets[0].id, afsas.application_flagged_set_id, afsa.applications_id] + ) + + await queryRunner.query( + ` + DELETE FROM application_flagged_set WHERE id = $1 + `, + [afsa.application_flagged_set_id] + ) + } else { + // set rule_key + await queryRunner.query( + ` + UPDATE application_flagged_set + SET rule_key = $1 + WHERE id = $2`, + [ruleKey, afsa.application_flagged_set_id] + ) + } + } + + await queryRunner.query( + `ALTER TABLE "application_flagged_set" ALTER COLUMN "rule_key" SET NOT NULL` + ) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "listings" DROP COLUMN "last_application_update_at"`) + await queryRunner.query(`ALTER TABLE "listings" DROP COLUMN "afs_last_run_at"`) + await queryRunner.query( + `ALTER TABLE "application_flagged_set" DROP CONSTRAINT "UQ_2983d3205a16bfae28323d021ea"` + ) + await queryRunner.query(`ALTER TABLE "application_flagged_set" DROP COLUMN "rule_key"`) + } +} diff --git a/backend/core/src/migration/1661788864862-adding-review-status.ts b/backend/core/src/migration/1661788864862-adding-review-status.ts new file mode 100644 index 0000000000..8ffa03d603 --- /dev/null +++ b/backend/core/src/migration/1661788864862-adding-review-status.ts @@ -0,0 +1,38 @@ +import { MigrationInterface, QueryRunner } from "typeorm" + +export class addingReviewStatus1661788864862 implements MigrationInterface { + name = "addingReviewStatus1661788864862" + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "applications" ADD "review_status" character varying NOT NULL DEFAULT 'valid'` + ) + await queryRunner.query(` + UPDATE applications + SET review_status = 'flagged' + WHERE id IN ( + SELECT + apps.applications_id + FROM application_flagged_set_applications_applications apps + JOIN application_flagged_set afs ON afs.id = apps.application_flagged_set_id + WHERE afs.status = 'flagged' + ) + `) + + await queryRunner.query( + `ALTER TABLE "application_flagged_set" ALTER COLUMN "status" SET DEFAULT 'pending'` + ) + await queryRunner.query(` + UPDATE application_flagged_set + SET status = 'pending' + WHERE status = 'flagged' + `) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "applications" DROP COLUMN "review_status"`) + await queryRunner.query( + `ALTER TABLE "application_flagged_set" ALTER COLUMN "status" SET DEFAULT 'flagged'` + ) + } +} diff --git a/backend/core/src/migration/1661810986580-add-app-flag-notification-boolean.ts b/backend/core/src/migration/1661810986580-add-app-flag-notification-boolean.ts new file mode 100644 index 0000000000..6666f2e4ba --- /dev/null +++ b/backend/core/src/migration/1661810986580-add-app-flag-notification-boolean.ts @@ -0,0 +1,17 @@ +import { MigrationInterface, QueryRunner } from "typeorm" + +export class addAppFlagNotificationBoolean1661810986580 implements MigrationInterface { + name = "addAppFlagNotificationBoolean1661810986580" + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "application_flagged_set" ADD "show_confirmation_alert" boolean NOT NULL DEFAULT false` + ) + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "application_flagged_set" DROP COLUMN "show_confirmation_alert"` + ) + } +} diff --git a/backend/core/src/migration/1664300247901-setsAfsLastRunAt.ts b/backend/core/src/migration/1664300247901-setsAfsLastRunAt.ts new file mode 100644 index 0000000000..578d126650 --- /dev/null +++ b/backend/core/src/migration/1664300247901-setsAfsLastRunAt.ts @@ -0,0 +1,15 @@ +import {MigrationInterface, QueryRunner} from "typeorm"; + +export class setsAfsLastRunAt1664300247901 implements MigrationInterface { + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + UPDATE listings + SET afs_last_run_at = $1 + `, [new Date()]) + } + + public async down(queryRunner: QueryRunner): Promise { + } + +} diff --git a/backend/core/src/paper-applications/dto/paper-application.dto.ts b/backend/core/src/paper-applications/dto/paper-application.dto.ts index 609c8546bb..ad5df2085d 100644 --- a/backend/core/src/paper-applications/dto/paper-application.dto.ts +++ b/backend/core/src/paper-applications/dto/paper-application.dto.ts @@ -1,5 +1,5 @@ import { Expose, Type } from "class-transformer" -import { IsDate, IsOptional, IsUUID, ValidateNested } from "class-validator" +import { IsOptional, IsUUID, ValidateNested } from "class-validator" import { OmitType } from "@nestjs/swagger" import { ValidationsGroupsEnum } from "../../shared/types/validations-groups-enum" import { PaperApplication } from "../entities/paper-application.entity" @@ -40,18 +40,6 @@ export class PaperApplicationUpdateDto extends OmitType(PaperApplicationDto, [ @IsUUID(4, { groups: [ValidationsGroupsEnum.default] }) id?: string - @Expose() - @IsOptional({ groups: [ValidationsGroupsEnum.default] }) - @IsDate({ groups: [ValidationsGroupsEnum.default] }) - @Type(() => Date) - createdAt?: Date - - @Expose() - @IsOptional({ groups: [ValidationsGroupsEnum.default] }) - @IsDate({ groups: [ValidationsGroupsEnum.default] }) - @Type(() => Date) - updatedAt?: Date - @Expose() @IsOptional({ groups: [ValidationsGroupsEnum.default] }) @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) diff --git a/backend/core/src/seeder/seed.ts b/backend/core/src/seeder/seed.ts index 97299ffe5e..2b4bfa35ad 100644 --- a/backend/core/src/seeder/seed.ts +++ b/backend/core/src/seeder/seed.ts @@ -52,6 +52,7 @@ import { ApplicationMethodType } from "../application-methods/types/application- import { UnitTypesService } from "../unit-types/unit-types.service" import dayjs from "dayjs" import { CountyCode } from "../shared/types/county-code" +import { ApplicationFlaggedSetsCronjobConsumer } from "../application-flagged-sets/application-flagged-sets-cronjob-consumer" const argv = yargs.scriptName("seed").options({ test: { type: "boolean", default: false }, @@ -211,7 +212,9 @@ async function seed() { // Starts listening for shutdown hooks app.enableShutdownHooks() const userService = await app.resolve(UserService) - + const afsProcessingService = await app.resolve( + ApplicationFlaggedSetsCronjobConsumer + ) const userRepo = app.get>(getRepositoryToken(User)) const rolesRepo = app.get>(getRepositoryToken(UserRoles)) const jurisdictions = await createJurisdictions(app) @@ -360,11 +363,12 @@ async function seed() { for (let i = 0; i < 10; i++) { for (const listing of listings) { await Promise.all([ - await makeNewApplication(app, listing, unitTypes, listing.jurisdictionName, user1), - await makeNewApplication(app, listing, unitTypes, listing.jurisdictionName, user2), + await makeNewApplication(app, listing, unitTypes, listing.jurisdictionName, user1, i), + await makeNewApplication(app, listing, unitTypes, listing.jurisdictionName, user2, i + 10), ]) } } + await afsProcessingService.process() await app.close() } diff --git a/backend/core/src/seeder/seeds/applications.ts b/backend/core/src/seeder/seeds/applications.ts index e07b0124d6..50cb14001b 100644 --- a/backend/core/src/seeder/seeds/applications.ts +++ b/backend/core/src/seeder/seeds/applications.ts @@ -1,6 +1,6 @@ import { INestApplicationContext } from "@nestjs/common" -import { Repository } from "typeorm" import { getRepositoryToken } from "@nestjs/typeorm" +import { Repository } from "typeorm" import { IncomePeriod } from "../../applications/types/income-period-enum" import { Language } from "../../shared/types/language-enum" import { InputType } from "../../shared/types/input-type" @@ -12,6 +12,7 @@ import { User } from "../../auth/entities/user.entity" import { Application } from "../../applications/entities/application.entity" import { ApplicationsService } from "../../applications/services/applications.service" import { ApplicationCreateDto } from "../../applications/dto/application-create.dto" +import { ApplicationReviewStatus } from "../../applications/types/application-review-status-enum" const getApplicationCreateDtoTemplate = ( jurisdictionString: string @@ -223,17 +224,95 @@ export const makeNewApplication = async ( listing: Listing, unitTypes: UnitType[], jurisdictionString: string, - user?: User + user?: User, + pos = 0 ) => { - const dto: ApplicationCreateDto = JSON.parse( + let dto: ApplicationCreateDto = JSON.parse( JSON.stringify(getApplicationCreateDtoTemplate(jurisdictionString)) ) + const applicationRepo = app.get>(getRepositoryToken(Application)) + dto.listing = listing dto.preferredUnit = unitTypes - const applicationRepo = app.get>(getRepositoryToken(Application)) - return await applicationRepo.save({ + if (pos === 0 || pos === 10) { + dto.reviewStatus = ApplicationReviewStatus.pending + } + // modifications set up + const splitEmail = dto.applicant.emailAddress.split("@") + const modifiedEmail = `${splitEmail[0]}${pos}@${splitEmail[1]}` + const modifiedFirstName = `${dto.applicant.firstName}${pos}` + const modifiedLastName = `${dto.applicant.lastName}${pos}` + + // modifications to applicant + dto.applicant.firstName = modifiedFirstName + dto.applicant.lastName = modifiedLastName + dto.applicant.emailAddress = modifiedEmail + + // modifications to householdmembers + if (dto.householdMembers?.length) { + dto.householdMembers.forEach((mem) => { + const splitEmail = mem.emailAddress.split("@") + mem.emailAddress = `${splitEmail[0]}${pos}+${modifiedFirstName}@${splitEmail[1]}` + mem.firstName = `${modifiedFirstName}_${mem.firstName}${pos}` + mem.lastName = `${modifiedLastName}_${mem.lastName}${pos}` + }) + } + + await applicationRepo.save({ ...dto, user, confirmationCode: ApplicationsService.generateConfirmationCode(), }) + + if (pos === 0 || pos === 10) { + // create a flagged duplicate by email + dto = JSON.parse(JSON.stringify(getApplicationCreateDtoTemplate(jurisdictionString))) + dto.listing = listing + dto.preferredUnit = unitTypes + dto.reviewStatus = ApplicationReviewStatus.pending + // modifications to applicant + dto.applicant.firstName = `${modifiedFirstName} B` + dto.applicant.lastName = `${modifiedLastName} B` + dto.applicant.emailAddress = modifiedEmail + // modifications to householdmembers + if (dto.householdMembers?.length) { + dto.householdMembers.forEach((mem) => { + const splitEmail = mem.emailAddress.split("@") + mem.emailAddress = `${splitEmail[0]}${pos}+${modifiedFirstName}HHEmail@${splitEmail[1]}` + mem.firstName = `${modifiedFirstName}_${mem.firstName}${pos} HHEmail` + mem.lastName = `${modifiedLastName}_${mem.lastName}${pos} HHEmail` + }) + } + + await applicationRepo.save({ + ...dto, + user, + confirmationCode: ApplicationsService.generateConfirmationCode(), + }) + + // create a flagged duplicate by name and DOB + dto = JSON.parse(JSON.stringify(getApplicationCreateDtoTemplate(jurisdictionString))) + dto.listing = listing + dto.preferredUnit = unitTypes + dto.reviewStatus = ApplicationReviewStatus.pending + // modifications to applicant + dto.applicant.firstName = modifiedFirstName + dto.applicant.lastName = modifiedLastName + dto.applicant.emailAddress = `${modifiedEmail}B` + // modifications to householdmembers + if (dto.householdMembers?.length) { + dto.householdMembers.forEach((mem) => { + const splitEmail = mem.emailAddress.split("@") + mem.emailAddress = `${splitEmail[0]}${pos}+${modifiedFirstName}HHName@${splitEmail[1]}` + mem.firstName = `${modifiedFirstName}_${mem.firstName}${pos} HHName` + mem.lastName = `${modifiedLastName}_${mem.lastName}${pos} HHName` + }) + } + + await applicationRepo.save({ + ...dto, + user, + confirmationCode: ApplicationsService.generateConfirmationCode(), + }) + } } diff --git a/backend/core/src/shared/shared.module.ts b/backend/core/src/shared/shared.module.ts index d878195615..d3b4101055 100644 --- a/backend/core/src/shared/shared.module.ts +++ b/backend/core/src/shared/shared.module.ts @@ -27,6 +27,7 @@ import Joi from "joi" TWILIO_PHONE_NUMBER: Joi.string().default("dummy_phone_number"), AUTH_LOCK_LOGIN_AFTER_FAILED_ATTEMPTS: Joi.number().default(5), AUTH_LOCK_LOGIN_COOLDOWN_MS: Joi.number().default(1000 * 60 * 30), + AFS_PROCESSING_CRON_STRING: Joi.string().default("0 0 * * *"), }), }), ], diff --git a/backend/core/src/translations/dto/translation.dto.ts b/backend/core/src/translations/dto/translation.dto.ts index f7c724895a..e48cc43d7a 100644 --- a/backend/core/src/translations/dto/translation.dto.ts +++ b/backend/core/src/translations/dto/translation.dto.ts @@ -1,6 +1,6 @@ import { OmitType } from "@nestjs/swagger" import { Expose, Type } from "class-transformer" -import { IsDate, IsOptional, IsUUID } from "class-validator" +import { IsOptional, IsUUID } from "class-validator" import { IdDto } from "../../shared/dto/id.dto" import { ValidationsGroupsEnum } from "../../shared/types/validations-groups-enum" import { Translation } from "../entities/translation.entity" @@ -26,16 +26,4 @@ export class TranslationUpdateDto extends OmitType(TranslationDto, [ @IsOptional({ groups: [ValidationsGroupsEnum.default] }) @IsUUID(4, { groups: [ValidationsGroupsEnum.default] }) id?: string - - @Expose() - @IsOptional({ groups: [ValidationsGroupsEnum.default] }) - @IsDate({ groups: [ValidationsGroupsEnum.default] }) - @Type(() => Date) - createdAt?: Date - - @Expose() - @IsOptional({ groups: [ValidationsGroupsEnum.default] }) - @IsDate({ groups: [ValidationsGroupsEnum.default] }) - @Type(() => Date) - updatedAt?: Date } diff --git a/backend/core/src/units/dto/unit-ami-chart-override-update.dto.ts b/backend/core/src/units/dto/unit-ami-chart-override-update.dto.ts index 919c605296..3209fa825e 100644 --- a/backend/core/src/units/dto/unit-ami-chart-override-update.dto.ts +++ b/backend/core/src/units/dto/unit-ami-chart-override-update.dto.ts @@ -1,6 +1,6 @@ import { OmitType } from "@nestjs/swagger" -import { Expose, Type } from "class-transformer" -import { IsDate, IsOptional, IsUUID } from "class-validator" +import { Expose } from "class-transformer" +import { IsOptional, IsUUID } from "class-validator" import { ValidationsGroupsEnum } from "../../shared/types/validations-groups-enum" import { UnitAmiChartOverrideDto } from "./unit-ami-chart-override.dto" @@ -13,16 +13,4 @@ export class UnitAmiChartOverrideUpdateDto extends OmitType(UnitAmiChartOverride @IsOptional({ groups: [ValidationsGroupsEnum.default] }) @IsUUID(4, { groups: [ValidationsGroupsEnum.default] }) id?: string - - @Expose() - @IsOptional({ groups: [ValidationsGroupsEnum.default] }) - @IsDate({ groups: [ValidationsGroupsEnum.default] }) - @Type(() => Date) - createdAt?: Date - - @Expose() - @IsOptional({ groups: [ValidationsGroupsEnum.default] }) - @IsDate({ groups: [ValidationsGroupsEnum.default] }) - @Type(() => Date) - updatedAt?: Date } diff --git a/backend/core/src/units/dto/unit-update.dto.ts b/backend/core/src/units/dto/unit-update.dto.ts index 3f81bb2f4b..17089eae9a 100644 --- a/backend/core/src/units/dto/unit-update.dto.ts +++ b/backend/core/src/units/dto/unit-update.dto.ts @@ -1,6 +1,6 @@ import { OmitType } from "@nestjs/swagger" import { Expose, Type } from "class-transformer" -import { IsDate, IsDefined, IsOptional, IsUUID, ValidateNested } from "class-validator" +import { IsDefined, IsOptional, IsUUID, ValidateNested } from "class-validator" import { ValidationsGroupsEnum } from "../../shared/types/validations-groups-enum" import { IdDto } from "../../shared/dto/id.dto" import { UnitRentTypeUpdateDto } from "../../unit-rent-types/dto/unit-rent-type.dto" @@ -23,18 +23,6 @@ export class UnitUpdateDto extends OmitType(UnitDto, [ @IsUUID(4, { groups: [ValidationsGroupsEnum.default] }) id?: string - @Expose() - @IsOptional({ groups: [ValidationsGroupsEnum.default] }) - @IsDate({ groups: [ValidationsGroupsEnum.default] }) - @Type(() => Date) - createdAt?: Date - - @Expose() - @IsOptional({ groups: [ValidationsGroupsEnum.default] }) - @IsDate({ groups: [ValidationsGroupsEnum.default] }) - @Type(() => Date) - updatedAt?: Date - @Expose() @IsOptional({ groups: [ValidationsGroupsEnum.default] }) @ValidateNested({ groups: [ValidationsGroupsEnum.default] }) diff --git a/backend/core/test/afs/afs.e2e-spec.ts b/backend/core/test/afs/afs.e2e-spec.ts index 8b27bbf8af..985f163695 100644 --- a/backend/core/test/afs/afs.e2e-spec.ts +++ b/backend/core/test/afs/afs.e2e-spec.ts @@ -14,8 +14,6 @@ import { HouseholdMember } from "../../src/applications/entities/household-membe import { ThrottlerModule } from "@nestjs/throttler" import { ApplicationFlaggedSet } from "../../src/application-flagged-sets/entities/application-flagged-set.entity" import { getTestAppBody } from "../lib/get-test-app-body" -import { FlaggedSetStatus } from "../../src/application-flagged-sets/types/flagged-set-status-enum" -import { Rule } from "../../src/application-flagged-sets/types/rule-enum" import { ApplicationDto } from "../../src/applications/dto/application.dto" import { Listing } from "../../src/listings/entities/listing.entity" import { ListingStatus } from "../../src/listings/types/listing-status-enum" @@ -23,6 +21,8 @@ import { ListingStatus } from "../../src/listings/types/listing-status-enum" // See https://www.typescriptlang.org/docs/handbook/modules.html#export--and-import--require import dbOptions from "../../ormconfig.test" import { EmailService } from "../../src/email/email.service" +import { ApplicationFlaggedSetsCronjobConsumer } from "../../src/application-flagged-sets/application-flagged-sets-cronjob-consumer" +import { ListingRepository } from "../../src/listings/db/listing.repository" // Cypress brings in Chai types for the global expect, but we want to use jest // expect here so we need to re-declare it. @@ -37,9 +37,9 @@ describe("ApplicationFlaggedSets", () => { let afsRepository: Repository let householdMembersRepository: Repository let listingsRepository: Repository+ let afsProcessingService: ApplicationFlaggedSetsCronjobConsumer let listing1Id: string let updateApplication - let getApplication let getAfsesForListingId const setupDb = async () => { @@ -60,7 +60,13 @@ describe("ApplicationFlaggedSets", () => { AuthModule, ListingsModule, ApplicationsModule, - TypeOrmModule.forFeature([ApplicationFlaggedSet, Application, HouseholdMember, Listing]), + TypeOrmModule.forFeature([ + ApplicationFlaggedSet, + Application, + HouseholdMember, + Listing, + ListingRepository, + ]), ThrottlerModule.forRoot({ ttl: 60, limit: 5, @@ -74,6 +80,7 @@ describe("ApplicationFlaggedSets", () => { app = moduleRef.createNestApplication() app = applicationSetup(app) await app.init() + applicationsRepository = app.get>(getRepositoryToken(Application)) afsRepository = app.get>( getRepositoryToken(ApplicationFlaggedSet) @@ -82,6 +89,7 @@ describe("ApplicationFlaggedSets", () => { getRepositoryToken(HouseholdMember) ) listingsRepository = app.get>(getRepositoryToken(Listing)) + const listing = (await listingsRepository.find({ take: 1 }))[0] await listingsRepository.save({ ...listing, @@ -90,6 +98,7 @@ describe("ApplicationFlaggedSets", () => { adminAccessToken = await getUserAccessToken(app, "admin@example.com", "abcdef") listing1Id = listing.id + await setupDb() updateApplication = async (application: ApplicationDto) => { @@ -102,15 +111,6 @@ describe("ApplicationFlaggedSets", () => { ).body } - getApplication = async (id: string) => { - return ( - await supertest(app.getHttpServer()) - .get(`/applications/${id}`) - .set(...setAuthorization(adminAccessToken)) - .expect(200) - ).body - } - getAfsesForListingId = async (listingId) => { return ( await supertest(app.getHttpServer()) @@ -119,109 +119,10 @@ describe("ApplicationFlaggedSets", () => { .expect(200) ).body } - }) - - it(`should mark two similar application as flagged`, async () => { - function checkAppsInAfsForRule(afsResponse, apps, rule) { - const afsesForRule = afsResponse.body.items.filter((item) => item.rule === rule) - expect(afsesForRule.length).toBe(1) - expect(afsesForRule[0].status).toBe(FlaggedSetStatus.flagged) - for (const appId of apps.map((app) => app.body.id)) { - expect(afsesForRule[0].applications.map((app) => app.id).includes(appId)).toBe(true) - } - expect(afsesForRule[0].applications.length).toBe(apps.length) - } - const appContent = getTestAppBody(listing1Id) - const apps = [] - await Promise.all( - [appContent, appContent].map(async (payload) => { - const appRes = await supertest(app.getHttpServer()) - .post("/applications/submit") - .send(payload) - .expect(201) - apps.push(appRes) - }) + afsProcessingService = app.get( + ApplicationFlaggedSetsCronjobConsumer ) - - let afses = await supertest(app.getHttpServer()) - .get(`/applicationFlaggedSets?listingId=${listing1Id}`) - .set(...setAuthorization(adminAccessToken)) - - expect(Array.isArray(afses.body.items)).toBe(true) - expect(afses.body.items.length).toBe(2) - - checkAppsInAfsForRule(afses, apps, Rule.nameAndDOB) - checkAppsInAfsForRule(afses, apps, Rule.email) - - const app3 = await supertest(app.getHttpServer()) - .post("/applications/submit") - .send(appContent) - .expect(201) - - apps.push(app3) - - afses = await supertest(app.getHttpServer()) - .get(`/applicationFlaggedSets?listingId=${listing1Id}`) - .set(...setAuthorization(adminAccessToken)) - - expect(Array.isArray(afses.body.items)).toBe(true) - expect(afses.body.items.length).toBe(2) - - checkAppsInAfsForRule(afses, apps, Rule.nameAndDOB) - checkAppsInAfsForRule(afses, apps, Rule.email) - }) - - it(`should resolve an application flagged set`, async () => { - const appContent1 = getTestAppBody(listing1Id) - const appContent2 = getTestAppBody(listing1Id) - - appContent2.applicant.emailAddress = "another@email.com" - const apps = [] - - await Promise.all( - [appContent1, appContent2].map(async (payload) => { - const appRes = await supertest(app.getHttpServer()) - .post("/applications/submit") - .send(payload) - .expect(201) - apps.push(appRes) - }) - ) - - let afses = await supertest(app.getHttpServer()) - .get(`/applicationFlaggedSets?listingId=${listing1Id}`) - .set(...setAuthorization(adminAccessToken)) - - expect(afses.body.meta.totalFlagged).toBe(1) - - let resolveRes = await supertest(app.getHttpServer()) - .post(`/applicationFlaggedSets/resolve`) - .send({ afsId: afses.body.items[0].id, applications: [{ id: apps[0].body.id }] }) - .set(...setAuthorization(adminAccessToken)) - .expect(201) - - afses = await supertest(app.getHttpServer()) - .get(`/applicationFlaggedSets?listingId=${listing1Id}`) - .set(...setAuthorization(adminAccessToken)) - - expect(afses.body.meta.totalFlagged).toBe(0) - - let resolvedAfs = resolveRes.body - expect(resolvedAfs.status).toBe(FlaggedSetStatus.resolved) - expect(resolvedAfs.applications.filter((app) => app.markedAsDuplicate === true).length).toBe(1) - expect(resolvedAfs.applications.filter((app) => app.markedAsDuplicate === false).length).toBe(1) - - resolveRes = await supertest(app.getHttpServer()) - .post(`/applicationFlaggedSets/resolve`) - .send({ afsId: afses.body.items[0].id, applications: [{ id: apps[1].body.id }] }) - .set(...setAuthorization(adminAccessToken)) - .expect(201) - - resolvedAfs = resolveRes.body - expect(resolvedAfs.status).toBe(FlaggedSetStatus.resolved) - expect(resolvedAfs.applications.filter((app) => app.markedAsDuplicate === true).length).toBe(1) - expect(resolvedAfs.applications.filter((app) => app.markedAsDuplicate === false).length).toBe(1) }) it(`should take application edits into account (application toggles between conflicting and non conflicting in an AFS of 2 apps)`, async () => { @@ -241,6 +142,8 @@ describe("ApplicationFlaggedSets", () => { apps.push(appRes.body) } + await afsProcessingService.process() + let afses = await getAfsesForListingId(listing1Id) expect(afses.meta.totalFlagged).toBe(0) @@ -252,6 +155,8 @@ describe("ApplicationFlaggedSets", () => { app2.applicant.emailAddress = app1Seed.applicant.emailAddress await updateApplication(app2) + await afsProcessingService.process() + afses = await getAfsesForListingId(listing1Id) expect(afses.meta.totalFlagged).toBe(1) @@ -262,6 +167,8 @@ describe("ApplicationFlaggedSets", () => { app2.applicant.emailAddress = app2Seed.applicant.emailAddress await updateApplication(app2) + await afsProcessingService.process() + afses = await getAfsesForListingId(listing1Id) expect(afses.meta.totalFlagged).toBe(0) @@ -288,6 +195,8 @@ describe("ApplicationFlaggedSets", () => { apps.push(appRes.body) } + await afsProcessingService.process() + let afses = await getAfsesForListingId(listing1Id) expect(afses.meta.totalFlagged).toBe(0) @@ -302,6 +211,8 @@ describe("ApplicationFlaggedSets", () => { await updateApplication(app2) app3 = await updateApplication(app3) + await afsProcessingService.process() + afses = await getAfsesForListingId(listing1Id) expect(afses.meta.totalFlagged).toBe(1) @@ -313,6 +224,8 @@ describe("ApplicationFlaggedSets", () => { app3.applicant.emailAddress = app3Seed.applicant.emailAddress app3 = await updateApplication(app3) + await afsProcessingService.process() + afses = await getAfsesForListingId(listing1Id) expect(afses.meta.totalFlagged).toBe(1) @@ -320,62 +233,6 @@ describe("ApplicationFlaggedSets", () => { expect(afses.items[0].applications.map((app) => app.id).includes(app3.id)).toBe(false) }) - it(`should take application edits into account (application toggles between conflicting and non conflicting in an AFS of 3 apps, AFS already resolved)`, async () => { - const app1Seed = getTestAppBody(listing1Id) - const app2Seed = getTestAppBody(listing1Id) - const app3Seed = getTestAppBody(listing1Id) - - // Three applications conflict by email rule - app2Seed.applicant.firstName = "AnotherFirstName" - app3Seed.applicant.firstName = "ThirdFirstName" - const apps = [] - - for (const payload of [app1Seed, app2Seed, app3Seed]) { - const appRes = await supertest(app.getHttpServer()) - .post("/applications/submit") - .send(payload) - .expect(201) - apps.push(appRes.body) - } - - // eslint-disable-next-line - let [app1, app2, app3] = apps - expect(app3.markedAsDuplicate).toBe(false) - - const afses = await getAfsesForListingId(listing1Id) - const afsToBeResolved = afses.items[0] - - const resolveRes = await supertest(app.getHttpServer()) - .post(`/applicationFlaggedSets/resolve`) - .send({ afsId: afsToBeResolved.id, applications: [{ id: app3.id }] }) - .set(...setAuthorization(adminAccessToken)) - .expect(201) - expect(resolveRes.body.resolvedTime).not.toBe(null) - expect(resolveRes.body.resolvingUser).not.toBe(null) - expect(resolveRes.body.status).toBe(FlaggedSetStatus.resolved) - - app3 = await getApplication(app3.id) - expect(app3.markedAsDuplicate).toBe(true) - - // App3 now does not conflict with any other applications - app3.applicant.emailAddress = "third@email.com" - app3 = await updateApplication(app3) - expect(app3.markedAsDuplicate).toBe(false) - - const previouslyResolvedAfs = ( - await supertest(app.getHttpServer()) - .get(`/applicationFlaggedSets/${afsToBeResolved.id}`) - .set(...setAuthorization(adminAccessToken)) - .expect(200) - ).body - expect(previouslyResolvedAfs.resolvedTime).toBe(null) - expect(previouslyResolvedAfs.resolvingUser).toBe(null) - expect(previouslyResolvedAfs.status).toBe(FlaggedSetStatus.flagged) - expect(previouslyResolvedAfs.applications.map((app) => app.id).includes(app1.id)).toBe(true) - expect(previouslyResolvedAfs.applications.map((app) => app.id).includes(app2.id)).toBe(true) - expect(previouslyResolvedAfs.applications.map((app) => app.id).includes(app3.id)).toBe(false) - }) - afterEach(async () => { await setupDb() jest.clearAllMocks() diff --git a/backend/core/test/lib/get-test-app-body.ts b/backend/core/test/lib/get-test-app-body.ts index f0598d8e6d..a4346cd310 100644 --- a/backend/core/test/lib/get-test-app-body.ts +++ b/backend/core/test/lib/get-test-app-body.ts @@ -1,4 +1,5 @@ import { + ApplicationReviewStatus, ApplicationStatus, ApplicationSubmissionType, ApplicationUpdate, @@ -13,6 +14,7 @@ export const getTestAppBody: (listingId?: string) => ApplicationUpdate = (listin id: listingId, }, language: Language.en, + reviewStatus: ApplicationReviewStatus.pending, status: ApplicationStatus.submitted, submissionType: ApplicationSubmissionType.electronical, acceptedTerms: false, diff --git a/backend/core/types/src/backend-swagger.ts b/backend/core/types/src/backend-swagger.ts index ac9e6fd941..59e244abe6 100644 --- a/backend/core/types/src/backend-swagger.ts +++ b/backend/core/types/src/backend-swagger.ts @@ -204,6 +204,38 @@ export class AmiChartsService { } export class ApplicationFlaggedSetsService { + /** + * Meta information for application flagged sets + */ + meta( + params: { + /** */ + page?: number + /** */ + limit?: number + /** */ + listingId: string + /** */ + view?: string + } = {} as any, + options: IRequestOptions = {} + ): Promise { + return new Promise((resolve, reject) => { + let url = basePath + "/applicationFlaggedSets/meta" + + const configs: IRequestConfig = getConfigs("get", "application/json", url, options) + configs.params = { + page: params["page"], + limit: params["limit"], + listingId: params["listingId"], + view: params["view"], + } + let data = null + + configs.data = data + axios(configs, resolve, reject) + }) + } /** * List application flagged sets */ @@ -215,6 +247,8 @@ export class ApplicationFlaggedSetsService { limit?: number /** */ listingId: string + /** */ + view?: string } = {} as any, options: IRequestOptions = {} ): Promise { @@ -226,6 +260,7 @@ export class ApplicationFlaggedSetsService { page: params["page"], limit: params["limit"], listingId: params["listingId"], + view: params["view"], } let data = null @@ -272,6 +307,42 @@ export class ApplicationFlaggedSetsService { let data = params.body + configs.data = data + axios(configs, resolve, reject) + }) + } + /** + * Reset flagged set confirmation alert + */ + resetConfirmationAlert( + params: { + /** requestBody */ + body?: Id + } = {} as any, + options: IRequestOptions = {} + ): Promise { + return new Promise((resolve, reject) => { + let url = basePath + "/applicationFlaggedSets/{id}" + + const configs: IRequestConfig = getConfigs("put", "application/json", url, options) + + let data = params.body + + configs.data = data + axios(configs, resolve, reject) + }) + } + /** + * Trigger the duplicate check process + */ + process(options: IRequestOptions = {}): Promise { + return new Promise((resolve, reject) => { + let url = basePath + "/applicationFlaggedSets/process" + + const configs: IRequestConfig = getConfigs("post", "application/json", url, options) + + let data = null + configs.data = data axios(configs, resolve, reject) }) @@ -2249,12 +2320,23 @@ export interface AmiChartUpdate { /** */ id?: string +} +export interface ApplicationFlaggedSetMeta { /** */ - createdAt?: Date + totalCount?: number /** */ - updatedAt?: Date + totalResolvedCount?: number + + /** */ + totalPendingCount?: number + + /** */ + totalNamePendingCount?: number + + /** */ + totalEmailPendingCount?: number } export interface Address { @@ -2543,6 +2625,9 @@ export interface Application { /** */ submissionType: ApplicationSubmissionType + /** */ + reviewStatus?: ApplicationReviewStatus + /** */ applicant: Applicant @@ -2660,16 +2745,22 @@ export interface ApplicationFlaggedSet { updatedAt: Date /** */ - rule: string + rule: EnumApplicationFlaggedSetRule /** */ - resolvedTime?: Date + ruleKey: string /** */ - status: EnumApplicationFlaggedSetStatus + resolvedTime?: Date /** */ listingId: string + + /** */ + showConfirmationAlert: boolean + + /** */ + status: EnumApplicationFlaggedSetStatus } export interface ApplicationFlaggedSetPaginationMeta { @@ -2706,6 +2797,14 @@ export interface ApplicationFlaggedSetResolve { /** */ applications: Id[] + + /** */ + status: EnumApplicationFlaggedSetResolveStatus +} + +export interface Status { + /** */ + status: string } export interface Asset { @@ -2817,12 +2916,6 @@ export interface AssetUpdate { /** */ id?: string - /** */ - createdAt?: Date - - /** */ - updatedAt?: Date - /** */ fileId: string @@ -2837,12 +2930,6 @@ export interface PaperApplicationUpdate { /** */ id?: string - /** */ - createdAt?: Date - - /** */ - updatedAt?: Date - /** */ file?: CombinedFileTypes } @@ -2854,12 +2941,6 @@ export interface ApplicationMethodUpdate { /** */ id?: string - /** */ - createdAt?: Date - - /** */ - updatedAt?: Date - /** */ paperApplications?: PaperApplicationUpdate[] @@ -3138,6 +3219,9 @@ export interface ApplicationCreate { /** */ submissionType: ApplicationSubmissionType + /** */ + reviewStatus?: ApplicationReviewStatus + /** */ listing: Id @@ -3462,13 +3546,10 @@ export interface ApplicationUpdate { submissionType: ApplicationSubmissionType /** */ - id?: string + reviewStatus?: ApplicationReviewStatus /** */ - createdAt?: Date - - /** */ - updatedAt?: Date + id?: string /** */ deletedAt?: Date @@ -3897,11 +3978,6 @@ export interface Email { appUrl?: string } -export interface Status { - /** */ - status: string -} - export interface Confirm { /** */ token: string @@ -3955,12 +4031,6 @@ export interface UserUpdate { /** */ email?: string - /** */ - createdAt?: Date - - /** */ - updatedAt?: Date - /** */ password?: string @@ -4167,12 +4237,6 @@ export interface JurisdictionUpdate { /** */ id?: string - /** */ - createdAt?: Date - - /** */ - updatedAt?: Date - /** */ name: string @@ -5007,6 +5071,12 @@ export interface Listing { /** */ closedAt?: Date + + /** */ + afsLastRunAt?: Date + + /** */ + lastApplicationUpdateAt?: Date } export interface PaginatedListing { @@ -5408,6 +5478,9 @@ export interface ListingCreate { /** */ customMapPin?: boolean + /** */ + lastApplicationUpdateAt?: Date + /** */ countyCode?: string @@ -5425,12 +5498,6 @@ export interface ListingEventUpdate { /** */ id?: string - /** */ - createdAt?: Date - - /** */ - updatedAt?: Date - /** */ file?: AssetUpdate @@ -5454,12 +5521,6 @@ export interface UnitAmiChartOverrideUpdate { /** */ id?: string - /** */ - createdAt?: Date - - /** */ - updatedAt?: Date - /** */ items: AmiChartItem[] } @@ -5468,12 +5529,6 @@ export interface UnitUpdate { /** */ id?: string - /** */ - createdAt?: Date - - /** */ - updatedAt?: Date - /** */ amiChart?: Id @@ -5610,12 +5665,6 @@ export interface ListingUpdate { /** */ id?: string - /** */ - createdAt?: Date - - /** */ - updatedAt?: Date - /** */ applicationMethods: ApplicationMethodUpdate[] @@ -5832,6 +5881,9 @@ export interface ListingUpdate { /** */ customMapPin?: boolean + /** */ + lastApplicationUpdateAt?: Date + /** */ countyCode?: string @@ -5848,9 +5900,6 @@ export interface MultiselectQuestionsFilterParams { /** */ jurisdiction?: string - - /** */ - applicationSection?: string } export interface MultiselectQuestionCreate { @@ -5977,12 +6026,6 @@ export interface TranslationUpdate { /** */ id?: string - /** */ - createdAt?: Date - - /** */ - updatedAt?: Date - /** */ translations: object @@ -6058,9 +6101,26 @@ export enum ApplicationSubmissionType { "paper" = "paper", "electronical" = "electronical", } + +export enum ApplicationReviewStatus { + "pending" = "pending", + "pendingAndValid" = "pendingAndValid", + "valid" = "valid", + "duplicate" = "duplicate", +} export type AllExtraDataTypes = BooleanInput | TextInput | AddressInput +export enum EnumApplicationFlaggedSetRule { + "Name and DOB" = "Name and DOB", + "Email" = "Email", +} export enum EnumApplicationFlaggedSetStatus { "flagged" = "flagged", + "pending" = "pending", + "resolved" = "resolved", +} +export enum EnumApplicationFlaggedSetResolveStatus { + "flagged" = "flagged", + "pending" = "pending", "resolved" = "resolved", } export enum ApplicationMethodType { diff --git a/shared-helpers/index.ts b/shared-helpers/index.ts index 34ad5b7c14..1906f3b578 100644 --- a/shared-helpers/index.ts +++ b/shared-helpers/index.ts @@ -18,3 +18,4 @@ export * from "./src/stringFormatting" export * from "./src/summaryTables" export * from "./src/token" export * from "./src/unitTypes" +export * from "./src/DateFormat" diff --git a/shared-helpers/src/DateFormat.ts b/shared-helpers/src/DateFormat.ts new file mode 100644 index 0000000000..264947fa68 --- /dev/null +++ b/shared-helpers/src/DateFormat.ts @@ -0,0 +1,11 @@ +import { t } from "@bloom-housing/ui-components" +import dayjs from "dayjs" + +function formatDateTime(date: Date, showTime?: boolean) { + return ( + dayjs(date).format("MMMM D, YYYY") + + (showTime ? ` ${t("t.at")} ` + dayjs(date).format("h:mmA") : "") + ) +} + +export { formatDateTime as default, formatDateTime } diff --git a/sites/partners/cypress/integration/04-application.spec.ts b/sites/partners/cypress/integration/04-application.spec.ts index cae311542b..325e550f71 100644 --- a/sites/partners/cypress/integration/04-application.spec.ts +++ b/sites/partners/cypress/integration/04-application.spec.ts @@ -10,7 +10,7 @@ describe("Application Management Tests", () => { it("Application grid should display correct number of results", () => { cy.visit("/") cy.getByTestId("listing-status-cell").eq(1).click() - cy.getByID("lbTotalPages").contains("20") + cy.getByID("lbTotalPages").contains("24") cy.get(".applications-table") .first() .find(".ag-center-cols-container") diff --git a/sites/partners/lib/hooks.ts b/sites/partners/lib/hooks.ts index fcc6eb03f6..662db11439 100644 --- a/sites/partners/lib/hooks.ts +++ b/sites/partners/lib/hooks.ts @@ -1,7 +1,7 @@ -import { useContext } from "react" -import useSWR, { mutate } from "swr" +import { useCallback, useContext, useState } from "react" +import useSWR from "swr" import qs from "qs" - +import dayjs from "dayjs" import { AuthContext } from "@bloom-housing/shared-helpers" import { ApplicationSection, @@ -12,7 +12,7 @@ import { EnumUserFilterParamsComparison, UserRolesOnly, } from "@bloom-housing/backend-core/types" - +import { setSiteAlertMessage } from "@bloom-housing/ui-components" export interface PaginationProps { page?: number limit: number | "all" @@ -27,6 +27,11 @@ interface UseSingleApplicationDataProps extends PaginationProps { listingId: string } +interface UseSingleFlaggedApplicationDataProps extends UseSingleApplicationDataProps { + view?: string + limit: number +} + type UseUserListProps = PaginationProps & { search?: string } @@ -130,24 +135,23 @@ export function useFlaggedApplicationsList({ listingId, page, limit, -}: UseSingleApplicationDataProps) { + view, +}: UseSingleFlaggedApplicationDataProps) { const { applicationFlaggedSetsService } = useContext(AuthContext) const params = { listingId, page, + limit, } - const queryParams = new URLSearchParams() - queryParams.append("listingId", listingId) - queryParams.append("page", page.toString()) - - if (typeof limit === "number") { - queryParams.append("limit", limit.toString()) - Object.assign(params, limit) + if (view) { + Object.assign(params, { view }) } - const endpoint = `${process.env.backendApiBase}/applicationFlaggedSets?${queryParams.toString()}` + const paramString = qs.stringify(params) + + const endpoint = `${process.env.backendApiBase}/applicationFlaggedSets?${paramString}` const fetcher = () => applicationFlaggedSetsService.list(params) @@ -155,9 +159,11 @@ export function useFlaggedApplicationsList({ return { data, + loading: !error && !data, error, } } + export function useApplicationsData( currentPage: number, delayedFilterValue: string, @@ -199,23 +205,48 @@ export function useApplicationsData( appsError: error, } } + +export function useFlaggedApplicationsMeta(listingId: string) { + const { applicationFlaggedSetsService } = useContext(AuthContext) + + const params = { + listingId, + } + + const queryParams = new URLSearchParams() + queryParams.append("listingId", listingId) + + const endpoint = `${ + process.env.backendApiBase + }/applicationFlaggedSetsMeta?${queryParams.toString()}` + + const fetcher = () => applicationFlaggedSetsService.meta(params) + + const { data, error } = useSWR(endpoint, fetcher) + + return { + data, + loading: !error && !data, + error, + } +} export function useSingleFlaggedApplication(afsId: string) { const { applicationFlaggedSetsService } = useContext(AuthContext) - const endpoint = `${process.env.backendApiBase}/applicationFlaggedSets/${afsId}` const fetcher = () => applicationFlaggedSetsService.retrieve({ afsId, }) - const { data, error } = useSWR(endpoint, fetcher) + const cacheKey = `${process.env.backendApiBase}/applicationFlaggedSets/${afsId}` - const revalidate = () => mutate(endpoint) + const { data, error } = useSWR(cacheKey, fetcher) return { - revalidate, + cacheKey, data, error, + loading: !error && !data, } } @@ -423,3 +454,42 @@ export function useUserList({ page, limit, search = "" }: UseUserListProps) { error, } } + +export const useApplicationsExport = (listingId: string, includeDemographics: boolean) => { + const { applicationsService } = useContext(AuthContext) + + const [csvExportLoading, setCsvExportLoading] = useState(false) + const [csvExportError, setCsvExportError] = useState(false) + + const onExport = useCallback(async () => { + setCsvExportError(false) + setCsvExportLoading(true) + + try { + const content = await applicationsService.listAsCsv({ + listingId, + includeDemographics, + }) + + const now = new Date() + const dateString = dayjs(now).format("YYYY-MM-DD_HH:mm:ss") + + const blob = new Blob([content], { type: "text/csv" }) + const fileLink = document.createElement("a") + fileLink.setAttribute("download", `applications-${listingId}-${dateString}.csv`) + fileLink.href = URL.createObjectURL(blob) + fileLink.click() + } catch (err) { + setCsvExportError(true) + setSiteAlertMessage(err.response.data.error, "alert") + } + + setCsvExportLoading(false) + }, [applicationsService, includeDemographics, listingId]) + + return { + onExport, + csvExportLoading, + csvExportError, + } +} diff --git a/sites/partners/page_content/locale_overrides/general.json b/sites/partners/page_content/locale_overrides/general.json index 4829e64be2..e7aa2b399d 100644 --- a/sites/partners/page_content/locale_overrides/general.json +++ b/sites/partners/page_content/locale_overrides/general.json @@ -40,6 +40,7 @@ "application.details.programs": "Application Programs", "application.details.residenceAddress": "Residence Address", "application.details.signatureOnTerms": "Signature on Terms of Agreement", + "application.details.submissionType.digital": "Digital", "application.details.submissionType.electronical": "Electronic", "application.details.submissionType.paper": "Paper", "application.details.submittedBy": "Submitted By", @@ -49,7 +50,19 @@ "application.details.type": "Application Submission Type", "application.details.vouchers": "Housing Voucher or Subsidy", "application.details.workInRegion": "Work in Region", + "applications.allApplications": "All Applications", + "applications.duplicate": "Duplicate", + "applications.duplicatesAlert": "Preview applications that are pending review. Duplicates can be resolved when applications close.", + "applications.duplicatesAlertDate": "Preview applications that are pending review. Duplicates can be resolved when applications close on %{date}.", + "applications.duplicates.duplicateApplications": "Duplicate Applications", + "applications.duplicates.validApplications": "Valid Applications", + "applications.duplicates.duplicateGroup": "Duplicate group", + "applications.duplicates.primaryApplicant": "Primary applicant", + "applications.namedob": "Name + DOB", "applications.newApplication": "New Application", + "applications.pending": "Pending", + "applications.pendingReview": "Pending Review", + "applications.scanForDuplicates": "Scan for duplicates", "applications.table.additionalPhoneType": "Additional Phone Type", "applications.table.altContactAgency": "Alt Contact Agency", "applications.table.altContactCity": "Alt Contact City", @@ -78,28 +91,39 @@ "applications.table.residenceState": "Residence State", "applications.table.residenceStreet": "Residence Street Address", "applications.table.residenceZip": "Residence Zip", - "applications.table.subsidyOrVoucher": "Subsidy or Voucher", + "applications.table.reviewStatus": "Application Status", "applications.table.searchError": "Enter at least 3 characters to search", + "applications.table.subsidyOrVoucher": "Subsidy or Voucher", "applications.table.workCity": "Work City", "applications.table.workState": "Work State", "applications.table.workStreet": "Work Street Address", "applications.table.workZip": "Work Zip", "applications.totalApplications": "Total Applications", "applications.totalSets": "Total Sets", + "applications.valid": "Valid", + "applications.validPending": "Valid (Pending)", "authentication.createAccount.errors.tokenMissing": "Wrong token provided.", "authentication.createAccount.firstName": "First Name", "authentication.createAccount.lastName": "Last Name", "errors.alert.emailConflict": "That email is already in use", + "errors.maxLessThanMinOccupancyError": "Max Occupancy must be greater than or equal to Minimum Occupancy", + "errors.minGreaterThanMaxOccupancyError": "Minimum Occupancy must be less than or equal to Max Occupancy", + "errors.partialAddress": "Cannot enter a partial address", "errors.unauthorized.message": "Uh oh, you are not allowed to access this page.", "errors.unauthorized.title": "Unauthorized", "errors.urlError": "Please enter a valid url", - "errors.partialAddress": "Cannot enter a partial address", - "errors.minGreaterThanMaxOccupancyError": "Minimum Occupancy must be less than or equal to Max Occupancy", - "errors.maxLessThanMinOccupancyError": "Max Occupancy must be greater than or equal to Minimum Occupancy", + "flags.confirmationAlertPlural": "You confirmed %{amount} valid applications. You can make changes and save your updates.", + "flags.confirmationAlertSingular": "You confirmed 1 valid application. You can make changes and save your updates.", + "flags.emailRule": "%{email}: Email", "flags.flaggedSet": "Flagged Set", "flags.markedAsDuplicate": "%{quantity} applications marked as duplicate", + "flags.nameDobRule": "%{name}: Name + DOB", + "flags.pendingDescription": "One or more applications are still pending review.", "flags.resolveFlag": "Resolve Flag", + "flags.resolvedDescription": "Selected applications will be marked as valid and all others will be confirmed as duplicates.", "flags.ruleName": "Rule Name", + "flags.selectValidApplications": "Select Valid Applications", + "flags.updateStatus": "Update Status", "leasingAgent.name": "Leasing Agent Name", "leasingAgent.namePlaceholder": "Full Name", "leasingAgent.officeHoursPlaceholder": "ex: 9:00am - 5:00pm, Monday to Friday", @@ -212,9 +236,9 @@ "listings.sections.lotteryResultsEdit": "Edit Results", "listings.sections.lotteryResultsHelperText": "Upload Results", "listings.sections.openHouse": "Open House", + "listings.sections.photoHelperText": "Select JPEG or PNG files", "listings.sections.photoSubtitle": "Upload an image for the listing that will be used as a preview.", "listings.sections.photoTitle": "Listing Photo", - "listings.sections.photoHelperText": "Select JPEG or PNG files", "listings.sections.rankingsResultsSubtitle": "Provide details about what happens to applications once they are submitted.", "listings.sections.rankingsResultsTitle": "Rankings & Results", "listings.selectJurisdiction": "You must first select a jurisdiction", @@ -335,17 +359,20 @@ "t.label": "Label", "t.language": "Language", "t.link": "Link", - "t.listingSingle": "Listing", "t.listing": "Listings", + "t.listingSingle": "Listing", "t.notes": "Notes", "t.option": "Option", "t.optional": "Optional", "t.order": "Order", "t.otherRelationShip": "Other Relationship", + "t.pending": "Pending", "t.post": "Post", "t.preview": "Preview", "t.previewLowercase": "preview", + "t.resolved": "Resolved", "t.role": "Role", + "t.rule": "Rule", "t.save": "Save", "t.saveExit": "Save & Exit", "t.saveNew": "Save & New", @@ -361,14 +388,14 @@ "users.addPassword": "Add a Password", "users.addUser": "Add User", "users.administrator": "Administrator", - "users.allListings": "All listings", "users.allJurisdictions": "All Jurisdictions", + "users.allListings": "All listings", + "users.alljurisdictionalizedListings": "All %{jurisdiction} Listings", "users.confirmAccount": "Confirm Account", "users.confirmed": "Confirmed", "users.doYouWantDeleteUser": "Do you really want to delete this user?", "users.editUser": "Edit User", "users.jurisdictionalizedListings": "%{jurisdiction} Listings", - "users.alljurisdictionalizedListings": "All %{jurisdiction} Listings", "users.makeNote": "When creating your password make sure you make note of it so you remember it in the future.", "users.needUniquePassword": "You'll need to add a unique password in order to confirm your account.", "users.partner": "Partner", diff --git a/sites/partners/pages/application/[id]/applicationsCols.tsx b/sites/partners/pages/application/[id]/applicationsCols.tsx new file mode 100644 index 0000000000..9fd91ddc40 --- /dev/null +++ b/sites/partners/pages/application/[id]/applicationsCols.tsx @@ -0,0 +1,110 @@ +import Link from "next/link" + +import { t } from "@bloom-housing/ui-components" +import { convertDataToPst } from "../../../lib/helpers" +import { ApplicationSubmissionType } from "@bloom-housing/backend-core/types" + +export const getCols = () => [ + { + headerName: t("application.details.number"), + field: "id", + sortable: false, + filter: false, + resizable: true, + pinned: "left", + headerCheckboxSelection: true, + checkboxSelection: true, + cellRendererFramework: ({ data }) => { + if (!data?.id && !data?.confirmationCode) return "" + return ( + + {data.confirmationCode || data.id} + + ) + }, + width: 180, + }, + + { + headerName: t("application.name.firstName"), + field: "applicant.firstName", + sortable: false, + filter: false, + resizable: true, + unSortIcon: true, + }, + { + headerName: t("application.name.lastName"), + field: "applicant.lastName", + sortable: false, + filter: false, + resizable: true, + unSortIcon: true, + }, + { + headerName: t("applications.table.primaryDob"), + field: "applicant", + sortable: false, + filter: false, + resizable: true, + unSortIcon: true, + valueFormatter: ({ value }) => { + if (!value) return "" + + const isValidDOB = !!value?.birthMonth && !!value?.birthDay && value?.birthYear + + return isValidDOB ? `${value.birthMonth}/${value.birthDay}/${value.birthYear}` : "" + }, + width: 120, + }, + { + headerName: t("application.details.type"), + field: "submissionType", + sortable: false, + filter: false, + resizable: true, + unSortIcon: true, + valueGetter: ({ data }) => { + return data.submissionType === ApplicationSubmissionType.electronical + ? t("application.details.submissionType.digital") + : t("application.details.submissionType.paper") + }, + width: 130, + }, + { + headerName: t("applications.table.applicationSubmissionDate"), + field: "submissionDate", + sortable: false, + filter: false, + resizable: true, + valueGetter: ({ data }) => { + if (!data?.submissionDate) return "" + + const { submissionDate } = data + + const dateTime = convertDataToPst( + submissionDate, + data?.submissionType || ApplicationSubmissionType.electronical + ) + + return `${dateTime.date} ${t("t.at")} ${dateTime.time}` + }, + }, + { + headerName: t("applications.table.reviewStatus"), + field: "reviewStatus", + pinned: "right", + sortable: false, + filter: false, + resizable: true, + valueGetter: ({ data }) => { + if (data.reviewStatus === "valid") return t("applications.valid") + if (data.reviewStatus === "pendingAndValid") return t("applications.validPending") + if (data.reviewStatus === "duplicate") return t("applications.duplicate") + return t("applications.pending") + }, + width: 140, + }, +] + +export default getCols diff --git a/sites/partners/pages/application/[id]/review.tsx b/sites/partners/pages/application/[id]/review.tsx new file mode 100644 index 0000000000..fa19487cb6 --- /dev/null +++ b/sites/partners/pages/application/[id]/review.tsx @@ -0,0 +1,285 @@ +import React, { useMemo, useState, useContext } from "react" +import Head from "next/head" +import dayjs from "dayjs" +import { useSWRConfig } from "swr" +import { useRouter } from "next/router" +import { GridApi } from "ag-grid-community" +import { useForm } from "react-hook-form" +import { + t, + Button, + NavigationHeader, + AlertBox, + AppearanceStyleType, + useMutate, + StatusBar, + AgTable, + useAgTable, + GridSection, + Modal, + AppearanceBorderType, + Field, +} from "@bloom-housing/ui-components" +import { useSingleFlaggedApplication } from "../../../lib/hooks" +import Layout from "../../../layouts" +import { getCols } from "./applicationsCols" +import { AuthContext } from "@bloom-housing/shared-helpers" +import { + ApplicationFlaggedSet, + ApplicationReviewStatus, + EnumApplicationFlaggedSetStatus, + EnumApplicationFlaggedSetResolveStatus, + ApplicationFlaggedSetResolve, +} from "@bloom-housing/backend-core/types" + +const Flag = () => { + const router = useRouter() + const flagsetId = router.query.id as string + + const [saveModalOpen, setSaveModalOpen] = useState(false) + const [gridApi, setGridApi] = useState(null) + + const columns = useMemo(() => getCols(), []) + + const { data, cacheKey } = useSingleFlaggedApplication(flagsetId) + const { reset, isSuccess, isLoading, isError } = useMutate() + const { applicationFlaggedSetsService } = useContext(AuthContext) + + const { mutate } = useSWRConfig() + + const { mutate: saveSetMutate, isLoading: isSaveLoading } = useMutate() + + const saveSet = (formattedData: ApplicationFlaggedSetResolve) => { + void saveSetMutate(() => + applicationFlaggedSetsService + .resolve({ + body: formattedData, + }) + .then(() => { + // next issue: set success alert + }) + .catch((e) => { + // next issue: set failure alert + console.log(e) + }) + .finally(() => { + void mutate(cacheKey) + }) + ) + } + + // eslint-disable-next-line @typescript-eslint/unbound-method + const { register, getValues } = useForm() + + const selectFlaggedApps = (data: ApplicationFlaggedSet, gridApi: GridApi) => { + if (!data || !gridApi) return + gridApi.forEachNode((row) => { + row.setSelected( + row.data.reviewStatus === ApplicationReviewStatus.pendingAndValid || + row.data.reviewStatus === ApplicationReviewStatus.valid + ) + }) + } + + const tableOptions = useAgTable() + + if (!data) return null + + const getTitle = () => { + if (data.rule === "Email") { + return t(`flags.emailRule`, { + email: data?.applications[0].applicant.emailAddress, + }) + } else if (data?.rule === "Name and DOB") { + return t("flags.nameDobRule", { + name: `${data?.applications[0].applicant.firstName} ${data?.applications[0].applicant.lastName}`, + }) + } + return "" + } + + const numberConfirmedApps = data?.applications?.filter( + (app) => app.reviewStatus === ApplicationReviewStatus.valid + ).length + + return ( + + + {t("nav.siteTitlePartners")} + + + {getTitle()}

} + /> + +
+ router.back()}> + {t("t.back")} + + } + tagStyle={ + data?.status === EnumApplicationFlaggedSetStatus.resolved + ? AppearanceStyleType.success + : AppearanceStyleType.primary + } + tagLabel={ + data?.status === EnumApplicationFlaggedSetStatus.resolved + ? t("t.resolved") + : t("applications.pendingReview") + } + /> +
+ +
+
+ {(isSuccess || isError) && ( + reset()} + > + {isSuccess ? t("t.updated") : t("account.settings.alerts.genericError")} + + )} + +
+
+ {data?.showConfirmationAlert && ( + { + await applicationFlaggedSetsService?.resetConfirmationAlert({ + body: { id: data.id }, + }) + void mutate(cacheKey) + }} + > + {numberConfirmedApps !== 1 + ? t("flags.confirmationAlertPlural", { amount: numberConfirmedApps }) + : t("flags.confirmationAlertSingular")} + + )} +

{t("flags.selectValidApplications")}

+ selectFlaggedApps(data, gridApi), + }} + /> +
+ + +
+
+
+ setSaveModalOpen(false)} + actions={[ + , + , + ]} + > + +

{t("flags.pendingDescription")}

+ + +

{t("flags.resolvedDescription")}

+
+
+ ) +} + +export default Flag diff --git a/sites/partners/pages/listings/[id]/applications/index.tsx b/sites/partners/pages/listings/[id]/applications/index.tsx index fe9174483c..c154d55690 100644 --- a/sites/partners/pages/listings/[id]/applications/index.tsx +++ b/sites/partners/pages/listings/[id]/applications/index.tsx @@ -1,6 +1,5 @@ -import React, { useState, useMemo, useContext } from "react" +import React, { useContext, useMemo } from "react" import { useRouter } from "next/router" -import dayjs from "dayjs" import Head from "next/head" import { AgTable, @@ -8,7 +7,6 @@ import { Button, LocalizedLink, SiteAlert, - setSiteAlertMessage, useAgTable, Breadcrumbs, BreadcrumbLink, @@ -19,6 +17,7 @@ import { useSingleListingData, useFlaggedApplicationsList, useApplicationsData, + useApplicationsExport, } from "../../../../lib/hooks" import { ListingStatusBar } from "../../../../src/listings/ListingStatusBar" import Layout from "../../../../layouts" @@ -27,21 +26,25 @@ import { EnumApplicationsApiExtraModelOrder, EnumApplicationsApiExtraModelOrderBy, } from "@bloom-housing/backend-core/types" +import { ApplicationsSideNav } from "../../../../src/applications/ApplicationsSideNav" const ApplicationsList = () => { - const { applicationsService, profile } = useContext(AuthContext) + const { profile } = useContext(AuthContext) const router = useRouter() + const listingId = router.query.id as string const tableOptions = useAgTable() - const [csvExportLoading, setCsvExportLoading] = useState(false) - const [csvExportError, setCsvExportError] = useState(false) + const { onExport, csvExportLoading, csvExportError } = useApplicationsExport( + listingId, + profile?.roles?.isAdmin ?? false + ) /* Data Fetching */ - const listingId = router.query.id as string const { listingDto } = useSingleListingData(listingId) const countyCode = listingDto?.countyCode const listingName = listingDto?.name + const isListingOpen = listingDto?.status === "active" const { data: flaggedApps } = useFlaggedApplicationsList({ listingId, page: 1, @@ -67,9 +70,10 @@ const ApplicationsList = () => { this.linkWithId.classList.add("text-blue-700") this.linkWithId.innerText = params.value - this.linkWithId.addEventListener("click", function () { - void router.push(`/application/${applicationId}`) - }) + !isListingOpen && + this.linkWithId.addEventListener("click", function () { + void router.push(`/application/${applicationId}`) + }) } getGui() { @@ -77,32 +81,6 @@ const ApplicationsList = () => { } } - const onExport = async () => { - setCsvExportError(false) - setCsvExportLoading(true) - - try { - const content = await applicationsService.listAsCsv({ - listingId, - includeDemographics: profile?.roles?.isAdmin ?? false, - }) - - const now = new Date() - const dateString = dayjs(now).format("YYYY-MM-DD_HH:mm:ss") - - const blob = new Blob([content], { type: "text/csv" }) - const fileLink = document.createElement("a") - fileLink.setAttribute("download", `applications-${listingId}-${dateString}.csv`) - fileLink.href = URL.createObjectURL(blob) - fileLink.click() - } catch (err) { - setCsvExportError(true) - setSiteAlertMessage(err.response.data.error, "alert") - } - - setCsvExportLoading(false) - } - // get the highest value from householdSize and limit to 6 const maxHouseholdSize = useMemo(() => { let max = 1 @@ -140,7 +118,6 @@ const ApplicationsList = () => { flagsQty: flaggedApps?.meta?.totalFlagged, listingLabel: t("t.listingSingle"), applicationsLabel: t("nav.applications"), - flagsLabel: t("nav.flags"), }} breadcrumbs={ @@ -155,51 +132,62 @@ const ApplicationsList = () => { -
-
- - - - - - - - } - /> +
+
+ {listingDto && ( + <> + + + + + + + + + + } + /> + + )}
diff --git a/sites/partners/pages/listings/[id]/applications/pending/index.tsx b/sites/partners/pages/listings/[id]/applications/pending/index.tsx new file mode 100644 index 0000000000..cc7ade394d --- /dev/null +++ b/sites/partners/pages/listings/[id]/applications/pending/index.tsx @@ -0,0 +1,203 @@ +import React from "react" +import { useRouter } from "next/router" +import Head from "next/head" +import { + AgTable, + t, + useAgTable, + Breadcrumbs, + BreadcrumbLink, + NavigationHeader, + AlertBox, +} from "@bloom-housing/ui-components" +import { useSingleListingData, useFlaggedApplicationsList } from "../../../../../lib/hooks" +import { ListingStatusBar } from "../../../../../src/listings/ListingStatusBar" +import Layout from "../../../../../layouts" +import { ApplicationsSideNav } from "../../../../../src/applications/ApplicationsSideNav" +import { formatDateTime } from "@bloom-housing/shared-helpers/src/DateFormat" + +const ApplicationsList = () => { + const router = useRouter() + const listingId = router.query.id as string + const type = router.query.type as string + + const tableOptions = useAgTable() + + /* Data Fetching */ + const { listingDto } = useSingleListingData(listingId) + const listingName = listingDto?.name + const isListingOpen = listingDto?.status === "active" + let view = "pending" + if (type && type === "name_dob") { + view = "pendingNameAndDoB" + } else if (type && type === "email") { + view = "pendingEmail" + } + + const { data: flaggedAppsData, loading: flaggedAppsLoading } = useFlaggedApplicationsList({ + listingId, + page: tableOptions.pagination.currentPage, + limit: tableOptions.pagination.itemsPerPage, + view, + }) + + const columns = [ + { + headerName: t("applications.duplicates.duplicateGroup"), + field: "id", + cellRenderer: "formatLinkCell", + valueGetter: ({ data }) => { + if (!data?.applications?.length) return "" + const applicant = data.applications[0]?.applicant + + return `${applicant.firstName} ${applicant.lastName}: ${data.rule}` + }, + flex: 1, + minWidth: 250, + }, + { + headerName: t("applications.duplicates.primaryApplicant"), + field: "", + valueGetter: ({ data }) => { + if (!data?.applications?.length) return "" + const applicant = data.applications[0]?.applicant + + return `${applicant.firstName} ${applicant.lastName}` + }, + }, + { + headerName: t("t.rule"), + field: "rule", + width: 150, + }, + { + headerName: t("applications.pendingReview"), + field: "", + valueGetter: ({ data }) => { + return `${data?.applications?.length ?? 0}` + }, + type: "rightAligned", + width: 100, + }, + ] + + class formatEnabledCell { + linkWithId: HTMLSpanElement + init(params) { + const applicationId = params.data.id + this.linkWithId = document.createElement("button") + this.linkWithId.innerText = params.value + this.linkWithId.classList.add("text-blue-700") + this.linkWithId.addEventListener("click", function () { + void router.push(`/application/${applicationId}/review`) + }) + } + getGui() { + return this.linkWithId + } + } + class formatDisabledCell { + disabledLink: HTMLSpanElement + init(params) { + this.disabledLink = document.createElement("button") + this.disabledLink.innerText = params.value + this.disabledLink.classList.add("text-gray-750") + this.disabledLink.classList.add("cursor-default") + } + getGui() { + return this.disabledLink + } + } + + const gridComponents = { + formatLinkCell: isListingOpen ? formatDisabledCell : formatEnabledCell, + } + + return ( + + + {t("nav.siteTitlePartners")} + + + + {t("t.listing")} + {listingName} + + {t("nav.applications")} + + + {t("t.pending")} + + + } + /> + + + +
+
+ {listingDto && ( + <> + + +
+ {isListingOpen && ( + + {listingDto?.applicationDueDate + ? t("applications.duplicatesAlertDate", { + date: formatDateTime(listingDto.applicationDueDate, true), + }) + : t("applications.duplicatesAlert")} + + )} + +
+ + )} +
+
+
+ ) +} + +export default ApplicationsList diff --git a/sites/partners/pages/listings/[id]/applications/resolved/index.tsx b/sites/partners/pages/listings/[id]/applications/resolved/index.tsx new file mode 100644 index 0000000000..e78c7b360c --- /dev/null +++ b/sites/partners/pages/listings/[id]/applications/resolved/index.tsx @@ -0,0 +1,154 @@ +import React from "react" +import { useRouter } from "next/router" +import Head from "next/head" +import { + AgTable, + t, + useAgTable, + Breadcrumbs, + BreadcrumbLink, + NavigationHeader, +} from "@bloom-housing/ui-components" +import { useSingleListingData, useFlaggedApplicationsList } from "../../../../../lib/hooks" +import { ListingStatusBar } from "../../../../../src/listings/ListingStatusBar" +import Layout from "../../../../../layouts" +import { ApplicationsSideNav } from "../../../../../src/applications/ApplicationsSideNav" +import { getLinkCellFormatter } from "../../../../../src/applications/helpers" +import { Application, ApplicationReviewStatus } from "@bloom-housing/backend-core" + +const ApplicationsList = () => { + const router = useRouter() + const listingId = router.query.id as string + + const tableOptions = useAgTable() + + /* Data Fetching */ + const { listingDto } = useSingleListingData(listingId) + const listingName = listingDto?.name + const { data: flaggedAppsData, loading: flaggedAppsLoading } = useFlaggedApplicationsList({ + listingId, + page: tableOptions.pagination.currentPage, + limit: tableOptions.pagination.itemsPerPage, + view: "resolved", + }) + + const columns = [ + { + headerName: t("applications.duplicates.duplicateGroup"), + field: "id", + cellRenderer: "formatLinkCell", + valueGetter: ({ data }) => { + if (!data?.applications?.length) return "" + const applicant = data.applications[0]?.applicant + + return `${applicant.firstName} ${applicant.lastName}: ${data.rule}` + }, + flex: 1, + minWidth: 250, + }, + { + headerName: t("applications.duplicates.primaryApplicant"), + field: "", + valueGetter: ({ data }) => { + if (!data?.applications?.length) return "" + const applicant = data.applications[0]?.applicant + + return `${applicant.firstName} ${applicant.lastName}` + }, + }, + { + headerName: t("applications.duplicates.duplicateApplications"), + field: "", + valueGetter: ({ data }) => { + return data?.applications?.filter( + (app: Application) => app.reviewStatus === ApplicationReviewStatus.duplicate + ).length + }, + type: "rightAligned", + width: 130, + }, + { + headerName: t("applications.duplicates.validApplications"), + field: "", + valueGetter: ({ data }) => { + return data?.applications?.filter( + (app: Application) => app.reviewStatus === ApplicationReviewStatus.valid + ).length + }, + type: "rightAligned", + width: 130, + }, + ] + + return ( + + + {t("nav.siteTitlePartners")} + + + + {t("t.listing")} + {listingName} + + {t("nav.applications")} + + + {t("t.resolved")} + + + } + /> + + + +
+
+ + +
+ +
+
+
+
+ ) +} + +export default ApplicationsList diff --git a/sites/partners/pages/listings/[id]/flags/[flagId]/index.tsx b/sites/partners/pages/listings/[id]/flags/[flagId]/index.tsx deleted file mode 100644 index 6f133836ac..0000000000 --- a/sites/partners/pages/listings/[id]/flags/[flagId]/index.tsx +++ /dev/null @@ -1,213 +0,0 @@ -import React, { useMemo, useState, useCallback, useContext, useEffect } from "react" -import Head from "next/head" -import { useRouter } from "next/router" -import { AgGridReact } from "ag-grid-react" -import { GridApi, RowNode, GridOptions } from "ag-grid-community" - -import Layout from "../../../../../layouts/" -import { - t, - Button, - NavigationHeader, - AlertBox, - AppearanceStyleType, - useMutate, - StatusBar, -} from "@bloom-housing/ui-components" -import { AuthContext } from "@bloom-housing/shared-helpers" -import { useSingleFlaggedApplication } from "../../../../../lib/hooks" -import { getCols } from "../../../../../src/flags/applicationsCols" -import { - EnumApplicationFlaggedSetStatus, - ApplicationFlaggedSet, -} from "@bloom-housing/backend-core/types" - -/* TODO: refactor this component to use the AgTable if search and pagination are needed */ - -const Flag = () => { - const { applicationFlaggedSetsService } = useContext(AuthContext) - - const router = useRouter() - const flagsetId = router.query.flagId as string - const listingId = router.query.id as string - - const [gridApi, setGridApi] = useState(null) - const [selectedRows, setSelectedRows] = useState([]) - - const columns = useMemo(() => getCols(), []) - - const { data, revalidate } = useSingleFlaggedApplication(flagsetId) - - const { mutate, reset, isSuccess, isLoading, isError } = useMutate() - - const gridOptions: GridOptions = { - getRowNodeId: (data) => data.id, - } - - /* It selects all flagged rows on init and update (revalidate). */ - const selectFlaggedApps = useCallback(() => { - if (!data) return - - const duplicateIds = data.applications - .filter((item) => item.markedAsDuplicate) - .map((item) => item.id) - - gridApi.forEachNode((row) => { - if (duplicateIds.includes(row.id)) { - gridApi.selectNode(row, true) - } - }) - }, [data, gridApi]) - - useEffect(() => { - if (!gridApi) return - - selectFlaggedApps() - }, [data, gridApi, selectFlaggedApps]) - - const onGridReady = (params) => { - setGridApi(params.api) - } - - const onSelectionChanged = () => { - const selected = gridApi.getSelectedNodes() - setSelectedRows(selected) - } - - const deselectAll = useCallback(() => { - gridApi.deselectAll() - }, [gridApi]) - - const resolveFlag = useCallback(() => { - const applicationIds = selectedRows?.map((item) => ({ id: item.data.id })) || [] - - void reset() - - void mutate(() => - applicationFlaggedSetsService.resolve({ - body: { - afsId: flagsetId, - applications: applicationIds, - }, - }) - ).then(() => { - deselectAll() - void revalidate() - }) - }, [ - mutate, - reset, - revalidate, - deselectAll, - selectedRows, - applicationFlaggedSetsService, - flagsetId, - ]) - - if (!data) return null - - return ( - - - {t("nav.siteTitlePartners")} - - - -

{data.rule}

- - } - /> - -
- router.push(`/listings/${listingId}/flags`)} - > - {t("t.back")} - - } - tagStyle={ - data.status === EnumApplicationFlaggedSetStatus.resolved - ? AppearanceStyleType.success - : AppearanceStyleType.info - } - tagLabel={data.status} - /> -
- -
-
- {(isSuccess || isError) && ( - reset()} - > - {isSuccess ? "Updated" : t("account.settings.alerts.genericError")} - - )} - -
-
- - -
-
- -
-
-
-
- -
- - {t("flags.markedAsDuplicate", { - quantity: selectedRows.length, - })} - - - -
-
-
-
- ) -} - -export default Flag diff --git a/sites/partners/pages/listings/[id]/flags/index.tsx b/sites/partners/pages/listings/[id]/flags/index.tsx deleted file mode 100644 index 35aa92b0b2..0000000000 --- a/sites/partners/pages/listings/[id]/flags/index.tsx +++ /dev/null @@ -1,107 +0,0 @@ -import React, { useState, useMemo, useEffect } from "react" -import Head from "next/head" -import { useRouter } from "next/router" -import { AgGridReact } from "ag-grid-react" -import { ListingStatusBar } from "../../../../src/listings/ListingStatusBar" -import { useFlaggedApplicationsList, useSingleListingData } from "../../../../lib/hooks" -import Layout from "../../../../layouts" -import { - t, - AgPagination, - AG_PER_PAGE_OPTIONS, - Breadcrumbs, - BreadcrumbLink, - NavigationHeader, -} from "@bloom-housing/ui-components" -import { getFlagSetCols } from "../../../../src/flags/flagSetCols" - -const FlagsPage = () => { - const router = useRouter() - const listingId = router.query.id as string - - /* Pagination */ - const [itemsPerPage, setItemsPerPage] = useState(AG_PER_PAGE_OPTIONS[0]) - const [currentPage, setCurrentPage] = useState(1) - - // reset page to 1 when user change limit - useEffect(() => { - setCurrentPage(1) - }, [itemsPerPage]) - - const { listingDto } = useSingleListingData(listingId) - - const { data } = useFlaggedApplicationsList({ - listingId, - page: currentPage, - limit: itemsPerPage, - }) - - const listingName = listingDto?.name - - const defaultColDef = { - resizable: true, - maxWidth: 300, - } - - const columns = useMemo(() => getFlagSetCols(), []) - - if (!data) return null - - return ( - - - {t("nav.siteTitlePartners")} - - - - {t("t.listing")} - {listingName} - - {t("nav.flags")} - - - } - /> - - - -
-
-
- - - -
-
-
-
- ) -} - -export default FlagsPage diff --git a/sites/partners/pages/listings/[id]/index.tsx b/sites/partners/pages/listings/[id]/index.tsx index ffd872ce17..493a3a28f5 100644 --- a/sites/partners/pages/listings/[id]/index.tsx +++ b/sites/partners/pages/listings/[id]/index.tsx @@ -35,7 +35,6 @@ import DetailApplicationDates from "../../../src/listings/PaperListingDetails/se import DetailPreferences from "../../../src/listings/PaperListingDetails/sections/DetailPreferences" import DetailCommunityType from "../../../src/listings/PaperListingDetails/sections/DetailCommunityType" import DetailPrograms from "../../../src/listings/PaperListingDetails/sections/DetailPrograms" -import { useFlaggedApplicationsList } from "../../../lib/hooks" interface ListingProps { listing: Listing @@ -46,12 +45,6 @@ export default function ListingDetail(props: ListingProps) { const [errorAlert, setErrorAlert] = useState(false) const [unitDrawer, setUnitDrawer] = useState(null) - const { data: flaggedApps } = useFlaggedApplicationsList({ - listingId: listing.id, - page: 1, - limit: 1, - }) - if (!listing) return null return ( @@ -68,10 +61,8 @@ export default function ListingDetail(props: ListingProps) { listingId={listing.id} tabs={{ show: listing.status !== ListingStatus.pending, - flagsQty: flaggedApps?.meta?.totalFlagged, listingLabel: t("t.listingSingle"), applicationsLabel: t("nav.applications"), - flagsLabel: t("nav.flags"), }} breadcrumbs={ diff --git a/sites/partners/src/applications/ApplicationsColDefs.ts b/sites/partners/src/applications/ApplicationsColDefs.ts index ad29ed4b6a..1f771f75e5 100644 --- a/sites/partners/src/applications/ApplicationsColDefs.ts +++ b/sites/partners/src/applications/ApplicationsColDefs.ts @@ -42,7 +42,6 @@ export function getColDefs(maxHouseholdSize: number, countyCode: string) { sortable: true, unSortIcon: true, filter: false, - pinned: "left", width: 200, minWidth: 150, sort: "asc", @@ -67,7 +66,6 @@ export function getColDefs(maxHouseholdSize: number, countyCode: string) { filter: false, width: 150, minWidth: 120, - pinned: "left", cellRenderer: "formatLinkCell", }, { @@ -78,7 +76,6 @@ export function getColDefs(maxHouseholdSize: number, countyCode: string) { filter: false, width: 150, minWidth: 120, - pinned: "left", valueFormatter: ({ value }) => t(`application.details.submissionType.${value}`), comparator: compareStrings, }, @@ -89,7 +86,6 @@ export function getColDefs(maxHouseholdSize: number, countyCode: string) { sortable: true, unSortIcon: true, filter: false, - pinned: "left", width: 125, minWidth: 100, comparator: compareStrings, @@ -101,7 +97,6 @@ export function getColDefs(maxHouseholdSize: number, countyCode: string) { sortable: true, unSortIcon: true, filter: "agTextColumnFilter", - pinned: "left", width: 125, minWidth: 100, comparator: compareStrings, diff --git a/sites/partners/src/applications/ApplicationsSideNav.tsx b/sites/partners/src/applications/ApplicationsSideNav.tsx new file mode 100644 index 0000000000..1e46f46556 --- /dev/null +++ b/sites/partners/src/applications/ApplicationsSideNav.tsx @@ -0,0 +1,65 @@ +import React from "react" +import { useRouter } from "next/router" +import { t, SideNav, Tabs, TabList, Tab } from "@bloom-housing/ui-components" +import { useFlaggedApplicationsMeta } from "../../lib/hooks" +import LinkComponent from "../LinkComponent" + +type ApplicationsSideNavProps = { + className?: string + listingId: string + listingOpen?: boolean +} + +const ApplicationsSideNav = ({ + className, + listingId, + listingOpen = false, +}: ApplicationsSideNavProps) => { + const router = useRouter() + const { data } = useFlaggedApplicationsMeta(listingId) + const resolvedNav = listingOpen + ? [] + : { + label: t("t.resolved"), + url: `/listings/${listingId}/applications/resolved`, + count: data?.totalResolvedCount || 0, + } + + const items = [ + { + label: t("applications.allApplications"), + url: `/listings/${listingId}/applications`, + count: data?.totalCount || 0, + }, + { + label: t("applications.pendingReview"), + url: `/listings/${listingId}/applications/pending`, + count: data?.totalPendingCount || 0, + }, + ] + .concat(resolvedNav) + .reduce((acc, curr) => { + // check which element is currently active + + if (curr.url === router.asPath) { + Object.assign(curr, { current: true }) + } + + acc.push(curr) + + return acc + }, []) + + return ( + <> +
+ +
+
+ +
+ + ) +} + +export { ApplicationsSideNav as default, ApplicationsSideNav } diff --git a/sites/partners/src/applications/PaperApplicationForm/FormTypes.ts b/sites/partners/src/applications/PaperApplicationForm/FormTypes.ts index 1fc56bc736..d514a6ab28 100644 --- a/sites/partners/src/applications/PaperApplicationForm/FormTypes.ts +++ b/sites/partners/src/applications/PaperApplicationForm/FormTypes.ts @@ -1,5 +1,5 @@ import { DateFieldValues, DOBFieldValues, TimeFieldValues } from "@bloom-housing/ui-components" -import { Language, IncomePeriod } from "@bloom-housing/backend-core/types" +import { Language, IncomePeriod, ApplicationReviewStatus } from "@bloom-housing/backend-core/types" export type Address = { street: string @@ -57,6 +57,7 @@ export type ApplicationTypes = { } preferences?: Record programs?: Record + reviewStatus?: ApplicationReviewStatus } export type FormTypes = { diff --git a/sites/partners/src/applications/PaperApplicationForm/PaperApplicationForm.tsx b/sites/partners/src/applications/PaperApplicationForm/PaperApplicationForm.tsx index 7609b8c76e..795690640b 100644 --- a/sites/partners/src/applications/PaperApplicationForm/PaperApplicationForm.tsx +++ b/sites/partners/src/applications/PaperApplicationForm/PaperApplicationForm.tsx @@ -16,6 +16,7 @@ import { Application, ApplicationStatus, ApplicationSection, + ApplicationReviewStatus, } from "@bloom-housing/backend-core/types" import { mapFormToApi, mapApiToForm } from "../../../lib/formatApplicationData" import { useSingleListingData } from "../../../lib/hooks" @@ -115,9 +116,11 @@ const ApplicationForm = ({ listingId, editMode, application }: ApplicationFormPr const result = editMode ? await applicationsService.update({ id: application.id, - body: { id: application.id, ...body }, + body: { id: application.id, ...body, reviewStatus: application.reviewStatus }, + }) + : await applicationsService.create({ + body: { ...body, reviewStatus: ApplicationReviewStatus.valid }, }) - : await applicationsService.create({ body }) setLoading(false) diff --git a/sites/partners/src/applications/helpers.ts b/sites/partners/src/applications/helpers.ts new file mode 100644 index 0000000000..0495ce8290 --- /dev/null +++ b/sites/partners/src/applications/helpers.ts @@ -0,0 +1,54 @@ +import { t } from "@bloom-housing/ui-components" +import { NextRouter } from "next/router" + +export const tableColumns = [ + { + headerName: t("applications.duplicates.duplicateGroup"), + field: "", + sortable: false, + filter: false, + cellRenderer: "formatLinkCell", + flex: 1, + minWidth: 200, + }, + { + headerName: t("applications.duplicates.primaryApplicant"), + field: "", + sortable: false, + filter: false, + }, + { + headerName: t("t.rule"), + field: "rule", + sortable: false, + filter: false, + }, + { + headerName: t("applications.pendingReview"), + field: "", + sortable: false, + filter: false, + type: "rightAligned", + }, +] + +export const getLinkCellFormatter = (router: NextRouter) => + class formatLinkCell { + linkWithId: HTMLSpanElement + + init(params) { + const applicationId = params.data.id + + this.linkWithId = document.createElement("button") + this.linkWithId.classList.add("text-blue-700") + this.linkWithId.innerText = params.value + + this.linkWithId.addEventListener("click", function () { + void router.push(`/application/${applicationId}/review`) + }) + } + + getGui() { + return this.linkWithId + } + } diff --git a/sites/partners/src/flags/applicationsCols.tsx b/sites/partners/src/flags/applicationsCols.tsx index d74c094a70..6c2981c015 100644 --- a/sites/partners/src/flags/applicationsCols.tsx +++ b/sites/partners/src/flags/applicationsCols.tsx @@ -2,13 +2,13 @@ import Link from "next/link" import { t } from "@bloom-housing/ui-components" import { convertDataToPst } from "../../lib/helpers" -import { ApplicationSubmissionType } from "@bloom-housing/backend-core/types" +import { ApplicationStatus, ApplicationSubmissionType } from "@bloom-housing/backend-core/types" export const getCols = () => [ { headerName: t("application.details.number"), field: "id", - sortable: true, + sortable: false, filter: false, resizable: true, unSortIcon: true, @@ -24,7 +24,7 @@ export const getCols = () => [ { headerName: t("application.name.firstName"), field: "applicant.firstName", - sortable: true, + sortable: false, filter: false, resizable: true, unSortIcon: true, @@ -33,7 +33,7 @@ export const getCols = () => [ { headerName: t("application.name.lastName"), field: "applicant.lastName", - sortable: true, + sortable: false, filter: false, resizable: true, unSortIcon: true, @@ -56,19 +56,12 @@ export const getCols = () => [ }, }, { - headerName: t("t.email"), - field: "applicant.emailAddress", - sortable: false, - filter: false, - resizable: true, - flex: 1, - }, - { - headerName: t("t.phone"), - field: "applicant.phoneNumber", + headerName: t("application.details.type"), + field: "submissionType", sortable: false, filter: false, resizable: true, + unSortIcon: true, flex: 1, }, { @@ -91,4 +84,15 @@ export const getCols = () => [ return `${dateTime.date} ${t("t.at")} ${dateTime.time}` }, }, + { + headerName: t("applications.table.reviewStatus"), + field: "reviewStatus", + sortable: false, + filter: false, + resizable: true, + flex: 1, + valueGetter: ({ data }) => { + return data.reviewStatus === "flagged" ? t("applications.pendingReview") : data.status + }, + }, ] diff --git a/sites/partners/src/flags/flagSetCols.tsx b/sites/partners/src/flags/flagSetCols.tsx index c9ea639036..9dacbf610d 100644 --- a/sites/partners/src/flags/flagSetCols.tsx +++ b/sites/partners/src/flags/flagSetCols.tsx @@ -87,7 +87,7 @@ export const getFlagSetCols = () => [ flex: 1, cellRendererFramework: ({ data }) => { const styleType = - data.status === EnumApplicationFlaggedSetStatus.flagged + data.status === EnumApplicationFlaggedSetStatus.pending ? AppearanceStyleType.info : AppearanceStyleType.success diff --git a/sites/public/pages/applications/review/terms.tsx b/sites/public/pages/applications/review/terms.tsx index c02279e3b9..2a244a5c91 100644 --- a/sites/public/pages/applications/review/terms.tsx +++ b/sites/public/pages/applications/review/terms.tsx @@ -27,6 +27,7 @@ import { import FormsLayout from "../../../layouts/forms" import { useFormConductor } from "../../../lib/hooks" import { UserStatus } from "../../../lib/constants" +import { ApplicationReviewStatus } from "@bloom-housing/backend-core" const ApplicationTerms = () => { const router = useRouter() @@ -54,6 +55,7 @@ const ApplicationTerms = () => { .submit({ body: { ...application, + reviewStatus: ApplicationReviewStatus.pending, listing: { id: listing.id, }, diff --git a/ui-components/index.ts b/ui-components/index.ts index bc9cc0fa41..c26bf41d8b 100644 --- a/ui-components/index.ts +++ b/ui-components/index.ts @@ -89,6 +89,7 @@ export * from "./src/navigation/ProgressNav" export * from "./src/navigation/TabNav" export * from "./src/navigation/Tabs" export * from "./src/navigation/Breadcrumbs" +export * from "./src/navigation/SideNav" /* Notifications */ export * from "./src/notifications/AlertBox" diff --git a/ui-components/src/global/vendor/ag_grid.scss b/ui-components/src/global/vendor/ag_grid.scss index 3f8d93c84f..49fb7cb04c 100644 --- a/ui-components/src/global/vendor/ag_grid.scss +++ b/ui-components/src/global/vendor/ag_grid.scss @@ -11,6 +11,12 @@ ) ); + --ag-selected-row-background-color: var(--bloom-color-primary-light); + + a { + color: var(--bloom-color-primary-dark); + } + .ag-row { height: ag-param(row-height); } @@ -72,6 +78,15 @@ @apply border-b-0; } + .ag-pinned-right-header, + .ag-cell.ag-cell-first-right-pinned:not(.ag-cell-range-left):not(.ag-cell-range-single-cell) { + @apply border-gray-450; + @apply border-r-0; + @apply border-t-0; + @apply border-l-4; + @apply border-b-0; + } + .ag-row { @apply border-t-0; @apply border-l-0; @@ -85,29 +100,48 @@ .ag-body-viewport { ::-webkit-scrollbar { - // -webkit-appearance: none; height: 8px; - @apply bg-gray-600; } ::-webkit-scrollbar-thumb { border-radius: 8px; - @apply bg-gray-600; + @apply bg-gray-550; } + } - ::-webkit-scrollbar-track { - @apply bg-gray-100; - -webkit-box-shadow: inset 0 0 1px rgba(0, 0, 0, 0.7); - box-shadow: inset 0 0 1px rgba(0, 0, 0, 0.7); - } + .ag-body-horizontal-scroll { + border-bottom: 1px solid var(--bloom-color-gray-500); + border-radius: var(--bloom-rounded-md); } .ag-root-wrapper { @apply border-b-0; @apply rounded-t-md; - @apply rounded-b-none; + @apply rounded-b-md; overflow: visible; } + + .ag-layout-auto-height { + .ag-center-cols-container, + .ag-center-cols-clipper { + --table-min-height: 124px; + min-height: var(--table-min-height); + } + } + + .ag-ltr { + .ag-selection-checkbox { + margin-right: var(--bloom-s6); + } + } + + .ag-horizontal-right-spacer:not(.ag-scroller-corner) { + border: none; + } + + .ag-horizontal-left-spacer:not(.ag-scroller-corner) { + border: none; + } } .data-pager { diff --git a/ui-components/src/headers/PageHeader.scss b/ui-components/src/headers/PageHeader.scss index ce5f961e39..fc6582b02d 100644 --- a/ui-components/src/headers/PageHeader.scss +++ b/ui-components/src/headers/PageHeader.scss @@ -15,7 +15,7 @@ border-top: var(--bloom-border-1) solid var(--border-color); color: var(--text-color); - @media (min-width: $screen-sm) { + @media (min-width: $screen-md) { padding: var(--bloom-s10) 0; } @@ -47,6 +47,7 @@ .page-header__title { text-align: center; font-family: var(--text-font-family); + word-break: break-all; @media (min-width: $screen-md) { font-size: var(--title-font-size); diff --git a/ui-components/src/navigation/Breadcrumbs.scss b/ui-components/src/navigation/Breadcrumbs.scss index b91c54fc78..c1dff87f4b 100644 --- a/ui-components/src/navigation/Breadcrumbs.scss +++ b/ui-components/src/navigation/Breadcrumbs.scss @@ -2,6 +2,7 @@ ol { @apply flex; @apply items-center; + flex-wrap: wrap; line-height: 1.5rem; } diff --git a/ui-components/src/navigation/SideNav.scss b/ui-components/src/navigation/SideNav.scss index aec8fd3939..0d9677e8ab 100644 --- a/ui-components/src/navigation/SideNav.scss +++ b/ui-components/src/navigation/SideNav.scss @@ -8,9 +8,11 @@ --selection-parent-accent: var(--bloom-color-gray-450); --hover-link-color: var(--bloom-color-primary); --hover-background-color: var(--bloom-color-primary-lighter); + --background-color: var(--bloom-color-white); border: var(--border); border-radius: var(--border-radius); + background-color: var(--background-color); & > ul { & > li:first-child > a { @@ -70,3 +72,57 @@ padding-inline-start: calc(var(--current-padding-inline) + var(--current-padding-block)); } } + +.side-nav__horizontal { + display: flex; + + ul { + width: 100%; + } + + @media (min-width: $screen-sm) { + a { + &[aria-current]:not(:focus) { + box-shadow: inset 0px -3px 0px 0px var(--hover-link-color); + } + + &.has-current-child:not(:focus) { + box-shadow: inset 0px 0px -3px 0px var(--selection-parent-accent); + } + } + + ul { + width: auto; + display: flex; + flex-direction: row; + @media (min-width: $screen-md) { + flex-direction: row; + } + @media (max-width: $screen-sm) { + flex-direction: column; + width: 100%; + } + } + + li:not(:last-child) { + border-right: var(--border); + border-bottom: 0px; + } + + & > ul { + & > li:first-child > a { + border-bottom-left-radius: var(--border-radius); + border-bottom-right-radius: 0px; + } + + & > li:last-child > a { + border-bottom-right-radius: var(--border-radius); + border-bottom-left-radius: 0px; + } + } + } +} + +.side-nav__count { + margin-left: var(--bloom-s4); +} diff --git a/ui-components/src/navigation/SideNav.tsx b/ui-components/src/navigation/SideNav.tsx index 7156dc05b8..c1d36eb5c5 100644 --- a/ui-components/src/navigation/SideNav.tsx +++ b/ui-components/src/navigation/SideNav.tsx @@ -11,8 +11,8 @@ export interface SideNavItemProps { } export interface SideNavProps { - navItems?: SideNavItemProps[] className?: string + navItems?: SideNavItemProps[] } const ItemLabel = ({ item }: { item: SideNavItemProps }) => { @@ -20,7 +20,7 @@ const ItemLabel = ({ item }: { item: SideNavItemProps }) => { return ( <> {item.label} - {item.count} + {item.count} ) } else { diff --git a/ui-components/src/navigation/Tabs.scss b/ui-components/src/navigation/Tabs.scss index 693b2f7e79..b052ba2e88 100644 --- a/ui-components/src/navigation/Tabs.scss +++ b/ui-components/src/navigation/Tabs.scss @@ -10,6 +10,29 @@ } } +.tabs__horizontal { + ul { + @apply flex; + @apply flex-row; + } + + .tabs__tab { + &:first-of-type { + @apply border-l; + @apply rounded-tl-lg; + @apply rounded-tr-lg; + @apply rounded-tr-none; + @apply rounded-bl-lg; + } + &:last-of-type { + @apply rounded-tr-lg; + @apply rounded-br-lg; + } + + border-bottom-color: var(--bloom-color-gray-450); + } +} + .tabs__tab { @apply bg-white; @apply text-gray-800; @@ -30,14 +53,10 @@ @apply border-l; @apply rounded-tl-lg; @apply rounded-tr-lg; - @screen md { - @apply rounded-tr-none; - } + @apply rounded-tr-none; } &:last-of-type { - @screen md { - @apply rounded-tr-lg; - } + @apply rounded-tr-lg; } &:hover { border-bottom-color: map-get($tailwind-gray, 400); diff --git a/ui-components/src/navigation/Tabs.stories.tsx b/ui-components/src/navigation/Tabs.stories.tsx index 2bb20f5468..b5a08f7054 100644 --- a/ui-components/src/navigation/Tabs.stories.tsx +++ b/ui-components/src/navigation/Tabs.stories.tsx @@ -8,13 +8,11 @@ export default {