Skip to content

Commit

Permalink
Merge branch 'master' into emailSendTask
Browse files Browse the repository at this point in the history
  • Loading branch information
sashko9807 authored Sep 25, 2024
2 parents b04e94a + c814b46 commit df4319f
Show file tree
Hide file tree
Showing 10 changed files with 428 additions and 34 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,7 @@ export class CampaignApplicationService {
}
}

async findOne(
async findOne(
id: string,
isAdminFlag: boolean,
person: Prisma.PersonGetPayload<{ include: { organizer: { select: { id: true } } } }>,
Expand All @@ -175,6 +175,27 @@ export class CampaignApplicationService {
throw error
}
}
}

async findOne(id: string, isAdminFlag: boolean, person: Prisma.PersonGetPayload<{ include: { organizer: {select:{id:true}}}}>) {
try {
const singleCampaignApplication = await this.prisma.campaignApplication.findUnique({
where: { id },
})
if (!singleCampaignApplication) {
throw new NotFoundException('Campaign application doesnt exist')
}

if (isAdminFlag === false && singleCampaignApplication.organizerId !== person.organizer?.id) {
throw new ForbiddenException('User is not admin or organizer of the campaignApplication')
}

return singleCampaignApplication
} catch (error) {
Logger.error('Error in findOne():', error)
throw error
}
}

