Skip to content

Commit b5bd2b4

Browse files
authored
chore: Add Mattermost Plugin IntegrationProvider (#10361)
1 parent 9ddd5e5 commit b5bd2b4

11 files changed

+180
-30
lines changed

packages/server/dataloader/integrationAuthLoaders.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import TeamMemberIntegrationAuthId from '../../client/shared/gqlIds/TeamMemberIn
33
import errorFilter from '../graphql/errorFilter'
44
import isValid from '../graphql/isValid'
55
import getKysely from '../postgres/getKysely'
6+
import {TeamMemberIntegrationAuth} from '../postgres/pg'
67
import {IGetBestTeamIntegrationAuthQueryResult} from '../postgres/queries/generated/getBestTeamIntegrationAuthQuery'
78
import {IntegrationProviderServiceEnum} from '../postgres/queries/generated/getIntegrationProvidersByIdsQuery'
89
import {IGetTeamMemberIntegrationAuthQueryResult} from '../postgres/queries/generated/getTeamMemberIntegrationAuthQuery'
@@ -211,3 +212,31 @@ export const slackNotificationsByTeamIdAndEvent = (parent: RootDataLoader) => {
211212
})
212213
})
213214
}
215+
216+
export const teamMemberIntegrationAuthsByTeamId = (parent: RootDataLoader) => {
217+
return new DataLoader<
218+
{teamId: string; service: IntegrationProviderServiceEnum},
219+
TeamMemberIntegrationAuth[],
220+
string
221+
>(
222+
async (keys) => {
223+
const pg = getKysely()
224+
const teamIds = keys.map(({teamId}) => teamId)
225+
const services = keys.map(({service}) => service)
226+
const res = (await pg
227+
.selectFrom('TeamMemberIntegrationAuth')
228+
.selectAll()
229+
.where(({eb}) => eb('teamId', 'in', teamIds))
230+
.where(({eb}) => eb('service', 'in', services))
231+
.execute()) as unknown as TeamMemberIntegrationAuth[]
232+
233+
return keys.map((key) =>
234+
res.filter(({teamId, service}) => teamId === key.teamId && service === key.service)
235+
)
236+
},
237+
{
238+
...parent.dataLoaderOptions,
239+
cacheKeyFn: ({teamId, service}) => `${teamId}-${service}`
240+
}
241+
)
242+
}

packages/server/graphql/mutations/addIntegrationProvider.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ const addIntegrationProvider = {
7070
oAuth1ProviderMetadataInput,
7171
oAuth2ProviderMetadataInput,
7272
webhookProviderMetadataInput,
73+
sharedSecretMetadataInput,
7374
...rest
7475
} = input
7576

