Skip to content

Commit

Permalink
chore: Add Mattermost Plugin IntegrationProvider (#10361)
Browse files Browse the repository at this point in the history
  • Loading branch information
Dschoordsch authored Oct 24, 2024
1 parent 9ddd5e5 commit b5bd2b4
Show file tree
Hide file tree
Showing 11 changed files with 180 additions and 30 deletions.
29 changes: 29 additions & 0 deletions packages/server/dataloader/integrationAuthLoaders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import TeamMemberIntegrationAuthId from '../../client/shared/gqlIds/TeamMemberIn
import errorFilter from '../graphql/errorFilter'
import isValid from '../graphql/isValid'
import getKysely from '../postgres/getKysely'
import {TeamMemberIntegrationAuth} from '../postgres/pg'
import {IGetBestTeamIntegrationAuthQueryResult} from '../postgres/queries/generated/getBestTeamIntegrationAuthQuery'
import {IntegrationProviderServiceEnum} from '../postgres/queries/generated/getIntegrationProvidersByIdsQuery'
import {IGetTeamMemberIntegrationAuthQueryResult} from '../postgres/queries/generated/getTeamMemberIntegrationAuthQuery'
Expand Down Expand Up @@ -211,3 +212,31 @@ export const slackNotificationsByTeamIdAndEvent = (parent: RootDataLoader) => {
})
})
}

export const teamMemberIntegrationAuthsByTeamId = (parent: RootDataLoader) => {
return new DataLoader<
{teamId: string; service: IntegrationProviderServiceEnum},
TeamMemberIntegrationAuth[],
string
>(
async (keys) => {
const pg = getKysely()
const teamIds = keys.map(({teamId}) => teamId)
const services = keys.map(({service}) => service)
const res = (await pg
.selectFrom('TeamMemberIntegrationAuth')
.selectAll()
.where(({eb}) => eb('teamId', 'in', teamIds))
.where(({eb}) => eb('service', 'in', services))
.execute()) as unknown as TeamMemberIntegrationAuth[]

return keys.map((key) =>
res.filter(({teamId, service}) => teamId === key.teamId && service === key.service)
)
},
{
...parent.dataLoaderOptions,
cacheKeyFn: ({teamId, service}) => `${teamId}-${service}`
}
)
}
5 changes: 4 additions & 1 deletion packages/server/graphql/mutations/addIntegrationProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ const addIntegrationProvider = {
oAuth1ProviderMetadataInput,
oAuth2ProviderMetadataInput,
webhookProviderMetadataInput,
sharedSecretMetadataInput,
...rest
} = input

