Skip to content

Commit

Permalink
📨 Notify users of proposal discussions (#4849)
Browse files Browse the repository at this point in the history
* Fix thread participant notifications

* Save proposal discussion notifications

* Email proposal discussion notifications

* Don't fetch the entire thread text on forum post notification

* Add the Prisma migration

* Test proposal discussion role notifications
(not essential but still nice to have IMO)
  • Loading branch information
thesan committed May 28, 2024
1 parent 4bb466d commit b662b3e
Show file tree
Hide file tree
Showing 15 changed files with 510 additions and 39 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
-- AlterEnum
-- This migration adds more than one value to an enum.
-- With PostgreSQL versions 11 and earlier, this is not possible
-- in a single migration. This can be worked around by creating
-- multiple migrations, each migration adding only one value to
-- the enum.


ALTER TYPE "NotificationKind" ADD VALUE 'PROPOSAL_DISCUSSION_MENTION';
ALTER TYPE "NotificationKind" ADD VALUE 'PROPOSAL_DISCUSSION_REPLY';
ALTER TYPE "NotificationKind" ADD VALUE 'PROPOSAL_DISCUSSION_CREATOR';
ALTER TYPE "NotificationKind" ADD VALUE 'PROPOSAL_DISCUSSION_CONTRIBUTOR';
ALTER TYPE "NotificationKind" ADD VALUE 'PROPOSAL_DISCUSSION_ALL';
ALTER TYPE "NotificationKind" ADD VALUE 'PROPOSAL_ENTITY_DISCUSSION';
10 changes: 6 additions & 4 deletions packages/server/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -87,9 +87,11 @@ enum NotificationKind {
// PROPOSAL_VOTE_CREATOR
// Proposal: ProposalDiscussionPostCreatedEvent
// PROPOSAL_DISCUSSION_MENTION
// PROPOSAL_DISCUSSION_CREATOR
// PROPOSAL_DISCUSSION_CONTRIBUTOR
PROPOSAL_DISCUSSION_MENTION
PROPOSAL_DISCUSSION_REPLY
PROPOSAL_DISCUSSION_CREATOR
PROPOSAL_DISCUSSION_CONTRIBUTOR
PROPOSAL_DISCUSSION_ALL
// Referendum
ELECTION_ANNOUNCING_STARTED
Expand All @@ -108,7 +110,7 @@ enum NotificationKind {
// Proposal
// PROPOSAL_ENTITY_STATUS
// PROPOSAL_ENTITY_VOTE
// PROPOSAL_ENTITY_DISCUSSION
PROPOSAL_ENTITY_DISCUSSION
}

enum NotificationEmailStatus {
Expand Down
2 changes: 2 additions & 0 deletions packages/server/src/notifier/model/email/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
fromElectionVotingStartedNotification,
} from './election'
import { fromPostAddedNotification, fromThreadCreatedNotification } from './forum'
import { fromProposalPostCreatedNotification } from './proposal'
import { Notification, hasEmailAddress } from './utils'

export const createEmailNotifier =
Expand All @@ -25,6 +26,7 @@ export const createEmailNotifier =
const emailHandlers = [
fromPostAddedNotification,
fromThreadCreatedNotification,
fromProposalPostCreatedNotification,
fromElectionAnnouncingStartedNotification,
fromElectionVotingStartedNotification,
fromElectionRevealingStartedNotification,
Expand Down
68 changes: 68 additions & 0 deletions packages/server/src/notifier/model/email/proposal.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { match } from 'ts-pattern'

import { PIONEER_URL } from '@/common/config'
import { renderPioneerEmail } from '@/common/email-templates/pioneer-email'

import { EmailFromNotificationFn } from './utils'
import { getProposalPost } from './utils/proposal'

export const fromProposalPostCreatedNotification: EmailFromNotificationFn = async ({ id, kind, entityId, member }) => {
if (
kind !== 'PROPOSAL_DISCUSSION_MENTION' &&
kind !== 'PROPOSAL_DISCUSSION_REPLY' &&
kind !== 'PROPOSAL_DISCUSSION_CREATOR' &&
kind !== 'PROPOSAL_DISCUSSION_CONTRIBUTOR' &&
kind !== 'PROPOSAL_DISCUSSION_ALL' &&
kind !== 'PROPOSAL_ENTITY_DISCUSSION'
) {
return
}

if (!entityId) {
throw Error(`Missing proposal discussion post id in notification ${kind}, with id: ${id}`)
}

const { author, proposal, proposalId } = await getProposalPost(entityId)

const emailSubject = `[Pioneer] proposal: ${proposal}`

const emailSummary: string = match(kind)
.with('PROPOSAL_DISCUSSION_MENTION', () => `${author} mentioned you regarding the proposal ${proposal}.`)
.with('PROPOSAL_DISCUSSION_REPLY', () => `${author} replied to your post regarding the proposal ${proposal}.`)
.with(
'PROPOSAL_DISCUSSION_CREATOR',
'PROPOSAL_DISCUSSION_CONTRIBUTOR',
'PROPOSAL_DISCUSSION_ALL',
'PROPOSAL_ENTITY_DISCUSSION',
() => `${author} posted regarding the proposal ${proposal}.`
)
.exhaustive()

const emailText: string = match(kind)
.with('PROPOSAL_DISCUSSION_MENTION', () => `${author} mentioned you regarding the proposal ${proposal}.`)
.with('PROPOSAL_DISCUSSION_REPLY', () => `${author} replied to your post regarding the proposal ${proposal}.`)
.with(
'PROPOSAL_DISCUSSION_CREATOR',
'PROPOSAL_DISCUSSION_CONTRIBUTOR',
'PROPOSAL_DISCUSSION_ALL',
'PROPOSAL_ENTITY_DISCUSSION',
() => `${author} posted regarding the proposal ${proposal}.`
)
.exhaustive()

const emailHtml = renderPioneerEmail({
memberHandle: member.name,
summary: emailSummary,
text: emailText,
button: {
label: 'See on Pioneer',
href: `${PIONEER_URL}/#/proposals/preview/${proposalId}?post=${entityId}`,
},
})

return {
subject: emailSubject,
html: emailHtml,
to: member.email,
}
}
28 changes: 28 additions & 0 deletions packages/server/src/notifier/model/email/utils/proposal.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { request } from 'graphql-request'
import { memoize } from 'lodash'

import { QUERY_NODE_ENDPOINT } from '@/common/config'
import { GetProposalDiscussionPostDocument } from '@/common/queries'

interface ProposalDiscussionPost {
author: string
proposal: string
proposalId: string
}

export const getProposalPost = memoize(async (id: string): Promise<ProposalDiscussionPost> => {
const { proposalDiscussionPostByUniqueInput: post } = await request(
QUERY_NODE_ENDPOINT,
GetProposalDiscussionPostDocument,
{ id }
)
if (!post) {
throw Error(`Failed to fetch proposal discussion post ${id} on the QN`)
}

return {
author: post.author.handle,
proposal: post.discussionThread.proposal.title,
proposalId: post.discussionThread.proposal.id,
}
})
2 changes: 2 additions & 0 deletions packages/server/src/notifier/model/event/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
fromElectionVotingStartedEvent,
} from './election'
import { fromPostAddedEvent, fromThreadCreatedEvent } from './forum'
import { fromProposalPostAddedEvent } from './proposal'
import { buildEvents } from './utils/buildEvent'
import { ImplementedQNEvent } from './utils/types'

Expand All @@ -28,6 +29,7 @@ export const toNotificationEvents =
const notifEvent = match(event)
.with({ __typename: 'PostAddedEvent' }, (e) => fromPostAddedEvent(e, build, roles))
.with({ __typename: 'ThreadCreatedEvent' }, (e) => fromThreadCreatedEvent(e, build, roles))
.with({ __typename: 'ProposalDiscussionPostCreatedEvent' }, (e) => fromProposalPostAddedEvent(e, build, roles))
.with({ __typename: 'AnnouncingPeriodStartedEvent' }, (e) => fromElectionAnnouncingStartedEvent(e, build))
.with({ __typename: 'VotingPeriodStartedEvent' }, (e) => fromElectionVotingStartedEvent(e, build))
.with({ __typename: 'RevealingStageStartedEvent' }, (e) => fromElectionRevealingStartedEvent(e, build))
Expand Down
33 changes: 33 additions & 0 deletions packages/server/src/notifier/model/event/proposal.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { pick, uniq } from 'lodash'

import {
GetCurrentRolesQuery,
ProposalDiscussionPostCreatedEventFieldsFragmentDoc,
useFragment,
} from '@/common/queries'

import { NotifEventFromQNEvent, isOlderThan, getMentionedMemberIds } from './utils'

export const fromProposalPostAddedEvent: NotifEventFromQNEvent<
'ProposalDiscussionPostCreatedEvent',
[GetCurrentRolesQuery]
> = async (event, buildEvents, roles) => {
const postCreatedEvent = useFragment(ProposalDiscussionPostCreatedEventFieldsFragmentDoc, event)
const post = postCreatedEvent.post

const mentionedMemberIds = getMentionedMemberIds(post.text, roles)
const repliedToMemberId = post.repliesTo && [Number(post.repliesTo.authorId)]
const earlierPosts = post.discussionThread.posts.filter(isOlderThan(post))
const earlierAuthors = uniq(earlierPosts.map((post) => Number(post.authorId)))

const eventData = pick(postCreatedEvent, 'inBlock', 'id')

return buildEvents(eventData, post.id, [post.authorId], ({ generalEvent, entityEvent }) => [
generalEvent('PROPOSAL_DISCUSSION_MENTION', mentionedMemberIds),
generalEvent('PROPOSAL_DISCUSSION_REPLY', repliedToMemberId ?? []),
generalEvent('PROPOSAL_DISCUSSION_CREATOR', [post.discussionThread.proposal.creatorId]),
generalEvent('PROPOSAL_DISCUSSION_CONTRIBUTOR', earlierAuthors),
entityEvent('PROPOSAL_ENTITY_DISCUSSION', post.discussionThread.proposal.id),
generalEvent('PROPOSAL_DISCUSSION_ALL', 'ANY'),
])
}
2 changes: 1 addition & 1 deletion packages/server/src/notifier/model/event/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ type Created = { createdAt: any }
export const isOlderThan =
<A extends Created>(a: A) =>
<B extends Created>(b: B): boolean =>
Date.parse(String(a)) > Date.parse(String(b))
Date.parse(String(a.createdAt)) > Date.parse(String(b.createdAt))

export const getMentionedMemberIds = (text: string, roles: GetCurrentRolesQuery): number[] =>
uniq(
Expand Down
21 changes: 13 additions & 8 deletions packages/server/src/notifier/model/subscriptionKinds.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,12 @@ export type EntitySubscriptionKind = (typeof EntitySubscriptionKind)[keyof typeo
export const EntitySubscriptionKind = extract(
'FORUM_THREAD_ENTITY_POST',
'FORUM_CATEGORY_ENTITY_POST',
'FORUM_CATEGORY_ENTITY_THREAD'
'FORUM_CATEGORY_ENTITY_THREAD',
// 'FORUM_WATCHED_CATEGORY_SUBCATEGORY',

// 'PROPOSAL_ENTITY_STATUS',
// 'PROPOSAL_ENTITY_VOTE',
// 'PROPOSAL_ENTITY_DISCUSSION'
'PROPOSAL_ENTITY_DISCUSSION'
)

export type GeneralSubscriptionKind = (typeof GeneralSubscriptionKind)[keyof typeof GeneralSubscriptionKind]
Expand All @@ -32,9 +32,11 @@ export const GeneralSubscriptionKind = extract(
// 'PROPOSAL_STATUS_CREATOR',
// 'PROPOSAL_VOTE_ALL',
// 'PROPOSAL_VOTE_CREATOR',
// 'PROPOSAL_DISCUSSION_MENTION',
// 'PROPOSAL_DISCUSSION_CREATOR',
// 'PROPOSAL_DISCUSSION_CONTRIBUTOR',
'PROPOSAL_DISCUSSION_MENTION',
'PROPOSAL_DISCUSSION_REPLY',
'PROPOSAL_DISCUSSION_CREATOR',
'PROPOSAL_DISCUSSION_CONTRIBUTOR',
'PROPOSAL_DISCUSSION_ALL',

'ELECTION_ANNOUNCING_STARTED',
'ELECTION_VOTING_STARTED',
Expand All @@ -49,11 +51,14 @@ const defaultSubscriptions: GeneralSubscriptionKind[] = [
'FORUM_THREAD_CREATOR',
'FORUM_THREAD_CONTRIBUTOR',
'FORUM_THREAD_MENTION',

// 'PROPOSAL_STATUS_CREATOR',
// 'PROPOSAL_VOTE_CREATOR',
// 'PROPOSAL_DISCUSSION_MENTION',
// 'PROPOSAL_DISCUSSION_CREATOR',
// 'PROPOSAL_DISCUSSION_CONTRIBUTOR',
'PROPOSAL_DISCUSSION_MENTION',
'PROPOSAL_DISCUSSION_REPLY',
'PROPOSAL_DISCUSSION_CREATOR',
'PROPOSAL_DISCUSSION_CONTRIBUTOR',

'ELECTION_ANNOUNCING_STARTED',
'ELECTION_VOTING_STARTED',
'ELECTION_REVEALING_STARTED',
Expand Down
13 changes: 13 additions & 0 deletions packages/server/src/notifier/queries/entities/proposal.graphql
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
query GetProposalDiscussionPost($id: ID!) {
proposalDiscussionPostByUniqueInput(where: { id: $id }) {
author {
handle
}
discussionThread {
proposal {
id
title
}
}
}
}
53 changes: 28 additions & 25 deletions packages/server/src/notifier/queries/events.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@ fragment PostAddedEventFields on PostAddedEvent {
posts {
authorId
createdAt
text
}
categoryId
}
Expand Down Expand Up @@ -53,27 +52,30 @@ fragment ElectionRevealingStartedFields on RevealingStageStartedEvent {
inBlock
}

# fragment ProposalDiscussionPostCreatedEventFields on ProposalDiscussionPostCreatedEvent {
# __typename
# id
# inBlock
# post {
# id
# authorId
# text
# discussionThread {
# id
# proposal {
# id
# creatorId
# }
# posts {
# authorId
# text
# }
# }
# }
# }
fragment ProposalDiscussionPostCreatedEventFields on ProposalDiscussionPostCreatedEvent {
__typename
id
inBlock
post {
id
authorId
createdAt
text
repliesTo {
authorId
}
discussionThread {
proposal {
id # Users subscribe to proposals rather than proposal discussions
creatorId
}
posts {
authorId
createdAt
}
}
}
}

query GetNotificationEvents($from: Int, $exclude: [ID!]) {
events(
Expand All @@ -85,6 +87,7 @@ query GetNotificationEvents($from: Int, $exclude: [ID!]) {
VotingPeriodStartedEvent
RevealingStageStartedEvent
# PostTextUpdatedEvent
ProposalDiscussionPostCreatedEvent
]
inBlock_gte: $from
NOT: { id_in: $exclude }
Expand All @@ -106,8 +109,8 @@ query GetNotificationEvents($from: Int, $exclude: [ID!]) {
... on RevealingStageStartedEvent {
...ElectionRevealingStartedFields
}
# ... on ProposalDiscussionPostCreatedEvent {
# ...ProposalDiscussionPostCreatedEventFields
# }
... on ProposalDiscussionPostCreatedEvent {
...ProposalDiscussionPostCreatedEventFields
}
}
}
1 change: 1 addition & 0 deletions packages/server/test/_mocks/notifier/events/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export * from './forum'
export * from './proposal'
Loading

0 comments on commit b662b3e

Please sign in to comment.