@@ -86,7 +87,8 @@ const addIntegrationProvider = {
8687
[
8788
oAuth1ProviderMetadataInput,
8889
oAuth2ProviderMetadataInput,
89-
webhookProviderMetadataInput
90+
webhookProviderMetadataInput,
91+
sharedSecretMetadataInput
9092
].filter(isNotNull).length !== 1
9193
) {
9294
return {error: {message: 'Exactly 1 metadata provider is expected'}}
@@ -99,6 +101,7 @@ const addIntegrationProvider = {
99101
...oAuth1ProviderMetadataInput,
100102
...oAuth2ProviderMetadataInput,
101103
...webhookProviderMetadataInput,
104+
...sharedSecretMetadataInput,
102105
...(scope === 'global'
103106
? {orgId: null, teamId: null}
104107
: scope === 'org'

packages/server/graphql/mutations/helpers/notifications/MattermostNotifier.ts

Lines changed: 55 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@ import makeAppURL from 'parabol-client/utils/makeAppURL'
44
import findStageById from 'parabol-client/utils/meetings/findStageById'
55
import {phaseLabelLookup} from 'parabol-client/utils/meetings/lookups'
66
import appOrigin from '../../../../appOrigin'
7-
import {IntegrationProviderMattermost as IIntegrationProviderMattermost} from '../../../../postgres/queries/getIntegrationProvidersByIds'
7+
import {TeamMemberIntegrationAuth} from '../../../../postgres/pg'
8+
import {IntegrationProviderMattermost} from '../../../../postgres/queries/getIntegrationProvidersByIds'
89
import {SlackNotification, Team} from '../../../../postgres/types'
910
import IUser from '../../../../postgres/types/IUser'
1011
import {AnyMeeting, MeetingTypeEnum} from '../../../../postgres/types/Meeting'
@@ -23,16 +24,18 @@ import {
2324
makeHackedFieldButtonValue
2425
} from './makeMattermostAttachments'
2526

26-
type IntegrationProviderMattermost = IIntegrationProviderMattermost & {teamId: string}
27-
2827
const notifyMattermost = async (
2928
event: SlackNotification['event'],
30-
webhookUrl: string,
29+
channel: {webhookUrl: string | null; serverBaseUrl: string | null; sharedSecret: string | null},
3130
user: IUser,
3231
teamId: string,
3332
textOrAttachmentsArray: string | unknown[],
3433
notificationText?: string
3534
) => {
35+
const {webhookUrl} = channel
36+
if (!webhookUrl) {
37+
return 'success'
38+
}
3639
const manager = new MattermostServerManager(webhookUrl)
3740
const result = await manager.postMessage(textOrAttachmentsArray, notificationText)
3841
if (result instanceof Error) {
@@ -89,7 +92,11 @@ const makeEndMeetingButtons = (meeting: AnyMeeting) => {
8992
}
9093
}
9194

92-
type MattermostNotificationAuth = IntegrationProviderMattermost & {userId: string}
95+
type MattermostNotificationAuth = IntegrationProviderMattermost & {
96+
userId: string
97+
teamId: string
98+
channel: string | null
99+
}
93100

94101
const makeTeamPromptStartMeetingNotification = (
95102
team: Team,
@@ -164,8 +171,6 @@ const MattermostNotificationHelper: NotificationIntegrationHelper<MattermostNoti
164171
notificationChannel
165172
) => ({
166173
async startMeeting(meeting, team, user) {
167-
const {webhookUrl} = notificationChannel
168-
169174
const searchParams = {
170175
utm_source: 'mattermost meeting start',
171176
utm_medium: 'product',
@@ -179,12 +184,11 @@ const MattermostNotificationHelper: NotificationIntegrationHelper<MattermostNoti
179184
meetingUrl
180185
)
181186

182-
return notifyMattermost('meetingStart', webhookUrl, user, team.id, notification)
187+
return notifyMattermost('meetingStart', notificationChannel, user, team.id, notification)
183188
},
184189

185190
async endMeeting(meeting, team, user) {
186191
const {summary} = meeting
187-
const {webhookUrl} = notificationChannel
188192

189193
const summaryText = await getSummaryText(meeting)
190194
const meetingUrl = makeAppURL(appOrigin, `meet/${meeting.id}`)
@@ -220,12 +224,11 @@ const MattermostNotificationHelper: NotificationIntegrationHelper<MattermostNoti
220224
title_link: meetingUrl
221225
})
222226
]
223-
return notifyMattermost('meetingEnd', webhookUrl, user, team.id, attachments)
227+
return notifyMattermost('meetingEnd', notificationChannel, user, team.id, attachments)
224228
},
225229

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

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

275278
return notifyMattermost(
276279
'MEETING_STAGE_TIME_LIMIT_START',
277-
webhookUrl,
280+
notificationChannel,
278281
user,
279282
team.id,
280283
attachments
281284
)
282285
},
283286
async endTimeLimit(meeting, team, user) {
284287
const {name: meetingName} = meeting
285-
const {webhookUrl} = notificationChannel
286288
const {name: teamName} = team
287289
const meetingUrl = makeAppURL(appOrigin, `meet/${meeting.id}`)
288290

@@ -312,11 +314,17 @@ const MattermostNotificationHelper: NotificationIntegrationHelper<MattermostNoti
312314
)
313315
]
314316

315-
return notifyMattermost('MEETING_STAGE_TIME_LIMIT_END', webhookUrl, user, team.id, attachments)
317+
return notifyMattermost(
318+
'MEETING_STAGE_TIME_LIMIT_END',
319+
notificationChannel,
320+
user,
321+
team.id,
322+
attachments
323+
)
316324
},
317325
async integrationUpdated(user) {
318326
const message = `Integration webhook configuration updated`
319-
const {webhookUrl, teamId} = notificationChannel
327+
const {teamId} = notificationChannel
320328

321329
const attachments = [
322330
makeFieldsAttachment(
@@ -331,7 +339,7 @@ const MattermostNotificationHelper: NotificationIntegrationHelper<MattermostNoti
331339
}
332340
)
333341
]
334-
return notifyMattermost('meetingEnd', webhookUrl, user, teamId, attachments)
342+
return notifyMattermost('meetingEnd', notificationChannel, user, teamId, attachments)
335343
},
336344
async standupResponseSubmitted() {
337345
// Not implemented
@@ -340,17 +348,37 @@ const MattermostNotificationHelper: NotificationIntegrationHelper<MattermostNoti
340348
})
341349

