diff --git a/packages/server/prisma/migrations/20240517130826_proposal_discusions/migration.sql b/packages/server/prisma/migrations/20240517130826_proposal_discusions/migration.sql new file mode 100644 index 0000000000..9ed74fd933 --- /dev/null +++ b/packages/server/prisma/migrations/20240517130826_proposal_discusions/migration.sql @@ -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'; diff --git a/packages/server/prisma/schema.prisma b/packages/server/prisma/schema.prisma index f992484640..a4b5f1ff2a 100644 --- a/packages/server/prisma/schema.prisma +++ b/packages/server/prisma/schema.prisma @@ -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 @@ -108,7 +110,7 @@ enum NotificationKind { // Proposal // PROPOSAL_ENTITY_STATUS // PROPOSAL_ENTITY_VOTE - // PROPOSAL_ENTITY_DISCUSSION + PROPOSAL_ENTITY_DISCUSSION } enum NotificationEmailStatus { diff --git a/packages/server/src/notifier/model/email/index.ts b/packages/server/src/notifier/model/email/index.ts index 52df2d3fd3..00cb036ed6 100644 --- a/packages/server/src/notifier/model/email/index.ts +++ b/packages/server/src/notifier/model/email/index.ts @@ -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 = @@ -25,6 +26,7 @@ export const createEmailNotifier = const emailHandlers = [ fromPostAddedNotification, fromThreadCreatedNotification, + fromProposalPostCreatedNotification, fromElectionAnnouncingStartedNotification, fromElectionVotingStartedNotification, fromElectionRevealingStartedNotification, diff --git a/packages/server/src/notifier/model/email/proposal.ts b/packages/server/src/notifier/model/email/proposal.ts new file mode 100644 index 0000000000..aea66651f7 --- /dev/null +++ b/packages/server/src/notifier/model/email/proposal.ts @@ -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, + } +} diff --git a/packages/server/src/notifier/model/email/utils/proposal.ts b/packages/server/src/notifier/model/email/utils/proposal.ts new file mode 100644 index 0000000000..62923b774a --- /dev/null +++ b/packages/server/src/notifier/model/email/utils/proposal.ts @@ -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 => { + 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, + } +}) diff --git a/packages/server/src/notifier/model/event/index.ts b/packages/server/src/notifier/model/event/index.ts index 837682aa23..dc6272d2a4 100644 --- a/packages/server/src/notifier/model/event/index.ts +++ b/packages/server/src/notifier/model/event/index.ts @@ -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' @@ -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)) diff --git a/packages/server/src/notifier/model/event/proposal.ts b/packages/server/src/notifier/model/event/proposal.ts new file mode 100644 index 0000000000..c2973195cc --- /dev/null +++ b/packages/server/src/notifier/model/event/proposal.ts @@ -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'), + ]) +} diff --git a/packages/server/src/notifier/model/event/utils/index.ts b/packages/server/src/notifier/model/event/utils/index.ts index 48c56749f4..a97af48ddd 100644 --- a/packages/server/src/notifier/model/event/utils/index.ts +++ b/packages/server/src/notifier/model/event/utils/index.ts @@ -12,7 +12,7 @@ type Created = { createdAt: any } export const isOlderThan = (a: A) => (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( diff --git a/packages/server/src/notifier/model/subscriptionKinds.ts b/packages/server/src/notifier/model/subscriptionKinds.ts index bdd46fe8f4..2b218d7dc7 100644 --- a/packages/server/src/notifier/model/subscriptionKinds.ts +++ b/packages/server/src/notifier/model/subscriptionKinds.ts @@ -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] @@ -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', @@ -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', diff --git a/packages/server/src/notifier/queries/entities/proposal.graphql b/packages/server/src/notifier/queries/entities/proposal.graphql new file mode 100644 index 0000000000..f5d02992ed --- /dev/null +++ b/packages/server/src/notifier/queries/entities/proposal.graphql @@ -0,0 +1,13 @@ +query GetProposalDiscussionPost($id: ID!) { + proposalDiscussionPostByUniqueInput(where: { id: $id }) { + author { + handle + } + discussionThread { + proposal { + id + title + } + } + } +} diff --git a/packages/server/src/notifier/queries/events.graphql b/packages/server/src/notifier/queries/events.graphql index 0814aaa70d..fb0e7f327c 100644 --- a/packages/server/src/notifier/queries/events.graphql +++ b/packages/server/src/notifier/queries/events.graphql @@ -16,7 +16,6 @@ fragment PostAddedEventFields on PostAddedEvent { posts { authorId createdAt - text } categoryId } @@ -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( @@ -85,6 +87,7 @@ query GetNotificationEvents($from: Int, $exclude: [ID!]) { VotingPeriodStartedEvent RevealingStageStartedEvent # PostTextUpdatedEvent + ProposalDiscussionPostCreatedEvent ] inBlock_gte: $from NOT: { id_in: $exclude } @@ -106,8 +109,8 @@ query GetNotificationEvents($from: Int, $exclude: [ID!]) { ... on RevealingStageStartedEvent { ...ElectionRevealingStartedFields } - # ... on ProposalDiscussionPostCreatedEvent { - # ...ProposalDiscussionPostCreatedEventFields - # } + ... on ProposalDiscussionPostCreatedEvent { + ...ProposalDiscussionPostCreatedEventFields + } } } diff --git a/packages/server/test/_mocks/notifier/events/index.ts b/packages/server/test/_mocks/notifier/events/index.ts index 77422f7e50..77433bbe04 100644 --- a/packages/server/test/_mocks/notifier/events/index.ts +++ b/packages/server/test/_mocks/notifier/events/index.ts @@ -1 +1,2 @@ export * from './forum' +export * from './proposal' diff --git a/packages/server/test/_mocks/notifier/events/proposal.ts b/packages/server/test/_mocks/notifier/events/proposal.ts new file mode 100644 index 0000000000..c779a3070b --- /dev/null +++ b/packages/server/test/_mocks/notifier/events/proposal.ts @@ -0,0 +1,45 @@ +import { maskFragment } from '@test/_mocks/utils' + +import { GetNotificationEventsQuery, ProposalDiscussionPostCreatedEventFieldsFragment } from '@/common/queries' + +type ProposalDiscussionPostCreatedEventMock = { + proposal?: string + proposalCreator?: string | number + author?: string | number + text?: string + repliesTo?: string | number + posts?: { author?: string | number }[] +} +export const proposalDiscussionPostCreatedEvent = ( + post: number, + { + proposal = `proposal:${post}`, + proposalCreator = `creator:${proposal}`, + author = `postAuthor:${post}`, + text = `text:${post}`, + repliesTo, + posts, + }: ProposalDiscussionPostCreatedEventMock = {} +): GetNotificationEventsQuery['events'][0] => + maskFragment( + 'ProposalDiscussionPostCreatedEventFields', + 'ProposalDiscussionPostCreatedEvent' + )({ + id: `event:${post}`, + inBlock: 1, + post: { + id: `post:${post}`, + authorId: String(author), + createdAt: Date(), + text, + repliesTo: { authorId: String(repliesTo) }, + discussionThread: { + proposal: { id: String(proposal), creatorId: String(proposalCreator) }, + posts: + posts?.map(({ author }) => ({ + authorId: String(author), + createdAt: new Date(0).toString(), + })) ?? [], + }, + }, + }) diff --git a/packages/server/test/notifier.test.ts b/packages/server/test/notifier.test.ts index 1b732d5979..a2e25d6dec 100644 --- a/packages/server/test/notifier.test.ts +++ b/packages/server/test/notifier.test.ts @@ -4,7 +4,7 @@ import { GetForumCategoryDocument, GetNotificationEventsDocument, GetThreadDocum import { run } from '@/notifier' import { createMember } from './_mocks/notifier/createMember' -import { postAddedEvent, threadCreatedEvent } from './_mocks/notifier/events' +import { postAddedEvent, proposalDiscussionPostCreatedEvent, threadCreatedEvent } from './_mocks/notifier/events' import { electionAnnouncingEvent, electionRevealingEvent, electionVotingEvent } from './_mocks/notifier/events/election' import { clearDb, mockRequest, mockEmailProvider } from './setup' @@ -705,6 +705,250 @@ describe('Notifier', () => { }) }) + describe('proposal', () => { + describe('ProposalDiscussionPostCreatedEvent', () => { + it('Member notifications', async () => { + // ------------------- + // Initialize database + // ------------------- + + // - Alice is using the default behavior for general subscriptions + // - Alice should be notified of any post from the foo proposal + const alice = await createMember(1, 'alice', [{ kind: 'PROPOSAL_ENTITY_DISCUSSION', entityId: 'foo' }]) + + // - Bob should be notified of all proposal discussion post + // - Bob should not be notified of discussions on the foo and bar proposals + const bob = await createMember(2, 'bob', [ + { kind: 'PROPOSAL_DISCUSSION_ALL' }, + { kind: 'PROPOSAL_ENTITY_DISCUSSION', entityId: 'foo', shouldNotify: false }, + { kind: 'PROPOSAL_ENTITY_DISCUSSION', entityId: 'bar', shouldNotify: false }, + ]) + + // ------------------- + // Mock QN responses + // ------------------- + + mockRequest + .mockReturnValueOnce({ workers: [], electedCouncils: [] }) + .mockReturnValueOnce({ + events: [ + // Bob should be notified as he is subscribed to all proposal discussion. + proposalDiscussionPostCreatedEvent(1), + // (the rest of the post are on proposals ignored by Bob) + // Alice should be notified as she is subscribed to the foo proposal. + proposalDiscussionPostCreatedEvent(2, { proposal: 'foo' }), + // Alice should be notified as she is mentioned in the post. Bob should be notified too. + proposalDiscussionPostCreatedEvent(3, { + text: `Hi [@Alice](#mention?member-id=${alice.id})`, + proposal: 'bar', + }), + // Alice should be notified as she is the proposal creator. + proposalDiscussionPostCreatedEvent(4, { proposalCreator: alice.id, proposal: 'bar' }), + // Alice should be notified as she is replied to. + proposalDiscussionPostCreatedEvent(5, { repliesTo: alice.id, proposal: 'bar' }), + // Alice should be notified as she wrote a post in the discussion. + proposalDiscussionPostCreatedEvent(6, { posts: [{ author: alice.id }], proposal: 'bar' }), + ], + }) + .mockReturnValue({ + events: [], + proposalDiscussionPostByUniqueInput: { + author: { handle: 'proposal:title' }, + discussionThread: { + proposal: { id: 'proposal:id', title: 'proposal:title' }, + }, + }, + }) + + // ------------------- + // Run + // ------------------- + + await run() + + // ------------------- + // Check notifications + // ------------------- + + const notifications = await prisma.notification.findMany() + + // Post 1 is not in the proposal foo or bar + expect(notifications).toContainEqual( + expect.objectContaining({ + eventId: 'event:1', + memberId: bob.id, + kind: 'PROPOSAL_DISCUSSION_ALL', + entityId: 'post:1', + }) + ) + + // Post 2 is the proposal watched by Alice + expect(notifications).toContainEqual( + expect.objectContaining({ + eventId: 'event:2', + memberId: alice.id, + kind: 'PROPOSAL_ENTITY_DISCUSSION', + entityId: 'post:2', + }) + ) + + // Post 3 mentions Alice + expect(notifications).toContainEqual( + expect.objectContaining({ + eventId: 'event:3', + memberId: alice.id, + kind: 'PROPOSAL_DISCUSSION_MENTION', + entityId: 'post:3', + }) + ) + + // Post 4 is on a proposal created by Alice + expect(notifications).toContainEqual( + expect.objectContaining({ + eventId: 'event:4', + memberId: alice.id, + kind: 'PROPOSAL_DISCUSSION_CREATOR', + entityId: 'post:4', + }) + ) + + // Post 5 replies to Alice + expect(notifications).toContainEqual( + expect.objectContaining({ + eventId: 'event:5', + memberId: alice.id, + kind: 'PROPOSAL_DISCUSSION_REPLY', + entityId: 'post:5', + }) + ) + + // Post 6 is on a discussion where Alice wrote a post + expect(notifications).toContainEqual( + expect.objectContaining({ + eventId: 'event:6', + memberId: alice.id, + kind: 'PROPOSAL_DISCUSSION_CONTRIBUTOR', + entityId: 'post:6', + }) + ) + + expect(notifications).toHaveLength(6) + + // ------------------- + // Check emails + // ------------------- + + expect(mockEmailProvider.sentEmails).toContainEqual( + expect.objectContaining({ + to: bob.email, + subject: expect.stringContaining('proposal:title'), + html: expect.stringMatching(/\/#\/proposals\/preview\/proposal:id\?post=post:1/s), + }) + ) + + expect(mockEmailProvider.sentEmails).toContainEqual( + expect.objectContaining({ + to: alice.email, + subject: expect.stringContaining('proposal:title'), + html: expect.stringMatching(/\/#\/proposals\/preview\/proposal:id\?post=post:2/s), + }) + ) + + expect(mockEmailProvider.sentEmails).toContainEqual( + expect.objectContaining({ + to: alice.email, + subject: expect.stringContaining('proposal:title'), + html: expect.stringMatching(/\/#\/proposals\/preview\/proposal:id\?post=post:3/s), + }) + ) + + expect(mockEmailProvider.sentEmails).toContainEqual( + expect.objectContaining({ + to: alice.email, + subject: expect.stringContaining('proposal:title'), + html: expect.stringMatching(/\/#\/proposals\/preview\/proposal:id\?post=post:4/s), + }) + ) + + expect(mockEmailProvider.sentEmails).toContainEqual( + expect.objectContaining({ + to: alice.email, + subject: expect.stringContaining('proposal:title'), + html: expect.stringMatching(/\/#\/proposals\/preview\/proposal:id\?post=post:5/s), + }) + ) + + expect(mockEmailProvider.sentEmails).toContainEqual( + expect.objectContaining({ + to: alice.email, + subject: expect.stringContaining('proposal:title'), + html: expect.stringMatching(/\/#\/proposals\/preview\/proposal:id\?post=post:6/s), + }) + ) + }) + + it('Role notifications', async () => { + // ------------------- + // Initialize database + // ------------------- + + const alice = await createMember(1, 'alice') + const bob = await createMember(2, 'bob') + + // ------------------- + // Mock QN responses + // ------------------- + + mockRequest + .mockReturnValueOnce({ + workers: [{ groupId: 'forumWorkingGroup', isLead: true, membershipId: alice.id.toString() }], + electedCouncils: [{ councilMembers: [{ memberId: bob.id.toString() }] }], + }) + .mockReturnValueOnce({ + events: [ + proposalDiscussionPostCreatedEvent(1, { + text: 'Hello [@Forum Lead](#mention?role=lead_forumWorkingGroup)', + }), + proposalDiscussionPostCreatedEvent(2, { text: 'Hello [@Council](#mention?role=council)' }), + proposalDiscussionPostCreatedEvent(3, { author: alice.id, text: 'Hello [@Dao](#mention?role=dao)' }), + ], + }) + .mockReturnValue({ + events: [], + proposalDiscussionPostByUniqueInput: { + author: { handle: 'proposal:title' }, + discussionThread: { + proposal: { id: 'proposal:id', title: 'proposal:title' }, + }, + }, + }) + + // ------------------- + // Run + // ------------------- + + await run() + + // ------------------- + // Check notifications + // ------------------- + + const notifications = await prisma.notification.findMany() + + // Post 1 notify forum lead + expect(notifications).toContainEqual(expect.objectContaining({ entityId: 'post:1', memberId: alice.id })) + + // Post 2 notify councilors + expect(notifications).toContainEqual(expect.objectContaining({ entityId: 'post:2', memberId: bob.id })) + + // Post 3 notify DAO (except for Alice who posted the thread) + expect(notifications).toContainEqual(expect.objectContaining({ entityId: 'post:3', memberId: bob.id })) + + expect(notifications).toHaveLength(3) + }) + }) + }) + describe('retries', () => { it('should retry failed notifications', async () => { // ------------------- diff --git a/packages/ui/src/common/api/schemas/backendSchema.graphql b/packages/ui/src/common/api/schemas/backendSchema.graphql index 26c7b09788..06e8ad9b1d 100644 --- a/packages/ui/src/common/api/schemas/backendSchema.graphql +++ b/packages/ui/src/common/api/schemas/backendSchema.graphql @@ -13,6 +13,7 @@ enum EntitySubscriptionKind { FORUM_THREAD_ENTITY_POST FORUM_CATEGORY_ENTITY_POST FORUM_CATEGORY_ENTITY_THREAD + PROPOSAL_ENTITY_DISCUSSION } enum EntitySubscriptionStatus { @@ -39,6 +40,11 @@ enum GeneralSubscriptionKind { ELECTION_ANNOUNCING_STARTED ELECTION_VOTING_STARTED ELECTION_REVEALING_STARTED + PROPOSAL_DISCUSSION_MENTION + PROPOSAL_DISCUSSION_REPLY + PROPOSAL_DISCUSSION_CREATOR + PROPOSAL_DISCUSSION_CONTRIBUTOR + PROPOSAL_DISCUSSION_ALL } type GeneralSubscription { @@ -62,6 +68,11 @@ enum NotificationKind { FORUM_THREAD_ENTITY_POST FORUM_CATEGORY_ENTITY_POST FORUM_CATEGORY_ENTITY_THREAD + PROPOSAL_DISCUSSION_MENTION + PROPOSAL_DISCUSSION_REPLY + PROPOSAL_DISCUSSION_CREATOR + PROPOSAL_DISCUSSION_CONTRIBUTOR + PROPOSAL_DISCUSSION_ALL } enum NotificationEmailStatus {