Skip to content

Commit

Permalink
add email send retries
Browse files Browse the repository at this point in the history
  • Loading branch information
kdembler committed Sep 6, 2023
1 parent f54b1fc commit 265c59f
Show file tree
Hide file tree
Showing 12 changed files with 286 additions and 62 deletions.
2 changes: 1 addition & 1 deletion packages/server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -195,7 +195,7 @@ query {
notifications {
kind
entityId
isSent
status
}
}
```
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
/*
Warnings:
- You are about to drop the column `isSent` on the `Notification` table. All the data in the column will be lost.
*/
-- CreateEnum
CREATE TYPE "NotificationStatus" AS ENUM ('PENDING', 'SENT', 'FAILED');

-- AlterTable
ALTER TABLE "Notification" DROP COLUMN "isSent",
ADD COLUMN "retryCount" INTEGER NOT NULL DEFAULT 0,
ADD COLUMN "status" "NotificationStatus" NOT NULL DEFAULT 'PENDING';
24 changes: 15 additions & 9 deletions packages/server/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -38,14 +38,15 @@ model Subscription {
}

model Notification {
id Int @id @default(autoincrement())
member Member @relation(fields: [memberId], references: [id])
memberId Int
kind NotificationKind
eventId String
entityId String?
isSent Boolean @default(false)
isRead Boolean @default(false)
id Int @id @default(autoincrement())
member Member @relation(fields: [memberId], references: [id])
memberId Int
kind NotificationKind
eventId String
entityId String?
status NotificationStatus @default(PENDING)
retryCount Int @default(0)
isRead Boolean @default(false)
@@unique([memberId, eventId])
@@index(memberId)
Expand Down Expand Up @@ -92,7 +93,6 @@ enum NotificationKind {
ELECTION_ANNOUNCING_STARTED
ELECTION_VOTING_STARTED
ELECTION_REVEALING_STARTED
// ------------------
// Entity specific
Expand All @@ -108,3 +108,9 @@ enum NotificationKind {
// PROPOSAL_ENTITY_VOTE
// PROPOSAL_ENTITY_DISCUSSION
}

enum NotificationStatus {
PENDING
SENT
FAILED
}
2 changes: 2 additions & 0 deletions packages/server/src/common/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,5 @@ export { PORT, APP_SECRET_KEY, QUERY_NODE_ENDPOINT, PIONEER_URL, EMAIL_SENDER, S
export const STARTING_BLOCK = Number(_STARTING_BLOCK ?? 0)

export const INITIAL_MEMBERSHIPS = _INITIAL_MEMBERSHIPS ? [JSON.parse(_INITIAL_MEMBERSHIPS)].flat() : []

export const EMAIL_MAX_RETRY_COUNT = 3
7 changes: 4 additions & 3 deletions packages/server/src/notifier/api/notification.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import * as Prisma from '@prisma/client'
import { arg, booleanArg, enumType, list, objectType, queryField, stringArg } from 'nexus'
import { Notification, NotificationKind } from 'nexus-prisma'
import { Notification, NotificationKind, NotificationStatus } from 'nexus-prisma'

import { authMemberId } from '@/auth/model/token'
import { Context } from '@/common/api'

export const NotificationKindEnum = enumType(NotificationKind)
export const NotificationStatusEnum = enumType(NotificationStatus)

export const NotificationFields = objectType({
name: Notification.$name,
Expand All @@ -15,7 +16,7 @@ export const NotificationFields = objectType({
t.field(Notification.kind)
t.field(Notification.eventId)
t.field(Notification.entityId)
t.field(Notification.isSent)
t.field(Notification.status)
t.field(Notification.isRead)
},
})
Expand All @@ -29,7 +30,7 @@ export const notificationsQuery = queryField('notifications', {
kind: arg({ type: NotificationKind.name }),
eventId: stringArg(),
entityId: stringArg(),
isSent: booleanArg(),
status: arg({ type: NotificationStatus.name }),
isRead: booleanArg(),
},

Expand Down
4 changes: 2 additions & 2 deletions packages/server/src/notifier/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { createNotifications } from './createNotifications'
import { sendNotifications } from './sendNotifications'
import { processNotifications } from './sendNotifications'

export const run = async () => {
await createNotifications()
await sendNotifications()
await processNotifications()
}
3 changes: 2 additions & 1 deletion packages/server/src/notifier/scripts/mockEmail.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@ const sendEmail = async () => {
},
memberId: 1,
isRead: false,
isSent: false,
retryCount: 0,
status: 'PENDING' as const,
}

const notification = {
Expand Down
43 changes: 33 additions & 10 deletions packages/server/src/notifier/sendNotifications.ts
Original file line number Diff line number Diff line change
@@ -1,35 +1,58 @@
import { pick } from 'lodash'
import { Member, Notification } from '@prisma/client'
import { error, info, warn } from 'npmlog'

import { EMAIL_MAX_RETRY_COUNT } from '@/common/config'
import { prisma } from '@/common/prisma'
import { EmailProvider, createEmailProvider, errorMessage } from '@/common/utils'

import { createEmailNotifier } from './model/email'

export const sendNotifications = async (): Promise<any[]> => {
export const processNotifications = async (): Promise<void> => {
let emailProvider: EmailProvider
try {
emailProvider = createEmailProvider()
} catch (err) {
warn('Email notifications', 'Failed to configure email provider with error:', errorMessage(err))
return []
return
}

const notifications = await prisma.notification.findMany({ where: { isSent: false }, include: { member: true } })
const notifyViaEmail = createEmailNotifier(emailProvider)
const notifications = await prisma.notification.findMany({ where: { status: 'PENDING' }, include: { member: true } })
await sendNotifications(notifications, emailProvider)
}

type NotificationWithMember = Notification & { member: Member }

export const sendNotifications = async (
notifications: NotificationWithMember[],
emailProvider: EmailProvider
): Promise<void> => {
const notifyViaEmail = createEmailNotifier(emailProvider)
info('Email notifications', 'Attempt to email', notifications.length, 'notifications')

return Promise.all(
await Promise.all(
notifications.map(async (notification) => {
const { id, retryCount } = notification

try {
await notifyViaEmail(notification)
return await prisma.notification.update({ where: pick(notification, 'id'), data: { isSent: true } })
return await prisma.notification.update({ where: { id }, data: { status: 'SENT' } })
} catch (errData) {
error('Email notification failure', `Failed to email ${notification.id} with error:`, errorMessage(errData))
if (retryCount >= EMAIL_MAX_RETRY_COUNT) {
error(
'Email notification failure',
`Failed to email ${notification.id} with ${EMAIL_MAX_RETRY_COUNT} retries. Error:`,
errorMessage(errData)
)
return await prisma.notification.update({ where: { id }, data: { status: 'FAILED' } })
} else {
warn(
'Email notification failure',
`Failed to email ${notification.id}. Will retry. Error:`,
errorMessage(errData)
)
return await prisma.notification.update({ where: { id }, data: { retryCount: retryCount + 1 } })
}
}
// TODO: update a fail counter instead so it can be retried N time later
await prisma.notification.update({ where: pick(notification, 'id'), data: { isSent: true } })
})
)
}
8 changes: 4 additions & 4 deletions packages/server/test/api/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { isUndefined } from 'lodash'

import { prisma } from '@/common/prisma'

import { clearDb, mockRequest, mockSendEmail } from '../setup'
import { clearDb, mockRequest, mockEmailProvider } from '../setup'

import { api, authApi, gql, jwtRegex, keyring, Member, signWith, verifyEmailLinkRegex } from './utils'

Expand All @@ -30,7 +30,7 @@ describe('API: Authentication', () => {
})

it('Member signs up ', async () => {
mockSendEmail.mockReset()
mockEmailProvider.reset()
mockRequest.mockReset()
mockRequest.mockReturnValue({ membershipByUniqueInput: { controllerAccount: ALICE.controller.address } })

Expand Down Expand Up @@ -90,8 +90,8 @@ describe('API: Authentication', () => {

authToken = success?.signup

expect(mockSendEmail).toHaveBeenCalledTimes(1)
expect(mockSendEmail).toHaveBeenCalledWith(
expect(mockEmailProvider.sentEmails.length).toBe(1)
expect(mockEmailProvider.sentEmails).toContainEqual(
expect.objectContaining({
to: ALICE.email,
subject: 'Confirm your email for Pioneer',
Expand Down
5 changes: 2 additions & 3 deletions packages/server/test/api/notifier.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -241,7 +241,7 @@ describe('API: notifier', () => {
kind
eventId
entityId
isSent
status
isRead
}
}
Expand All @@ -254,7 +254,6 @@ describe('API: notifier', () => {
kind: 'FORUM_POST_ALL',
eventId: 'post_creation:1',
entityId: 'post:1',
isSent: false,
isRead: false,
memberId: ALICE.id,
},
Expand All @@ -268,7 +267,7 @@ describe('API: notifier', () => {
kind: 'FORUM_POST_ALL',
eventId: 'post_creation:1',
entityId: 'post:1',
isSent: false,
status: 'PENDING',
isRead: false,
},
],
Expand Down
Loading

0 comments on commit 265c59f

Please sign in to comment.