342350
async function getMattermost(dataLoader: DataLoaderWorker, teamId: string, userId: string) {
343-
const provider = await dataLoader
344-
.get('bestTeamIntegrationProviders')
345-
.load({service: 'mattermost', teamId, userId})
346-
return provider && provider.teamId
347-
? [
348-
MattermostNotificationHelper({
349-
...(provider as IntegrationProviderMattermost),
350-
userId
351-
})
352-
]
353-
: []
351+
const auths = await dataLoader
352+
.get('teamMemberIntegrationAuthsByTeamId')
353+
.load({service: 'mattermost', teamId})
354+
355+
// filter the auths
356+
// for webhook, keep only 1 as we don't know the channel
357+
// if there are sharedSecret integrations, prefer these, but keep channels unique
358+
const filteredAuths = auths.reduce((acc, auth) => {
359+
if (auth.channel) {
360+
if (!acc.some((a) => a.channel === auth.channel)) {
361+
acc.push(auth)
362+
}
363+
}
364+
return acc
365+
}, [] as TeamMemberIntegrationAuth[])
366+
if (filteredAuths.length === 0) {
367+
const webhookAuth =
368+
auths.find((auth) => auth.userId === userId) ?? auths.filter((auth) => !auth.channel)[0]
369+
if (webhookAuth) {
370+
filteredAuths.push(webhookAuth)
371+
}
372+
}
373+
374+
return Promise.all(
375+
filteredAuths.map(async (auth) => {
376+
const provider = (await dataLoader
377+
.get('integrationProviders')
378+
.loadNonNull(auth.providerId)) as IntegrationProviderMattermost
379+
return MattermostNotificationHelper({...provider, teamId, userId, channel: auth.channel})
380+
})
381+
)
354382
}
355383

356384
export const MattermostNotifier = createNotifier(getMattermost)

