From b3c6292747525bb5c720824684174374eb89ed7e Mon Sep 17 00:00:00 2001 From: Eric McGarry Date: Thu, 9 Jul 2026 11:10:18 -0400 Subject: [PATCH 1/2] fix: listing cascade delete unit groups --- .../migration.sql | 23 ++++++++++++ api/prisma/schema.prisma | 6 ++-- api/src/services/external-listing.service.ts | 28 +++++++++++++-- .../services/external-listing.service.spec.ts | 36 +++++++++++++------ 4 files changed, 77 insertions(+), 16 deletions(-) create mode 100644 api/prisma/migrations/65_listing_cascade_unit_groups/migration.sql diff --git a/api/prisma/migrations/65_listing_cascade_unit_groups/migration.sql b/api/prisma/migrations/65_listing_cascade_unit_groups/migration.sql new file mode 100644 index 0000000000..5d829d414c --- /dev/null +++ b/api/prisma/migrations/65_listing_cascade_unit_groups/migration.sql @@ -0,0 +1,23 @@ +-- DropForeignKey +ALTER TABLE "unit_group" DROP CONSTRAINT "unit_group_listing_id_fkey"; + +-- DropForeignKey +ALTER TABLE "unit_group_ami_levels" DROP CONSTRAINT "unit_group_ami_levels_unit_group_id_fkey"; + +-- AlterTable +ALTER TABLE "application_snapshot" ALTER COLUMN "income_vouchers" DROP DEFAULT; + +-- AlterTable +ALTER TABLE "applications" ALTER COLUMN "income_vouchers" DROP DEFAULT; + +-- AlterTable +ALTER TABLE "user_notification_preferences" ALTER COLUMN "updated_at" DROP DEFAULT; + +-- AlterTable +ALTER TABLE "user_notification_preferences_snapshot" ALTER COLUMN "updated_at" DROP DEFAULT; + +-- AddForeignKey +ALTER TABLE "unit_group" ADD CONSTRAINT "unit_group_listing_id_fkey" FOREIGN KEY ("listing_id") REFERENCES "listings"("id") ON DELETE CASCADE ON UPDATE NO ACTION; + +-- AddForeignKey +ALTER TABLE "unit_group_ami_levels" ADD CONSTRAINT "unit_group_ami_levels_unit_group_id_fkey" FOREIGN KEY ("unit_group_id") REFERENCES "unit_group"("id") ON DELETE CASCADE ON UPDATE NO ACTION; diff --git a/api/prisma/schema.prisma b/api/prisma/schema.prisma index 7ebaed02ae..85e8ba983a 100644 --- a/api/prisma/schema.prisma +++ b/api/prisma/schema.prisma @@ -48,7 +48,7 @@ model AccessibilitySnapshot { @@map("accessibility_snapshot") } -// AcivityLog Section +// ActivityLog Section model ActivityLog { id String @id() @default(dbgenerated("uuid_generate_v4()")) @db.Uuid createdAt DateTime @default(now()) @map("created_at") @db.Timestamp(6) @@ -1793,7 +1793,7 @@ model UnitGroup { totalAvailable Int? @map("total_available") totalCount Int? @map("total_count") - listings Listings? @relation(fields: [listingId], references: [id], onDelete: NoAction, onUpdate: NoAction) + listings Listings? @relation(fields: [listingId], references: [id], onDelete: Cascade, onUpdate: NoAction) unitGroupAmiLevels UnitGroupAmiLevels[] unitTypes UnitTypes[] @@ -1846,7 +1846,7 @@ model UnitGroupAmiLevels { percentageOfIncomeValue Decimal? @map("percentage_of_income_value") @db.Decimal unitGroupId String? @map("unit_group_id") @db.Uuid - unitGroup UnitGroup? @relation(fields: [unitGroupId], references: [id], onDelete: NoAction, onUpdate: NoAction) + unitGroup UnitGroup? @relation(fields: [unitGroupId], references: [id], onDelete: Cascade, onUpdate: NoAction) amiChart AmiChart? @relation(fields: [amiChartId], references: [id], onDelete: NoAction, onUpdate: NoAction) @@map("unit_group_ami_levels") diff --git a/api/src/services/external-listing.service.ts b/api/src/services/external-listing.service.ts index 7e7b08424a..219ca77f9f 100644 --- a/api/src/services/external-listing.service.ts +++ b/api/src/services/external-listing.service.ts @@ -93,11 +93,17 @@ export class ExternalListingService { throw new HttpException('Target jurisdiction not found', 500); } - let listingIdsToDelete = []; + const buildingAddressIdsToDelete = []; + const listingIdsToDelete = []; // query for existing external listings within the system const currentExternalListings = await this.prisma.listings.findMany({ - select: { id: true, contentUpdatedAt: true, externalListingId: true }, + select: { + id: true, + buildingAddressId: true, + contentUpdatedAt: true, + externalListingId: true, + }, where: { externalJurisdictionId: externalJurisdiction.id, jurisdictionId: jurisdictionId, @@ -105,7 +111,10 @@ export class ExternalListingService { }); if (listings.length === 0) { - listingIdsToDelete = currentExternalListings.map((listing) => listing.id); + currentExternalListings.forEach((listing) => { + buildingAddressIdsToDelete.push(listing.buildingAddressId); + listingIdsToDelete.push(listing.id); + }); } else { // query and compare reserved community types const internalReservedCommunityTypes = @@ -183,6 +192,10 @@ export class ExternalListingService { await this.prisma.listings.delete({ where: { id: existingListing.id }, }); + await this.prisma.address.delete({ + where: { id: existingListing.buildingAddressId }, + }); + // recreate with updated data this.createExternalListing( combinedRCTs, @@ -198,6 +211,7 @@ export class ExternalListingService { } } else { // add to list for deletion + buildingAddressIdsToDelete.push(existingListing.buildingAddressId); listingIdsToDelete.push(existingListing.id); } } @@ -232,6 +246,14 @@ export class ExternalListingService { }, }, }); + + await this.prisma.address.deleteMany({ + where: { + id: { + in: buildingAddressIdsToDelete, + }, + }, + }); } return { diff --git a/api/test/unit/services/external-listing.service.spec.ts b/api/test/unit/services/external-listing.service.spec.ts index 7f82ee1785..ac01efba3d 100644 --- a/api/test/unit/services/external-listing.service.spec.ts +++ b/api/test/unit/services/external-listing.service.spec.ts @@ -14,7 +14,7 @@ describe('Testing external listing service', () => { let httpService: HttpService; let prisma: PrismaService; let service: ExternalListingService; - const externalURL = 'example.com'; + const externalURL = 'https://www.example.com'; beforeAll(async () => { const module: TestingModule = await Test.createTestingModule({ @@ -153,7 +153,7 @@ describe('Testing external listing service', () => { await expect( service.ingest({ - externalURL: 'example.com', + externalURL: externalURL, jurisdictionId: randomUUID(), targetName: 'mismatch', }), @@ -187,7 +187,7 @@ describe('Testing external listing service', () => { await expect( service.ingest({ - externalURL: 'example.com', + externalURL: externalURL, jurisdictionId: randomUUID(), targetName: 'mismatch', }), @@ -199,7 +199,6 @@ describe('Testing external listing service', () => { const externalListingDate = new Date(); const externalListingId = randomUUID(); const externalRCTId = randomUUID(); - const externalURL = 'example.com'; const externalURTId = randomUUID(); const externalUTId = randomUUID(); @@ -217,7 +216,7 @@ describe('Testing external listing service', () => { listings: [ { id: externalListingId, - contentUpdateAt: externalListingDate, + contentUpdatedAt: externalListingDate, jurisdictionId: externalJurisdictionId, }, ], @@ -309,7 +308,7 @@ describe('Testing external listing service', () => { listings: [ { id: externalListingId, - contentUpdateAt: externalListingDate, + contentUpdatedAt: externalListingDate, jurisdictionId: externalJurisdictionId, }, ], @@ -349,6 +348,7 @@ describe('Testing external listing service', () => { .mockImplementationOnce(() => of(mockExternalDetailsResponse)) .mockImplementationOnce(() => of(mockExternalListingResponse)); + const internalBuildingAddressId = randomUUID(); const internalJurisdictionId = randomUUID(); const internalListingDate = dayjs(new Date()) .subtract(1, 'days') @@ -358,11 +358,13 @@ describe('Testing external listing service', () => { const internalURTId = randomUUID(); const internalUTId = randomUUID(); + prisma.address.delete = jest.fn().mockResolvedValue(undefined); prisma.listings.create = jest.fn().mockResolvedValue({}); prisma.listings.delete = jest.fn().mockResolvedValue(undefined); prisma.listings.findMany = jest.fn().mockResolvedValue([ { id: internalListingId, + buildingAddressId: internalBuildingAddressId, contentUpdatedAt: internalListingDate, externalListingId, }, @@ -387,6 +389,9 @@ describe('Testing external listing service', () => { expect(response.success).toEqual(true); + expect(prisma.address.delete).toHaveBeenCalledWith({ + where: { id: internalBuildingAddressId }, + }); expect(prisma.listings.delete).toHaveBeenCalledWith({ where: { id: internalListingId }, }); @@ -398,7 +403,6 @@ describe('Testing external listing service', () => { const externalListingDate = new Date(); const externalListingId = randomUUID(); const externalRCTId = randomUUID(); - const externalURL = 'example.com'; const externalURTId = randomUUID(); const externalUTId = randomUUID(); @@ -416,7 +420,7 @@ describe('Testing external listing service', () => { listings: [ { id: externalListingId, - contentUpdateAt: externalListingDate, + contentUpdatedAt: externalListingDate, jurisdictionId: externalJurisdictionId, }, ], @@ -432,6 +436,7 @@ describe('Testing external listing service', () => { .spyOn(httpService, 'get') .mockImplementationOnce(() => of(mockExternalDetailsResponse)); + const internalBuildingAddressId = randomUUID(); const internalJurisdictionId = randomUUID(); const internalListingId = randomUUID(); const internalRCTId = randomUUID(); @@ -442,6 +447,7 @@ describe('Testing external listing service', () => { prisma.listings.findMany = jest.fn().mockResolvedValue([ { id: internalListingId, + buildingAddressId: internalBuildingAddressId, contentUpdatedAt: externalListingDate, externalListingId, }, @@ -467,7 +473,12 @@ describe('Testing external listing service', () => { expect(response.success).toEqual(true); expect(prisma.listings.findMany).toHaveBeenCalledExactlyOnceWith({ - select: { id: true, contentUpdatedAt: true, externalListingId: true }, + select: { + id: true, + buildingAddressId: true, + contentUpdatedAt: true, + externalListingId: true, + }, where: { externalJurisdictionId: externalJurisdictionId, jurisdictionId: internalJurisdictionId, @@ -490,7 +501,6 @@ describe('Testing external listing service', () => { it('should delete a listing if no longer returned from external system', async () => { const externalJurisdictionId = randomUUID(); const externalRCTId = randomUUID(); - const externalURL = 'example.com'; const externalURTId = randomUUID(); const externalUTId = randomUUID(); @@ -518,13 +528,16 @@ describe('Testing external listing service', () => { .spyOn(httpService, 'get') .mockImplementationOnce(() => of(mockExternalDetailsResponse)); + const internalBuildingAddressId = randomUUID(); const internalJurisdictionId = randomUUID(); const internalListingId = randomUUID(); + prisma.address.deleteMany = jest.fn().mockResolvedValue(undefined); prisma.listings.deleteMany = jest.fn().mockResolvedValue(undefined); prisma.listings.findMany = jest.fn().mockResolvedValue([ { id: internalListingId, + buildingAddressId: internalBuildingAddressId, contentUpdatedAt: new Date(), externalListingId: randomUUID(), }, @@ -545,6 +558,9 @@ describe('Testing external listing service', () => { expect(prisma.unitRentTypes.findMany).not.toHaveBeenCalled(); expect(prisma.unitTypes.findMany).not.toHaveBeenCalled(); + expect(prisma.address.deleteMany).toHaveBeenCalledWith({ + where: { id: { in: [internalBuildingAddressId] } }, + }); expect(prisma.listings.deleteMany).toHaveBeenCalledWith({ where: { id: { in: [internalListingId] } }, }); From 7afc03dcc1c2d52977733e4da8d1760668a7d305 Mon Sep 17 00:00:00 2001 From: Eric McGarry Date: Thu, 9 Jul 2026 12:03:34 -0400 Subject: [PATCH 2/2] fix: incomeVouchers default value --- api/test/integration/application.e2e-spec.ts | 4 ++++ api/test/integration/lottery.e2e-spec.ts | 5 +++++ 2 files changed, 9 insertions(+) diff --git a/api/test/integration/application.e2e-spec.ts b/api/test/integration/application.e2e-spec.ts index 8ac17beab2..db2fce88b1 100644 --- a/api/test/integration/application.e2e-spec.ts +++ b/api/test/integration/application.e2e-spec.ts @@ -797,6 +797,7 @@ describe('Application Controller Tests', () => { data: { listings: { connect: { id: listing2.id } }, confirmationCode: randomUUID(), + incomeVouchers: [], preferences: [], submissionType: ApplicationSubmissionTypeEnum.electronical, status: ApplicationStatusEnum.submitted, @@ -808,6 +809,7 @@ describe('Application Controller Tests', () => { data: { listings: { connect: { id: listing2.id } }, confirmationCode: randomUUID(), + incomeVouchers: [], preferences: [], submissionType: ApplicationSubmissionTypeEnum.electronical, status: ApplicationStatusEnum.submitted, @@ -1215,6 +1217,7 @@ describe('Application Controller Tests', () => { data: { listings: { connect: { id: listing2.id } }, confirmationCode: randomUUID(), + incomeVouchers: [], preferences: [], submissionType: ApplicationSubmissionTypeEnum.electronical, status: ApplicationStatusEnum.submitted, @@ -1226,6 +1229,7 @@ describe('Application Controller Tests', () => { data: { listings: { connect: { id: listing2.id } }, confirmationCode: randomUUID(), + incomeVouchers: [], preferences: [], submissionType: ApplicationSubmissionTypeEnum.electronical, status: ApplicationStatusEnum.submitted, diff --git a/api/test/integration/lottery.e2e-spec.ts b/api/test/integration/lottery.e2e-spec.ts index 4e219cab60..586f648e09 100644 --- a/api/test/integration/lottery.e2e-spec.ts +++ b/api/test/integration/lottery.e2e-spec.ts @@ -1145,6 +1145,7 @@ describe('Lottery Controller Tests', () => { lotteryStatus: LotteryStatusEnum.releasedToPartners, applications: [ { + incomeVouchers: [], preferences: [], status: ApplicationStatusEnum.submitted, confirmationCode: 'ABCD1234', @@ -1160,6 +1161,7 @@ describe('Lottery Controller Tests', () => { }, }, { + incomeVouchers: [], preferences: [], status: ApplicationStatusEnum.submitted, confirmationCode: 'EFGH5678', @@ -1175,6 +1177,7 @@ describe('Lottery Controller Tests', () => { }, }, { + incomeVouchers: [], preferences: [], status: ApplicationStatusEnum.submitted, confirmationCode: 'IJKL9012', @@ -1190,6 +1193,7 @@ describe('Lottery Controller Tests', () => { }, }, { + incomeVouchers: [], preferences: [], status: ApplicationStatusEnum.submitted, confirmationCode: 'ABC678', @@ -1205,6 +1209,7 @@ describe('Lottery Controller Tests', () => { }, }, { + incomeVouchers: [], preferences: [], status: ApplicationStatusEnum.submitted, confirmationCode: 'MNOP3456',