From 0440920d63f6e9f9ffd6d685f166c75d114eb7f5 Mon Sep 17 00:00:00 2001 From: Hugo Lavernhe Date: Thu, 25 Apr 2024 13:47:08 +0200 Subject: [PATCH 1/5] Format JOY ticker (#4832) --- packages/ui/src/memberships/components/ProfileComponent.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ui/src/memberships/components/ProfileComponent.tsx b/packages/ui/src/memberships/components/ProfileComponent.tsx index 402e33fd39..19fe899c6b 100644 --- a/packages/ui/src/memberships/components/ProfileComponent.tsx +++ b/packages/ui/src/memberships/components/ProfileComponent.tsx @@ -42,7 +42,7 @@ export function ProfileComponent() { - Buy Joy tokens + Buy JOY tokens From 167a71ca0521f7a9a586f9d3e79f8aaabb3101e9 Mon Sep 17 00:00:00 2001 From: Theophile Sandoz Date: Wed, 1 May 2024 16:30:30 +0200 Subject: [PATCH 2/5] =?UTF-8?q?=F0=9F=A7=B9=20Fix=20some=20React=20dom=20v?= =?UTF-8?q?alidation=20warnings=20(#4845)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix some React dom validation warnings --- .../accounts/modals/TransferModal/TransferSignModal.tsx | 2 +- .../src/common/components/Stepper/HorizontalStepper.tsx | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/ui/src/accounts/modals/TransferModal/TransferSignModal.tsx b/packages/ui/src/accounts/modals/TransferModal/TransferSignModal.tsx index 6ba17ba24b..540e2a1aac 100644 --- a/packages/ui/src/accounts/modals/TransferModal/TransferSignModal.tsx +++ b/packages/ui/src/accounts/modals/TransferModal/TransferSignModal.tsx @@ -65,7 +65,7 @@ export function TransferSignModal({ onClose, from, amount, to, service, transact - + You are transferring stake from “{from.name}” account to “{to.name}”{' '} destination. diff --git a/packages/ui/src/common/components/Stepper/HorizontalStepper.tsx b/packages/ui/src/common/components/Stepper/HorizontalStepper.tsx index b73c4055f7..03bc5122a2 100644 --- a/packages/ui/src/common/components/Stepper/HorizontalStepper.tsx +++ b/packages/ui/src/common/components/Stepper/HorizontalStepper.tsx @@ -1,4 +1,4 @@ -import React from 'react' +import React, { Fragment } from 'react' import styled, { css } from 'styled-components' import { asStepsToRender, StepperStep, StepToRender } from '@/common/components/Stepper/types' @@ -16,15 +16,15 @@ export const HorizontalStepper = ({ steps }: HorizontalStepperProps) => { return ( {stepsToRender.map((step, index) => ( - <> - + + {step.isPast ? : index + 1} {step.title} {index < stepsToRender.length - 1 && } - + ))} ) From 671a31114b3dc7463f3c6a046d3b2c6627dc29ed Mon Sep 17 00:00:00 2001 From: Theophile Sandoz Date: Thu, 2 May 2024 11:28:48 +0200 Subject: [PATCH 3/5] =?UTF-8?q?=F0=9F=94=A7=20Change=20the=20election=20an?= =?UTF-8?q?nouncing=20period=20notification=20copy=20(#4847)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Change the election announcing period notification copy --- packages/server/src/notifier/model/email/election.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/server/src/notifier/model/email/election.ts b/packages/server/src/notifier/model/email/election.ts index 524d4a222c..6d0f4e14e3 100644 --- a/packages/server/src/notifier/model/email/election.ts +++ b/packages/server/src/notifier/model/email/election.ts @@ -13,7 +13,7 @@ export const fromElectionAnnouncingStartedNotification: EmailFromNotificationFn html: renderPioneerEmail({ memberHandle: member.name, summary: 'New election started.', - text: 'New Joystream council has just been elected and announcing period for the next election has started. Follow the link below to announce your candidacy.', + text: 'New election announcing period has just started. Follow the link below to announce your candidacy.', button: { label: 'See on Pioneer', href: `${PIONEER_URL}/#/election`, From b662b3e37f1cf143c5005ac28751c30bb0b1db49 Mon Sep 17 00:00:00 2001 From: Theophile Sandoz Date: Tue, 28 May 2024 16:39:01 +0200 Subject: [PATCH 4/5] =?UTF-8?q?=F0=9F=93=A8=20Notify=20users=20of=20propos?= =?UTF-8?q?al=20discussions=20(#4849)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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) --- .../migration.sql | 14 + packages/server/prisma/schema.prisma | 10 +- .../server/src/notifier/model/email/index.ts | 2 + .../src/notifier/model/email/proposal.ts | 68 +++++ .../notifier/model/email/utils/proposal.ts | 28 ++ .../server/src/notifier/model/event/index.ts | 2 + .../src/notifier/model/event/proposal.ts | 33 +++ .../src/notifier/model/event/utils/index.ts | 2 +- .../src/notifier/model/subscriptionKinds.ts | 21 +- .../queries/entities/proposal.graphql | 13 + .../src/notifier/queries/events.graphql | 53 ++-- .../test/_mocks/notifier/events/index.ts | 1 + .../test/_mocks/notifier/events/proposal.ts | 45 ++++ packages/server/test/notifier.test.ts | 246 +++++++++++++++++- .../common/api/schemas/backendSchema.graphql | 11 + 15 files changed, 510 insertions(+), 39 deletions(-) create mode 100644 packages/server/prisma/migrations/20240517130826_proposal_discusions/migration.sql create mode 100644 packages/server/src/notifier/model/email/proposal.ts create mode 100644 packages/server/src/notifier/model/email/utils/proposal.ts create mode 100644 packages/server/src/notifier/model/event/proposal.ts create mode 100644 packages/server/src/notifier/queries/entities/proposal.graphql create mode 100644 packages/server/test/_mocks/notifier/events/proposal.ts 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 { From 520b999199a937bb49341dc1ee25fb66dddf66d6 Mon Sep 17 00:00:00 2001 From: Theophile Sandoz Date: Tue, 28 May 2024 17:17:33 +0200 Subject: [PATCH 5/5] Bump version to `3.6.0` --- CHANGELOG.md | 13 +++++++++++-- packages/server/package.json | 2 +- packages/ui/package.json | 2 +- 3 files changed, 13 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e858d0a9ab..4d969bef25 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [3.6.0] - 2024-05-28 + +### Added +- Notification support for proposal discussions. + +### Fixed +- Copy fixes. + ## [3.5.2] - 2024-05-01 ### Fixed @@ -389,8 +397,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [0.1.1] - 2022-12-02 -[unreleased]: https://github.com/Joystream/pioneer/compare/v3.5.2...HEAD -[3.5.1]: https://github.com/Joystream/pioneer/compare/v3.5.1...v3.5.2 +[unreleased]: https://github.com/Joystream/pioneer/compare/v3.6.0...HEAD +[3.6.0]: https://github.com/Joystream/pioneer/compare/v3.5.2...v3.6.0 +[3.5.2]: https://github.com/Joystream/pioneer/compare/v3.5.1...v3.5.2 [3.5.1]: https://github.com/Joystream/pioneer/compare/v3.5.0...v3.5.1 [3.5.0]: https://github.com/Joystream/pioneer/compare/v3.4.0...v3.5.0 [3.4.0]: https://github.com/Joystream/pioneer/compare/v3.3.1...v3.4.0 diff --git a/packages/server/package.json b/packages/server/package.json index 7833ef9519..4923e1503e 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -1,6 +1,6 @@ { "name": "server", - "version": "3.2.0", + "version": "3.6.0", "license": "GPL-3.0-only", "scripts": { "prisma": "prisma", diff --git a/packages/ui/package.json b/packages/ui/package.json index f290e3d33a..cb208f25b1 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@joystream/pioneer", - "version": "3.5.2", + "version": "3.6.0", "license": "GPL-3.0-only", "scripts": { "build": "node --max_old_space_size=4096 ./build.js",