async deleteFile(id: string, isAdminFlag: boolean, person: Prisma.PersonGetPayload<{ include: { organizer: {select:{id:true}}}}>) {
try {
Expand Down
47 changes: 26 additions & 21 deletions apps/api/src/campaign/campaign.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1036,30 +1036,35 @@ export class CampaignService {

async createCampaignNotificationList(updated: { title: string; id: string }) {
// Generate list in the marketing platform
let listId: string
const lists = await this.marketingNotificationsService.provider.getContactLists()
const campaignEmailLists = lists.body.result
const exists = campaignEmailLists.find((campaign) => campaign.name === updated.title)
if (exists) {
listId = exists.id
} else {
listId = await this.marketingNotificationsService.provider.createNewContactList({
name: updated.title || updated.id,
})
}
try {
let listId: string
const lists = await this.marketingNotificationsService.provider.getContactLists()
const campaignEmailLists = lists.body.result
const exists = campaignEmailLists.find((campaign) => campaign.name === updated.title)
if (exists) {
listId = exists.id
} else {
listId = await this.marketingNotificationsService.provider.createNewContactList({
name: updated.title || updated.id,
})
}

const name = updated.title || ''
const name = updated.title || ''

// Save the list_id in the DB
await this.prisma.notificationList.create({
data: {
id: listId,
name: name.slice(0, 99),
campaignId: updated.id,
},
})
// Save the list_id in the DB
await this.prisma.notificationList.create({
data: {
id: listId,
name: name.slice(0, 99),
campaignId: updated.id,
},
})

return listId
return listId
} catch (err) {
console.log(err)
throw new InternalServerErrorException(JSON.stringify(err.response.body.errors))
}
}

async sendNewCampaignNotification(
Expand Down
17 changes: 17 additions & 0 deletions apps/api/src/common/mapChunk.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
/**
* Create a chunked array of new Map()
* @param map map to be chunked
* @param chunkSize The size of the chunk
* @returns Array chunk of new Map()
*/

export function mapChunk<T extends Map<any, any>>(map: T, chunkSize: number) {
return Array.from(map.entries()).reduce<T[]>((chunk, curr, index) => {
const ch = Math.floor(index / chunkSize)
if (!chunk[ch]) {
chunk[ch] = new Map() as T
}
chunk[ch].set(curr[0], curr[1])
return chunk
}, [])
}
35 changes: 35 additions & 0 deletions apps/api/src/notifications/dto/massmail.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { ApiProperty } from '@nestjs/swagger'
import { Expose } from 'class-transformer'
import { IsDateString, IsNumber, IsOptional, IsString } from 'class-validator'

export class MassMailDto {
@ApiProperty()
@Expose()
@IsString()
listId: string

@ApiProperty()
@Expose()
@IsString()
templateId: string

@ApiProperty()
@Expose()
@IsString()
@IsOptional()
subject: string

//Sendgrid limits sending emails to 1000 at once.
@ApiProperty()
@Expose()
@IsNumber()
@IsOptional()
chunkSize = 1000

//Remove users registered after the dateThreshold from mail list
@ApiProperty()
@Expose()
@IsDateString()
@IsOptional()
dateThreshold: Date = new Date()
}
13 changes: 12 additions & 1 deletion apps/api/src/notifications/notifications.controller.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { BadRequestException, Body, Controller, Get, Post } from '@nestjs/common'
import { ApiTags } from '@nestjs/swagger'
import { AuthenticatedUser, Public } from 'nest-keycloak-connect'
import { AuthenticatedUser, Public, RoleMatchingMode, Roles } from 'nest-keycloak-connect'
import {
SendConfirmationDto,
SubscribeDto,
Expand All @@ -11,6 +11,8 @@ import {

import { MarketingNotificationsService } from './notifications.service'
import { KeycloakTokenParsed } from '../auth/keycloak'
import { MassMailDto } from './dto/massmail.dto'
import { RealmViewSupporters, ViewSupporters } from '@podkrepi-bg/podkrepi-types'

@ApiTags('notifications')
@Controller('notifications')
Expand Down Expand Up @@ -63,4 +65,13 @@ export class MarketingNotificationsController {
user.email || '',
)
}

@Post('/send-newsletter-consent')
@Roles({
roles: [RealmViewSupporters.role, ViewSupporters.role],
mode: RoleMatchingMode.ANY,
})
async sendMassMail(@Body() data: MassMailDto) {
return await this.marketingNotificationsService.sendConsentMail(data)
}
}
103 changes: 102 additions & 1 deletion apps/api/src/notifications/notifications.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,28 @@ import {
UnregisteredNotificationConsent,
} from '@prisma/client'
import { NotificationsProviderInterface } from './providers/notifications.interface.providers'
import { SendGridParams } from './providers/notifications.sendgrid.types'
import { ContactsResponse, SendGridParams } from './providers/notifications.sendgrid.types'
import { DateTime } from 'luxon'
import * as crypto from 'crypto'
import { CampaignService } from '../campaign/campaign.service'
import { KeycloakTokenParsed } from '../auth/keycloak'
import { MassMailDto } from './dto/massmail.dto'
import { randomUUID } from 'crypto'
import { mapChunk } from '../common/mapChunk'

type UnregisteredInsert = {
id: string
email: string
consent: boolean
}

type MailList = {
id: string
hash: string
registered: boolean
}

export type ContactsMap = Map<string, MailList>

@Injectable()
export class MarketingNotificationsService {
Expand Down Expand Up @@ -492,4 +509,88 @@ export class MarketingNotificationsService {

return minutesPassed <= period
}

private generateMapFromMailList(emailList: string[], contacts: ContactsMap): void {
for (const email of emailList) {
const id = randomUUID()

contacts.set(email, {
id: id,
hash: this.generateHash(id),
registered: false,
})
}
}

private updateMailListMap(
regUser: Person[],
contacts: ContactsMap,
skipAfterDate: Date,
unregisteredConsent: UnregisteredNotificationConsent[],
) {
for (const registeredUser of regUser) {
const createdAt = new Date(registeredUser.createdAt)

// Remove email if it belongs to user created after the change has been deployed, as they had already decided
// whether to give consent or not.
if (contacts.get(registeredUser.email as string) && createdAt > skipAfterDate) {
Logger.debug(`Removing email ${registeredUser.email} from list`)
contacts.delete(registeredUser.email as string)
continue
}
//Update the value of this mail
contacts.set(registeredUser.email as string, {
id: registeredUser.id,
hash: this.generateHash(registeredUser.id),
registered: true,
})
}

Logger.debug('Removing emails in unregistered consent emails')
for (const consent of unregisteredConsent) {
if (contacts.has(consent.email)) {
Logger.debug(`Removing email ${consent.email}`)
contacts.delete(consent.email)
continue
}
}
}

private async insertUnregisteredConsentFromContacts(contacts: ContactsMap) {
const emailsToAdd: UnregisteredInsert[] = []
for (const [key, value] of contacts) {
if (value.registered) continue
emailsToAdd.push({ id: value.id, email: key, consent: false })
}

await this.prisma.unregisteredNotificationConsent.createMany({
data: emailsToAdd,
})
}
async sendConsentMail(data: MassMailDto) {
const contacts = await this.marketingNotificationsProvider.getContactsFromList(data)

const sendList: ContactsMap = new Map()
const emailList = contacts.map((contact: ContactsResponse) => contact.email)
this.generateMapFromMailList(emailList, sendList)
const registeredMails = await this.prisma.person.findMany({
where: { email: { in: emailList } },
})

const unregisteredUsers = await this.prisma.unregisteredNotificationConsent.findMany()

const skipUsersAfterDate = new Date(data.dateThreshold)
this.updateMailListMap(registeredMails, sendList, skipUsersAfterDate, unregisteredUsers)

await this.insertUnregisteredConsentFromContacts(sendList)

const contactsChunked = mapChunk<ContactsMap>(sendList, data.chunkSize)
Logger.debug(`Splitted email list into ${contactsChunked.length} chunk`)
await this.marketingNotificationsProvider.sendBulkEmail(
data,
contactsChunked,
'Podkrepi.BG Newsletter Subscription Consent',
)
return { contactCount: sendList.size }
}
}
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
import { MassMailDto } from '../dto/massmail.dto'
import { ContactsMap } from '../notifications.service'
import { PersonalizationData } from '@sendgrid/helpers/classes/personalization'

type NotificationsInterfaceParams = {
CreateListParams: unknown
UpdateListParams: unknown
Expand All @@ -8,6 +12,7 @@ type NotificationsInterfaceParams = {
RemoveFromUnsubscribedParams: unknown
AddToUnsubscribedParams: unknown
SendNotificationParams: unknown
GetContactsFromListParam: unknown

// Responses
CreateListRes: unknown
Expand All @@ -19,6 +24,7 @@ type NotificationsInterfaceParams = {
RemoveFromUnsubscribedRes: unknown
AddToUnsubscribedRes: unknown
SendNotificationRes: unknown
GetContactsFromListRes: unknown
contactListsRes: unknown
}

Expand All @@ -36,5 +42,20 @@ export abstract class NotificationsProviderInterface<
data: T['RemoveFromUnsubscribedParams'],
): Promise<T['RemoveFromUnsubscribedRes']>
abstract sendNotification(data: T['SendNotificationParams']): Promise<T['SendNotificationRes']>
abstract getContactsFromList(
data: T['GetContactsFromListParam'],
): Promise<T['GetContactsFromListRes']>
abstract prepareTemplatePersonalizations(
data: MassMailDto,
contacts: ContactsMap,
date?: Date,
): PersonalizationData[]

abstract sendBulkEmail(
data: MassMailDto,
contactsMap: ContactsMap[],
value: string,
timeout?: number,
): Promise<void>
abstract getContactLists(): Promise<T['contactListsRes']>
}
Loading

0 comments on commit df4319f

Please sign in to comment.