From ba699aa648727b37650cb7338dce08d5c04427f1 Mon Sep 17 00:00:00 2001 From: Emily Jablonski <65367387+emilyjablonski@users.noreply.github.com> Date: Thu, 12 Sep 2024 09:15:00 -0600 Subject: [PATCH] feat: public lottery view, totals data (#4279) --- .../migrations/16_lottery_total/migration.sql | 18 +++ api/prisma/schema.prisma | 15 ++- api/src/controllers/lottery.controller.ts | 17 +++ .../application-lottery-position.dto.ts | 12 +- .../application-lottery-total.dto.ts | 32 ++++++ api/src/dtos/listings/listing-update.dto.ts | 1 + api/src/dtos/listings/listing.dto.ts | 10 ++ .../dtos/lottery/lottery-public-result.dto.ts | 4 +- .../dtos/lottery/lottery-public-total.dto.ts | 18 +++ api/src/services/lottery.service.ts | 95 +++++++++++++--- api/test/integration/lottery.e2e-spec.ts | 100 +++++++++++++++++ .../services/google-translate.service.spec.ts | 49 ++++++++ .../unit/services/lottery.service.spec.ts | 105 +++++++++++++++++- shared-helpers/src/types/backend-swagger.ts | 43 +++++++ sites/partners/src/lib/listings/formTypes.ts | 13 +-- .../application/[id]/lottery-results.tsx | 21 +++- 16 files changed, 517 insertions(+), 36 deletions(-) create mode 100644 api/prisma/migrations/16_lottery_total/migration.sql create mode 100644 api/src/dtos/applications/application-lottery-total.dto.ts create mode 100644 api/src/dtos/lottery/lottery-public-total.dto.ts create mode 100644 api/test/unit/services/google-translate.service.spec.ts diff --git a/api/prisma/migrations/16_lottery_total/migration.sql b/api/prisma/migrations/16_lottery_total/migration.sql new file mode 100644 index 0000000000..4ab8a323b0 --- /dev/null +++ b/api/prisma/migrations/16_lottery_total/migration.sql @@ -0,0 +1,18 @@ +-- CreateTable +CREATE TABLE "application_lottery_totals" ( + "id" UUID NOT NULL DEFAULT uuid_generate_v4(), + "total" INTEGER NOT NULL, + "listing_id" UUID NOT NULL, + "multiselect_question_id" UUID, + + CONSTRAINT "application_lottery_totals_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "application_lottery_totals_listing_id_idx" ON "application_lottery_totals"("listing_id"); + +-- AddForeignKey +ALTER TABLE "application_lottery_totals" ADD CONSTRAINT "application_lottery_totals_listing_id_fkey" FOREIGN KEY ("listing_id") REFERENCES "listings"("id") ON DELETE CASCADE ON UPDATE NO ACTION; + +-- AddForeignKey +ALTER TABLE "application_lottery_totals" ADD CONSTRAINT "application_lottery_totals_multiselect_question_id_fkey" FOREIGN KEY ("multiselect_question_id") REFERENCES "multiselect_questions"("id") ON DELETE NO ACTION ON UPDATE NO ACTION; diff --git a/api/prisma/schema.prisma b/api/prisma/schema.prisma index 0903557a3a..5391236e45 100644 --- a/api/prisma/schema.prisma +++ b/api/prisma/schema.prisma @@ -440,6 +440,18 @@ model ApplicationLotteryPositions { @@map("application_lottery_positions") } +model ApplicationLotteryTotal { + id String @id() @default(dbgenerated("uuid_generate_v4()")) @db.Uuid + total Int + listingId String @map("listing_id") @db.Uuid + listings Listings @relation(fields: [listingId], references: [id], onDelete: Cascade, onUpdate: NoAction) + multiselectQuestionId String? @map("multiselect_question_id") @db.Uuid + multiselectQuestions MultiselectQuestions? @relation(fields: [multiselectQuestionId], references: [id], onDelete: NoAction, onUpdate: NoAction) + + @@index([listingId]) + @@map("application_lottery_totals") +} + model ListingUtilities { id String @id() @default(dbgenerated("uuid_generate_v4()")) @db.Uuid createdAt DateTime @default(now()) @map("created_at") @db.Timestamp(6) @@ -592,7 +604,7 @@ model Listings { requestedChangesUserId String? @map("requested_changes_user_id") @db.Uuid requestedChangesUser UserAccounts? @relation("requested_changes_user", fields: [requestedChangesUserId], references: [id], onDelete: NoAction, onUpdate: NoAction) applicationLotteryPositions ApplicationLotteryPositions[] - + applicationLotteryTotals ApplicationLotteryTotal[] @@index([jurisdictionId]) @@map("listings") } @@ -632,6 +644,7 @@ model MultiselectQuestions { jurisdictions Jurisdictions[] listings ListingMultiselectQuestions[] applicationLotteryPositions ApplicationLotteryPositions[] + applicationLotteryTotals ApplicationLotteryTotal[] @@map("multiselect_questions") } diff --git a/api/src/controllers/lottery.controller.ts b/api/src/controllers/lottery.controller.ts index 81580eca80..d3578f9f14 100644 --- a/api/src/controllers/lottery.controller.ts +++ b/api/src/controllers/lottery.controller.ts @@ -36,6 +36,7 @@ import { PermissionAction } from '../../src/decorators/permission-action.decorat import { permissionActions } from '../../src/enums/permissions/permission-actions-enum'; import { AdminOrJurisdictionalAdminGuard } from '../../src/guards/admin-or-jurisdiction-admin.guard'; import { PublicLotteryResult } from '../../src/dtos/lottery/lottery-public-result.dto'; +import { PublicLotteryTotal } from '../../src/dtos/lottery/lottery-public-total.dto'; @Controller('lottery') @ApiTags('lottery') @@ -163,4 +164,20 @@ export class LotteryController { mapTo(User, req['user']), ); } + + @Get(`lotteryTotals/:id`) + @ApiOkResponse({ + type: PublicLotteryTotal, + isArray: true, + }) + @ApiOperation({ + summary: 'Get lottery totals by listing id', + operationId: 'lotteryTotals', + }) + async lotteryTotals( + @Request() req: ExpressRequest, + @Param('id', new ParseUUIDPipe({ version: '4' })) id: string, + ): Promise { + return this.lotteryService.lotteryTotals(id, mapTo(User, req['user'])); + } } diff --git a/api/src/dtos/applications/application-lottery-position.dto.ts b/api/src/dtos/applications/application-lottery-position.dto.ts index cd0d8da43d..8633e24780 100644 --- a/api/src/dtos/applications/application-lottery-position.dto.ts +++ b/api/src/dtos/applications/application-lottery-position.dto.ts @@ -1,6 +1,12 @@ import { ApiProperty } from '@nestjs/swagger'; import { Expose } from 'class-transformer'; -import { IsDefined, IsNumber, IsString, IsUUID } from 'class-validator'; +import { + IsDefined, + IsNumber, + IsOptional, + IsString, + IsUUID, +} from 'class-validator'; import { ValidationsGroupsEnum } from '../../enums/shared/validation-groups-enum'; export class ApplicationLotteryPosition { @@ -21,9 +27,9 @@ export class ApplicationLotteryPosition { @Expose() @IsString({ groups: [ValidationsGroupsEnum.default] }) @IsUUID(4, { groups: [ValidationsGroupsEnum.default] }) - @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) @ApiProperty() - multiselectQuestionId: string; + multiselectQuestionId?: string; @Expose() @IsNumber({}, { groups: [ValidationsGroupsEnum.default] }) diff --git a/api/src/dtos/applications/application-lottery-total.dto.ts b/api/src/dtos/applications/application-lottery-total.dto.ts new file mode 100644 index 0000000000..688f81a00d --- /dev/null +++ b/api/src/dtos/applications/application-lottery-total.dto.ts @@ -0,0 +1,32 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Expose } from 'class-transformer'; +import { + IsDefined, + IsNumber, + IsOptional, + IsString, + IsUUID, +} from 'class-validator'; +import { ValidationsGroupsEnum } from '../../enums/shared/validation-groups-enum'; + +export class ApplicationLotteryTotal { + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @IsUUID(4, { groups: [ValidationsGroupsEnum.default] }) + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ApiProperty() + listingId: string; + + @Expose() + @IsString({ groups: [ValidationsGroupsEnum.default] }) + @IsUUID(4, { groups: [ValidationsGroupsEnum.default] }) + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @ApiPropertyOptional() + multiselectQuestionId?: string; + + @Expose() + @IsNumber({}, { groups: [ValidationsGroupsEnum.default] }) + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @ApiProperty() + total: number; +} diff --git a/api/src/dtos/listings/listing-update.dto.ts b/api/src/dtos/listings/listing-update.dto.ts index fa92cf0b65..ef74211018 100644 --- a/api/src/dtos/listings/listing-update.dto.ts +++ b/api/src/dtos/listings/listing-update.dto.ts @@ -47,6 +47,7 @@ export class ListingUpdate extends OmitType(Listing, [ 'afsLastRunAt', 'urlSlug', 'applicationConfig', + 'applicationLotteryTotals', ]) { @Expose() @ValidateNested({ groups: [ValidationsGroupsEnum.default], each: true }) diff --git a/api/src/dtos/listings/listing.dto.ts b/api/src/dtos/listings/listing.dto.ts index 3864715503..d4817ac293 100644 --- a/api/src/dtos/listings/listing.dto.ts +++ b/api/src/dtos/listings/listing.dto.ts @@ -1,5 +1,6 @@ import { Expose, Transform, TransformFnParams, Type } from 'class-transformer'; import { + IsArray, IsBoolean, IsDate, IsDefined, @@ -39,6 +40,7 @@ import { listingUrlSlug } from '../../utilities/listing-url-slug'; import { User } from '../users/user.dto'; import { requestedChangesUserMapper } from '../../utilities/requested-changes-user'; import { LotteryDateParamValidator } from '../../utilities/lottery-date-validator'; +import { ApplicationLotteryTotal } from '../applications/application-lottery-total.dto'; class Listing extends AbstractDTO { @Expose() @@ -603,6 +605,14 @@ class Listing extends AbstractDTO { @IsBoolean({ groups: [ValidationsGroupsEnum.default] }) @ApiPropertyOptional() lotteryOptIn?: boolean; + + @Expose() + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + @IsArray({ groups: [ValidationsGroupsEnum.default] }) + @ValidateNested({ groups: [ValidationsGroupsEnum.default], each: true }) + @Type(() => ApplicationLotteryTotal) + @ApiProperty({ type: ApplicationLotteryTotal, isArray: true }) + applicationLotteryTotals: ApplicationLotteryTotal[]; } export { Listing as default, Listing }; diff --git a/api/src/dtos/lottery/lottery-public-result.dto.ts b/api/src/dtos/lottery/lottery-public-result.dto.ts index 6f2b9b2ce1..9644629a1b 100644 --- a/api/src/dtos/lottery/lottery-public-result.dto.ts +++ b/api/src/dtos/lottery/lottery-public-result.dto.ts @@ -1,4 +1,4 @@ -import { ApiProperty } from '@nestjs/swagger'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { Expose } from 'class-transformer'; import { IsDefined, IsNumber, IsOptional, IsUUID } from 'class-validator'; import { ValidationsGroupsEnum } from '../../enums/shared/validation-groups-enum'; @@ -11,7 +11,7 @@ export class PublicLotteryResult { ordinal: number; @Expose() - @ApiProperty() + @ApiPropertyOptional() @IsOptional({ groups: [ValidationsGroupsEnum.default] }) @IsUUID(4, { groups: [ValidationsGroupsEnum.default] }) multiselectQuestionId?: string; diff --git a/api/src/dtos/lottery/lottery-public-total.dto.ts b/api/src/dtos/lottery/lottery-public-total.dto.ts new file mode 100644 index 0000000000..f889f10be1 --- /dev/null +++ b/api/src/dtos/lottery/lottery-public-total.dto.ts @@ -0,0 +1,18 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Expose } from 'class-transformer'; +import { IsDefined, IsNumber, IsOptional, IsUUID } from 'class-validator'; +import { ValidationsGroupsEnum } from '../../enums/shared/validation-groups-enum'; + +export class PublicLotteryTotal { + @Expose() + @IsNumber({}, { groups: [ValidationsGroupsEnum.default] }) + @ApiProperty() + @IsDefined({ groups: [ValidationsGroupsEnum.default] }) + total: number; + + @Expose() + @ApiPropertyOptional() + @IsOptional({ groups: [ValidationsGroupsEnum.default] }) + @IsUUID(4, { groups: [ValidationsGroupsEnum.default] }) + multiselectQuestionId?: string; +} diff --git a/api/src/services/lottery.service.ts b/api/src/services/lottery.service.ts index 78b4613d63..f2e59b2b81 100644 --- a/api/src/services/lottery.service.ts +++ b/api/src/services/lottery.service.ts @@ -10,6 +10,7 @@ import { import { SchedulerRegistry } from '@nestjs/schedule'; import { ConfigService } from '@nestjs/config'; import { + ApplicationLotteryTotal, ListingEventsTypeEnum, ListingsStatusEnum, LotteryStatusEnum, @@ -48,6 +49,7 @@ import { ListingViews } from '../../src/enums/listings/view-enum'; import { startCronJob } from '../utilities/cron-job-starter'; import { EmailService } from './email.service'; import { PublicLotteryResult } from '../../src/dtos/lottery/lottery-public-result.dto'; +import { PublicLotteryTotal } from '../../src/dtos/lottery/lottery-public-total.dto'; view.csv = { ...view.details, @@ -136,6 +138,9 @@ export class LotteryService { await this.prisma.applicationLotteryPositions.deleteMany({ where: { listingId: listingId }, }); + await this.prisma.applicationLotteryTotal.deleteMany({ + where: { listingId: listingId }, + }); } try { @@ -241,6 +246,14 @@ export class LotteryService { })), }); + await this.prisma.applicationLotteryTotal.create({ + data: { + listingId, + total: filteredApplications.length, + multiselectQuestionId: null, + }, + }); + // order by ordinal filteredApplications = filteredApplications.sort( (a, b) => @@ -279,6 +292,13 @@ export class LotteryService { multiselectQuestionId: id, })), }); + await this.prisma.applicationLotteryTotal.create({ + data: { + listingId, + total: applicationsWithThisPreference.length, + multiselectQuestionId: id, + }, + }); } } } @@ -1118,31 +1138,78 @@ export class LotteryService { throw new ForbiddenException(); } - const applicationUserId = await this.prisma.applications.findFirstOrThrow({ + if (!user.userRoles?.isAdmin) { + const applicationUserId = await this.prisma.applications.findFirst({ + select: { + userId: true, + }, + where: { + id: applicationId, + }, + }); + + if (!applicationUserId) { + throw new BadRequestException( + `User requesting lottery results did not submit an application to this listing`, + ); + } + + await this.permissionService.canOrThrow( + user, + 'application', + permissionActions.read, + { + userId: applicationUserId.userId, + }, + ); + } + + const results = await this.prisma.applicationLotteryPositions.findMany({ select: { - userId: true, + ordinal: true, + multiselectQuestionId: true, }, where: { - id: applicationId, + applicationId, }, }); - await this.permissionService.canOrThrow( - user, - 'application', - permissionActions.read, - { - userId: applicationUserId.userId, - }, - ); + return results; + } - const results = await this.prisma.applicationLotteryPositions.findMany({ + /* + * @param id - listing id + * @returns an array of totals + */ + public async lotteryTotals( + listingId: string, + user: User, + ): Promise { + if (!user) { + throw new ForbiddenException(); + } + + if (!user.userRoles?.isAdmin) { + const application = await this.prisma.applications.findFirst({ + where: { + listingId, + userId: user.id, + }, + }); + if (!application) { + throw new BadRequestException( + `User requesting lottery totals did not submit an application to this listing`, + ); + } + } + + const results = await this.prisma.applicationLotteryTotal.findMany({ select: { - ordinal: true, + total: true, multiselectQuestionId: true, }, where: { - applicationId, + listingId, }, }); diff --git a/api/test/integration/lottery.e2e-spec.ts b/api/test/integration/lottery.e2e-spec.ts index d8c37250b2..80082d2303 100644 --- a/api/test/integration/lottery.e2e-spec.ts +++ b/api/test/integration/lottery.e2e-spec.ts @@ -1132,6 +1132,8 @@ describe('Lottery Controller Tests', () => { const jurisdiction = await prisma.jurisdictions.create({ data: jurisdictionFactory(), }); + await reservedCommunityTypeFactoryAll(jurisdiction.id, prisma); + const listing1 = await listingFactory(jurisdiction.id, prisma, { status: ListingsStatusEnum.closed, }); @@ -1172,4 +1174,102 @@ describe('Lottery Controller Tests', () => { expect(res.body).toEqual([{ ordinal: 1, multiselectQuestionId: null }]); }); }); + describe('lotteryTotals', () => { + it('should return totals', async () => { + const unitTypeA = await unitTypeFactorySingle( + prisma, + UnitTypeEnum.oneBdrm, + ); + const jurisdiction = await prisma.jurisdictions.create({ + data: jurisdictionFactory(), + }); + await reservedCommunityTypeFactoryAll(jurisdiction.id, prisma); + + const preferenceA = await multiselectQuestionFactory(jurisdiction.id, { + multiselectQuestion: { + text: 'City Employees', + description: 'Employees of the local city.', + applicationSection: + MultiselectQuestionsApplicationSectionEnum.preferences, + options: [ + { + text: 'At least one member of my household is a city employee', + collectAddress: false, + ordinal: 0, + }, + ], + }, + }); + + const preferenceACreated = await prisma.multiselectQuestions.create({ + data: preferenceA, + }); + + const listing1 = await listingFactory(jurisdiction.id, prisma, { + status: ListingsStatusEnum.closed, + multiselectQuestions: [preferenceACreated], + }); + + const listing1Created = await prisma.listings.create({ + data: listing1, + }); + + const appA = await applicationFactory({ + listingId: listing1Created.id, + unitTypeId: unitTypeA.id, + multiselectQuestions: [preferenceACreated], + }); + + await prisma.applications.create({ + data: appA, + include: { + applicant: true, + }, + }); + + const appB = await applicationFactory({ + listingId: listing1Created.id, + unitTypeId: unitTypeA.id, + }); + + await prisma.applications.create({ + data: appB, + include: { + applicant: true, + }, + }); + + await lotteryService.lotteryGenerate( + { + user: { + id: randomUUID(), + userRoles: { + isAdmin: true, + }, + }, + } as unknown as ExpressRequest, + {} as Response, + { id: listing1Created.id }, + ); + + const res = await request(app.getHttpServer()) + .get(`/lottery/lotteryTotals/${listing1Created.id}`) + .set({ passkey: process.env.API_PASS_KEY || '' }) + .set('Cookie', adminAccessToken) + .expect(200); + + expect(res.body).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + multiselectQuestionId: null, + total: 2, + }), + expect.objectContaining({ + multiselectQuestionId: preferenceACreated.id, + total: 1, + }), + ]), + ); + }); + }); }); diff --git a/api/test/unit/services/google-translate.service.spec.ts b/api/test/unit/services/google-translate.service.spec.ts new file mode 100644 index 0000000000..7e37b750cd --- /dev/null +++ b/api/test/unit/services/google-translate.service.spec.ts @@ -0,0 +1,49 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { LanguagesEnum } from '@prisma/client'; +import { Translate } from '@google-cloud/translate/build/src/v2'; +import { GoogleTranslateService } from '../../../src/services/google-translate.service'; +import { PrismaService } from '../../../src/services/prisma.service'; +jest.mock('@google-cloud/translate/build/src/v2'); + +describe('GoogleTranslateService', () => { + let service: GoogleTranslateService; + let prisma: PrismaService; + + beforeAll(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [PrismaService, GoogleTranslateService], + }).compile(); + + service = module.get(GoogleTranslateService); + prisma = module.get(PrismaService); + }); + + describe('isConfigured', () => { + it('should return true if all variables are present', () => { + process.env.GOOGLE_API_ID = 'api-id'; + process.env.GOOGLE_API_EMAIL = 'api-email'; + process.env.GOOGLE_API_KEY = 'api-key'; + expect(service.isConfigured()).toBe(true); + }); + it('should return false if variables are not present', () => { + delete process.env.GOOGLE_API_ID; + delete process.env.GOOGLE_API_EMAIL; + delete process.env.GOOGLE_API_KEY; + expect(service.isConfigured()).toBe(false); + }); + }); + describe('fetch', () => { + it('should make the translate service', () => { + process.env.GOOGLE_API_ID = 'api-id'; + process.env.GOOGLE_API_EMAIL = 'api-email'; + process.env.GOOGLE_API_KEY = 'api-key'; + + service.fetch( + ['listing.petPolicy', 'listing.unitAmenities'], + LanguagesEnum.es, + ); + + expect(Translate).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/api/test/unit/services/lottery.service.spec.ts b/api/test/unit/services/lottery.service.spec.ts index 296759495d..7cac3d1046 100644 --- a/api/test/unit/services/lottery.service.spec.ts +++ b/api/test/unit/services/lottery.service.spec.ts @@ -128,9 +128,16 @@ describe('Testing lottery service', () => { .fn() .mockResolvedValue({ id: randomUUID() }); + prisma.applicationLotteryTotal.create = jest + .fn() + .mockResolvedValue({ id: randomUUID() }); + await service.lotteryRandomizer(listingId, applications, []); - expect(prisma.applicationLotteryPositions.createMany).toHaveBeenCalled(); + expect( + prisma.applicationLotteryPositions.createMany, + ).toHaveBeenCalledTimes(1); + expect(prisma.applicationLotteryTotal.create).toHaveBeenCalledTimes(1); const args = (prisma.applicationLotteryPositions.createMany as any).mock .calls[0][0].data; @@ -173,6 +180,10 @@ describe('Testing lottery service', () => { .fn() .mockResolvedValue({ id: randomUUID() }); + prisma.applicationLotteryTotal.create = jest + .fn() + .mockResolvedValue({ id: randomUUID() }); + await service.lotteryRandomizer(listingId, applications, preferences); const args = (prisma.applicationLotteryPositions.createMany as any).mock @@ -188,6 +199,7 @@ describe('Testing lottery service', () => { } expect(prisma.applicationLotteryPositions.createMany).toBeCalledTimes(1); + expect(prisma.applicationLotteryTotal.create).toBeCalledTimes(1); }); it('should store randomized ordinals and preference specific ordinals', async () => { @@ -216,6 +228,10 @@ describe('Testing lottery service', () => { .fn() .mockResolvedValue({ id: randomUUID() }); + prisma.applicationLotteryTotal.create = jest + .fn() + .mockResolvedValue({ id: randomUUID() }); + await service.lotteryRandomizer(listingId, applications, preferences); const args = (prisma.applicationLotteryPositions.createMany as any).mock @@ -248,10 +264,35 @@ describe('Testing lottery service', () => { ); expect(prisma.applicationLotteryPositions.createMany).toBeCalledTimes(2); + expect(prisma.applicationLotteryTotal.create).toBeCalledTimes(2); }); }); describe('Testing lotteryGenerate()', () => { + it('should error if not an admin', async () => { + const listingId = randomUUID(); + const requestingUser = { + firstName: 'requesting fName', + lastName: 'requesting lName', + email: 'requestingUser@email.com', + jurisdictions: [{ id: 'juris id' }], + userRoles: { isAdmin: false }, + } as unknown as User; + + prisma.listings.findUnique = jest.fn(); + + await expect( + async () => + await service.lotteryGenerate( + { user: requestingUser } as unknown as ExpressRequest, + {} as unknown as Response, + { id: listingId }, + ), + ).rejects.toThrowError(); + + expect(prisma.listings.findUnique).not.toHaveBeenCalled(); + }); + it('should build lottery when no prior lottery has been ran', async () => { const listingId = randomUUID(); const requestingUser = { @@ -298,6 +339,10 @@ describe('Testing lottery service', () => { .fn() .mockResolvedValue({ id: randomUUID() }); + prisma.applicationLotteryTotal.createMany = jest + .fn() + .mockResolvedValue({ id: randomUUID() }); + prisma.listings.update = jest.fn().mockResolvedValue({ id: listingId, lotteryLastRunAt: null, @@ -351,6 +396,8 @@ describe('Testing lottery service', () => { }); expect(prisma.applicationLotteryPositions.createMany).toHaveBeenCalled(); + expect(prisma.applicationLotteryTotal.create).toHaveBeenCalled(); + expect(prisma.listings.update).toHaveBeenCalledWith({ data: { lotteryLastRunAt: expect.anything(), @@ -409,6 +456,11 @@ describe('Testing lottery service', () => { .fn() .mockResolvedValue({ id: randomUUID() }); + prisma.applicationLotteryTotal.deleteMany = jest.fn(); + prisma.applicationLotteryTotal.create = jest + .fn() + .mockResolvedValue({ id: randomUUID() }); + prisma.listings.update = jest.fn().mockResolvedValue({ id: listingId, lotteryLastRunAt: null, @@ -435,9 +487,14 @@ describe('Testing lottery service', () => { expect( prisma.applicationLotteryPositions.deleteMany, ).toHaveBeenCalledWith({ where: { listingId: listingId } }); + expect(prisma.applicationLotteryTotal.deleteMany).toHaveBeenCalledWith({ + where: { listingId: listingId }, + }); expect(prisma.applications.findMany).toHaveBeenCalled(); expect(prisma.applicationLotteryPositions.createMany).toHaveBeenCalled(); + expect(prisma.applicationLotteryTotal.create).toHaveBeenCalled(); + expect(prisma.listings.update).toHaveBeenCalled(); }); }); @@ -1157,10 +1214,11 @@ describe('Testing lottery service', () => { const applicationId = randomUUID(); const publicUser = { id: 'public id', + userRoles: {}, } as User; it('should query for lottery positions', async () => { - prisma.applications.findFirstOrThrow = jest + prisma.applications.findFirst = jest .fn() .mockResolvedValue({ userId: publicUser.id }); prisma.applicationLotteryPositions.findMany = jest @@ -1182,4 +1240,47 @@ describe('Testing lottery service', () => { }); }); }); + + describe('Testing lotteryTotals()', () => { + const listingId = randomUUID(); + const publicUser = { + id: 'public id', + userRoles: {}, + } as User; + + it('should query for lottery totals', async () => { + prisma.applications.findFirst = jest + .fn() + .mockResolvedValue({ userId: publicUser.id }); + prisma.applicationLotteryTotal.findMany = jest.fn().mockResolvedValue([ + { total: 10, multiselectQuestionId: null, listingId }, + { total: 5, multiselectQuestionId: 'preference id', listingId }, + ]); + await service.lotteryTotals(listingId, publicUser); + + expect(prisma.applicationLotteryTotal.findMany).toHaveBeenCalledWith({ + select: { + total: true, + multiselectQuestionId: true, + }, + where: { + listingId, + }, + }); + }); + + it('should fail for no user', async () => { + prisma.applications.findFirstOrThrow = jest + .fn() + .mockResolvedValue({ userId: publicUser.id }); + prisma.applicationLotteryTotal.findMany = jest.fn().mockResolvedValue([ + { total: 10, multiselectQuestionId: null, listingId }, + { total: 5, multiselectQuestionId: 'preference id', listingId }, + ]); + + await expect( + async () => await service.lotteryTotals(listingId, null), + ).rejects.toThrowError(); + }); + }); }); diff --git a/shared-helpers/src/types/backend-swagger.ts b/shared-helpers/src/types/backend-swagger.ts index 846a0a9a70..05acb5a42e 100644 --- a/shared-helpers/src/types/backend-swagger.ts +++ b/shared-helpers/src/types/backend-swagger.ts @@ -2324,6 +2324,27 @@ export class LotteryService { /** 适配ios13,get请求不允许带body */ + axios(configs, resolve, reject) + }) + } + /** + * Get lottery totals by listing id + */ + lotteryTotals( + params: { + /** */ + id: string + } = {} as any, + options: IRequestOptions = {} + ): Promise { + return new Promise((resolve, reject) => { + let url = basePath + "/lottery/lotteryTotals/{id}" + url = url.replace("{id}", params["id"] + "") + + const configs: IRequestConfig = getConfigs("get", "application/json", url, options) + + /** 适配ios13,get请求不允许带body */ + axios(configs, resolve, reject) }) } @@ -3017,6 +3038,17 @@ export interface UnitsSummary { totalAvailable?: number } +export interface ApplicationLotteryTotal { + /** */ + listingId: string + + /** */ + multiselectQuestionId: string + + /** */ + total: number +} + export interface Listing { /** */ id: string @@ -3308,6 +3340,9 @@ export interface Listing { /** */ lotteryOptIn?: boolean + + /** */ + applicationLotteryTotals: ApplicationLotteryTotal[] } export interface PaginationMeta { @@ -5781,6 +5816,14 @@ export interface PublicLotteryResult { multiselectQuestionId: string } +export interface PublicLotteryTotal { + /** */ + total: number + + /** */ + multiselectQuestionId: string +} + export enum ListingViews { "fundamentals" = "fundamentals", "base" = "base", diff --git a/sites/partners/src/lib/listings/formTypes.ts b/sites/partners/src/lib/listings/formTypes.ts index 1f5d58410a..172d656790 100644 --- a/sites/partners/src/lib/listings/formTypes.ts +++ b/sites/partners/src/lib/listings/formTypes.ts @@ -155,18 +155,7 @@ export const formDefaults: FormListing = { // showWaitlist: false, reviewOrderType: null, unitsSummary: [], - // unitsSummarized: { - // unitTypes: [], - // priorityTypes: [], - // amiPercentages: [], - // byUnitTypeAndRent: [], - // byUnitType: [], - // byAMI: [], - // hmi: { - // columns: [], - // rows: [], - // }, - // }, + applicationLotteryTotals: [], } export type TempUnit = Unit & { diff --git a/sites/public/src/pages/account/application/[id]/lottery-results.tsx b/sites/public/src/pages/account/application/[id]/lottery-results.tsx index b4112b43a1..9b1ea88d02 100644 --- a/sites/public/src/pages/account/application/[id]/lottery-results.tsx +++ b/sites/public/src/pages/account/application/[id]/lottery-results.tsx @@ -4,6 +4,7 @@ import { t } from "@bloom-housing/ui-components" import { AuthContext, BloomCard, CustomIconMap, RequireLogin } from "@bloom-housing/shared-helpers" import { Application, + PublicLotteryTotal, Listing, MultiselectQuestionsApplicationSectionEnum, PublicLotteryResult, @@ -22,6 +23,7 @@ export default () => { const { applicationsService, listingsService, profile, lotteryService } = useContext(AuthContext) const [application, setApplication] = useState() const [results, setResults] = useState() + const [totals, setTotals] = useState() const [listing, setListing] = useState() const [unauthorized, setUnauthorized] = useState(false) const [noApplication, setNoApplication] = useState(false) @@ -39,6 +41,14 @@ export default () => { .publicLotteryResults({ id: applicationId }) .then((results) => { setResults(results) + lotteryService + .lotteryTotals({ id: retrievedListing.id }) + .then((totals) => { + setTotals(totals) + }) + .catch((err) => { + console.error(`Error fetching lottery totals: ${err}`) + }) }) .catch((err) => { console.error(`Error fetching lottery results: ${err}`) @@ -108,7 +118,7 @@ export default () => { listing?.unitsAvailable !== 1 ? "Plural" : "" }`, { - applications: 2500, // TODO: Plug in BE data + applications: totals?.find((total) => !total.multiselectQuestionId).total, units: listing?.unitsAvailable, } )} @@ -181,7 +191,14 @@ export default () => { result.multiselectQuestionId === question.multiselectQuestions.id ) return result - ? preferenceRank(result.ordinal, question.multiselectQuestions.text, 2500) // TODO: Plug in BE data + ? preferenceRank( + result.ordinal, + question.multiselectQuestions.text, + totals?.find( + (total) => + total.multiselectQuestionId === question.multiselectQuestions.id + )?.total + ) : null })}