Expand All @@ -86,7 +87,8 @@ const addIntegrationProvider = {
[
oAuth1ProviderMetadataInput,
oAuth2ProviderMetadataInput,
webhookProviderMetadataInput
webhookProviderMetadataInput,
sharedSecretMetadataInput
].filter(isNotNull).length !== 1
) {
return {error: {message: 'Exactly 1 metadata provider is expected'}}
Expand All @@ -99,6 +101,7 @@ const addIntegrationProvider = {
...oAuth1ProviderMetadataInput,
...oAuth2ProviderMetadataInput,
...webhookProviderMetadataInput,
...sharedSecretMetadataInput,
...(scope === 'global'
? {orgId: null, teamId: null}
: scope === 'org'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ import makeAppURL from 'parabol-client/utils/makeAppURL'
import findStageById from 'parabol-client/utils/meetings/findStageById'
import {phaseLabelLookup} from 'parabol-client/utils/meetings/lookups'
import appOrigin from '../../../../appOrigin'
import {IntegrationProviderMattermost as IIntegrationProviderMattermost} from '../../../../postgres/queries/getIntegrationProvidersByIds'
import {TeamMemberIntegrationAuth} from '../../../../postgres/pg'
import {IntegrationProviderMattermost} from '../../../../postgres/queries/getIntegrationProvidersByIds'
import {SlackNotification, Team} from '../../../../postgres/types'
import IUser from '../../../../postgres/types/IUser'
import {AnyMeeting, MeetingTypeEnum} from '../../../../postgres/types/Meeting'
Expand All @@ -23,16 +24,18 @@ import {
makeHackedFieldButtonValue
} from './makeMattermostAttachments'

type IntegrationProviderMattermost = IIntegrationProviderMattermost & {teamId: string}

const notifyMattermost = async (
event: SlackNotification['event'],
webhookUrl: string,
channel: {webhookUrl: string | null; serverBaseUrl: string | null; sharedSecret: string | null},
user: IUser,
teamId: string,
textOrAttachmentsArray: string | unknown[],
notificationText?: string
) => {
const {webhookUrl} = channel
if (!webhookUrl) {
return 'success'
}
const manager = new MattermostServerManager(webhookUrl)
const result = await manager.postMessage(textOrAttachmentsArray, notificationText)
if (result instanceof Error) {
Expand Down Expand Up @@ -89,7 +92,11 @@ const makeEndMeetingButtons = (meeting: AnyMeeting) => {
}
}

type MattermostNotificationAuth = IntegrationProviderMattermost & {userId: string}
type MattermostNotificationAuth = IntegrationProviderMattermost & {
userId: string
teamId: string
channel: string | null
}

const makeTeamPromptStartMeetingNotification = (
team: Team,
Expand Down Expand Up @@ -164,8 +171,6 @@ const MattermostNotificationHelper: NotificationIntegrationHelper<MattermostNoti
notificationChannel
) => ({
async startMeeting(meeting, team, user) {
const {webhookUrl} = notificationChannel

const searchParams = {
utm_source: 'mattermost meeting start',
utm_medium: 'product',
Expand All @@ -179,12 +184,11 @@ const MattermostNotificationHelper: NotificationIntegrationHelper<MattermostNoti
meetingUrl
)

return notifyMattermost('meetingStart', webhookUrl, user, team.id, notification)
return notifyMattermost('meetingStart', notificationChannel, user, team.id, notification)
},

async endMeeting(meeting, team, user) {
const {summary} = meeting
const {webhookUrl} = notificationChannel

const summaryText = await getSummaryText(meeting)
const meetingUrl = makeAppURL(appOrigin, `meet/${meeting.id}`)
Expand Down Expand Up @@ -220,12 +224,11 @@ const MattermostNotificationHelper: NotificationIntegrationHelper<MattermostNoti
title_link: meetingUrl
})
]
return notifyMattermost('meetingEnd', webhookUrl, user, team.id, attachments)
return notifyMattermost('meetingEnd', notificationChannel, user, team.id, attachments)
},

async startTimeLimit(scheduledEndTime, meeting, team, user) {
const {name: meetingName, phases, facilitatorStageId} = meeting
const {webhookUrl} = notificationChannel

const {name: teamName} = team
const stageRes = findStageById(phases, facilitatorStageId)
Expand Down Expand Up @@ -274,15 +277,14 @@ const MattermostNotificationHelper: NotificationIntegrationHelper<MattermostNoti

return notifyMattermost(
'MEETING_STAGE_TIME_LIMIT_START',
webhookUrl,
notificationChannel,
user,
team.id,
attachments
)
},
async endTimeLimit(meeting, team, user) {
const {name: meetingName} = meeting
const {webhookUrl} = notificationChannel
const {name: teamName} = team
const meetingUrl = makeAppURL(appOrigin, `meet/${meeting.id}`)

Expand Down Expand Up @@ -312,11 +314,17 @@ const MattermostNotificationHelper: NotificationIntegrationHelper<MattermostNoti
)
]

return notifyMattermost('MEETING_STAGE_TIME_LIMIT_END', webhookUrl, user, team.id, attachments)
return notifyMattermost(
'MEETING_STAGE_TIME_LIMIT_END',
notificationChannel,
user,
team.id,
attachments
)
},
async integrationUpdated(user) {
const message = `Integration webhook configuration updated`
const {webhookUrl, teamId} = notificationChannel
const {teamId} = notificationChannel

const attachments = [
makeFieldsAttachment(
Expand All @@ -331,7 +339,7 @@ const MattermostNotificationHelper: NotificationIntegrationHelper<MattermostNoti
}
)
]
return notifyMattermost('meetingEnd', webhookUrl, user, teamId, attachments)
return notifyMattermost('meetingEnd', notificationChannel, user, teamId, attachments)
},
async standupResponseSubmitted() {
// Not implemented
Expand All @@ -340,17 +348,37 @@ const MattermostNotificationHelper: NotificationIntegrationHelper<MattermostNoti
})

