Skip to content

Commit

Permalink
Enhancement/campaign summaries (#311)
Browse files Browse the repository at this point in the history
* #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
  • Loading branch information
stann1 authored Aug 11, 2022
1 parent f6e4f67 commit ff9f1e7
Show file tree
Hide file tree
Showing 3 changed files with 119 additions and 115 deletions.
89 changes: 33 additions & 56 deletions apps/api/src/campaign/campaign.controller.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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'

Expand All @@ -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()
Expand All @@ -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()
Expand All @@ -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(
Expand Down
125 changes: 66 additions & 59 deletions apps/api/src/campaign/campaign.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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<Campaign[]> {
Expand All @@ -68,35 +65,77 @@ 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<CampaignSummaryDto[]> {
let campaignSums: CampaignSummaryDto[] = []

if (campaignIds && campaignIds.length > 0) {
const result = await this.prisma.$queryRaw<CampaignSummaryDto[]>`
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<CampaignSummaryDto[]>`
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<Campaign> {
const campaign = await this.prisma.campaign.findFirst({
where: { id: campaignId },
include: {
campaignFiles: true,
vaults: { select: { donations: { select: { amount: true } }, amount: true } },
incomingTransfers: { select: { amount: true } },
},
})
if (!campaign) {
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(
Expand Down Expand Up @@ -149,14 +188,6 @@ export class CampaignService {
},
},
campaignFiles: true,
vaults: {
select: {
donations: {
where: { status: DonationStatus.succeeded },
select: { amount: true, personId: true },
},
},
},
},
})

Expand All @@ -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<Campaign> {
Expand Down Expand Up @@ -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<string>()
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,
},
}
}
Expand Down
20 changes: 20 additions & 0 deletions apps/api/src/campaign/dto/campaign-summary.dto.ts
Original file line number Diff line number Diff line change
@@ -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
}

1 comment on commit ff9f1e7

@github-actions
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Coverage report

St.
Category Percentage Covered / Total
🟡 Statements 72.79% 1782/2448
🔴 Branches 46.06% 216/469
🔴 Functions 45.49% 217/477
🟡 Lines 70.86% 1571/2217

Test suite run success

172 tests passing in 62 suites.

Report generated by 🧪jest coverage report action from ff9f1e7

Please sign in to comment.