Skip to content

Commit

Permalink
feat: listing duplication endpoint (#4307)
Browse files Browse the repository at this point in the history
* feat: duplicate listing endpoint

* feat: unit test fix

* feat: permission tests

* feat: feature flag partners duplicating listings

* feat: handle logged out user

* feat: handle new field

* feat: improve e2e testing

* feat: comment improvements
  • Loading branch information
mcgarrye authored Sep 10, 2024
1 parent 81d666a commit c0ee2cb
Show file tree
Hide file tree
Showing 15 changed files with 709 additions and 1 deletion.
2 changes: 2 additions & 0 deletions api/.env.template
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
14 changes: 14 additions & 0 deletions api/src/controllers/listing.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<Listing> {
return await this.listingService.duplicate(dto, mapTo(User, req['user']));
}

@Delete()
@ApiOperation({ summary: 'Delete listing by id', operationId: 'delete' })
@UsePipes(new ValidationPipe(defaultValidationPipeOptions))
Expand Down
30 changes: 30 additions & 0 deletions api/src/dtos/listings/listing-duplicate.dto.ts
Original file line number Diff line number Diff line change
@@ -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;
}
100 changes: 100 additions & 0 deletions api/src/services/listing.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { ConfigService } from '@nestjs/config';
import { SchedulerRegistry } from '@nestjs/schedule';
import {
LanguagesEnum,
ListingEventsTypeEnum,
ListingsStatusEnum,
Prisma,
ReviewOrderTypeEnum,
Expand All @@ -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';
Expand Down Expand Up @@ -961,6 +963,104 @@ export class ListingService implements OnModuleInit {
return mapTo(Listing, rawListing);
}

async duplicate(
dto: ListingDuplicate,
requestingUser: User,
): Promise<Listing> {
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
*/
Expand Down
69 changes: 69 additions & 0 deletions api/test/integration/listing.e2e-spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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`)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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`)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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`)
Expand Down
Loading

0 comments on commit c0ee2cb

Please sign in to comment.