diff --git a/api/.env.template b/api/.env.template
index e60c09563a..2691b46033 100644
--- a/api/.env.template
+++ b/api/.env.template
@@ -44,6 +44,8 @@ LOTTERY_PUBLISH_PROCESSING_CRON_STRING=58 23 * * *
LOTTERY_PROCESSING_CRON_STRING=0 * * * *
# how many days till lottery data expires
LOTTERY_DAYS_TILL_EXPIRY=45
+# allow partners and jadmins to create duplicate listings
+ALLOW_PARTNERS_TO_DUPLICATE_LISTINGS=FALSE
# the list of allowed urls that can make requests to the api (strings must be exact matches)
CORS_ORIGINS=["http://localhost:3000", "http://localhost:3001"]
# spill over list of allowed urls that can make requests to the api (strings are turned into regex)
diff --git a/api/src/controllers/listing.controller.ts b/api/src/controllers/listing.controller.ts
index 0bfa43c9f8..a64f2d580a 100644
--- a/api/src/controllers/listing.controller.ts
+++ b/api/src/controllers/listing.controller.ts
@@ -32,6 +32,7 @@ import { PermissionAction } from '../decorators/permission-action.decorator';
import { PermissionTypeDecorator } from '../decorators/permission-type.decorator';
import Listing from '../dtos/listings/listing.dto';
import { ListingCreate } from '../dtos/listings/listing-create.dto';
+import { ListingDuplicate } from '../dtos/listings/listing-duplicate.dto';
import { ListingCsvQueryParams } from '../dtos/listings/listing-csv-query-params.dto';
import { ListingFilterParams } from '../dtos/listings/listings-filter-params.dto';
import { ListingsQueryParams } from '../dtos/listings/listings-query-params.dto';
@@ -156,6 +157,19 @@ export class ListingController {
);
}
+ @Post('duplicate')
+ @ApiOperation({ summary: 'Duplicate listing', operationId: 'duplicate' })
+ @UseInterceptors(ClassSerializerInterceptor)
+ @UsePipes(new ListingCreateUpdateValidationPipe(defaultValidationPipeOptions))
+ @ApiOkResponse({ type: Listing })
+ @UseGuards(ApiKeyGuard)
+ async duplicate(
+ @Request() req: ExpressRequest,
+ @Body() dto: ListingDuplicate,
+ ): Promise
{
+ return await this.listingService.duplicate(dto, mapTo(User, req['user']));
+ }
+
@Delete()
@ApiOperation({ summary: 'Delete listing by id', operationId: 'delete' })
@UsePipes(new ValidationPipe(defaultValidationPipeOptions))
diff --git a/api/src/dtos/listings/listing-duplicate.dto.ts b/api/src/dtos/listings/listing-duplicate.dto.ts
new file mode 100644
index 0000000000..54f996338b
--- /dev/null
+++ b/api/src/dtos/listings/listing-duplicate.dto.ts
@@ -0,0 +1,30 @@
+import { ApiProperty } from '@nestjs/swagger';
+import { Expose, Type } from 'class-transformer';
+import {
+ IsBoolean,
+ IsDefined,
+ IsString,
+ ValidateNested,
+} from 'class-validator';
+import { ValidationsGroupsEnum } from '../../enums/shared/validation-groups-enum';
+import { IdDTO } from '../shared/id.dto';
+
+export class ListingDuplicate {
+ @Expose()
+ @ApiProperty()
+ @IsDefined({ groups: [ValidationsGroupsEnum.default] })
+ @IsString({ groups: [ValidationsGroupsEnum.default] })
+ name: string;
+
+ @Expose()
+ @ApiProperty()
+ @IsBoolean({ groups: [ValidationsGroupsEnum.default] })
+ @IsDefined({ groups: [ValidationsGroupsEnum.default] })
+ includeUnits: boolean;
+
+ @Expose()
+ @ApiProperty()
+ @ValidateNested({ groups: [ValidationsGroupsEnum.default] })
+ @Type(() => IdDTO)
+ storedListing: IdDTO;
+}
diff --git a/api/src/services/listing.service.ts b/api/src/services/listing.service.ts
index 33ceb8c388..58c356a61c 100644
--- a/api/src/services/listing.service.ts
+++ b/api/src/services/listing.service.ts
@@ -11,6 +11,7 @@ import { ConfigService } from '@nestjs/config';
import { SchedulerRegistry } from '@nestjs/schedule';
import {
LanguagesEnum,
+ ListingEventsTypeEnum,
ListingsStatusEnum,
Prisma,
ReviewOrderTypeEnum,
@@ -25,6 +26,7 @@ import { TranslationService } from './translation.service';
import { AmiChart } from '../dtos/ami-charts/ami-chart.dto';
import { Listing } from '../dtos/listings/listing.dto';
import { ListingCreate } from '../dtos/listings/listing-create.dto';
+import { ListingDuplicate } from '../dtos/listings/listing-duplicate.dto';
import { ListingFilterParams } from '../dtos/listings/listings-filter-params.dto';
import { ListingsQueryParams } from '../dtos/listings/listings-query-params.dto';
import { ListingUpdate } from '../dtos/listings/listing-update.dto';
@@ -961,6 +963,104 @@ export class ListingService implements OnModuleInit {
return mapTo(Listing, rawListing);
}
+ async duplicate(
+ dto: ListingDuplicate,
+ requestingUser: User,
+ ): Promise {
+ const storedListing = await this.findOrThrow(
+ dto.storedListing.id,
+ ListingViews.details,
+ );
+ if (dto.name.trim() === storedListing.name) {
+ throw new BadRequestException('New listing name must be unique');
+ }
+
+ const userRoles =
+ process.env.ALLOW_PARTNERS_TO_DUPLICATE_LISTINGS === 'TRUE' &&
+ (requestingUser?.userRoles?.isJurisdictionalAdmin ||
+ requestingUser?.userRoles?.isPartner)
+ ? {
+ ...requestingUser.userRoles,
+ isAdmin: true,
+ }
+ : requestingUser?.userRoles;
+
+ await this.permissionService.canOrThrow(
+ { ...requestingUser, userRoles: userRoles },
+ 'listing',
+ permissionActions.create,
+ {
+ jurisdictionId: storedListing.jurisdictions.id,
+ },
+ );
+
+ const mappedListing = mapTo(ListingCreate, storedListing);
+
+ const listingEvents = mappedListing.listingEvents?.filter(
+ (event) => event.type !== ListingEventsTypeEnum.lotteryResults,
+ );
+
+ const listingImages = mappedListing.listingImages?.map((unsavedImage) => ({
+ assets: {
+ fileId: unsavedImage.assets.fileId,
+ label: unsavedImage.assets.label,
+ },
+ ordinal: unsavedImage.ordinal,
+ }));
+
+ if (!dto.includeUnits) {
+ delete mappedListing['units'];
+ }
+
+ const newListingData: ListingCreate = {
+ ...mappedListing,
+ name: dto.name,
+ status: ListingsStatusEnum.pending,
+ listingEvents: listingEvents,
+ listingMultiselectQuestions:
+ storedListing.listingMultiselectQuestions?.map((question) => ({
+ id: question.multiselectQuestionId,
+ ordinal: question.ordinal,
+ })),
+ listingImages: listingImages,
+ lotteryLastRunAt: undefined,
+ lotteryLastPublishedAt: undefined,
+ lotteryStatus: undefined,
+ };
+
+ const res = await this.create(newListingData, {
+ ...requestingUser,
+ userRoles: userRoles,
+ });
+
+ if (
+ process.env.ALLOW_PARTNERS_TO_DUPLICATE_LISTINGS === 'TRUE' &&
+ requestingUser.userRoles?.isPartner
+ ) {
+ await this.prisma.userAccounts.update({
+ data: {
+ listings: {
+ connect: { id: res.id },
+ },
+ },
+ where: {
+ id: requestingUser.id,
+ },
+ });
+
+ await this.prisma.activityLog.create({
+ data: {
+ module: 'user',
+ recordId: requestingUser.id,
+ action: 'update',
+ userAccounts: { connect: { id: requestingUser.id } },
+ },
+ });
+ }
+
+ return res;
+ }
+
/*
deletes a listing
*/
diff --git a/api/test/integration/listing.e2e-spec.ts b/api/test/integration/listing.e2e-spec.ts
index d4a354a5a6..7a55971964 100644
--- a/api/test/integration/listing.e2e-spec.ts
+++ b/api/test/integration/listing.e2e-spec.ts
@@ -658,6 +658,75 @@ describe('Listing Controller Tests', () => {
});
});
+ describe('duplicate endpoint', () => {
+ it('should duplicate listing, include units', async () => {
+ const jurisdictionA = await prisma.jurisdictions.create({
+ data: jurisdictionFactory(),
+ });
+ await reservedCommunityTypeFactoryAll(jurisdictionA.id, prisma);
+ const listingData = await listingFactory(jurisdictionA.id, prisma, {
+ numberOfUnits: 2,
+ });
+ const listing = await prisma.listings.create({
+ data: listingData,
+ include: {
+ units: true,
+ },
+ });
+
+ const newName = 'duplicate name 1';
+
+ const res = await request(app.getHttpServer())
+ .post('/listings/duplicate')
+ .set({ passkey: process.env.API_PASS_KEY || '' })
+ .send({
+ includeUnits: true,
+ name: newName,
+ storedListing: {
+ id: listing.id,
+ },
+ })
+ .set('Cookie', adminAccessToken)
+ .expect(201);
+
+ expect(res.body.name).toEqual(newName);
+ expect(res.body.units.length).toBe(listing.units.length);
+ });
+
+ it('should duplicate listing, exclude units', async () => {
+ const jurisdictionA = await prisma.jurisdictions.create({
+ data: jurisdictionFactory(),
+ });
+ await reservedCommunityTypeFactoryAll(jurisdictionA.id, prisma);
+ const listingData = await listingFactory(jurisdictionA.id, prisma, {
+ numberOfUnits: 2,
+ });
+ const listing = await prisma.listings.create({
+ data: listingData,
+ include: {
+ units: true,
+ },
+ });
+ const newName = 'duplicate name 2';
+
+ const res = await request(app.getHttpServer())
+ .post('/listings/duplicate')
+ .set({ passkey: process.env.API_PASS_KEY || '' })
+ .send({
+ includeUnits: false,
+ name: newName,
+ storedListing: {
+ id: listing.id,
+ },
+ })
+ .set('Cookie', adminAccessToken)
+ .expect(201);
+
+ expect(res.body.name).toEqual(newName);
+ expect(res.body.units).toEqual([]);
+ });
+ });
+
describe('process endpoint', () => {
it('should successfully process listings that are past due', async () => {
const jurisdictionA = await prisma.jurisdictions.create({
diff --git a/api/test/integration/permission-tests/permission-as-admin.e2e-spec.ts b/api/test/integration/permission-tests/permission-as-admin.e2e-spec.ts
index e8df503b16..9218e368e8 100644
--- a/api/test/integration/permission-tests/permission-as-admin.e2e-spec.ts
+++ b/api/test/integration/permission-tests/permission-as-admin.e2e-spec.ts
@@ -1272,6 +1272,42 @@ describe('Testing Permissioning of endpoints as Admin User', () => {
expect(activityLogResult).not.toBeNull();
});
+ it('should succeed for duplicate endpoint & create an activity log entry', async () => {
+ const jurisdictionA = await generateJurisdiction(
+ prisma,
+ 'permission juris 180',
+ );
+ await reservedCommunityTypeFactoryAll(jurisdictionA, prisma);
+
+ const listingData = await listingFactory(jurisdictionA, prisma);
+ const listing = await prisma.listings.create({
+ data: listingData,
+ });
+
+ const res = await request(app.getHttpServer())
+ .post('/listings/duplicate')
+ .set({ passkey: process.env.API_PASS_KEY || '' })
+ .send({
+ includeUnits: true,
+ name: 'name',
+ storedListing: {
+ id: listing.id,
+ },
+ })
+ .set('Cookie', cookies)
+ .expect(201);
+
+ const activityLogResult = await prisma.activityLog.findFirst({
+ where: {
+ module: 'listing',
+ action: permissionActions.create,
+ recordId: res.body.id,
+ },
+ });
+
+ expect(activityLogResult).not.toBeNull();
+ });
+
it('should succeed for process endpoint', async () => {
await request(app.getHttpServer())
.put(`/listings/closeListings`)
diff --git a/api/test/integration/permission-tests/permission-as-juris-admin-correct-juris.e2e-spec.ts b/api/test/integration/permission-tests/permission-as-juris-admin-correct-juris.e2e-spec.ts
index 95a3ccf966..2acc5e7281 100644
--- a/api/test/integration/permission-tests/permission-as-juris-admin-correct-juris.e2e-spec.ts
+++ b/api/test/integration/permission-tests/permission-as-juris-admin-correct-juris.e2e-spec.ts
@@ -1161,6 +1161,32 @@ describe('Testing Permissioning of endpoints as Jurisdictional Admin in the corr
expect(activityLogResult).not.toBeNull();
});
+ it('should error as forbidden for duplicate endpoint', async () => {
+ const jurisdictionA = await generateJurisdiction(
+ prisma,
+ 'permission juris 181',
+ );
+ await reservedCommunityTypeFactoryAll(jurisdictionA, prisma);
+
+ const listingData = await listingFactory(jurisdictionA, prisma);
+ const listing = await prisma.listings.create({
+ data: listingData,
+ });
+
+ const res = await request(app.getHttpServer())
+ .post('/listings/duplicate')
+ .set({ passkey: process.env.API_PASS_KEY || '' })
+ .send({
+ includeUnits: true,
+ name: 'name',
+ storedListing: {
+ id: listing.id,
+ },
+ })
+ .set('Cookie', cookies)
+ .expect(403);
+ });
+
it('should succeed for process endpoint', async () => {
await request(app.getHttpServer())
.put(`/listings/closeListings`)
diff --git a/api/test/integration/permission-tests/permission-as-juris-admin-wrong-juris.e2e-spec.ts b/api/test/integration/permission-tests/permission-as-juris-admin-wrong-juris.e2e-spec.ts
index 1b5a8b82fa..8c992667a6 100644
--- a/api/test/integration/permission-tests/permission-as-juris-admin-wrong-juris.e2e-spec.ts
+++ b/api/test/integration/permission-tests/permission-as-juris-admin-wrong-juris.e2e-spec.ts
@@ -1095,6 +1095,32 @@ describe('Testing Permissioning of endpoints as Jurisdictional Admin in the wron
.expect(403);
});
+ it('should error as forbidden for duplicate endpoint', async () => {
+ const jurisdictionA = await generateJurisdiction(
+ prisma,
+ 'permission juris 182',
+ );
+ await reservedCommunityTypeFactoryAll(jurisdictionA, prisma);
+
+ const listingData = await listingFactory(jurisdictionA, prisma);
+ const listing = await prisma.listings.create({
+ data: listingData,
+ });
+
+ const res = await request(app.getHttpServer())
+ .post('/listings/duplicate')
+ .set({ passkey: process.env.API_PASS_KEY || '' })
+ .send({
+ includeUnits: true,
+ name: 'name',
+ storedListing: {
+ id: listing.id,
+ },
+ })
+ .set('Cookie', cookies)
+ .expect(403);
+ });
+
it('should succeed for process endpoint', async () => {
await request(app.getHttpServer())
.put(`/listings/closeListings`)
diff --git a/api/test/integration/permission-tests/permission-as-no-user.e2e-spec.ts b/api/test/integration/permission-tests/permission-as-no-user.e2e-spec.ts
index f9d7eca37f..2df8fcc64e 100644
--- a/api/test/integration/permission-tests/permission-as-no-user.e2e-spec.ts
+++ b/api/test/integration/permission-tests/permission-as-no-user.e2e-spec.ts
@@ -1118,6 +1118,32 @@ describe('Testing Permissioning of endpoints as logged out user', () => {
.expect(403);
});
+ it('should error as forbidden for duplicate endpoint', async () => {
+ const jurisdictionA = await generateJurisdiction(
+ prisma,
+ 'permission juris 183',
+ );
+ await reservedCommunityTypeFactoryAll(jurisdictionA, prisma);
+
+ const listingData = await listingFactory(jurisdictionA, prisma);
+ const listing = await prisma.listings.create({
+ data: listingData,
+ });
+
+ const res = await request(app.getHttpServer())
+ .post('/listings/duplicate')
+ .set({ passkey: process.env.API_PASS_KEY || '' })
+ .send({
+ includeUnits: true,
+ name: 'name',
+ storedListing: {
+ id: listing.id,
+ },
+ })
+ .set('Cookie', cookies)
+ .expect(403);
+ });
+
it('should error as forbidden for process endpoint', async () => {
await request(app.getHttpServer())
.put(`/listings/closeListings`)
diff --git a/api/test/integration/permission-tests/permission-as-partner-correct-listing.e2e-spec.ts b/api/test/integration/permission-tests/permission-as-partner-correct-listing.e2e-spec.ts
index 6ebb1c78a0..58b5f0c77d 100644
--- a/api/test/integration/permission-tests/permission-as-partner-correct-listing.e2e-spec.ts
+++ b/api/test/integration/permission-tests/permission-as-partner-correct-listing.e2e-spec.ts
@@ -1100,6 +1100,32 @@ describe('Testing Permissioning of endpoints as partner with correct listing', (
.expect(403);
});
+ it('should error as forbidden for duplicate endpoint', async () => {
+ const jurisdictionA = await generateJurisdiction(
+ prisma,
+ 'permission juris 184',
+ );
+ await reservedCommunityTypeFactoryAll(jurisdictionA, prisma);
+
+ const listingData = await listingFactory(jurisdictionA, prisma);
+ const listing = await prisma.listings.create({
+ data: listingData,
+ });
+
+ const res = await request(app.getHttpServer())
+ .post('/listings/duplicate')
+ .set({ passkey: process.env.API_PASS_KEY || '' })
+ .send({
+ includeUnits: true,
+ name: 'name',
+ storedListing: {
+ id: listing.id,
+ },
+ })
+ .set('Cookie', cookies)
+ .expect(403);
+ });
+
it('should error as forbidden for process endpoint', async () => {
await request(app.getHttpServer())
.put(`/listings/closeListings`)
diff --git a/api/test/integration/permission-tests/permission-as-partner-wrong-listing.e2e-spec.ts b/api/test/integration/permission-tests/permission-as-partner-wrong-listing.e2e-spec.ts
index 4445936c1d..001ae06973 100644
--- a/api/test/integration/permission-tests/permission-as-partner-wrong-listing.e2e-spec.ts
+++ b/api/test/integration/permission-tests/permission-as-partner-wrong-listing.e2e-spec.ts
@@ -1056,6 +1056,32 @@ describe('Testing Permissioning of endpoints as partner with wrong listing', ()
.expect(403);
});
+ it('should error as forbidden for duplicate endpoint', async () => {
+ const jurisdictionA = await generateJurisdiction(
+ prisma,
+ 'permission juris 185',
+ );
+ await reservedCommunityTypeFactoryAll(jurisdictionA, prisma);
+
+ const listingData = await listingFactory(jurisdictionA, prisma);
+ const listing = await prisma.listings.create({
+ data: listingData,
+ });
+
+ const res = await request(app.getHttpServer())
+ .post('/listings/duplicate')
+ .set({ passkey: process.env.API_PASS_KEY || '' })
+ .send({
+ includeUnits: true,
+ name: 'name',
+ storedListing: {
+ id: listing.id,
+ },
+ })
+ .set('Cookie', cookies)
+ .expect(403);
+ });
+
it('should error as forbidden for process endpoint', async () => {
await request(app.getHttpServer())
.put(`/listings/closeListings`)
diff --git a/api/test/integration/permission-tests/permission-as-public.e2e-spec.ts b/api/test/integration/permission-tests/permission-as-public.e2e-spec.ts
index fc92f2fc3a..20d6af2334 100644
--- a/api/test/integration/permission-tests/permission-as-public.e2e-spec.ts
+++ b/api/test/integration/permission-tests/permission-as-public.e2e-spec.ts
@@ -1171,6 +1171,32 @@ describe('Testing Permissioning of endpoints as public user', () => {
.expect(403);
});
+ it('should error as forbidden for duplicate endpoint', async () => {
+ const jurisdictionA = await generateJurisdiction(
+ prisma,
+ 'permission juris 186',
+ );
+ await reservedCommunityTypeFactoryAll(jurisdictionA, prisma);
+
+ const listingData = await listingFactory(jurisdictionA, prisma);
+ const listing = await prisma.listings.create({
+ data: listingData,
+ });
+
+ const res = await request(app.getHttpServer())
+ .post('/listings/duplicate')
+ .set({ passkey: process.env.API_PASS_KEY || '' })
+ .send({
+ includeUnits: true,
+ name: 'name',
+ storedListing: {
+ id: listing.id,
+ },
+ })
+ .set('Cookie', cookies)
+ .expect(403);
+ });
+
it('should error as forbidden for process endpoint', async () => {
await request(app.getHttpServer())
.put(`/listings/closeListings`)
diff --git a/api/test/unit/services/listing.service.spec.ts b/api/test/unit/services/listing.service.spec.ts
index e277b4bd4a..45438f56d3 100644
--- a/api/test/unit/services/listing.service.spec.ts
+++ b/api/test/unit/services/listing.service.spec.ts
@@ -2134,6 +2134,274 @@ describe('Testing listing service', () => {
});
});
+ describe('Test duplicate endpoint', () => {
+ it('should duplicate a listing, including units', async () => {
+ const listing = mockListing(1, { numberToMake: 2, date: new Date() });
+
+ const newName = 'duplicate name';
+
+ prisma.listings.findUnique = jest.fn().mockResolvedValue({
+ ...listing,
+ jurisdictions: {
+ id: randomUUID(),
+ },
+ });
+
+ prisma.listings.create = jest.fn().mockResolvedValue({
+ ...listing,
+ id: 'duplicate id',
+ name: newName,
+ });
+
+ const newListing = await service.duplicate(
+ {
+ includeUnits: true,
+ name: newName,
+ storedListing: {
+ id: listing.id.toString(),
+ },
+ },
+ user,
+ );
+
+ expect(newListing.name).toBe(newName);
+ expect(newListing.units).toEqual(listing.units);
+
+ expect(prisma.listings.findUnique).toHaveBeenCalledWith({
+ where: {
+ id: listing.id.toString(),
+ },
+ 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,
+ listingsBuildingAddress: true,
+ listingsBuildingSelectionCriteriaFile: true,
+ listingsApplicationMailingAddress: 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,
+ },
+ },
+ },
+ });
+ });
+
+ it('should duplicate a listing, excluding units', async () => {
+ const listing = mockListing(1, { numberToMake: 2, date: new Date() });
+
+ const newName = 'duplicate name';
+
+ prisma.listings.findUnique = jest.fn().mockResolvedValue({
+ ...listing,
+ jurisdictions: {
+ id: randomUUID(),
+ },
+ });
+
+ prisma.listings.create = jest.fn().mockResolvedValue({
+ ...listing,
+ id: 'duplicate id',
+ name: newName,
+ units: [],
+ });
+
+ const newListing = await service.duplicate(
+ {
+ includeUnits: false,
+ name: newName,
+ storedListing: {
+ id: listing.id.toString(),
+ },
+ },
+ user,
+ );
+
+ expect(newListing.name).toBe(newName);
+ expect(newListing.units).toEqual([]);
+
+ expect(prisma.listings.findUnique).toHaveBeenCalledWith({
+ where: {
+ id: listing.id.toString(),
+ },
+ 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,
+ listingsBuildingAddress: true,
+ listingsBuildingSelectionCriteriaFile: true,
+ listingsApplicationMailingAddress: 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,
+ },
+ },
+ },
+ });
+ });
+
+ it('should fail to duplicate a listing with the same name', async () => {
+ const listing = mockListing(1, { numberToMake: 2, date: new Date() });
+
+ prisma.listings.findUnique = jest.fn().mockResolvedValue({
+ ...listing,
+ jurisdictions: {
+ id: randomUUID(),
+ },
+ });
+
+ await expect(
+ async () =>
+ await service.duplicate(
+ {
+ includeUnits: false,
+ name: listing.name,
+ storedListing: {
+ id: listing.id.toString(),
+ },
+ },
+ user,
+ ),
+ ).rejects.toThrowError();
+
+ expect(prisma.listings.findUnique).toHaveBeenCalledWith({
+ where: {
+ id: listing.id.toString(),
+ },
+ 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,
+ listingsBuildingAddress: true,
+ listingsBuildingSelectionCriteriaFile: true,
+ listingsApplicationMailingAddress: 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,
+ },
+ },
+ },
+ });
+ });
+ });
+
describe('Test delete endpoint', () => {
it('should delete a listing', async () => {
const id = randomUUID();
diff --git a/api/test/unit/services/lottery.service.spec.ts b/api/test/unit/services/lottery.service.spec.ts
index 1efd34f5d8..296759495d 100644
--- a/api/test/unit/services/lottery.service.spec.ts
+++ b/api/test/unit/services/lottery.service.spec.ts
@@ -810,7 +810,7 @@ describe('Testing lottery service', () => {
});
describe('Test autoPublishResults endpoint', () => {
- it('should call the updateMany', async () => {
+ it('should call the update', async () => {
prisma.listings.findMany = jest.fn().mockResolvedValue([
{
id: 'example id1',
diff --git a/shared-helpers/src/types/backend-swagger.ts b/shared-helpers/src/types/backend-swagger.ts
index 86a36f1d8b..846a0a9a70 100644
--- a/shared-helpers/src/types/backend-swagger.ts
+++ b/shared-helpers/src/types/backend-swagger.ts
@@ -329,6 +329,28 @@ export class ListingsService {
axios(configs, resolve, reject)
})
}
+ /**
+ * Duplicate listing
+ */
+ duplicate(
+ params: {
+ /** requestBody */
+ body?: ListingDuplicate
+ } = {} as any,
+ options: IRequestOptions = {}
+ ): Promise {
+ return new Promise((resolve, reject) => {
+ let url = basePath + "/listings/duplicate"
+
+ const configs: IRequestConfig = getConfigs("post", "application/json", url, options)
+
+ let data = params.body
+
+ configs.data = data
+
+ axios(configs, resolve, reject)
+ })
+ }
/**
* Trigger the listing process job
*/
@@ -3789,6 +3811,17 @@ export interface ListingCreate {
requestedChangesUser?: IdDTO
}
+export interface ListingDuplicate {
+ /** */
+ name: string
+
+ /** */
+ includeUnits: boolean
+
+ /** */
+ storedListing: IdDTO
+}
+
export interface ListingUpdate {
/** */
id: string