diff --git a/packages/server/prisma/migrations/20230903121816_election_events/migration.sql b/packages/server/prisma/migrations/20230903121816_election_events/migration.sql new file mode 100644 index 0000000000..856a091fc8 --- /dev/null +++ b/packages/server/prisma/migrations/20230903121816_election_events/migration.sql @@ -0,0 +1,11 @@ +-- 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 'ELECTION_ANNOUNCING_STARTED'; +ALTER TYPE "NotificationKind" ADD VALUE 'ELECTION_VOTING_STARTED'; +ALTER TYPE "NotificationKind" ADD VALUE 'ELECTION_REVEALING_STARTED'; diff --git a/packages/server/prisma/schema.prisma b/packages/server/prisma/schema.prisma index d5bf51e941..6abb81f959 100644 --- a/packages/server/prisma/schema.prisma +++ b/packages/server/prisma/schema.prisma @@ -89,10 +89,10 @@ enum NotificationKind { // PROPOSAL_DISCUSSION_CONTRIBUTOR // Referendum - // ELECTION_ANNOUNCING_STARTED - // ELECTION_VOTING_STARTED - // ELECTION_REVEALING_STARTED - // ELECTION_COUNCIL_ELECTED + ELECTION_ANNOUNCING_STARTED + ELECTION_VOTING_STARTED + ELECTION_REVEALING_STARTED + // ------------------ // Entity specific diff --git a/packages/server/src/notifier/model/email/election.ts b/packages/server/src/notifier/model/email/election.ts new file mode 100644 index 0000000000..524d4a222c --- /dev/null +++ b/packages/server/src/notifier/model/email/election.ts @@ -0,0 +1,64 @@ +import { PIONEER_URL } from '@/common/config' +import { renderPioneerEmail } from '@/common/email-templates/pioneer-email' + +import { EmailFromNotificationFn } from './utils' + +export const fromElectionAnnouncingStartedNotification: EmailFromNotificationFn = async ({ kind, member }) => { + if (kind !== 'ELECTION_ANNOUNCING_STARTED') { + return + } + + return { + subject: '[Pioneer] New election started', + 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.', + button: { + label: 'See on Pioneer', + href: `${PIONEER_URL}/#/election`, + }, + }), + to: member.email, + } +} + +export const fromElectionVotingStartedNotification: EmailFromNotificationFn = async ({ kind, member }) => { + if (kind !== 'ELECTION_VOTING_STARTED') { + return + } + + return { + subject: '[Pioneer] Election voting started', + html: renderPioneerEmail({ + memberHandle: member.name, + summary: 'Election voting started.', + text: 'Election voting period has just started. Follow the link below to cast your votes.', + button: { + label: 'See on Pioneer', + href: `${PIONEER_URL}/#/election`, + }, + }), + to: member.email, + } +} + +export const fromElectionRevealingStartedNotification: EmailFromNotificationFn = async ({ kind, member }) => { + if (kind !== 'ELECTION_REVEALING_STARTED') { + return + } + + return { + subject: '[Pioneer] Election revealing period started', + html: renderPioneerEmail({ + memberHandle: member.name, + summary: 'Election revealing started.', + text: 'Election revealing period has just started. Follow the link below to reveal your votes.', + button: { + label: 'See on Pioneer', + href: `${PIONEER_URL}/#/election`, + }, + }), + to: member.email, + } +} diff --git a/packages/server/src/notifier/model/email/index.ts b/packages/server/src/notifier/model/email/index.ts index 08576bbe5d..52df2d3fd3 100644 --- a/packages/server/src/notifier/model/email/index.ts +++ b/packages/server/src/notifier/model/email/index.ts @@ -3,6 +3,11 @@ import { verbose, error } from 'npmlog' import { Email, EmailProvider } from '@/common/utils/email' +import { + fromElectionAnnouncingStartedNotification, + fromElectionRevealingStartedNotification, + fromElectionVotingStartedNotification, +} from './election' import { fromPostAddedNotification, fromThreadCreatedNotification } from './forum' import { Notification, hasEmailAddress } from './utils' @@ -17,7 +22,13 @@ export const createEmailNotifier = pick(notification, 'id', 'eventId', 'kind', 'entityId') ) - const emailHandlers = [fromPostAddedNotification, fromThreadCreatedNotification] + const emailHandlers = [ + fromPostAddedNotification, + fromThreadCreatedNotification, + fromElectionAnnouncingStartedNotification, + fromElectionVotingStartedNotification, + fromElectionRevealingStartedNotification, + ] const emailPromises = emailHandlers.map((handler) => handler(notification)) const emailResults = await Promise.all(emailPromises) diff --git a/packages/server/src/notifier/model/event/election.ts b/packages/server/src/notifier/model/event/election.ts new file mode 100644 index 0000000000..8152edf04b --- /dev/null +++ b/packages/server/src/notifier/model/event/election.ts @@ -0,0 +1,39 @@ +import { pick } from 'lodash' + +import { + ElectionAnnouncingStartedEventFieldsFragmentDoc, + ElectionRevealingStartedFieldsFragmentDoc, + ElectionVotingStartedEventFieldsFragmentDoc, + useFragment, +} from '@/common/queries' + +import { NotifEventFromQNEvent } from './utils' + +export const fromElectionAnnouncingStartedEvent: NotifEventFromQNEvent<'AnnouncingPeriodStartedEvent'> = async ( + event, + buildEvents +) => { + const announcingPeriodStartedEvent = useFragment(ElectionAnnouncingStartedEventFieldsFragmentDoc, event) + const eventData = pick(announcingPeriodStartedEvent, 'inBlock', 'id') + return buildEvents(eventData, eventData.id, ({ generalEvent }) => [ + generalEvent('ELECTION_ANNOUNCING_STARTED', 'ANY'), + ]) +} + +export const fromElectionVotingStartedEvent: NotifEventFromQNEvent<'VotingPeriodStartedEvent'> = async ( + event, + buildEvents +) => { + const votingPeriodStartedEvent = useFragment(ElectionVotingStartedEventFieldsFragmentDoc, event) + const eventData = pick(votingPeriodStartedEvent, 'inBlock', 'id') + return buildEvents(eventData, eventData.id, ({ generalEvent }) => [generalEvent('ELECTION_VOTING_STARTED', 'ANY')]) +} + +export const fromElectionRevealingStartedEvent: NotifEventFromQNEvent<'RevealingStageStartedEvent'> = async ( + event, + buildEvents +) => { + const revealingPeriodStartedEvent = useFragment(ElectionRevealingStartedFieldsFragmentDoc, event) + const eventData = pick(revealingPeriodStartedEvent, 'inBlock', 'id') + return buildEvents(eventData, eventData.id, ({ generalEvent }) => [generalEvent('ELECTION_REVEALING_STARTED', 'ANY')]) +} diff --git a/packages/server/src/notifier/model/event/index.ts b/packages/server/src/notifier/model/event/index.ts index 71f382f810..a2658d564d 100644 --- a/packages/server/src/notifier/model/event/index.ts +++ b/packages/server/src/notifier/model/event/index.ts @@ -1,5 +1,12 @@ +import { match } from 'ts-pattern' + import { GetNotificationEventsQuery } from '@/common/queries' +import { + fromElectionAnnouncingStartedEvent, + fromElectionRevealingStartedEvent, + fromElectionVotingStartedEvent, +} from './election' import { fromPostAddedEvent, fromThreadCreatedEvent } from './forum' import { NotificationEvent } from './utils' import { buildEvents } from './utils/buildEvent' @@ -18,11 +25,13 @@ export const toNotificationEvents = const event = anyEvent as ImplementedQNEvent const build = buildEvents(allMemberIds, event) - switch (event.__typename) { - case 'PostAddedEvent': - return fromPostAddedEvent(event, build) + const notifEvent = match(event) + .with({ __typename: 'PostAddedEvent' }, (e) => fromPostAddedEvent(e, build)) + .with({ __typename: 'ThreadCreatedEvent' }, (e) => fromThreadCreatedEvent(e, build)) + .with({ __typename: 'AnnouncingPeriodStartedEvent' }, (e) => fromElectionAnnouncingStartedEvent(e, build)) + .with({ __typename: 'VotingPeriodStartedEvent' }, (e) => fromElectionVotingStartedEvent(e, build)) + .with({ __typename: 'RevealingStageStartedEvent' }, (e) => fromElectionRevealingStartedEvent(e, build)) + .exhaustive() - case 'ThreadCreatedEvent': - return fromThreadCreatedEvent(event, build) - } + return notifEvent } diff --git a/packages/server/src/notifier/model/event/utils/types.ts b/packages/server/src/notifier/model/event/utils/types.ts index e29592755a..8377f684ce 100644 --- a/packages/server/src/notifier/model/event/utils/types.ts +++ b/packages/server/src/notifier/model/event/utils/types.ts @@ -39,4 +39,4 @@ export type BuildEvents = ( export type NotifEventFromQNEvent = ( event: QNEvent, buildEvents: BuildEvents -) => NotificationEvent | Promise +) => Promise diff --git a/packages/server/src/notifier/model/subscriptionKinds.ts b/packages/server/src/notifier/model/subscriptionKinds.ts index 2cd8ba03e4..bdd46fe8f4 100644 --- a/packages/server/src/notifier/model/subscriptionKinds.ts +++ b/packages/server/src/notifier/model/subscriptionKinds.ts @@ -25,7 +25,7 @@ export const GeneralSubscriptionKind = extract( 'FORUM_THREAD_CONTRIBUTOR', 'FORUM_THREAD_ALL', - 'FORUM_THREAD_MENTION' + 'FORUM_THREAD_MENTION', // 'PROPOSAL_CREATED_ALL', // 'PROPOSAL_STATUS_ALL', @@ -36,10 +36,9 @@ export const GeneralSubscriptionKind = extract( // 'PROPOSAL_DISCUSSION_CREATOR', // 'PROPOSAL_DISCUSSION_CONTRIBUTOR', - // 'ELECTION_ANNOUNCING_STARTED', - // 'ELECTION_VOTING_STARTED', - // 'ELECTION_REVEALING_STARTED', - // 'ELECTION_COUNCIL_ELECTED' + 'ELECTION_ANNOUNCING_STARTED', + 'ELECTION_VOTING_STARTED', + 'ELECTION_REVEALING_STARTED' ) export const isDefaultSubscription = (type: GeneralSubscriptionKind): boolean => defaultSubscriptions.includes(type) @@ -55,8 +54,7 @@ const defaultSubscriptions: GeneralSubscriptionKind[] = [ // 'PROPOSAL_DISCUSSION_MENTION', // 'PROPOSAL_DISCUSSION_CREATOR', // 'PROPOSAL_DISCUSSION_CONTRIBUTOR', - // 'ELECTION_ANNOUNCING_STARTED', - // 'ELECTION_VOTING_STARTED', - // 'ELECTION_REVEALING_STARTED', - // 'ELECTION_COUNCIL_ELECTED', + 'ELECTION_ANNOUNCING_STARTED', + 'ELECTION_VOTING_STARTED', + 'ELECTION_REVEALING_STARTED', ] diff --git a/packages/server/src/notifier/queries/events.graphql b/packages/server/src/notifier/queries/events.graphql index ba873f6ea2..90d16cbff9 100644 --- a/packages/server/src/notifier/queries/events.graphql +++ b/packages/server/src/notifier/queries/events.graphql @@ -32,6 +32,24 @@ fragment ThreadCreatedEventFields on ThreadCreatedEvent { text } +fragment ElectionAnnouncingStartedEventFields on AnnouncingPeriodStartedEvent { + __typename + id + inBlock +} + +fragment ElectionVotingStartedEventFields on VotingPeriodStartedEvent { + __typename + id + inBlock +} + +fragment ElectionRevealingStartedFields on RevealingStageStartedEvent { + __typename + id + inBlock +} + # fragment ProposalDiscussionPostCreatedEventFields on ProposalDiscussionPostCreatedEvent { # __typename # id @@ -60,6 +78,9 @@ query GetNotificationEvents($from: Int, $exclude: [ID!]) { type_in: [ PostAddedEvent ThreadCreatedEvent + AnnouncingPeriodStartedEvent + VotingPeriodStartedEvent + RevealingStageStartedEvent # PostTextUpdatedEvent ] inBlock_gte: $from @@ -73,6 +94,15 @@ query GetNotificationEvents($from: Int, $exclude: [ID!]) { ... on ThreadCreatedEvent { ...ThreadCreatedEventFields } + ... on AnnouncingPeriodStartedEvent { + ...ElectionAnnouncingStartedEventFields + } + ... on VotingPeriodStartedEvent { + ...ElectionVotingStartedEventFields + } + ... on RevealingStageStartedEvent { + ...ElectionRevealingStartedFields + } # ... on ProposalDiscussionPostCreatedEvent { # ...ProposalDiscussionPostCreatedEventFields # } diff --git a/packages/server/test/_mocks/notifier/events/election.ts b/packages/server/test/_mocks/notifier/events/election.ts new file mode 100644 index 0000000000..f767cb2dc3 --- /dev/null +++ b/packages/server/test/_mocks/notifier/events/election.ts @@ -0,0 +1,35 @@ +import { maskFragment } from '@test/_mocks/utils' + +import { + ElectionAnnouncingStartedEventFieldsFragment, + ElectionRevealingStartedFieldsFragment, + ElectionVotingStartedEventFieldsFragment, + GetNotificationEventsQuery, +} from '@/common/queries' + +export const electionAnnouncingEvent = (id: string): GetNotificationEventsQuery['events'][0] => + maskFragment( + 'ElectionAnnouncingStartedEventFields', + 'AnnouncingPeriodStartedEvent' + )({ + id, + inBlock: 1, + }) + +export const electionVotingEvent = (id: string): GetNotificationEventsQuery['events'][0] => + maskFragment( + 'ElectionVotingStartedEventFields', + 'VotingPeriodStartedEvent' + )({ + id, + inBlock: 1, + }) + +export const electionRevealingEvent = (id: string): GetNotificationEventsQuery['events'][0] => + maskFragment( + 'ElectionRevealingStartedFields', + 'RevealingStageStartedEvent' + )({ + id, + inBlock: 1, + }) diff --git a/packages/server/test/notifier.test.ts b/packages/server/test/notifier.test.ts index df86418ba4..ce0f12464b 100644 --- a/packages/server/test/notifier.test.ts +++ b/packages/server/test/notifier.test.ts @@ -4,6 +4,7 @@ import { run } from '@/notifier' import { createMember } from './_mocks/notifier/createMember' import { postAddedEvent, threadCreatedEvent } from './_mocks/notifier/events' +import { electionAnnouncingEvent, electionRevealingEvent, electionVotingEvent } from './_mocks/notifier/events/election' import { clearDb, mockRequest, mockSendEmail } from './setup' describe('Notifier', () => { @@ -279,4 +280,193 @@ describe('Notifier', () => { expect(mockSendEmail).toHaveBeenCalledTimes(3) }) }) + + describe('election', () => { + it('ElectionAnnouncingStartedEvent', async () => { + // ------------------- + // Initialize database + // ------------------- + + // - Alice is using the default behavior for general subscriptions + // - Alice should be notified of any election changes + const alice = await createMember(1, 'alice') + + // - Bob should not be notified of any election changes + await createMember(2, 'bob', [{ kind: 'ELECTION_ANNOUNCING_STARTED', shouldNotify: false }]) + + // ------------------- + // Mock QN responses + // ------------------- + + const announcingId = 'announcing:id' + + mockRequest + .mockReturnValueOnce({ + events: [electionAnnouncingEvent(announcingId)], + }) + .mockReturnValue({ + events: [], + }) + + // ------------------- + // Run + // ------------------- + + await run() + + // ------------------- + // Check notifications + // ------------------- + + const notifications = await prisma.notification.findMany() + + expect(notifications).toContainEqual( + expect.objectContaining({ + eventId: announcingId, + memberId: alice.id, + kind: 'ELECTION_ANNOUNCING_STARTED', + isRead: false, + isSent: true, + }) + ) + expect(notifications).toHaveLength(1) + + // ------------------- + // Check emails + // ------------------- + + expect(mockSendEmail).toHaveBeenCalledWith( + expect.objectContaining({ + to: alice.email, + subject: expect.stringContaining('election started'), + html: expect.stringMatching(/\/#\/election/s), + }) + ) + expect(mockSendEmail).toHaveBeenCalledTimes(1) + }) + + it('ElectionVotingStartedEvent', async () => { + // ------------------- + // Initialize database + // ------------------- + + // - Alice is using the default behavior for general subscriptions + // - Alice should be notified of any election changes + const alice = await createMember(1, 'alice') + + // - Bob should not be notified of any election changes + await createMember(2, 'bob', [{ kind: 'ELECTION_VOTING_STARTED', shouldNotify: false }]) + + // ------------------- + // Mock QN responses + // ------------------- + + const votingId = 'voting:id' + + mockRequest + .mockReturnValueOnce({ + events: [electionVotingEvent(votingId)], + }) + .mockReturnValue({ + events: [], + }) + + // ------------------- + // Run + // ------------------- + + await run() + + // ------------------- + // Check notifications + // ------------------- + + const notifications = await prisma.notification.findMany() + + expect(notifications).toContainEqual( + expect.objectContaining({ + eventId: votingId, + memberId: alice.id, + kind: 'ELECTION_VOTING_STARTED', + isSent: true, + }) + ) + expect(notifications).toHaveLength(1) + + // ------------------- + // Check emails + // ------------------- + + expect(mockSendEmail).toHaveBeenCalledWith( + expect.objectContaining({ + to: alice.email, + subject: expect.stringContaining('voting started'), + html: expect.stringMatching(/\/#\/election/s), + }) + ) + expect(mockSendEmail).toHaveBeenCalledTimes(1) + }) + + it('ElectionRevealingStartedEvent', async () => { + // ------------------- + // Initialize database + // ------------------- + + // - Alice is using the default behavior for general subscriptions + // - Alice should be notified of any election changes + const alice = await createMember(1, 'alice') + + // - Bob should not be notified of any election changes + await createMember(2, 'bob', [{ kind: 'ELECTION_REVEALING_STARTED', shouldNotify: false }]) + + // ------------------- + // Mock QN responses + // ------------------- + + const revealingId = 'revealing:id' + + mockRequest + .mockReturnValueOnce({ + events: [electionRevealingEvent(revealingId)], + }) + .mockReturnValue({ + events: [], + }) + + // ------------------- + // Run + // ------------------- + + await run() + + // ------------------- + // Check notifications + // ------------------- + + const notifications = await prisma.notification.findMany() + + expect(notifications).toContainEqual( + expect.objectContaining({ + eventId: revealingId, + memberId: alice.id, + kind: 'ELECTION_REVEALING_STARTED', + isSent: true, + }) + ) + expect(notifications).toHaveLength(1) + + // ------------------- + // Check emails + // ------------------- + + expect(mockSendEmail).toHaveBeenCalledWith( + expect.objectContaining({ + to: alice.email, + subject: expect.stringContaining('revealing period started'), + html: expect.stringMatching(/\/#\/election/s), + }) + ) + expect(mockSendEmail).toHaveBeenCalledTimes(1) + }) + }) })