diff --git a/.env b/.env index a6f280a01..d690cde6f 100644 --- a/.env +++ b/.env @@ -97,6 +97,7 @@ PLATFORM_IBAN= IMPORT_TRX_TASK_INTERVAL_MINUTES=60 CHECK_IRIS_CONSENT_TASK_HOUR=10 BILLING_ADMIN_MAIL=billing_admin@podkrepi.bg +CAMPAIGN_ADMIN_MAIL= ## Cache ## ############## diff --git a/.env.example b/.env.example index f39073e05..6a8620a88 100644 --- a/.env.example +++ b/.env.example @@ -101,6 +101,7 @@ IMPORT_TRX_TASK_INTERVAL_MINUTES=60 #which hour of the day to run the check for consent CHECK_IRIS_CONSENT_TASK_HOUR=10 BILLING_ADMIN_MAIL=billing_admin@podkrepi.bg +CAMPAIGN_ADMIN_MAIL=responsible for campaign management ## Cache ## ############## diff --git a/apps/api/src/assets/templates/campaign-news-draft.json b/apps/api/src/assets/templates/campaign-news-draft.json new file mode 100644 index 000000000..8f04ccca3 --- /dev/null +++ b/apps/api/src/assets/templates/campaign-news-draft.json @@ -0,0 +1,3 @@ +{ + "subject": "Новина по Кампания Е Качена за Одобрение" +} diff --git a/apps/api/src/assets/templates/campaign-news-draft.mjml b/apps/api/src/assets/templates/campaign-news-draft.mjml new file mode 100644 index 000000000..e0375f1c3 --- /dev/null +++ b/apps/api/src/assets/templates/campaign-news-draft.mjml @@ -0,0 +1,69 @@ + + + + + + {{campaignNewsTitle}} + + + + + + +