async function getMattermost(dataLoader: DataLoaderWorker, teamId: string, userId: string) {
const provider = await dataLoader
.get('bestTeamIntegrationProviders')
.load({service: 'mattermost', teamId, userId})
return provider && provider.teamId
? [
MattermostNotificationHelper({
...(provider as IntegrationProviderMattermost),
userId
})
]
: []
const auths = await dataLoader
.get('teamMemberIntegrationAuthsByTeamId')
.load({service: 'mattermost', teamId})

// filter the auths
// for webhook, keep only 1 as we don't know the channel
// if there are sharedSecret integrations, prefer these, but keep channels unique
const filteredAuths = auths.reduce((acc, auth) => {
if (auth.channel) {
if (!acc.some((a) => a.channel === auth.channel)) {
acc.push(auth)
}
}
return acc
}, [] as TeamMemberIntegrationAuth[])
if (filteredAuths.length === 0) {
const webhookAuth =
auths.find((auth) => auth.userId === userId) ?? auths.filter((auth) => !auth.channel)[0]
if (webhookAuth) {
filteredAuths.push(webhookAuth)
}
}

return Promise.all(
filteredAuths.map(async (auth) => {
const provider = (await dataLoader
.get('integrationProviders')
.loadNonNull(auth.providerId)) as IntegrationProviderMattermost
return MattermostNotificationHelper({...provider, teamId, userId, channel: auth.channel})
})
)
}

