Skip to content

Commit 3a44162

Browse files
authored
Marketing Notifications -> Full Implementation (#534)
* Add notification subscription * Add Notifications controlle - sent email ver/ subscribe * add unsubscribe * add template generation and notification sending * implement feedback from PR-2 * apply feedback from PR-3/Pr-4 * Additional fixes * small test fix * apply review comments
1 parent 6618be9 commit 3a44162

File tree

110 files changed

+3713
-102
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

110 files changed

+3713
-102
lines changed

.env.example

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,10 @@ SENDGRID_API_KEY=sendgrid-key
6969
SENDGRID_SENDER_EMAIL=[email protected]
7070
SENDGRID_INTERNAL_EMAIL=[email protected]
7171
SENDGRID_CONTACTS_URL=/v3/marketing/contacts
72+
MARKETING_LIST_ID=6add1a52-f74e-4c14-af56-ec7e1d2318f0
73+
SENDGRID_SENDER_ID=
74+
## if marketing notifications should be active --> true/false -> defaults to false
75+
SEND_MARKETING_NOTIFICATIONS=
7276

7377
## Stripe ##
7478
############

apps/api/src/account/account.controller.spec.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import KeycloakConnect from 'keycloak-connect'
1313
import { mock, mockDeep } from 'jest-mock-extended'
1414
import { JwtService } from '@nestjs/jwt'
1515
import { EmailService } from '../email/email.service'
16+
import { MarketingNotificationsModule } from '../notifications/notifications.module'
1617

1718
describe('AccountController', () => {
1819
let controller: AccountController
@@ -28,6 +29,7 @@ describe('AccountController', () => {
2829

2930
beforeEach(async () => {
3031
const module: TestingModule = await Test.createTestingModule({
32+
imports: [MarketingNotificationsModule],
3133
controllers: [AccountController],
3234
providers: [
3335
AccountService,

apps/api/src/account/account.controller.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { Person } from '../domain/generated/person/entities'
88
import { UpdatePersonDto } from '../person/dto/update-person.dto'
99
import { PersonService } from '../person/person.service'
1010
import { AccountService } from './account.service'
11-
import { ApiTags } from '@nestjs/swagger';
11+
import { ApiTags } from '@nestjs/swagger'
1212

1313
@Controller('account')
1414
@ApiTags('account')

apps/api/src/account/account.service.spec.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,14 @@ import { PersonService } from '../person/person.service'
1212
import { MockPrismaService } from '../prisma/prisma-client.mock'
1313
import { AccountService } from './account.service'
1414

15+
import { MarketingNotificationsModule } from '../notifications/notifications.module'
16+
1517
describe('AccountService', () => {
1618
let service: AccountService
1719

1820
beforeEach(async () => {
1921
const module: TestingModule = await Test.createTestingModule({
22+
imports: [MarketingNotificationsModule],
2023
providers: [
2124
AccountService,
2225
PersonService,

apps/api/src/app/app.module.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,9 +52,10 @@ import { NotificationModule } from '../sockets/notifications/notification.module
5252
import { ScheduleModule } from '@nestjs/schedule'
5353
import { TasksModule } from '../tasks/tasks.module'
5454
import { BankTransactionsModule } from '../bank-transactions/bank-transactions.module'
55-
import { CacheModule, CacheInterceptor } from '@nestjs/cache-manager'
55+
import { CacheModule } from '@nestjs/cache-manager'
5656
import { CampaignNewsModule } from '../campaign-news/campaign-news.module'
5757
import { CampaignNewsFileModule } from '../campaign-news-file/campaign-news-file.module'
58+
import { MarketingNotificationsModule } from '../notifications/notifications.module'
5859

5960
@Module({
6061
imports: [
@@ -116,6 +117,7 @@ import { CampaignNewsFileModule } from '../campaign-news-file/campaign-news-file
116117
}),
117118
CampaignNewsModule,
118119
CampaignNewsFileModule,
120+
MarketingNotificationsModule,
119121
],
120122
controllers: [AppController],
121123
providers: [
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
{
2+
"subject": "Потвърдете абонирането си"
3+
}
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
<mjml>
2+
<mj-body background-color="#ffffff" font-size="13px" width="90%">
3+
<mj-section
4+
background-color="#009FE3"
5+
vertical-align="top"
6+
padding-bottom="0px"
7+
padding-top="0">
8+
<mj-column vertical-align="top" width="100%">
9+
<mj-text
10+
align="left"
11+
color="#ffffff"
12+
font-size="45px"
13+
font-weight="bold"
14+
font-family="open Sans Helvetica, Arial, sans-serif"
15+
padding-left="25px"
16+
padding-right="25px"
17+
padding-bottom="15px"
18+
padding-top="50px">
19+
Потвърдете съгласието си за получаване на известия и новини от Podkrepi.bg
20+
</mj-text>
21+
</mj-column>
22+
</mj-section>
23+
<mj-section background-color="#009fe3" padding-bottom="20px" padding-top="20px">
24+
<mj-column vertical-align="middle" width="100%">
25+
<mj-text
26+
align="left"
27+
color="#ffffff"
28+
font-size="22px"
29+
font-family="open Sans Helvetica, Arial, sans-serif"
30+
padding-left="25px"
31+
padding-right="25px">
32+
<br /><br />
33+
</mj-text>
34+
<mj-text
35+
align="left"
36+
color="#ffffff"
37+
font-size="18px"
38+
font-family="open Sans Helvetica, Arial, sans-serif"
39+
padding-left="25px"
40+
padding-right="25px">
41+
Благодарим ти, че ставаш част от нещо смислено. Абонирайки се ще получаваш актуална
42+
информация за всичко, което се случва в нашата платформа - събрани дарения, нови кампании,
43+
интересни новини.
44+
</mj-text>
45+
46+
<mj-button
47+
background-color="#FFCB57"
48+
font-family="Helvetica, Arial, sans-serif"
49+
font-size="17px"
50+
border-radius="30px"
51+
color="#000000"
52+
padding="15px 30px"
53+
href="{{subscribeLink}}"
54+
target="_blank">
55+
Потвърждавам абонирането си
56+
</mj-button>
57+
58+
<mj-text
59+
align="left"
60+
color="#ffffff"
61+
font-size="15px"
62+
font-family="open Sans Helvetica, Arial, sans-serif"
63+
padding-left="25px"
64+
padding-right="25px">
65+
Поздрави, <br />
66+
Екипът на Подкрепи.бг
67+
</mj-text>
68+
</mj-column>
69+
</mj-section>
70+
</mj-body>
71+
</mjml>

apps/api/src/auth/auth.module.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { ProviderLoginController } from './provider-login.controller'
1313
import { JwtModule, JwtService } from '@nestjs/jwt'
1414
import { EmailService } from '../email/email.service'
1515
import { TemplateService } from '../email/template.service'
16+
import { MarketingNotificationsModule } from '../notifications/notifications.module'
1617

1718
@Module({
1819
controllers: [LoginController, RegisterController, RefreshController, ProviderLoginController],
@@ -25,6 +26,7 @@ import { TemplateService } from '../email/template.service'
2526
useExisting: KeycloakConfigService,
2627
imports: [AppConfigModule],
2728
}),
29+
MarketingNotificationsModule,
2830
],
2931
exports: [AuthService],
3032
})

apps/api/src/auth/auth.service.spec.ts

Lines changed: 121 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,9 @@ import { ProviderDto } from './dto/provider.dto'
1919
import { EmailService } from '../email/email.service'
2020
import { JwtService } from '@nestjs/jwt'
2121
import { TemplateService } from '../email/template.service'
22-
import { PrismaService } from '../prisma/prisma.service'
22+
import { SendGridNotificationsProvider } from '../notifications/providers/notifications.sendgrid.provider'
23+
import { NotificationsProviderInterface } from '../notifications/providers/notifications.interface.providers'
24+
import { MarketingNotificationsModule } from '../notifications/notifications.module'
2325

2426
jest.mock('@keycloak/keycloak-admin-client')
2527

@@ -28,6 +30,7 @@ describe('AuthService', () => {
2830
let config: ConfigService
2931
let admin: KeycloakAdminClient
3032
let keycloak: KeycloakConnect.Keycloak
33+
let marketing: NotificationsProviderInterface<any>
3134

3235
const person: Person = {
3336
id: 'e43348aa-be33-4c12-80bf-2adfbf8736cd',
@@ -50,6 +53,7 @@ describe('AuthService', () => {
5053

5154
beforeEach(async () => {
5255
const module: TestingModule = await Test.createTestingModule({
56+
imports: [MarketingNotificationsModule],
5357
providers: [
5458
AuthService,
5559
{
@@ -58,6 +62,7 @@ describe('AuthService', () => {
5862
get: jest.fn((key: string) => {
5963
if (key === 'keycloak.clientId') return 'realm-a12345'
6064
if (key === 'keycloak.secret') return 'a12345'
65+
if (key === 'sendgrid.marketingListId') return 'list-id'
6166
return null
6267
}),
6368
},
@@ -87,12 +92,27 @@ describe('AuthService', () => {
8792
provide: TemplateService,
8893
useValue: mockDeep<TemplateService>(),
8994
},
95+
{
96+
provide: NotificationsProviderInterface,
97+
useClass: SendGridNotificationsProvider,
98+
},
9099
],
91-
}).compile()
100+
})
101+
.overrideProvider(ConfigService)
102+
.useValue({
103+
get: jest.fn((key: string) => {
104+
if (key === 'keycloak.clientId') return 'realm-a12345'
105+
if (key === 'keycloak.secret') return 'a12345'
106+
if (key === 'sendgrid.marketingListId') return 'list-id'
107+
return null
108+
}),
109+
})
110+
.compile()
92111

93112
service = module.get<AuthService>(AuthService)
94113
config = module.get<ConfigService>(ConfigService)
95114
admin = module.get<KeycloakAdminClient>(KeycloakAdminClient)
115+
marketing = module.get<NotificationsProviderInterface<any>>(NotificationsProviderInterface)
96116
keycloak = module.get<KeycloakConnect.Keycloak>(KEYCLOAK_INSTANCE)
97117
})
98118

@@ -303,13 +323,20 @@ describe('AuthService', () => {
303323
const password = 's3cret'
304324
const firstName = 'John'
305325
const lastName = 'Doe'
326+
const newsletter = true
306327

307328
it('should call keycloak and prisma', async () => {
308329
const keycloakId = 'u123'
309-
const registerDto = plainToClass(RegisterDto, { email, password, firstName, lastName })
330+
const registerDto = plainToClass(RegisterDto, {
331+
email,
332+
password,
333+
firstName,
334+
lastName,
335+
newsletter,
336+
})
337+
jest.spyOn(marketing, 'addContactsToList').mockImplementation(async () => true)
310338
const createUserSpy = jest.spyOn(service, 'createUser')
311339
const adminSpy = jest.spyOn(admin.users, 'create').mockResolvedValue({ id: keycloakId })
312-
313340
const prismaSpy = jest.spyOn(prismaMock.person, 'upsert').mockResolvedValue(person)
314341

315342
expect(await service.createUser(registerDto)).toBe(person)
@@ -346,7 +373,7 @@ describe('AuthService', () => {
346373

347374
// Check db creation
348375
expect(prismaSpy).toHaveBeenCalledWith({
349-
create: { keycloakId, email, firstName, lastName },
376+
create: { keycloakId, email, firstName, lastName, newsletter },
350377
update: { keycloakId },
351378
where: { email },
352379
})
@@ -377,5 +404,94 @@ describe('AuthService', () => {
377404
expect(loggerSpy).toBeCalled()
378405
loggerSpy.mockRestore()
379406
})
407+
408+
it('should subscribe email to marketing list if consent is given', async () => {
409+
const keycloakId = 'u123'
410+
const registerDto = plainToClass(RegisterDto, {
411+
email,
412+
password,
413+
firstName,
414+
lastName,
415+
// Add to marketing list
416+
newsletter: true,
417+
})
418+
const person: Person = {
419+
id: 'e43348aa-be33-4c12-80bf-2adfbf8736cd',
420+
firstName,
421+
lastName,
422+
keycloakId,
423+
email,
424+
emailConfirmed: false,
425+
phone: null,
426+
company: null,
427+
picture: null,
428+
createdAt: new Date('2021-10-07T13:38:11.097Z'),
429+
updatedAt: new Date('2021-10-07T13:38:11.097Z'),
430+
newsletter: true,
431+
address: null,
432+
birthday: null,
433+
personalNumber: null,
434+
stripeCustomerId: null,
435+
}
436+
jest.spyOn(prismaMock.person, 'upsert').mockResolvedValue(person)
437+
jest.spyOn(admin.users, 'create').mockResolvedValue({ id: keycloakId })
438+
const marketingSpy = jest
439+
.spyOn(marketing, 'addContactsToList')
440+
.mockImplementation(async () => true)
441+
442+
await service.createUser(registerDto)
443+
444+
// Check was added to list
445+
expect(marketingSpy).toHaveBeenCalledWith({
446+
contacts: [
447+
{
448+
email,
449+
first_name: firstName,
450+
last_name: lastName,
451+
},
452+
],
453+
list_ids: ['list-id'],
454+
})
455+
})
456+
457+
it('should NOT subscribe email to marketing list if NO consent is given', async () => {
458+
const keycloakId = 'u123'
459+
const registerDto = plainToClass(RegisterDto, {
460+
email,
461+
password,
462+
firstName,
463+
lastName,
464+
// Don't subscribe to marketing list
465+
newsletter: false,
466+
})
467+
const person: Person = {
468+
id: 'e43348aa-be33-4c12-80bf-2adfbf8736cd',
469+
firstName,
470+
lastName,
471+
keycloakId,
472+
email,
473+
emailConfirmed: false,
474+
phone: null,
475+
company: null,
476+
picture: null,
477+
createdAt: new Date('2021-10-07T13:38:11.097Z'),
478+
updatedAt: new Date('2021-10-07T13:38:11.097Z'),
479+
newsletter: false,
480+
address: null,
481+
birthday: null,
482+
personalNumber: null,
483+
stripeCustomerId: null,
484+
}
485+
jest.spyOn(prismaMock.person, 'upsert').mockResolvedValue(person)
486+
jest.spyOn(admin.users, 'create').mockResolvedValue({ id: keycloakId })
487+
const marketingSpy = jest
488+
.spyOn(marketing, 'addContactsToList')
489+
.mockImplementation(async () => true)
490+
491+
await service.createUser(registerDto)
492+
493+
// Check was not added to list
494+
expect(marketingSpy).not.toHaveBeenCalled()
495+
})
380496
})
381497
})

0 commit comments

Comments
 (0)