packages/server/graphql/public/typeDefs/AddIntegrationProviderInput.graphql

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,4 +43,9 @@ input AddIntegrationProviderInput {
4343
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
4444
"""
4545
oAuth2ProviderMetadataInput: IntegrationProviderMetadataInputOAuth2
46+
47+
"""
48+
Shared secret provider metadata, has to be non-null if token type is shared secret
49+
"""
50+
sharedSecretMetadataInput: IntegrationProviderMetadataInputSharedSecret
4651
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
"""
2+
Shared secret provider metadata
3+
"""
4+
input IntegrationProviderMetadataInputSharedSecret {
5+
"""
6+
The base URL used to access the provider
7+
"""
8+
serverBaseUrl: URL!
9+
10+
"""
11+
Shared secret between Parabol and the provider
12+
"""
13+
sharedSecret: String!
14+
}

packages/server/graphql/rootTypes.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import IntegrationProviderOAuth1 from './types/IntegrationProviderOAuth1'
22
import IntegrationProviderOAuth2 from './types/IntegrationProviderOAuth2'
3+
import IntegrationProviderSharedSecret from './types/IntegrationProviderSharedSecret'
34
import IntegrationProviderWebhook from './types/IntegrationProviderWebhook'
45
import JiraDimensionField from './types/JiraDimensionField'
56
import RenamePokerTemplatePayload from './types/RenamePokerTemplatePayload'
@@ -14,6 +15,7 @@ import UserTiersCount from './types/UserTiersCount'
1415
const rootTypes = [
1516
IntegrationProviderOAuth1,
1617
IntegrationProviderOAuth2,
18+
IntegrationProviderSharedSecret,
1719
IntegrationProviderWebhook,
1820
SetMeetingSettingsPayload,
1921
TimelineEventTeamCreated,

packages/server/graphql/types/AddIntegrationProviderInput.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@ import IntegrationProviderMetadataInputOAuth1, {
1313
import IntegrationProviderMetadataInputOAuth2, {
1414
IIntegrationProviderMetadataInputOAuth2
1515
} from './IntegrationProviderMetadataInputOAuth2'
16+
import IntegrationProviderMetadataInputSharedSecret, {
17+
IIntegrationProviderMetadataInputSharedSecret
18+
} from './IntegrationProviderMetadataInputSharedSecret'
1619
import IntegrationProviderMetadataInputWebhook, {
1720
IIntegrationProviderMetadataInputWebhook
1821
} from './IntegrationProviderMetadataInputWebhook'
@@ -27,6 +30,7 @@ export interface IAddIntegrationProviderInput {
2730
webhookProviderMetadataInput: IIntegrationProviderMetadataInputWebhook | null
2831
oAuth1ProviderMetadataInput: IIntegrationProviderMetadataInputOAuth1 | null
2932
oAuth2ProviderMetadataInput: IIntegrationProviderMetadataInputOAuth2 | null
33+
sharedSecretMetadataInput: IIntegrationProviderMetadataInputSharedSecret | null
3034
}
3135

3236
const AddIntegrationProviderInput = new GraphQLInputObjectType({
@@ -67,6 +71,11 @@ const AddIntegrationProviderInput = new GraphQLInputObjectType({
6771
type: IntegrationProviderMetadataInputOAuth2,
6872
description:
6973
'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'
74+
},
75+
sharedSecretMetadataInput: {
76+
type: IntegrationProviderMetadataInputSharedSecret,
77+
description:
78+
'Shared secret provider metadata, has to be non-null if token type is shared secret'
7079
}
7180
})
7281
})

packages/server/graphql/types/IntegrationProviderAuthStrategyEnum.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@ const IntegrationProviderAuthStrategyEnum = new GraphQLEnumType({
77
oauth1: {},
88
oauth2: {},
99
pat: {},
10-
webhook: {}
10+
webhook: {},
11+
sharedSecret: {}
1112
}
1213
})
1314

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import {GraphQLInputObjectType, GraphQLNonNull, GraphQLString} from 'graphql'
2+
import GraphQLURLType from './GraphQLURLType'
3+
4+
export interface IIntegrationProviderMetadataInputSharedSecret {
5+
serverBaseUrl: string
6+
sharedSecret: string
7+
}
8+
export const IntegrationProviderMetadataInputSharedSecret = new GraphQLInputObjectType({
9+
name: 'IntegrationProviderMetadataInputSharedSecret',
10+
description: 'Webhook provider metadata',
11+
fields: () => ({
12+
serverBaseUrl: {
13+
type: new GraphQLNonNull(GraphQLURLType),
14+
description: 'The base URL used to access the provider'
15+
},
16+
sharedSecret: {
17+
type: new GraphQLNonNull(GraphQLString),
18+
description: 'Shared secret between Parabol and the provider'
19+
}
20+
})
21+
})
22+
23+
export default IntegrationProviderMetadataInputSharedSecret
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import {GraphQLNonNull, GraphQLObjectType, GraphQLString} from 'graphql'
2+
import {GQLContext} from '../graphql'
3+
import GraphQLURLType from './GraphQLURLType'
4+
import IntegrationProvider, {integrationProviderFields} from './IntegrationProvider'
5+
6+
const IntegrationProviderSharedSecret = new GraphQLObjectType<any, GQLContext>({
7+
name: 'IntegrationProviderSharedSecret',
8+
description: 'An integration provider that connects via a shared secret',
9+
interfaces: () => [IntegrationProvider],
10+
isTypeOf: ({authStrategy}) => authStrategy === 'sharedSecret',
11+
fields: () => ({
12+
...integrationProviderFields(),
13+
serverBaseUrl: {
14+
type: new GraphQLNonNull(GraphQLURLType),
15+
description: 'The base URL of the OAuth1 server'
16+
},
17+
sharedSecret: {
18+
type: new GraphQLNonNull(GraphQLString),
19+
description: 'The shared secret used to sign requests'
20+
}
21+
})
22+
})
23+
24+
export default IntegrationProviderSharedSecret

0 commit comments

Comments
 (0)