Skip to content

Commit

Permalink
Add AFS e2e tests to cover update scenario (#1810)
Browse files Browse the repository at this point in the history
* Add AFS e2e tests to cover update scenario

* Update CHANGELOG.md

* Add constraint that leasing agent can only resolve AFS when listing is closed

* Make application updates trigger AFS recalculations

* Add AFS e2e tests to cover update scenario

* Update CHANGELOG.md

* Add constraint that leasing agent can only resolve AFS when listing is closed

* Make application updates trigger AFS recalculations

* Improve query efficiency in onApplicationUpdate AFS module

* refactor(backend): simplify AFS filtering by applicationId query

Co-authored-by: Sean Albert <[email protected]>
  • Loading branch information
pbn4 and seanmalbert authored Oct 4, 2021
1 parent 57d84dc commit e67582a
Show file tree
Hide file tree
Showing 11 changed files with 371 additions and 82 deletions.
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@ All notable changes to this project will be documented in this file. The format

- Add POST /users/invite endpoint and extend PUT /users/confirm with optional password change ([#1801](https://github.com/bloom-housing/bloom/pull/1801))
- Add `isPartner` filter to GET /user/list endpoint ([#1830](https://github.com/bloom-housing/bloom/pull/1830))
- Changes to applications done through `PUT /applications/:id` are now reflected in AFS ([#1810](https://github.com/bloom-housing/bloom/pull/1810))
- Add logic for connecting newly created user account to existing applications (matching based on applicant.emailAddress) ([#1807](https://github.com/bloom-housing/bloom/pull/1807))
- Changes to applications done through `PUT /applications/:id` are now reflected in AFS ([#1810](https://github.com/bloom-housing/bloom/pull/1810))
- Adds confirmationCode to applications table ([#1854](https://github.com/bloom-housing/bloom/pull/1854))

## Frontend
Expand Down Expand Up @@ -134,6 +136,7 @@ All notable changes to this project will be documented in this file. The format
- Fixes flakiness in authz.e2e-spec.ts related to logged in user trying to GET /applications which did not belong to him (sorting of UUID is not deterministic, so the user should fetch by specying a query param userId = self) [#1575](https://github.com/bloom-housing/bloom/pull/1575)
- Fixed ListingsService.retrieve `view` query param not being optional in autogenerated client (it should be) [#1575](https://github.com/bloom-housing/bloom/pull/1575)
- updated DTOs to omit entities and use DTOs for application-method, user-roles, user, listing and units-summary ([#1679](https://github.com/bloom-housing/bloom/pull/1679))
- makes application flagged sets module take applications edits into account (e.g. a leasing agent changes something in the application) ([#1810](https://github.com/bloom-housing/bloom/pull/1810))

### General

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,33 +11,17 @@ import {
ValidationPipe,
} from "@nestjs/common"
import { Request as ExpressRequest } from "express"
import { ApiBearerAuth, ApiOperation, ApiProperty, ApiTags } from "@nestjs/swagger"
import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger"
import { ResourceType } from "../auth/decorators/resource-type.decorator"
import { OptionalAuthGuard } from "../auth/guards/optional-auth.guard"
import { AuthzGuard } from "../auth/guards/authz.guard"
import { defaultValidationPipeOptions } from "../shared/default-validation-pipe-options"
import { mapTo } from "../shared/mapTo"
import { Expose } from "class-transformer"
import { IsUUID } from "class-validator"
import { ValidationsGroupsEnum } from "../shared/types/validations-groups-enum"
import { ApplicationFlaggedSetsService } from "./application-flagged-sets.service"
import { PaginationQueryParams } from "../shared/dto/pagination.dto"
import {
ApplicationFlaggedSetDto,
ApplicationFlaggedSetResolveDto,
PaginatedApplicationFlaggedSetDto,
} from "./dto/application-flagged-set.dto"

export class PaginatedApplicationFlaggedSetQueryParams extends PaginationQueryParams {
@Expose()
@ApiProperty({
type: String,
example: "listingId",
required: true,
})
@IsUUID(4, { groups: [ValidationsGroupsEnum.default] })
listingId: string
}
import { ApplicationFlaggedSetDto } from "./dto/application-flagged-set.dto"
import { PaginatedApplicationFlaggedSetDto } from "./dto/paginated-application-flagged-set.dto"
import { ApplicationFlaggedSetResolveDto } from "./dto/application-flagged-set-resolve.dto"
import { PaginatedApplicationFlaggedSetQueryParams } from "./paginated-application-flagged-set-query-params"

@Controller("/applicationFlaggedSets")
@ApiTags("applicationFlaggedSets")
Expand Down
Original file line number Diff line number Diff line change
@@ -1,24 +1,28 @@
import { Inject, Injectable, NotFoundException, Scope } from "@nestjs/common"
import { PaginatedApplicationFlaggedSetQueryParams } from "./application-flagged-sets.controller"
import { BadRequestException, Inject, Injectable, NotFoundException, Scope } from "@nestjs/common"
import { AuthzService } from "../auth/services/authz.service"
import { ApplicationFlaggedSet } from "./entities/application-flagged-set.entity"
import { InjectRepository } from "@nestjs/typeorm"
import {
Brackets,
DeepPartial,
EntityManager,
getManager,
getMetadataArgsStorage,
In,
QueryRunner,
Repository,
SelectQueryBuilder,
getManager,
EntityManager,
} from "typeorm"
import { paginate } from "nestjs-typeorm-paginate"
import { Application } from "../applications/entities/application.entity"
import { ApplicationFlaggedSetResolveDto } from "./dto/application-flagged-set.dto"
import { REQUEST } from "@nestjs/core"
import { Request as ExpressRequest } from "express"
import { User } from "../auth/entities/user.entity"
import { FlaggedSetStatus } from "./types/flagged-set-status-enum"
import { Rule } from "./types/rule-enum"
import { ApplicationFlaggedSetResolveDto } from "./dto/application-flagged-set-resolve.dto"
import { PaginatedApplicationFlaggedSetQueryParams } from "./paginated-application-flagged-set-query-params"
import { ListingStatus } from "../listings/types/listing-status-enum"

@Injectable({ scope: Scope.REQUEST })
export class ApplicationFlaggedSetsService {
Expand Down Expand Up @@ -71,11 +75,16 @@ export class ApplicationFlaggedSetsService {
const transApplicationsRepository = transactionalEntityManager.getRepository(Application)
const afs = await transAfsRepository.findOne({
where: { id: dto.afsId },
relations: ["applications"],
relations: ["applications", "listing"],
})
if (!afs) {
throw new NotFoundException()
}

if (afs.listing.status !== ListingStatus.closed) {
throw new BadRequestException("Listing must be closed before resolving any duplicates.")
}

// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
afs.resolvingUser = this.request.user as User
afs.resolvedTime = new Date()
Expand Down Expand Up @@ -115,6 +124,62 @@ export class ApplicationFlaggedSetsService {
}
}

private async _getAfsesContainingApplicationId(
queryRunnery: QueryRunner,
applicationId: string
): Promise<Array<{ application_flagged_set_id: string }>> {
const metadataArgsStorage = getMetadataArgsStorage().findJoinTable(
ApplicationFlaggedSet,
"applications"
)
const applicationsJunctionTableName = metadataArgsStorage.name
const query = `
SELECT DISTINCT application_flagged_set_id FROM ${applicationsJunctionTableName}
WHERE applications_id = $1
`
return await queryRunnery.query(query, [applicationId])
}

async onApplicationUpdate(
newApplication: Application,
transactionalEntityManager: EntityManager
) {
const transApplicationsRepository = transactionalEntityManager.getRepository(Application)
newApplication.markedAsDuplicate = false
await transApplicationsRepository.save(newApplication)

const transAfsRepository = transactionalEntityManager.getRepository(ApplicationFlaggedSet)

const afsIds = await this._getAfsesContainingApplicationId(
transAfsRepository.queryRunner,
newApplication.id
)
const afses = await transAfsRepository.find({
where: { id: In(afsIds.map((afs) => afs.application_flagged_set_id)) },
relations: ["applications"],
})
const afsesToBeSaved: Array<ApplicationFlaggedSet> = []
const afsesToBeRemoved: Array<ApplicationFlaggedSet> = []
for (const afs of afses) {
afs.status = FlaggedSetStatus.flagged
afs.resolvedTime = null
afs.resolvingUser = null
const applicationIndex = afs.applications.findIndex(
(application) => application.id === newApplication.id
)
afs.applications.splice(applicationIndex, 1)
if (afs.applications.length > 1) {
afsesToBeSaved.push(afs)
} else {
afsesToBeRemoved.push(afs)
}
}
await transAfsRepository.save(afsesToBeSaved)
await transAfsRepository.remove(afsesToBeRemoved)

await this.onApplicationSave(newApplication, transactionalEntityManager)
}

async fetchDuplicatesMatchingRule(
transactionalEntityManager: EntityManager,
application: Application,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { PaginationMeta } from "../../shared/dto/pagination.dto"
import { Expose } from "class-transformer"

export class ApplicationFlaggedSetPaginationMeta extends PaginationMeta {
@Expose()
totalFlagged: number
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { Expose, Type } from "class-transformer"
import { ArrayMaxSize, IsArray, IsDefined, IsUUID, ValidateNested } from "class-validator"
import { ValidationsGroupsEnum } from "../../shared/types/validations-groups-enum"
import { IdDto } from "../../shared/dto/id.dto"

export class ApplicationFlaggedSetResolveDto {
@Expose()
@IsUUID(4, { groups: [ValidationsGroupsEnum.default] })
afsId: string

@Expose()
@IsDefined({ groups: [ValidationsGroupsEnum.default] })
@IsArray({ groups: [ValidationsGroupsEnum.default] })
@ArrayMaxSize(512, { groups: [ValidationsGroupsEnum.default] })
@ValidateNested({ groups: [ValidationsGroupsEnum.default], each: true })
@Type(() => IdDto)
applications: IdDto[]
}
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
import { OmitType } from "@nestjs/swagger"
import { ApplicationFlaggedSet } from "../entities/application-flagged-set.entity"
import { Expose, Type } from "class-transformer"
import { ArrayMaxSize, IsArray, IsDefined, IsUUID, ValidateNested } from "class-validator"
import { ValidateNested } from "class-validator"
import { ValidationsGroupsEnum } from "../../shared/types/validations-groups-enum"
import { ApplicationDto } from "../../applications/dto/application.dto"
import { PaginationFactory, PaginationMeta } from "../../shared/dto/pagination.dto"
import { IdDto } from "../../shared/dto/id.dto"

export class ApplicationFlaggedSetDto extends OmitType(ApplicationFlaggedSet, [
Expand All @@ -27,29 +26,3 @@ export class ApplicationFlaggedSetDto extends OmitType(ApplicationFlaggedSet, [
@Type(() => IdDto)
listing: IdDto
}

export class ApplicationFlaggedSetPaginationMeta extends PaginationMeta {
@Expose()
totalFlagged: number
}

export class PaginatedApplicationFlaggedSetDto extends PaginationFactory<ApplicationFlaggedSetDto>(
ApplicationFlaggedSetDto
) {
@Expose()
meta: ApplicationFlaggedSetPaginationMeta
}

export class ApplicationFlaggedSetResolveDto {
@Expose()
@IsUUID(4, { groups: [ValidationsGroupsEnum.default] })
afsId: string

@Expose()
@IsDefined({ groups: [ValidationsGroupsEnum.default] })
@IsArray({ groups: [ValidationsGroupsEnum.default] })
@ArrayMaxSize(512, { groups: [ValidationsGroupsEnum.default] })
@ValidateNested({ groups: [ValidationsGroupsEnum.default], each: true })
@Type(() => IdDto)
applications: IdDto[]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { PaginationFactory } from "../../shared/dto/pagination.dto"
import { Expose } from "class-transformer"
import { ApplicationFlaggedSetPaginationMeta } from "./application-flagged-set-pagination-meta"
import { ApplicationFlaggedSetDto } from "./application-flagged-set.dto"

export class PaginatedApplicationFlaggedSetDto extends PaginationFactory<ApplicationFlaggedSetDto>(
ApplicationFlaggedSetDto
) {
@Expose()
meta: ApplicationFlaggedSetPaginationMeta
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ export class ApplicationFlaggedSet extends AbstractEntity {
@Type(() => Date)
resolvedTime?: Date | null

@ManyToOne(() => User, { eager: true, nullable: true, cascade: true })
@ManyToOne(() => User, { eager: true, nullable: true, cascade: false })
@JoinColumn()
@Expose()
@ValidateNested({ groups: [ValidationsGroupsEnum.default] })
Expand All @@ -37,7 +37,7 @@ export class ApplicationFlaggedSet extends AbstractEntity {
status: FlaggedSetStatus

@ManyToMany(() => Application)
@JoinTable()
@JoinTable({ name: "application_flagged_set_applications_applications" })
@Expose()
@ValidateNested({ groups: [ValidationsGroupsEnum.default], each: true })
applications: Application[]
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { PaginationQueryParams } from "../shared/dto/pagination.dto"
import { Expose } from "class-transformer"
import { ApiProperty } from "@nestjs/swagger"
import { IsUUID } from "class-validator"
import { ValidationsGroupsEnum } from "../shared/types/validations-groups-enum"

export class PaginatedApplicationFlaggedSetQueryParams extends PaginationQueryParams {
@Expose()
@ApiProperty({
type: String,
example: "listingId",
required: true,
})
@IsUUID(4, { groups: [ValidationsGroupsEnum.default] })
listingId: string
}
53 changes: 34 additions & 19 deletions backend/core/src/applications/applications.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import {
import { Application } from "./entities/application.entity"
import { ApplicationCreateDto, ApplicationUpdateDto } from "./dto/application.dto"
import { InjectRepository } from "@nestjs/typeorm"
import { getManager, QueryFailedError, Repository } from "typeorm"
import { QueryFailedError, Repository } from "typeorm"
import { paginate, Pagination } from "nestjs-typeorm-paginate"
import { PaginatedApplicationListQueryParams } from "./applications.controller"
import { ApplicationFlaggedSetsService } from "../application-flagged-sets/application-flagged-sets.service"
Expand Down Expand Up @@ -101,8 +101,7 @@ export class ApplicationsService {
throw new BadRequestException("Listing is not open for application submission.")
}
await this.authorizeUserAction(this.req.user, applicationCreateDto, authzActions.submit)
const application = await this._create(applicationCreateDto)
return application
return await this._create(applicationCreateDto)
}

async create(applicationCreateDto: ApplicationCreateDto) {
Expand Down Expand Up @@ -132,8 +131,19 @@ export class ApplicationsService {
id: application.id,
})

await this.repository.save(application)
return application
return await this.repository.manager.transaction(
"SERIALIZABLE",
async (transactionalEntityManager) => {
const applicationsRepository = transactionalEntityManager.getRepository(Application)
const newApplication = await applicationsRepository.save(application)
await this.applicationFlaggedSetsService.onApplicationUpdate(
application,
transactionalEntityManager
)

return await applicationsRepository.findOne({ id: newApplication.id })
}
)
}

async delete(applicationId: string) {
Expand Down Expand Up @@ -192,19 +202,22 @@ export class ApplicationsService {
}

private async _createApplication(applicationCreateDto: ApplicationUpdateDto) {
return await getManager().transaction("SERIALIZABLE", async (transactionalEntityManager) => {
const applicationsRepository = transactionalEntityManager.getRepository(Application)
const application = await applicationsRepository.save({
...applicationCreateDto,
user: this.req.user,
confirmationCode: ApplicationsService.generateConfirmationCode(),
})
await this.applicationFlaggedSetsService.onApplicationSave(
application,
transactionalEntityManager
)
return application
})
return await this.repository.manager.transaction(
"SERIALIZABLE",
async (transactionalEntityManager) => {
const applicationsRepository = transactionalEntityManager.getRepository(Application)
const application = await applicationsRepository.save({
...applicationCreateDto,
user: this.req.user,
confirmationCode: ApplicationsService.generateConfirmationCode(),
})
await this.applicationFlaggedSetsService.onApplicationSave(
application,
transactionalEntityManager
)
return await applicationsRepository.findOne({ id: application.id })
}
)
}

private async _create(applicationCreateDto: ApplicationUpdateDto) {
Expand Down Expand Up @@ -257,7 +270,9 @@ export class ApplicationsService {
throw e
}

const listing = await this.listingsService.findOne(application.listing.id)
// Listing is not eagerly joined on application entity so let's use the one provided with
// create dto
const listing = await this.listingsService.findOne(applicationCreateDto.listing.id)
if (application.applicant.emailAddress) {
await this.emailService.confirmation(listing, application, applicationCreateDto.appUrl)
}
Expand Down
Loading

0 comments on commit e67582a

Please sign in to comment.