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 {