Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

📢 Add support for role mentions #4789

Merged
merged 7 commits into from
Mar 7, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions packages/markdown-editor/dist/ckeditor.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion packages/markdown-editor/dist/ckeditor.js.map

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions packages/markdown-editor/src/MarkdownEditor.js
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,9 @@ function prepareLink({ type, itemId, addon }) {
case 'member': {
return `#mention?member-id=${itemId}`
}
case 'role': {
return `#mention?role=${itemId}`
}
case 'proposal': {
return `#mention?proposal-id=${itemId}`
}
Expand Down
48 changes: 28 additions & 20 deletions packages/server/src/notifier/createNotifications.ts
Original file line number Diff line number Diff line change
@@ -1,33 +1,30 @@
import { Prisma } from '@prisma/client'
import { Prisma, Subscription } from '@prisma/client'
import { request } from 'graphql-request'
import { clone, groupBy, isEqual, isObject, mapValues } from 'lodash'
import { info, verbose, warn } from 'npmlog'

import { QUERY_NODE_ENDPOINT, STARTING_BLOCK } from '@/common/config'
import { prisma } from '@/common/prisma'
import { GetNotificationEventsDocument } from '@/common/queries'
import { GetCurrentRolesDocument, GetNotificationEventsDocument } from '@/common/queries'
import { count, getTypename } from '@/common/utils'

import { toNotificationEvents } from './model/event'
import { notificationsFromEvent } from './model/notifications'
import { filterSubscriptions } from './model/subscriptionFilters'

interface ProgressDoc {
block: number
eventIds: string[]
}
const isProgressDoc = (consumed: any): consumed is ProgressDoc => isObject(consumed)
const defaultProgress: ProgressDoc = { block: STARTING_BLOCK, eventIds: [] }
const PROGRESS_KEY = { key: 'Progress' }
import { Notification, NotificationEvent, PotentialNotif, ProgressDocument } from './types'

export const createNotifications = async (): Promise<void> => {
// Check the last block that where processed
const { value: initialProgress } = (await prisma.store.findUnique({ where: PROGRESS_KEY })) ?? {}
const progress: ProgressDoc =
isProgressDoc(initialProgress) && initialProgress.block > STARTING_BLOCK ? clone(initialProgress) : defaultProgress
const progress: ProgressDocument =
isProgressDocument(initialProgress) && initialProgress.block > STARTING_BLOCK
? clone(initialProgress)
: defaultProgress

const allMembers = (await prisma.member.findMany()).map(({ id, receiveEmails }) => ({ id, receiveEmails }))

const qnRoles = await request(QUERY_NODE_ENDPOINT, GetCurrentRolesDocument)

/* eslint-disable-next-line no-constant-condition */
while (true) {
// Save the current process
Expand All @@ -39,33 +36,40 @@ export const createNotifications = async (): Promise<void> => {

// Fetch events from the query nodes and break if non are found
const qnVariables = { from: progress.block, exclude: progress.eventIds }
const qnData = await request(QUERY_NODE_ENDPOINT, GetNotificationEventsDocument, qnVariables)
const qnEvents = await request(QUERY_NODE_ENDPOINT, GetNotificationEventsDocument, qnVariables)
info(
'QN events',
`Received ${qnData.events.length} new events`,
mapValues(groupBy(qnData.events, getTypename), count),
`Received ${qnEvents.events.length} new events`,
mapValues(groupBy(qnEvents.events, getTypename), count),
`from block ${progress.block} onward excluding`,
progress.eventIds
)

if (qnData.events.length === 0) break
if (qnEvents.events.length === 0) break

// Generate the potential notification based on the query nodes data
const events = await Promise.all(qnData.events.map(toNotificationEvents(allMembers.map(({ id }) => id))))
const events: NotificationEvent[] = await Promise.all(
qnEvents.events.map(
toNotificationEvents(
allMembers.map(({ id }) => id),
qnRoles
)
)
)

// Update the progress
progress.block = Math.max(progress.block, ...events.map((event) => event.inBlock))
progress.eventIds = events.flatMap((event) => (event.inBlock === progress.block ? event.id : []))

const potentialNotifs = events.flatMap((event) => event.potentialNotifications)
const potentialNotifs: PotentialNotif[] = events.flatMap((event) => event.potentialNotifications)
if (potentialNotifs.length === 0) continue

// Fetch subscription related to the events
const subscriptionFilter = { OR: filterSubscriptions(potentialNotifs) }
const subscriptions = await prisma.subscription.findMany({ where: subscriptionFilter })
const subscriptions: Subscription[] = await prisma.subscription.findMany({ where: subscriptionFilter })

// Create and save new notifications
const notifications = events.flatMap(notificationsFromEvent(subscriptions, allMembers))
const notifications: Notification[] = events.flatMap(notificationsFromEvent(subscriptions, allMembers))
info('New notifications', 'Saving', notifications.length, 'new notifications')
verbose(
'New notifications',
Expand All @@ -79,3 +83,7 @@ export const createNotifications = async (): Promise<void> => {
}
}
}

const isProgressDocument = (consumed: any): consumed is ProgressDocument => isObject(consumed)
const defaultProgress: ProgressDocument = { block: STARTING_BLOCK, eventIds: [] }
const PROGRESS_KEY = { key: 'Progress' }
4 changes: 0 additions & 4 deletions packages/server/src/notifier/model/email/utils/forum.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ interface ForumPost {
author: string
threadId: string
thread: string
text: string
}
const cachedForumPosts: { [id: string]: ForumPost } = {}
export const getForumPost = async (id: string): Promise<ForumPost> => {
Expand All @@ -21,7 +20,6 @@ export const getForumPost = async (id: string): Promise<ForumPost> => {
author: post.author.handle,
threadId: post.thread.id,
thread: post.thread.title,
text: post.text,
}
}

Expand All @@ -31,7 +29,6 @@ export const getForumPost = async (id: string): Promise<ForumPost> => {
interface ForumThread {
author: string
title: string
text?: string
}
const cachedForumThreads: { [id: string]: ForumThread } = {}
export const getForumThread = async (id: string): Promise<ForumThread> => {
Expand All @@ -44,7 +41,6 @@ export const getForumThread = async (id: string): Promise<ForumThread> => {
cachedForumThreads[id] = {
author: thread.author.handle,
title: thread.title,
text: thread.initialPost?.text,
}
}

Expand Down
23 changes: 18 additions & 5 deletions packages/server/src/notifier/model/event/forum.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,23 @@
import { pick, uniq } from 'lodash'

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

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

export const fromPostAddedEvent: NotifEventFromQNEvent<'PostAddedEvent'> = async (event, buildEvents) => {
export const fromPostAddedEvent: NotifEventFromQNEvent<'PostAddedEvent', [GetCurrentRolesQuery]> = async (
event,
buildEvents,
roles
) => {
const postAddedEvent = useFragment(PostAddedEventFieldsFragmentDoc, event)
const post = postAddedEvent.post

const mentionedMemberIds = getMentionedMemberIds(post.text)
const mentionedMemberIds = getMentionedMemberIds(post.text, roles)
const repliedToMemberId = post.repliesTo && [Number(post.repliesTo.authorId)]
const earlierPosts = post.thread.posts.filter(isOlderThan(post))
const earlierAuthors = uniq(earlierPosts.map((post) => Number(post.authorId)))
Expand All @@ -27,11 +36,15 @@ export const fromPostAddedEvent: NotifEventFromQNEvent<'PostAddedEvent'> = async
])
}

export const fromThreadCreatedEvent: NotifEventFromQNEvent<'ThreadCreatedEvent'> = async (event, buildEvents) => {
export const fromThreadCreatedEvent: NotifEventFromQNEvent<'ThreadCreatedEvent', [GetCurrentRolesQuery]> = async (
event,
buildEvents,
roles
) => {
const threadCreatedEvent = useFragment(ThreadCreatedEventFieldsFragmentDoc, event)
const thread = threadCreatedEvent.thread

const mentionedMemberIds = getMentionedMemberIds(threadCreatedEvent.text)
const mentionedMemberIds = getMentionedMemberIds(threadCreatedEvent.text, roles)
const parentCategoryIds = await getParentCategories(thread.categoryId)

const eventData = pick(threadCreatedEvent, 'inBlock', 'id')
Expand Down
12 changes: 6 additions & 6 deletions packages/server/src/notifier/model/event/index.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,23 @@
import { match } from 'ts-pattern'

import { GetNotificationEventsQuery } from '@/common/queries'
import { GetCurrentRolesQuery, GetNotificationEventsQuery } from '@/common/queries'
import { NotificationEvent } from '@/notifier/types'

import {
fromElectionAnnouncingStartedEvent,
fromElectionRevealingStartedEvent,
fromElectionVotingStartedEvent,
} from './election'
import { fromPostAddedEvent, fromThreadCreatedEvent } from './forum'
import { NotificationEvent } from './utils'
import { buildEvents } from './utils/buildEvent'
import { ImplementedQNEvent } from './utils/types'

export { NotificationEvent, PotentialNotif, isGeneralPotentialNotif, isEntityPotentialNotif } from './utils'
export { isGeneralPotentialNotif, isEntityPotentialNotif } from './utils'

type AnyQNEvent = GetNotificationEventsQuery['events'][0]

export const toNotificationEvents =
(allMemberIds: number[]) =>
(allMemberIds: number[], roles: GetCurrentRolesQuery) =>
async (anyEvent: AnyQNEvent): Promise<NotificationEvent> => {
// NOTE: The conversion to ImplementedQNEvent assumes that the QN will only return
// events with fragments defined in the codegen document.
Expand All @@ -26,8 +26,8 @@ export const toNotificationEvents =
const build = buildEvents(allMemberIds, event)

const notifEvent = match(event)
.with({ __typename: 'PostAddedEvent' }, (e) => fromPostAddedEvent(e, build))
.with({ __typename: 'ThreadCreatedEvent' }, (e) => fromThreadCreatedEvent(e, build))
.with({ __typename: 'PostAddedEvent' }, (e) => fromPostAddedEvent(e, build, roles))
.with({ __typename: 'ThreadCreatedEvent' }, (e) => fromThreadCreatedEvent(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
3 changes: 2 additions & 1 deletion packages/server/src/notifier/model/event/utils/buildEvent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@ import { verbose } from 'npmlog'

import { getTypename } from '@/common/utils'
import { isDefaultSubscription } from '@/notifier/model/subscriptionKinds'
import { NotificationEvent, PotentialNotif } from '@/notifier/types'

import { BuildEvents, ImplementedQNEvent, NotificationEvent, NotifsBuilder, PotentialNotif } from './types'
import { BuildEvents, ImplementedQNEvent, NotifsBuilder } from './types'

export const buildEvents =
(allMemberIds: number[], event: ImplementedQNEvent): BuildEvents =>
Expand Down
58 changes: 50 additions & 8 deletions packages/server/src/notifier/model/event/utils/index.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,65 @@
import { uniq } from 'lodash'

import { EntitiyPotentialNotif, GeneralPotentialNotif, PotentialNotif } from './types'

import { GetCurrentRolesQuery } from '@/common/queries'
import { EntityPotentialNotif, GeneralPotentialNotif, PotentialNotif } from '@/notifier/types'
export { getParentCategories } from './getParentCategories'
export { NotifEventFromQNEvent, NotificationEvent, PotentialNotif } from './types'
export { NotifEventFromQNEvent } from './types'

export const isGeneralPotentialNotif = (p: PotentialNotif): p is GeneralPotentialNotif => 'relatedMembers' in p
export const isEntityPotentialNotif = (p: PotentialNotif): p is EntitiyPotentialNotif => 'relatedEntityId' in p
export const isEntityPotentialNotif = (p: PotentialNotif): p is EntityPotentialNotif => 'relatedEntityId' in p

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))

export const getMentionedMemberIds = (text: string): number[] =>
export const getMentionedMemberIds = (text: string, roles: GetCurrentRolesQuery): number[] =>
uniq(
Array.from(text.matchAll(/\[@[-.0-9A-Z\\_a-z]+\]\(#mention\?member-id=(\d+)\)/g)).flatMap((match) => {
const id = match[1] && Number(match[1])
return !id || isNaN(id) ? [] : Number(id)
Array.from(text.matchAll(/\[@[^\]\n]*\]\(#mention\?(member-id|role)=([^)\n]+?)\)/g)).flatMap((match) => {
const type = match[1]
const id = match[2]
if (!type || !id) return []

if (type === 'member-id') {
const memberId = Number(id)
return isNaN(memberId) ? [] : memberId
}

if (type !== 'role') return []

const councilIds = roles.electedCouncils.at(0)?.councilMembers.map((member) => Number(member.memberId)) ?? []
const workers = roles.workers
const leads = workers.filter((worker) => worker.isLead)
const [role, groupId] = id.split('_')

switch (role) {
case 'dao':
return [...councilIds, ...roles.workers.map((worker) => Number(worker.membershipId))]

case 'council':
return councilIds

case 'workers': {
if (!groupId) {
return []
}
return workers.filter((worker) => worker.groupId === groupId).map((worker) => Number(worker.membershipId))
}

case 'leads':
return leads.map((lead) => Number(lead.membershipId))

case 'lead': {
if (!groupId) {
return []
}
const leadId = leads.find((lead) => lead.groupId === groupId)?.membershipId ?? []
return leadId ? [Number(leadId)] : []
}

default:
return []
}
})
)
26 changes: 5 additions & 21 deletions packages/server/src/notifier/model/event/utils/types.ts
Original file line number Diff line number Diff line change
@@ -1,29 +1,12 @@
import { GetNotificationEventsQuery } from '@/common/queries'
import { DocWithFragments } from '@/common/utils/types'
import { EntitySubscriptionKind, GeneralSubscriptionKind } from '@/notifier/model/subscriptionKinds'
import { EntityPotentialNotif, GeneralPotentialNotif, NotificationEvent } from '@/notifier/types'

export type ImplementedQNEvent = DocWithFragments<Required<GetNotificationEventsQuery['events'][0]>>
type QNEvent<T extends ImplementedQNEvent['__typename']> = { __typename: T } & ImplementedQNEvent

type GeneralEventParams = {
kind: GeneralSubscriptionKind
relatedMembers: 'ANY' | { ids: number[] }
isDefault: boolean
}
type EntityEventParams = { kind: EntitySubscriptionKind; relatedEntityId: string }
export type GeneralPotentialNotif = { priority: number } & GeneralEventParams
export type EntitiyPotentialNotif = { priority: number } & EntityEventParams

export type PotentialNotif = GeneralPotentialNotif | EntitiyPotentialNotif

type PartialNotif = Omit<GeneralPotentialNotif, 'priority'> | Omit<EntitiyPotentialNotif, 'priority'>

export interface NotificationEvent {
id: string
inBlock: number
entityId: string
potentialNotifications: PotentialNotif[]
}
type PartialNotif = Omit<GeneralPotentialNotif, 'priority'> | Omit<EntityPotentialNotif, 'priority'>

export interface NotifsBuilder {
generalEvent: (kind: GeneralSubscriptionKind, members: 'ANY' | (number | string)[]) => PartialNotif | []
Expand All @@ -37,7 +20,8 @@ export type BuildEvents = (
build: (b: NotifsBuilder) => (PartialNotif | [])[]
) => NotificationEvent

export type NotifEventFromQNEvent<T extends ImplementedQNEvent['__typename']> = (
export type NotifEventFromQNEvent<T extends ImplementedQNEvent['__typename'], Args extends any[] = []> = (
event: QNEvent<T>,
buildEvents: BuildEvents
buildEvents: BuildEvents,
...args: Args
) => Promise<NotificationEvent>
6 changes: 3 additions & 3 deletions packages/server/src/notifier/model/notifications.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { Prisma, Subscription } from '@prisma/client'
import { Subscription } from '@prisma/client'

import { isGeneralPotentialNotif, NotificationEvent, PotentialNotif } from './event'
import { Notification, NotificationEvent, PotentialNotif } from '@/notifier/types'

type Notification = Prisma.NotificationCreateManyInput
import { isGeneralPotentialNotif } from './event'

interface PotentialNotifByMember {
data: PotentialNotif
Expand Down
3 changes: 2 additions & 1 deletion packages/server/src/notifier/model/subscriptionFilters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@ import { NotificationKind, Prisma } from '@prisma/client'
import { uniq } from 'lodash'

import { Subset } from '@/common/utils/types'
import { PotentialNotif } from '@/notifier/types'

import { isEntityPotentialNotif, PotentialNotif } from './event'
import { isEntityPotentialNotif } from './event'

type Filter = Subset<
Prisma.SubscriptionWhereInput,
Expand Down
4 changes: 0 additions & 4 deletions packages/server/src/notifier/queries/entities/forum.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ query GetPost($id: ID!) {
id
title
}
text
}
}

Expand All @@ -17,9 +16,6 @@ query GetThread($id: ID!) {
handle
}
title
initialPost {
text
}
}
}

Expand Down
Loading
Loading