From e3395b8139338c217901c3280654a5d63c894569 Mon Sep 17 00:00:00 2001 From: Eric McGarry <46828798+mcgarrye@users.noreply.github.com> Date: Thu, 10 Oct 2024 14:04:38 -0400 Subject: [PATCH] feat: record of listing copy (#4375) * feat: add copyOf column to db and export * feat: address questions --- .../18_copy_of_reference/migration.sql | 5 + api/prisma/schema.prisma | 12 +- .../services/listing-csv-export.service.ts | 15 +++ api/src/services/listing.service.ts | 31 +++-- api/test/integration/listing.e2e-spec.ts | 8 ++ .../unit/services/listing.service.spec.ts | 123 ++++++++++++++++++ 6 files changed, 179 insertions(+), 15 deletions(-) create mode 100644 api/prisma/migrations/18_copy_of_reference/migration.sql diff --git a/api/prisma/migrations/18_copy_of_reference/migration.sql b/api/prisma/migrations/18_copy_of_reference/migration.sql new file mode 100644 index 0000000000..1c89183878 --- /dev/null +++ b/api/prisma/migrations/18_copy_of_reference/migration.sql @@ -0,0 +1,5 @@ +-- AlterTable +ALTER TABLE "listings" ADD COLUMN "copy_of_id" UUID; + +-- AddForeignKey +ALTER TABLE "listings" ADD CONSTRAINT "listings_copy_of_id_fkey" FOREIGN KEY ("copy_of_id") REFERENCES "listings"("id") ON DELETE SET NULL ON UPDATE NO ACTION; diff --git a/api/prisma/schema.prisma b/api/prisma/schema.prisma index d03b7641b7..386002a966 100644 --- a/api/prisma/schema.prisma +++ b/api/prisma/schema.prisma @@ -146,8 +146,8 @@ model ApplicationFlaggedSet { listings Listings @relation(fields: [listingId], references: [id], onDelete: Cascade, onUpdate: NoAction) applications Applications[] - @@index([listingId]) @@unique([ruleKey, listingId]) + @@index([listingId]) @@map("application_flagged_set") } @@ -543,7 +543,7 @@ model Listings { closedAt DateTime? @map("closed_at") @db.Timestamptz(6) afsLastRunAt DateTime? @default(dbgenerated("'1970-01-01 00:00:00-07'::timestamp with time zone")) @map("afs_last_run_at") @db.Timestamptz(6) lastApplicationUpdateAt DateTime? @default(dbgenerated("'1970-01-01 00:00:00-07'::timestamp with time zone")) @map("last_application_update_at") @db.Timestamptz(6) - lotteryLastPublishedAt DateTime? @map("lottery_last_published_at") @db.Timestamptz(6) + lotteryLastPublishedAt DateTime? @map("lottery_last_published_at") @db.Timestamptz(6) lotteryLastRunAt DateTime? @map("lottery_last_run_at") @db.Timestamptz(6) lotteryStatus LotteryStatusEnum? @map("lottery_status") buildingAddressId String? @map("building_address_id") @db.Uuid @@ -551,6 +551,7 @@ model Listings { applicationDropOffAddressId String? @map("application_drop_off_address_id") @db.Uuid applicationMailingAddressId String? @map("application_mailing_address_id") @db.Uuid buildingSelectionCriteriaFileId String? @map("building_selection_criteria_file_id") @db.Uuid + copyOfId String? @map("copy_of_id") @db.Uuid jurisdictionId String? @map("jurisdiction_id") @db.Uuid leasingAgentAddressId String? @map("leasing_agent_address_id") @db.Uuid reservedCommunityTypeId String? @map("reserved_community_type_id") @db.Uuid @@ -606,6 +607,9 @@ model Listings { requestedChangesUser UserAccounts? @relation("requested_changes_user", fields: [requestedChangesUserId], references: [id], onDelete: NoAction, onUpdate: NoAction) applicationLotteryPositions ApplicationLotteryPositions[] applicationLotteryTotals ApplicationLotteryTotal[] + copyOf Listings? @relation("copy_of", fields: [copyOfId], references: [id]) + Listings Listings[] @relation("copy_of") + @@index([jurisdictionId]) @@map("listings") } @@ -931,8 +935,8 @@ model ScriptRuns { view ApplicationFlaggedSetPossibilities { key String - listingId String @map("listing_id") @db.Uuid - applicationId String @map("application_id") @db.Uuid + listingId String @map("listing_id") @db.Uuid + applicationId String @map("application_id") @db.Uuid type String @@id([key, applicationId]) diff --git a/api/src/services/listing-csv-export.service.ts b/api/src/services/listing-csv-export.service.ts index f5f08ac9ad..447ff368e7 100644 --- a/api/src/services/listing-csv-export.service.ts +++ b/api/src/services/listing-csv-export.service.ts @@ -37,6 +37,11 @@ import { ListingMultiselectQuestion } from '../dtos/listings/listing-multiselect views.csv = { ...views.details, + copyOf: { + select: { + id: true, + }, + }, userAccounts: true, }; @@ -348,6 +353,16 @@ export class ListingCsvExporterService implements CsvExporterServiceInterface { format: (val: string): string => formatLocalDate(val, this.dateFormat, this.timeZone), }, + { + path: 'copyOf', + label: 'Copy or Original', + format: (val: Listing): string => (val ? 'Copy' : 'Original'), + }, + { + path: 'copyOfId', + label: 'Copied From', + format: (val: string): string => val, + }, { path: 'developer', label: 'Developer', diff --git a/api/src/services/listing.service.ts b/api/src/services/listing.service.ts index c2c9d07c84..ebc619a5e2 100644 --- a/api/src/services/listing.service.ts +++ b/api/src/services/listing.service.ts @@ -133,12 +133,6 @@ views.details = { ...views.full, }; -views.csv = { - ...views.base, - ...views.full, - userAccounts: true, -}; - const LISTING_CRON_JOB_NAME = 'LISTING_CRON_JOB'; /* this is the service for listings @@ -645,7 +639,11 @@ export class ListingService implements OnModuleInit { /* creates a listing */ - async create(dto: ListingCreate, requestingUser: User): Promise { + async create( + dto: ListingCreate, + requestingUser: User, + copyOfId?: string, + ): Promise { await this.permissionService.canOrThrow( requestingUser, 'listing', @@ -897,6 +895,13 @@ export class ListingService implements OnModuleInit { : undefined, requestedChangesUser: undefined, contentUpdatedAt: new Date(), + copyOf: copyOfId + ? { + connect: { + id: copyOfId, + }, + } + : undefined, }, }); @@ -983,10 +988,14 @@ export class ListingService implements OnModuleInit { lotteryStatus: undefined, }; - const res = await this.create(newListingData, { - ...requestingUser, - userRoles: userRoles, - }); + const res = await this.create( + newListingData, + { + ...requestingUser, + userRoles: userRoles, + }, + storedListing.id, + ); if ( process.env.ALLOW_PARTNERS_TO_DUPLICATE_LISTINGS === 'TRUE' && diff --git a/api/test/integration/listing.e2e-spec.ts b/api/test/integration/listing.e2e-spec.ts index 7a55971964..6b67370153 100644 --- a/api/test/integration/listing.e2e-spec.ts +++ b/api/test/integration/listing.e2e-spec.ts @@ -724,6 +724,14 @@ describe('Listing Controller Tests', () => { expect(res.body.name).toEqual(newName); expect(res.body.units).toEqual([]); + + const newListing = await prisma.listings.findFirst({ + select: { + copyOfId: true, + }, + where: { id: res.body.id }, + }); + expect(newListing.copyOfId).toEqual(listing.id); }); }); diff --git a/api/test/unit/services/listing.service.spec.ts b/api/test/unit/services/listing.service.spec.ts index 45438f56d3..925e05c62e 100644 --- a/api/test/unit/services/listing.service.spec.ts +++ b/api/test/unit/services/listing.service.spec.ts @@ -2132,6 +2132,129 @@ describe('Testing listing service', () => { }, ); }); + + it('should create a simple, duplicate listing', async () => { + prisma.listings.create = jest.fn().mockResolvedValue({ + id: 'example id', + name: 'example name', + }); + + await service.create( + { + name: 'example listing name', + depositMin: '5', + assets: [ + { + fileId: randomUUID(), + label: 'example asset', + }, + ], + jurisdictions: { + id: randomUUID(), + }, + status: ListingsStatusEnum.pending, + displayWaitlistSize: false, + unitsSummary: null, + listingEvents: [], + } as ListingCreate, + user, + 'original id', + ); + + expect(prisma.listings.create).toHaveBeenCalledWith({ + include: { + applicationMethods: { + include: { + paperApplications: { + include: { + assets: true, + }, + }, + }, + }, + jurisdictions: true, + listingEvents: { + include: { + assets: true, + }, + }, + listingFeatures: true, + listingImages: { + include: { + assets: true, + }, + }, + listingMultiselectQuestions: { + include: { + multiselectQuestions: true, + }, + }, + listingUtilities: true, + listingsApplicationDropOffAddress: true, + listingsApplicationPickUpAddress: true, + listingsApplicationMailingAddress: true, + listingsBuildingAddress: true, + listingsBuildingSelectionCriteriaFile: true, + listingsLeasingAgentAddress: true, + listingsResult: true, + requestedChangesUser: true, + reservedCommunityTypes: true, + units: { + include: { + amiChart: { + include: { + jurisdictions: true, + unitGroupAmiLevels: true, + }, + }, + unitAccessibilityPriorityTypes: true, + unitAmiChartOverrides: true, + unitRentTypes: true, + unitTypes: true, + }, + }, + }, + data: { + name: 'example listing name', + contentUpdatedAt: expect.anything(), + depositMin: '5', + assets: { + create: [ + { + fileId: expect.anything(), + label: 'example asset', + }, + ], + }, + jurisdictions: { + connect: { + id: expect.anything(), + }, + }, + status: ListingsStatusEnum.pending, + displayWaitlistSize: false, + unitsSummary: undefined, + unitsAvailable: 0, + listingEvents: { + create: [], + }, + copyOf: { + connect: { + id: expect.anything(), + }, + }, + }, + }); + + expect(canOrThrowMock).toHaveBeenCalledWith( + user, + 'listing', + permissionActions.create, + { + jurisdictionId: expect.anything(), + }, + ); + }); }); describe('Test duplicate endpoint', () => {