+
+ + Има качена новина по кампания {{campaignName}}, която + изчаква одобрение от администратор! + + + + Към Новината + + + + Поздрави,
+ Екипът на Подкрепи.бг +
+
+
+
+
diff --git a/apps/api/src/campaign-file/campaign-file.controller.spec.ts b/apps/api/src/campaign-file/campaign-file.controller.spec.ts index 1ca210948..db17cabcd 100644 --- a/apps/api/src/campaign-file/campaign-file.controller.spec.ts +++ b/apps/api/src/campaign-file/campaign-file.controller.spec.ts @@ -48,7 +48,7 @@ describe('CampaignFileController', () => { ConfigService, { provide: CampaignService, - useValue: { getCampaignByIdAndCoordinatorId: jest.fn(() => null) }, + useValue: { verifyCampaignOwner: jest.fn(() => null) }, }, VaultService, CampaignNewsService, @@ -92,7 +92,7 @@ describe('CampaignFileController', () => { ).toEqual([fileId, fileId]) expect(personService.findOneByKeycloakId).toHaveBeenCalledWith(userMock.sub) - expect(campaignService.getCampaignByIdAndCoordinatorId).not.toHaveBeenCalled() + expect(campaignService.verifyCampaignOwner).not.toHaveBeenCalled() expect(campaignFileService.create).toHaveBeenCalledTimes(2) }) @@ -102,16 +102,13 @@ describe('CampaignFileController', () => { await expect(controller.create(campaignId, { roles: [] }, [], userMock)).rejects.toThrowError() expect(personService.findOneByKeycloakId).toHaveBeenCalledWith(userMock.sub) - expect(campaignService.getCampaignByIdAndCoordinatorId).not.toHaveBeenCalled() + expect(campaignService.verifyCampaignOwner).not.toHaveBeenCalled() }) it('should throw an error for user not owning updated campaign', async () => { await expect(controller.create(campaignId, { roles: [] }, [], userMock)).rejects.toThrowError() expect(personService.findOneByKeycloakId).toHaveBeenCalledWith(userMock.sub) - expect(campaignService.getCampaignByIdAndCoordinatorId).toHaveBeenCalledWith( - campaignId, - personIdMock, - ) + expect(campaignService.verifyCampaignOwner).toHaveBeenCalledWith(campaignId, personIdMock) }) }) diff --git a/apps/api/src/campaign-file/campaign-file.controller.ts b/apps/api/src/campaign-file/campaign-file.controller.ts index 458f183fe..dbd136931 100644 --- a/apps/api/src/campaign-file/campaign-file.controller.ts +++ b/apps/api/src/campaign-file/campaign-file.controller.ts @@ -23,7 +23,7 @@ import { FilesRoleDto } from './dto/files-role.dto' import { CampaignFileService } from './campaign-file.service' import { CampaignService } from '../campaign/campaign.service' import { KeycloakTokenParsed, isAdmin } from '../auth/keycloak' -import { ApiTags } from '@nestjs/swagger'; +import { ApiTags } from '@nestjs/swagger' import { CampaignFileRole } from '@prisma/client' @ApiTags('campaign-file') @@ -51,10 +51,7 @@ export class CampaignFileController { } if (!isAdmin(user)) { - const campaign = await this.campaignService.getCampaignByIdAndCoordinatorId( - campaignId, - person.id, - ) + const campaign = await this.campaignService.verifyCampaignOwner(campaignId, person.id) if (!campaign) { throw new NotFoundException( 'User ' + user.name + 'is not admin or coordinator of campaign with id: ' + campaignId, @@ -88,8 +85,8 @@ export class CampaignFileController { 'Content-Type': file.mimetype, 'Content-Disposition': 'attachment; filename="' + file.filename + '"', 'Cache-Control': file.mimetype.startsWith('image/') - ? 'public, s-maxage=15552000, stale-while-revalidate=15552000, immutable' - : 'no-store' + ? 'public, s-maxage=15552000, stale-while-revalidate=15552000, immutable' + : 'no-store', }) return new StreamableFile(file.stream) diff --git a/apps/api/src/campaign-news/campaign-news.module.ts b/apps/api/src/campaign-news/campaign-news.module.ts index 011c57f71..e1d3df1d6 100644 --- a/apps/api/src/campaign-news/campaign-news.module.ts +++ b/apps/api/src/campaign-news/campaign-news.module.ts @@ -5,11 +5,12 @@ import { PrismaService } from '../prisma/prisma.service' import { PersonModule } from '../person/person.module' import { MarketingNotificationsModule } from '../notifications/notifications.module' import { ConfigService } from '@nestjs/config' +import { EmailService } from '../email/email.service' @Module({ imports: [PersonModule, MarketingNotificationsModule], controllers: [CampaignNewsController], - providers: [CampaignNewsService, PrismaService, ConfigService], + providers: [CampaignNewsService, PrismaService, ConfigService, EmailService], exports: [CampaignNewsService], }) export class CampaignNewsModule {} diff --git a/apps/api/src/campaign-news/campaign-news.service.ts b/apps/api/src/campaign-news/campaign-news.service.ts index c887a2a05..a48d7a51b 100644 --- a/apps/api/src/campaign-news/campaign-news.service.ts +++ b/apps/api/src/campaign-news/campaign-news.service.ts @@ -8,12 +8,15 @@ import { SendGridParams } from '../notifications/providers/notifications.sendgri import { DateTime } from 'luxon' import { ConfigService } from '@nestjs/config' import { MarketingNotificationsService } from '../notifications/notifications.service' +import { CampaignNewsDraftEmailDto } from '../email/template.interface' +import { EmailService } from '../email/email.service' @Injectable() export class CampaignNewsService { constructor( private prisma: PrismaService, private readonly config: ConfigService, + private sendEmail: EmailService, private readonly marketingNotificationsService: MarketingNotificationsService, ) {} private RECORDS_PER_PAGE = 4 @@ -24,8 +27,14 @@ export class CampaignNewsService { try { const campaignNews = await this.prisma.campaignNews.create({ data: campaignNewsDto }) if (campaignNews.state === 'published' && notify) + // USER Notification //Don't await --> send to background - this.sendArticleNotification(campaignNews).catch((e) => console.log(e)) + this.sendArticleNotification(campaignNews).catch((e) => Logger.warn(e)) + + // ADMIN Notification + //Don't await --> send to background + this.notifyAdminsForNewsUpload(campaignNews).catch((e) => Logger.warn(e)) + return campaignNews } catch (error) { const message = 'Creating article about campaign failed' @@ -34,6 +43,39 @@ export class CampaignNewsService { } } + async notifyAdminsForNewsUpload(news: CampaignNews) { + const campaign = await this.prisma.campaign.findFirst({ + where: { id: news.campaignId }, + }) + + if (!campaign) return + + // Build the links + const stage = this.config.get('APP_ENV') === 'development' ? 'APP_URL_LOCAL' : 'APP_URL' + const appUrl = this.config.get(stage) + const newsLink = `${appUrl}/campaigns/${campaign.slug}/news/admin-panel` + const campaignLink = `${appUrl}/campaigns/${campaign.slug}` + const campaignAdminEmail = this.config.get('mail.campaignAdminEmail', '') + + if (!campaignAdminEmail) return + + // Prepare Email data + const recepient = { to: [campaignAdminEmail] } + + const mail = new CampaignNewsDraftEmailDto({ + campaignName: campaign.title, + campaignNewsTitle: news.title, + campaignLink, + newsLink, + }) + + // Send Notification + await this.sendEmail.sendFromTemplate(mail, recepient, { + //Allow users to receive the mail, regardles of unsubscribes + bypassUnsubscribeManagement: { enable: true }, + }) + } + async sendArticleNotification(news: CampaignNews) { const template = await this.prisma.marketingTemplates.findFirst({ where: { diff --git a/apps/api/src/campaign/campaign.service.ts b/apps/api/src/campaign/campaign.service.ts index 23dfd820b..e46c60d62 100644 --- a/apps/api/src/campaign/campaign.service.ts +++ b/apps/api/src/campaign/campaign.service.ts @@ -235,15 +235,23 @@ export class CampaignService { return campaigns } - async getCampaignByIdAndCoordinatorId( - campaignId: string, - coordinatorId: string, - ): Promise { - const campaign = await this.prisma.campaign.findFirst({ - where: { id: campaignId, coordinator: { personId: coordinatorId } }, - include: { coordinator: true }, + // Check if the campaign exists by coordinator or organizer + async verifyCampaignOwner(campaignId: string, personId: string): Promise { + const campaignByCoordinator = await this.prisma.campaign.findFirst({ + where: { id: campaignId, coordinator: { personId } }, + include: { coordinator: true, organizer: true }, }) - return campaign + + if (campaignByCoordinator !== null) { + return campaignByCoordinator + } + + const campaignByOrganizer = await this.prisma.campaign.findFirst({ + where: { id: campaignId, organizer: { personId } }, + include: { coordinator: true, organizer: true }, + }) + + return campaignByOrganizer } async getCampaignByIdWithPersonIds(id: string) { @@ -630,8 +638,15 @@ export class CampaignService { }) //if donation is switching to successful, increment the vault amount and send notification - if (newDonationStatus === DonationStatus.succeeded) { - await this.vaultService.incrementVaultAmount(donation.targetVaultId, donation.amount, tx) + if ( + donation.status != DonationStatus.succeeded && + newDonationStatus === DonationStatus.succeeded + ) { + await this.vaultService.incrementVaultAmount( + donation.targetVaultId, + paymentData.netAmount, + tx, + ) this.notificationService.sendNotification('successfulDonation', { ...updatedDonation, person: updatedDonation.person, @@ -739,8 +754,15 @@ export class CampaignService { async createDonationWish(wish: string, donationId: string, campaignId: string) { const person = await this.prisma.donation.findUnique({ where: { id: donationId } }).person() - await this.prisma.donationWish.create({ - data: { + await this.prisma.donationWish.upsert({ + where: { donationId }, + create: { + message: wish, + donationId, + campaignId, + personId: person?.id, + }, + update: { message: wish, donationId, campaignId, @@ -1056,7 +1078,7 @@ export class CampaignService { throw new UnauthorizedException() } - const campaign = await this.getCampaignByIdAndCoordinatorId(campaignId, person.id) + const campaign = await this.verifyCampaignOwner(campaignId, person.id) if (!campaign) { throw new UnauthorizedException() } diff --git a/apps/api/src/config/configuration.ts b/apps/api/src/config/configuration.ts index 57e0b38f0..6dee37d3a 100644 --- a/apps/api/src/config/configuration.ts +++ b/apps/api/src/config/configuration.ts @@ -45,7 +45,10 @@ export default () => ({ banksEndPoint: process.env.IRIS_API_URL + '/banks?country=bulgaria', ibansEndPoint: process.env.IRIS_API_URL + '/ibans', transactionsEndPoint: process.env.IRIS_API_URL + '/transactions', + }, + mail: { billingAdminEmail: process.env.BILLING_ADMIN_MAIL, + campaignAdminEmail: process.env.CAMPAIGN_ADMIN_MAIL, }, tasks: { import_transactions: { interval: process.env.IMPORT_TRX_TASK_INTERVAL_MINUTES }, diff --git a/apps/api/src/donations/events/stripe-payment.service.spec.ts b/apps/api/src/donations/events/stripe-payment.service.spec.ts index 8586aade3..48988c2c1 100644 --- a/apps/api/src/donations/events/stripe-payment.service.spec.ts +++ b/apps/api/src/donations/events/stripe-payment.service.spec.ts @@ -11,7 +11,14 @@ import { INestApplication } from '@nestjs/common' import request from 'supertest' import { StripeModule, StripeModuleConfig, StripePayloadService } from '@golevelup/nestjs-stripe' -import { Donation, DonationType, RecurringDonationStatus } from '@prisma/client' +import { + Campaign, + CampaignState, + Donation, + DonationType, + RecurringDonationStatus, + Vault, +} from '@prisma/client' import { campaignId, @@ -218,17 +225,17 @@ describe('StripePaymentService', () => { .mockName('createDonationWish') .mockImplementation(() => Promise.resolve()) - prismaMock.donation.create.mockResolvedValue({ + prismaMock.donation.findUnique.mockResolvedValue({ id: 'test-donation-id', type: DonationType.donation, - status: DonationStatus.succeeded, + status: DonationStatus.waiting, provider: 'stripe', extCustomerId: paymentData.stripeCustomerId ?? '', extPaymentIntentId: paymentData.paymentIntentId, extPaymentMethodId: 'card', targetVaultId: 'test-vault-id', - amount: paymentData.netAmount, - chargedAmount: paymentData.netAmount, + amount: 0, //amount is 0 on donation created from payment-intent + chargedAmount: 0, currency: 'BGN', createdAt: new Date(), updatedAt: new Date(), @@ -237,14 +244,34 @@ describe('StripePaymentService', () => { personId: 'donation-person', }) + prismaMock.donation.update.mockResolvedValue({ + id: 'test-donation-id', + targetVaultId: 'test-vault-id', + amount: paymentData.netAmount, + status: 'succeeded', + person: { firstName: 'Full', lastName: 'Name' }, + } as Donation & { person: unknown }) + + prismaMock.vault.update.mockResolvedValue({ campaignId: 'test-campaign' } as Vault) + + prismaMock.campaign.findFirst.mockResolvedValue({ + id: 'test-campaign', + state: CampaignState.active, + targetAmount: paymentData.netAmount, + vaults: [{ amount: paymentData.netAmount }], + } as unknown as Campaign) + jest.spyOn(prismaMock, '$transaction').mockImplementation((callback) => callback(prismaMock)) const mockedUpdateDonationPayment = jest .spyOn(campaignService, 'updateDonationPayment') .mockName('updateDonationPayment') - const mockedIncrementVaultAmount = jest - .spyOn(vaultService, 'incrementVaultAmount') - .mockImplementation() + const mockedIncrementVaultAmount = jest.spyOn(vaultService, 'incrementVaultAmount') + + const mockedUpdateCampaignStatusIfTargetReached = jest.spyOn( + campaignService, + 'updateCampaignStatusIfTargetReached', + ) return request(app.getHttpServer()) .post(defaultStripeWebhookEndpoint) @@ -255,16 +282,17 @@ describe('StripePaymentService', () => { .then(() => { expect(mockedCampaignById).toHaveBeenCalledWith(campaignId) //campaignId from the Stripe Event expect(mockedUpdateDonationPayment).toHaveBeenCalled() - expect(prismaMock.donation.create).toHaveBeenCalled() + expect(prismaMock.donation.findUnique).toHaveBeenCalled() + expect(prismaMock.donation.create).not.toHaveBeenCalled() expect(mockedIncrementVaultAmount).toHaveBeenCalled() - expect(prismaMock.donation.update).toHaveBeenCalledWith({ - where: { id: 'test-donation-id' }, + expect(prismaMock.donation.update).toHaveBeenCalledTimes(2) //once for the amount and second time for assigning donation to the person + expect(mockedUpdateCampaignStatusIfTargetReached).toHaveBeenCalled() + expect(prismaMock.campaign.update).toHaveBeenCalledWith({ + where: { + id: 'test-campaign', + }, data: { - person: { - connect: { - email: paymentData.billingEmail, - }, - }, + state: CampaignState.complete, }, }) expect(mockedcreateDonationWish).toHaveBeenCalled() @@ -293,31 +321,22 @@ describe('StripePaymentService', () => { mockChargeEventSucceeded.data.object as Stripe.Charge, ) - jest.spyOn(prismaMock, '$transaction').mockImplementation((callback) => callback(prismaMock)) - const mockedUpdateDonationPayment = jest - .spyOn(campaignService, 'updateDonationPayment') - .mockName('updateDonationPayment') - - const mockedIncrementVaultAmount = jest - .spyOn(vaultService, 'incrementVaultAmount') - .mockImplementation() - const mockedcreateDonationWish = jest .spyOn(campaignService, 'createDonationWish') .mockName('createDonationWish') .mockImplementation(() => Promise.resolve()) - prismaMock.donation.create.mockResolvedValue({ + prismaMock.donation.findUnique.mockResolvedValue({ id: 'test-donation-id', type: DonationType.donation, - status: DonationStatus.succeeded, + status: DonationStatus.waiting, provider: 'stripe', extCustomerId: paymentData.stripeCustomerId ?? '', extPaymentIntentId: paymentData.paymentIntentId, extPaymentMethodId: 'card', targetVaultId: 'test-vault-id', - amount: paymentData.netAmount, - chargedAmount: paymentData.netAmount, + amount: 0, //amount is 0 on donation created from payment-intent + chargedAmount: 0, currency: 'BGN', createdAt: new Date(), updatedAt: new Date(), @@ -326,6 +345,23 @@ describe('StripePaymentService', () => { personId: 'donation-person', }) + prismaMock.donation.update.mockResolvedValue({ + id: 'test-donation-id', + targetVaultId: 'test-vault-id', + amount: (mockInvoicePaidEvent.data.object as Stripe.Invoice).amount_paid, + status: 'succeeded', + person: { firstName: 'Full', lastName: 'Name' }, + } as Donation & { person: unknown }) + + prismaMock.vault.update.mockResolvedValue({ campaignId: 'test-campaign' } as Vault) + + jest.spyOn(prismaMock, '$transaction').mockImplementation((callback) => callback(prismaMock)) + const mockedUpdateDonationPayment = jest + .spyOn(campaignService, 'updateDonationPayment') + .mockName('updateDonationPayment') + + const mockedIncrementVaultAmount = jest.spyOn(vaultService, 'incrementVaultAmount') + return request(app.getHttpServer()) .post(defaultStripeWebhookEndpoint) .set('stripe-signature', header) @@ -335,8 +371,9 @@ describe('StripePaymentService', () => { .then(() => { expect(mockedCampaignById).toHaveBeenCalledWith(campaignId) //campaignId from the Stripe Event expect(mockedUpdateDonationPayment).toHaveBeenCalled() - expect(prismaMock.donation.create).toHaveBeenCalled() - expect(prismaMock.donation.update).not.toHaveBeenCalled() + expect(prismaMock.donation.findUnique).toHaveBeenCalled() + expect(prismaMock.donation.create).not.toHaveBeenCalled() + expect(prismaMock.donation.update).toHaveBeenCalledOnce() //for the donation to succeeded expect(mockedIncrementVaultAmount).toHaveBeenCalled() expect(mockedcreateDonationWish).toHaveBeenCalled() }) diff --git a/apps/api/src/donations/events/stripe-payment.service.ts b/apps/api/src/donations/events/stripe-payment.service.ts index 3a214299b..6658ea241 100644 --- a/apps/api/src/donations/events/stripe-payment.service.ts +++ b/apps/api/src/donations/events/stripe-payment.service.ts @@ -117,8 +117,9 @@ export class StripePaymentService { DonationStatus.succeeded, metadata, ) + //updateDonationPayment will mark the campaign as completed if amount is reached - await this.checkForCompletedCampaign(metadata.campaignId) + await this.cancelSubscriptionsIfCompletedCampaign(metadata.campaignId) //and finally save the donation wish if (donationId && metadata?.wish) { @@ -306,11 +307,11 @@ export class StripePaymentService { ) //updateDonationPayment will mark the campaign as completed if amount is reached - await this.checkForCompletedCampaign(metadata.campaignId) + await this.cancelSubscriptionsIfCompletedCampaign(metadata.campaignId) } //if the campaign is finished, we need to stop all active subscriptions - async checkForCompletedCampaign(campaignId: string) { + async cancelSubscriptionsIfCompletedCampaign(campaignId: string) { const updatedCampaign = await this.campaignService.getCampaignById(campaignId) if (updatedCampaign.state === CampaignState.complete) { const recurring = diff --git a/apps/api/src/email/template.interface.ts b/apps/api/src/email/template.interface.ts index 371ee6f13..035fe0fc6 100644 --- a/apps/api/src/email/template.interface.ts +++ b/apps/api/src/email/template.interface.ts @@ -12,6 +12,7 @@ export enum TemplateType { unrecognizedDonation = 'unrecognized-donation', expiringIrisConsent = 'expiring-iris-consent', confirmConsent = 'confirm-notifications-consent', + campaignNewsDraft = 'campaign-news-draft', } export type TemplateTypeKeys = keyof typeof TemplateType export type TemplateTypeValues = typeof TemplateType[TemplateTypeKeys] @@ -80,3 +81,12 @@ export class ConfirmConsentEmailDto extends EmailTemplate<{ }> { name = TemplateType.confirmConsent } + +export class CampaignNewsDraftEmailDto extends EmailTemplate<{ + campaignLink: string + campaignName: string + newsLink: string + campaignNewsTitle: string +}> { + name = TemplateType.campaignNewsDraft +} diff --git a/apps/api/src/prisma/prisma-client-exception.filter.ts b/apps/api/src/prisma/prisma-client-exception.filter.ts index 9b8e9bf75..228a4faac 100644 --- a/apps/api/src/prisma/prisma-client-exception.filter.ts +++ b/apps/api/src/prisma/prisma-client-exception.filter.ts @@ -67,11 +67,13 @@ export class PrismaClientExceptionFilter extends BaseExceptionFilter { return { property: el, children: [], constraints } }) - response.status(status).json({ - statusCode: status, - message, - error: this.cleanUpException(exception), - }) + if (response) { + response.status(status).json({ + statusCode: status, + message, + error: this.cleanUpException(exception), + }) + } } /** diff --git a/apps/api/src/tasks/bank-import/import-transactions.task.ts b/apps/api/src/tasks/bank-import/import-transactions.task.ts index ec7fc1543..1d5332517 100644 --- a/apps/api/src/tasks/bank-import/import-transactions.task.ts +++ b/apps/api/src/tasks/bank-import/import-transactions.task.ts @@ -62,7 +62,7 @@ export class IrisTasks { this.bankBIC = this.config.get('iris.bankBIC', '') this.IBAN = this.config.get('iris.platformIBAN', '') this.apiUrl = this.config.get('iris.apiUrl', '') - this.billingAdminEmail = this.config.get('iris.billingAdminEmail', '') + this.billingAdminEmail = this.config.get('mail.billingAdminEmail', '') this.checkForRequiredVariables() } @@ -324,7 +324,7 @@ export class IrisTasks { transactionAmount.currency = Currency.BGN } else { // mark as unrecognized - matchedRef = null; + matchedRef = null } } diff --git a/manifests/base/deployment.yaml b/manifests/base/deployment.yaml index 9707213d3..46b98591e 100644 --- a/manifests/base/deployment.yaml +++ b/manifests/base/deployment.yaml @@ -125,6 +125,8 @@ spec: value: '10' - name: BILLING_ADMIN_MAIL value: billing_admin@podkrepi.bg + - name: CAMPAIGN_ADMIN_MAIL + value: campaign_coordinators@podkrepi.bg - name: IRIS_AGENT_HASH valueFrom: secretKeyRef: