From ff9f1e73ab0e5c4e320802315450313b34b79925 Mon Sep 17 00:00:00 2001 From: stann1 Date: Thu, 11 Aug 2022 20:03:11 +0300 Subject: [PATCH] Enhancement/campaign summaries (#311) * #303 added paging params for donation list * #303 added count to response; added paging query dto * #303 fixed paging validation * #303 added http logger middleware * added aggregation on campaign vaults/donations; modified summary structure * renamed method * #310 extracted agg query to method; added donors to response * #310 added summary dto; better field names; fixed tests * #310 fixed the wrong amounts in summaries --- .../src/campaign/campaign.controller.spec.ts | 89 +++++-------- apps/api/src/campaign/campaign.service.ts | 125 +++++++++--------- .../src/campaign/dto/campaign-summary.dto.ts | 20 +++ 3 files changed, 119 insertions(+), 115 deletions(-) create mode 100644 apps/api/src/campaign/dto/campaign-summary.dto.ts diff --git a/apps/api/src/campaign/campaign.controller.spec.ts b/apps/api/src/campaign/campaign.controller.spec.ts index ec9a3f3fb..ad7bc992b 100644 --- a/apps/api/src/campaign/campaign.controller.spec.ts +++ b/apps/api/src/campaign/campaign.controller.spec.ts @@ -12,6 +12,7 @@ import { KeycloakTokenParsed } from '../auth/keycloak' import { ConfigService } from '@nestjs/config' import { PersonService } from '../person/person.service' import * as paymentReferenceGenerator from './helpers/payment-reference' +import { CampaignSummaryDto } from './dto/campaign-summary.dto' describe('CampaignController', () => { let controller: CampaignController @@ -70,9 +71,16 @@ describe('CampaignController', () => { organizer: {}, campaignFiles: [], paymentReference: paymentReferenceMock, - vaults: [{ donations: [{ amount: 100 }, { amount: 10 }] }, { donations: [] }], + vaults: [], }, } + const mockSummary = { + id: 'testId', + reachedAmount: 110, + currentAmount: 0, + blockedAmount: 0, + donors: 2, + } as CampaignSummaryDto const personIdMock = 'testPersonId' @@ -99,13 +107,14 @@ describe('CampaignController', () => { describe('getData ', () => { it('should return proper campaign list', async () => { - const mockList = jest.fn().mockResolvedValue([mockCampaign]) - jest.spyOn(prismaService.campaign, 'findMany').mockImplementation(mockList) + const mockList = [mockCampaign] + prismaMock.campaign.findMany.mockResolvedValue(mockList) + prismaMock.$queryRaw.mockResolvedValue([mockSummary]) expect(await controller.getData()).toEqual([ { ...mockCampaign, - ...{ summary: [{ reachedAmount: 110 }], vaults: [] }, + summary: { reachedAmount: 110, currentAmount: 0, blockedAmount: 0, donors: 2 }, }, ]) expect(prismaService.campaign.findMany).toHaveBeenCalled() @@ -116,41 +125,26 @@ describe('CampaignController', () => { it('should return proper campaign list', async () => { const mockAdminCampaign = { ...mockCreateCampaign, - ...{ - id: 'testId', - state: CampaignState.draft, - createdAt: new Date('2022-04-08T06:36:33.661Z'), - updatedAt: new Date('2022-04-08T06:36:33.662Z'), - deletedAt: null, - approvedById: null, - beneficiary: { firstName: 'Test', lastName: 'Test' }, - coordinator: { firstName: 'Test', lastName: 'Test' }, - organizer: { firstName: 'Test', lastName: 'Test' }, - campaignType: { name: 'Test type' }, - vaults: [ - { - donations: [ - { amount: 100, personId: 'donorId1' }, - { amount: 10, personId: null }, - ], - }, - { - donations: [ - { amount: 100, personId: 'donorId1' }, - { amount: 100, personId: 'donorId2' }, - { amount: 100, personId: null }, - ], - }, - ], - }, + id: 'testId', + state: CampaignState.draft, + createdAt: new Date('2022-04-08T06:36:33.661Z'), + updatedAt: new Date('2022-04-08T06:36:33.662Z'), + deletedAt: null, + approvedById: null, + beneficiary: { firstName: 'Test', lastName: 'Test' }, + coordinator: { firstName: 'Test', lastName: 'Test' }, + organizer: { firstName: 'Test', lastName: 'Test' }, + campaignType: { name: 'Test type' }, + paymentReference: paymentReferenceMock, } - const mockList = jest.fn().mockResolvedValue([mockAdminCampaign]) - jest.spyOn(prismaService.campaign, 'findMany').mockImplementation(mockList) + const mockList = [mockAdminCampaign] + prismaMock.campaign.findMany.mockResolvedValue(mockList) + prismaMock.$queryRaw.mockResolvedValue([mockSummary]) expect(await controller.getAdminList()).toEqual([ { ...mockAdminCampaign, - ...{ summary: [{ reachedAmount: 410, donors: 4 }], vaults: [] }, + summary: { reachedAmount: 110, currentAmount: 0, blockedAmount: 0, donors: 2 }, }, ]) expect(prismaService.campaign.findMany).toHaveBeenCalled() @@ -161,33 +155,16 @@ describe('CampaignController', () => { it('should return proper campaign', async () => { const slug = 'test-name' - const mockObject = jest.fn().mockResolvedValue({ + const mockObject = { ...mockCampaign, - ...{ - slug, - vaults: [ - { - donations: [ - { amount: 100, personId: 'donorId1' }, - { amount: 10, personId: null }, - ], - }, - { - donations: [ - { amount: 100, personId: 'donorId1' }, - { amount: 100, personId: 'donorId2' }, - { amount: 100, personId: null }, - ], - }, - ], - }, - }) - jest.spyOn(prismaService.campaign, 'findFirst').mockImplementation(mockObject) + } + prismaMock.campaign.findFirst.mockResolvedValue(mockObject) + prismaMock.$queryRaw.mockResolvedValue([mockSummary]) expect(await controller.viewBySlug(slug)).toEqual({ campaign: { ...mockCampaign, - ...{ summary: [{ reachedAmount: 410, donors: 4 }], vaults: [], slug }, + summary: { reachedAmount: 110, currentAmount: 0, blockedAmount: 0, donors: 2 }, }, }) expect(prismaService.campaign.findFirst).toHaveBeenCalledWith( diff --git a/apps/api/src/campaign/campaign.service.ts b/apps/api/src/campaign/campaign.service.ts index 999fc4fb8..923227b85 100644 --- a/apps/api/src/campaign/campaign.service.ts +++ b/apps/api/src/campaign/campaign.service.ts @@ -25,6 +25,8 @@ import { CreateCampaignDto } from './dto/create-campaign.dto' import { UpdateCampaignDto } from './dto/update-campaign.dto' import { PaymentData } from '../donations/helpers/payment-intent-helpers' import { getAllowedPreviousStatus } from '../donations/helpers/donation-status-updates' +import { Prisma } from '@prisma/client' +import { CampaignSummaryDto } from './dto/campaign-summary.dto' @Injectable() export class CampaignService { @@ -45,17 +47,12 @@ export class CampaignService { beneficiary: { select: { person: true } }, coordinator: { select: { person: true } }, organizer: { select: { person: true } }, - vaults: { - select: { - donations: { where: { status: DonationStatus.succeeded }, select: { amount: true } }, - }, - }, campaignFiles: true, }, }) + const campaignSums = await this.getCampaignSums() - //TODO: remove this when Prisma starts supporting nested groupbys - return campaigns.map(this.addReachedAmountAndDonors) + return campaigns.map((c) => this.addVaultAndDonationSummaries(c, campaignSums)) } async listAllCampaigns(): Promise { @@ -68,19 +65,60 @@ export class CampaignService { beneficiary: { select: { person: { select: { firstName: true, lastName: true } } } }, coordinator: { select: { person: { select: { firstName: true, lastName: true } } } }, organizer: { select: { person: { select: { firstName: true, lastName: true } } } }, - vaults: { - select: { - donations: { where: { status: DonationStatus.succeeded }, select: { amount: true } }, - amount: true, - }, - }, incomingTransfers: { select: { amount: true } }, outgoingTransfers: { select: { amount: true } }, }, }) + const campaignSums = await this.getCampaignSums() + + return campaigns.map((c) => this.addVaultAndDonationSummaries(c, campaignSums)) + } + + async getCampaignSums(campaignIds?: string[]): Promise { + let campaignSums: CampaignSummaryDto[] = [] + + if (campaignIds && campaignIds.length > 0) { + const result = await this.prisma.$queryRaw` + SELECT + MAX(d.total) as "reachedAmount", + SUM(v.amount) as "currentAmount", + SUM(v."blockedAmount") as "blockedAmount", + MAX(d.donors) as donors, + v.campaign_id as id + FROM api.vaults v + LEFT join ( + select target_vault_id, sum(amount) as total, count(id) as donors + from api.donations d + where status = 'succeeded' + group by target_vault_id + ) as d + on d.target_vault_id = v.id + GROUP BY v.campaign_id + HAVING v.campaign_id in (${Prisma.join(campaignIds)}) + ` + campaignSums = result || [] + } else { + const result = await this.prisma.$queryRaw` + SELECT + MAX(d.total) as "reachedAmount", + SUM(v.amount) as "currentAmount", + SUM(v."blockedAmount") as "blockedAmount", + MAX(d.donors) as donors, + v.campaign_id as id + FROM api.vaults v + LEFT join ( + select target_vault_id, sum(amount) as total, count(id) as donors + from api.donations d + where status = 'succeeded' + group by target_vault_id + ) as d + on d.target_vault_id = v.id + GROUP BY v.campaign_id + ` + campaignSums = result || [] + } - //TODO: remove this when Prisma starts supporting nested groupbys - return campaigns.map(this.addReachedAmountAndDonors) + return campaignSums } async getCampaignById(campaignId: string): Promise { @@ -88,7 +126,6 @@ export class CampaignService { where: { id: campaignId }, include: { campaignFiles: true, - vaults: { select: { donations: { select: { amount: true } }, amount: true } }, incomingTransfers: { select: { amount: true } }, }, }) @@ -96,7 +133,9 @@ export class CampaignService { Logger.warn('No campaign record with ID: ' + campaignId) throw new NotFoundException('No campaign record with ID: ' + campaignId) } - return campaign + const campaignSums = await this.getCampaignSums([campaign.id]) + + return this.addVaultAndDonationSummaries(campaign, campaignSums) } async getCampaignByIdAndCoordinatorId( @@ -149,14 +188,6 @@ export class CampaignService { }, }, campaignFiles: true, - vaults: { - select: { - donations: { - where: { status: DonationStatus.succeeded }, - select: { amount: true, personId: true }, - }, - }, - }, }, }) @@ -165,7 +196,9 @@ export class CampaignService { throw new NotFoundException('No campaign record with slug: ' + slug) } - return this.addReachedAmountAndDonors(campaign) + const campaignSums = await this.getCampaignSums([campaign.id]) + + return this.addVaultAndDonationSummaries(campaign, campaignSums) } async getCampaignByPaymentReference(paymentReference: string): Promise { @@ -478,41 +511,15 @@ export class CampaignService { } } - private addReachedAmountAndDonors( - campaign: Campaign & { - vaults: { donations: { amount: number; personId?: string | null }[] }[] - }, - ) { - let campaignAmountReached = 0 - const donors = new Set() - let shouldAddDonors = false - let anonymousDonors = 0 - - for (const vault of campaign.vaults) { - for (const donation of vault.donations) { - campaignAmountReached += donation.amount - - if (donation.personId !== undefined) { - shouldAddDonors = true - if (donation.personId === null) { - anonymousDonors++ - } else { - donors.add(donation.personId) - } - } - } - } - + private addVaultAndDonationSummaries(campaign: Campaign, campaignSums: CampaignSummaryDto[]) { + const csum = campaignSums.find((e) => e.id === campaign.id) return { ...campaign, - ...{ - summary: [ - { - reachedAmount: campaignAmountReached, - donors: shouldAddDonors ? donors.size + anonymousDonors : undefined, - }, - ], - vaults: [], + summary: { + reachedAmount: csum?.reachedAmount || 0, + currentAmount: csum?.currentAmount || 0, + blockedAmount: csum?.blockedAmount || 0, + donors: csum?.donors || 0, }, } } diff --git a/apps/api/src/campaign/dto/campaign-summary.dto.ts b/apps/api/src/campaign/dto/campaign-summary.dto.ts new file mode 100644 index 000000000..c59b9e05c --- /dev/null +++ b/apps/api/src/campaign/dto/campaign-summary.dto.ts @@ -0,0 +1,20 @@ +import { ApiProperty } from '@nestjs/swagger' +import { Expose } from 'class-transformer' + +@Expose() +export class CampaignSummaryDto { + @ApiProperty() + id: string + + @ApiProperty() + reachedAmount: number + + @ApiProperty() + currentAmount: number + + @ApiProperty() + blockedAmount: number + + @ApiProperty() + donors: number +}