export const MattermostNotifier = createNotifier(getMattermost)
Original file line number Diff line number Diff line change
Expand Up @@ -43,4 +43,9 @@ input AddIntegrationProviderInput {
OAuth2 provider metadata, has to be non-null if token type is OAuth2, refactor once we get https://github.com/graphql/graphql-spec/pull/825
"""
oAuth2ProviderMetadataInput: IntegrationProviderMetadataInputOAuth2

"""
Shared secret provider metadata, has to be non-null if token type is shared secret
"""
sharedSecretMetadataInput: IntegrationProviderMetadataInputSharedSecret
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
"""
Shared secret provider metadata
"""
input IntegrationProviderMetadataInputSharedSecret {
"""
The base URL used to access the provider
"""
serverBaseUrl: URL!

"""
Shared secret between Parabol and the provider
"""
sharedSecret: String!
}
2 changes: 2 additions & 0 deletions packages/server/graphql/rootTypes.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import IntegrationProviderOAuth1 from './types/IntegrationProviderOAuth1'
import IntegrationProviderOAuth2 from './types/IntegrationProviderOAuth2'
import IntegrationProviderSharedSecret from './types/IntegrationProviderSharedSecret'
import IntegrationProviderWebhook from './types/IntegrationProviderWebhook'
import JiraDimensionField from './types/JiraDimensionField'
import RenamePokerTemplatePayload from './types/RenamePokerTemplatePayload'
Expand All @@ -14,6 +15,7 @@ import UserTiersCount from './types/UserTiersCount'
const rootTypes = [
IntegrationProviderOAuth1,
IntegrationProviderOAuth2,
IntegrationProviderSharedSecret,
IntegrationProviderWebhook,
SetMeetingSettingsPayload,
TimelineEventTeamCreated,
Expand Down
9 changes: 9 additions & 0 deletions packages/server/graphql/types/AddIntegrationProviderInput.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ import IntegrationProviderMetadataInputOAuth1, {
import IntegrationProviderMetadataInputOAuth2, {
IIntegrationProviderMetadataInputOAuth2
} from './IntegrationProviderMetadataInputOAuth2'
import IntegrationProviderMetadataInputSharedSecret, {
IIntegrationProviderMetadataInputSharedSecret
} from './IntegrationProviderMetadataInputSharedSecret'
import IntegrationProviderMetadataInputWebhook, {
IIntegrationProviderMetadataInputWebhook
} from './IntegrationProviderMetadataInputWebhook'
Expand All @@ -27,6 +30,7 @@ export interface IAddIntegrationProviderInput {
webhookProviderMetadataInput: IIntegrationProviderMetadataInputWebhook | null
oAuth1ProviderMetadataInput: IIntegrationProviderMetadataInputOAuth1 | null
oAuth2ProviderMetadataInput: IIntegrationProviderMetadataInputOAuth2 | null
sharedSecretMetadataInput: IIntegrationProviderMetadataInputSharedSecret | null
}

const AddIntegrationProviderInput = new GraphQLInputObjectType({
Expand Down Expand Up @@ -67,6 +71,11 @@ const AddIntegrationProviderInput = new GraphQLInputObjectType({
type: IntegrationProviderMetadataInputOAuth2,
description:
'OAuth2 provider metadata, has to be non-null if token type is OAuth2, refactor once we get https://github.com/graphql/graphql-spec/pull/825'
},
sharedSecretMetadataInput: {
type: IntegrationProviderMetadataInputSharedSecret,
description:
'Shared secret provider metadata, has to be non-null if token type is shared secret'
}
})
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ const IntegrationProviderAuthStrategyEnum = new GraphQLEnumType({
oauth1: {},
oauth2: {},
pat: {},
webhook: {}
webhook: {},
sharedSecret: {}
}
})

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import {GraphQLInputObjectType, GraphQLNonNull, GraphQLString} from 'graphql'
import GraphQLURLType from './GraphQLURLType'

export interface IIntegrationProviderMetadataInputSharedSecret {
serverBaseUrl: string
sharedSecret: string
}
export const IntegrationProviderMetadataInputSharedSecret = new GraphQLInputObjectType({
name: 'IntegrationProviderMetadataInputSharedSecret',
description: 'Webhook provider metadata',
fields: () => ({
serverBaseUrl: {
type: new GraphQLNonNull(GraphQLURLType),
description: 'The base URL used to access the provider'
},
sharedSecret: {
type: new GraphQLNonNull(GraphQLString),
description: 'Shared secret between Parabol and the provider'
}
})
})

export default IntegrationProviderMetadataInputSharedSecret
24 changes: 24 additions & 0 deletions packages/server/graphql/types/IntegrationProviderSharedSecret.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import {GraphQLNonNull, GraphQLObjectType, GraphQLString} from 'graphql'
import {GQLContext} from '../graphql'
import GraphQLURLType from './GraphQLURLType'
import IntegrationProvider, {integrationProviderFields} from './IntegrationProvider'

const IntegrationProviderSharedSecret = new GraphQLObjectType<any, GQLContext>({
name: 'IntegrationProviderSharedSecret',
description: 'An integration provider that connects via a shared secret',
interfaces: () => [IntegrationProvider],
isTypeOf: ({authStrategy}) => authStrategy === 'sharedSecret',
fields: () => ({
...integrationProviderFields(),
serverBaseUrl: {
type: new GraphQLNonNull(GraphQLURLType),
description: 'The base URL of the OAuth1 server'
},
sharedSecret: {
type: new GraphQLNonNull(GraphQLString),
description: 'The shared secret used to sign requests'
}
})
})

export default IntegrationProviderSharedSecret
14 changes: 13 additions & 1 deletion packages/server/postgres/queries/getIntegrationProvidersByIds.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,12 +35,24 @@ interface IntegrationProviderPAT extends IntegrationProviderActive {
clientId: string
}

export interface IntegrationProviderMattermost extends Omit<IntegrationProviderWebhook, 'scope'> {
export interface IntegrationProviderMattermostPlugin extends IntegrationProviderActive {
service: 'mattermost'
authStrategy: 'sharedSecret'
serverBaseUrl: string
sharedSecret: string
}

export interface IntegrationProviderMattermostWebhook
extends Omit<IntegrationProviderWebhook, 'scope'> {
service: 'mattermost'
scope: Omit<IntegrationProviderScopeEnum, 'global'>
scopeGlobal: false
}

export type IntegrationProviderMattermost =
| IntegrationProviderMattermostPlugin
| IntegrationProviderMattermostWebhook

export interface IntegrationProviderGcalOAuth2 extends IntegrationProviderOAuth2 {
service: 'gcal'
}
Expand Down

0 comments on commit b5bd2b4

Please sign in to comment.