Skip to content

Commit

Permalink
Merge pull request #737 from dhis2/feat/slack-webhook
Browse files Browse the repository at this point in the history
feat(notifications): add slack webhook for new apps
  • Loading branch information
Birkbjo authored Aug 1, 2023
2 parents 13ab555 + d86ba80 commit 3b0b708
Show file tree
Hide file tree
Showing 12 changed files with 12,083 additions and 12,529 deletions.
1 change: 1 addition & 0 deletions server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
"@hapi/inert": "^7.1.0",
"@hapi/vision": "^7.0.1",
"@hapipal/schmervice": "^2.1.0",
"@slack/webhook": "^6.1.0",
"adm-zip": "^0.5.5",
"auth0": "^2.36.1",
"aws-sdk": "^2.981.0",
Expand Down
11 changes: 10 additions & 1 deletion server/src/routes/v1/apps/formatting/convertAppsToApiV1Format.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,17 @@ const convertDbAppViewRowToAppApiV1Object = (app) => ({
reviews: [],
})

const getMediaUrl = ({ serverUrl, organisationSlug, appId, mediaId }) =>
`${serverUrl}/v1/apps/media/${organisationSlug}/${appId}/${mediaId}`

const convertAppToV1Media = (app, serverUrl) => {
return {
imageUrl: `${serverUrl}/v1/apps/media/${app.organisation_slug}/${app.app_id}/${app.media_id}`,
imageUrl: getMediaUrl({
serverUrl,
organisationSlug: app.organisation_slug,
appId: app.app_id,
mediaId: app.media_id,
}),
caption: '',
created: +new Date(app.media_created_at),
description: '',
Expand Down Expand Up @@ -122,4 +130,5 @@ const convertAll = (apps, request) => {
module.exports = {
convertAppsToApiV1Format: convertAll,
convertAppToV1AppVersion,
getMediaUrl,
}
13 changes: 9 additions & 4 deletions server/src/routes/v1/apps/formatting/index.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
const {
convertAppToV1AppVersion,
convertAppsToApiV1Format,
getMediaUrl,
} = require('./convertAppsToApiV1Format')

module.exports = {
convertAppToV1AppVersion: require('./convertAppsToApiV1Format')
.convertAppToV1AppVersion,
convertAppsToApiV1Format: require('./convertAppsToApiV1Format')
.convertAppsToApiV1Format,
convertAppToV1AppVersion,
convertAppsToApiV1Format,
getMediaUrl,
}
36 changes: 31 additions & 5 deletions server/src/routes/v1/apps/handlers/createApp.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,10 @@ const {
} = require('../../../../security')
const App = require('../../../../services/app')
const Organisation = require('../../../../services/organisation')
const { saveFile } = require('../../../../utils')
const { saveFile, getServerUrl } = require('../../../../utils')
const { validateImageMetadata } = require('../../../../utils/validateMime')
const { getMediaUrl } = require('../../apps/formatting')
const { server } = require('@hapi/hapi')

module.exports = {
method: 'POST',
Expand Down Expand Up @@ -39,6 +41,7 @@ module.exports = {
if (!canCreateApp(request, h)) {
throw Boom.unauthorized()
}
const { notificationService } = request.services(true)

const { db } = h.context
const { id: currentUserId } = await getCurrentUserFromRequest(
Expand Down Expand Up @@ -77,11 +80,12 @@ module.exports = {
)
}

const app = await db.transaction(async trx => {
const { appType } = appJsonPayload
const { appType } = appJsonPayload
const { name, description, sourceUrl } = appJsonPayload
let logoMediaId
const app = await db.transaction(async (trx) => {
const { file } = payload

const { name, description, sourceUrl } = appJsonPayload
const {
version,
demoUrl,
Expand Down Expand Up @@ -142,7 +146,7 @@ module.exports = {
const logoMetadata = logo.hapi
validateImageMetadata(request.server.mime, logoMetadata)

const { id: logoId } = await App.createMediaForApp(
const { id: logoId, media_id } = await App.createMediaForApp(
app.id,
{
userId: currentUserId,
Expand All @@ -154,6 +158,7 @@ module.exports = {
},
trx
)
logoMediaId = logoId

const appUpload = saveFile(
`${app.id}/${appVersion.id}`,
Expand All @@ -166,6 +171,27 @@ module.exports = {
return app
})

const imageUrl = getMediaUrl({
serverUrl: getServerUrl(request),
organisationSlug: organisation.slug,
appId: app.id,
mediaId: logoMediaId,
})
notificationService
.sendNewAppNotifications({
appName: name,
organisationName: organisation.name,
imageUrl,
sourceUrl,
link: `${getServerUrl(request, { base: true })}/user/app/${
app.id
}`,
})
.catch((e) => {
//.catch() so we don't have to await
request.logger.error('Failed to send notification %o', e)
})

return h.response(app).created(`/v2/apps/${app.id}`)
},
}
2 changes: 1 addition & 1 deletion server/src/routes/v1/apps/handlers/getSingleApp.js
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ module.exports = {
db
)
isDeveloper =
appsUserCanEdit.map(app => app.app_id).indexOf(appId) !== -1
appsUserCanEdit.map((app) => app.app_id).indexOf(appId) !== -1
} catch (err) {
//no user on request
debug('No user in request')
Expand Down
3 changes: 3 additions & 0 deletions server/src/server/env-config.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@ const config = {
dsn: process.env.SENTRY_DSN,
environment: process.env.SENTRY_ENVIRONMENT || process.env.NODE_ENV,
},
slack: {
webhookUrl: process.env.SLACK_WEBHOOK_URL,
},
auth: {
noAuthUserIdMapping: process.env.NO_AUTH_MAPPED_USER_ID,
config: {
Expand Down
4 changes: 4 additions & 0 deletions server/src/server/init-server.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ const staticFrontendRoutes = require('../plugins/staticFrontendRoutes')
const { getUserDecoration } = require('../security')
const { createAppVersionService } = require('../services/appVersion')
const { createEmailService } = require('../services/EmailService')
const {
createNotificationService,
} = require('../services/NotificationService/NotificationService.js')

exports.init = async (knex, config) => {
debug('Starting server...')
Expand Down Expand Up @@ -109,6 +112,7 @@ exports.init = async (knex, config) => {

await server.registerService(createEmailService)
await server.registerService(createAppVersionService)
await server.registerService(createNotificationService)

await server.register({
plugin: staticFrontendRoutes,
Expand Down
25 changes: 25 additions & 0 deletions server/src/services/NotificationService/NotificationMessager.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
const messagerTypes = {
WEBHOOK: 'webhook',
email: 'email',
}

class NotificationMessager {
constructor(name, { type } = {}) {
if (this.constructor === NotificationMessager) {
throw new TypeError(
'Class "NotificationMessager" cannot be instantiated directly.'
)
}
this.name = name
this.type = type
}

send() {
throw new Error('Method "sendNotification" must be implemented.')
}
}

module.exports = {
messagerTypes,
NotificationMessager,
}
72 changes: 72 additions & 0 deletions server/src/services/NotificationService/NotificationService.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
const Schmervice = require('@hapipal/schmervice')
const { SlackWebhookMessager } = require('./SlackWebhookMessager.js')

class NotificationService extends Schmervice.Service {
constructor(server, schmerviceOptions, { messagers }) {
super(server, schmerviceOptions)

if (!messagers || messagers.length < 1) {
server.logger.warn(
'No messagers provided to NotificationService, notifications will not be sent.'
)
} else {
const messagersName = messagers.map((m) => m.name).join(', ')
server.logger.info(
`Init NotificationService with messagers: ${messagersName}`
)
}

this.messagers = messagers
}

async sendNewAppNotifications({
appName,
imageUrl,
link,
organisationName,
sourceUrl,
}) {
const newAppMessagers = this.messagers.filter(
(m) => !!m.sendNewAppNotification
)
if (newAppMessagers.length < 1) {
return Promise.resolve(null)
}

const promises = newAppMessagers.map((messager) => {
this.server.logger.info(
`Sending new app notification, using messager: ${messager.name}`
)
return messager.sendNewAppNotification({
appName,
imageUrl,
link,
organisationName,
sourceUrl,
})
})
return Promise.all(promises)
}
}

const createNotificationService = (server, schmerviceOptions) => {
const service = new NotificationService(server, schmerviceOptions, {
messagers: createMessagers(server),
})
return Schmervice.withName('notificationService', service)
}

const createMessagers = (server) => {
const messagers = []
const { config } = server.realm.settings.bind
if (config.slack?.webhookUrl) {
const slackMessager = new SlackWebhookMessager(
'slack',
config.slack.webhookUrl
)
messagers.push(slackMessager)
}
return messagers
}

module.exports = { NotificationService, createNotificationService }
98 changes: 98 additions & 0 deletions server/src/services/NotificationService/SlackWebhookMessager.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
const { IncomingWebhook } = require('@slack/webhook')
const debug = require('debug')(
'apphub:server:services:NotificationService:SlackWebhookMessager'
)
const {
NotificationMessager,
messagerTypes,
} = require('./NotificationMessager.js')

class SlackWebhookMessager extends NotificationMessager {
constructor(name, slackWebhookURL) {
super(name, { type: messagerTypes.WEBHOOK })
this.webhook = new IncomingWebhook(slackWebhookURL)
}

/**
* @param slackWebHookSendArguments
* see https://github.com/slackapi/node-slack-sdk/blob/3498055d6c86beb82d51ddd583e123461379f5b7/packages/webhook/src/IncomingWebhook.ts#L99
* shape of {
username?: string;
icon_emoji?: string;
icon_url?: string;
channel?: string;
text?: string;
link_names?: boolean;
agent?: Agent;
timeout?: number;
attachments?: MessageAttachment[];
blocks?: (KnownBlock | Block)[];
unfurl_links?: boolean;
unfurl_media?: boolean;
metadata?: {
event_type: string;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
event_payload: Record<string, any>;
}
*/
async send(slackWebHookSendArguments) {
debug(
'Sending notification',
JSON.stringify(slackWebHookSendArguments, null, 2)
)
return this.webhook.send(slackWebHookSendArguments)
}

async sendNewAppNotification({
appName,
organisationName,
imageUrl,
sourceUrl,
link,
}) {
// see https://app.slack.com/block-kit-builder
const blocks = [
{
type: 'header',
text: {
type: 'plain_text',
text: 'A new app has been uploaded',
},
},
{
type: 'section',
text: {
type: 'mrkdwn',
text: `*Name:*\n${appName}\n*Organisation:*\n${organisationName}`,
},
accessory: {
type: 'image',
image_url: imageUrl,
alt_text: 'app logo',
},
},
{
type: 'section',
text: {
type: 'mrkdwn',
text: `*Source code*:\n <${sourceUrl}>`,
},
accessory: {
type: 'button',
text: {
type: 'plain_text',
text: 'Review now',
},
url: link,
},
},
]

return this.send({ blocks })
}
}

module.exports = {
default: SlackWebhookMessager,
SlackWebhookMessager,
}
16 changes: 10 additions & 6 deletions server/src/utils/getServerUrl.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
/***
* Returns the server url for the backend itself based on the request. If we're running behind a proxy, try to fetch the forwarded protocol
* @param {object} request incoming hapijs request
/**
* Returns the server API url for the backend itself based on the request. If we're running behind a proxy, try to fetch the forwarded protocol
* If { base: true } is passed, return the server base url (without /api)
* @param {Object} request incoming hapijs request
* @param {Object} options options objects
* @param {boolean} options.base if true, return server base url (without /api)
*/
const getServerUrl = request => {
const getServerUrl = (request, { base } = { base: false }) => {
const protocol =
request.headers['x-forwarded-proto'] || request.server.info.protocol
const host = request.headers['x-forwarded-host'] || request.info.hostname
Expand Down Expand Up @@ -32,7 +35,8 @@ const getServerUrl = request => {
portToUseInUrl = `:${port}`
}

return `${protocol}://${host}${portToUseInUrl}/api`
}
const apiPart = base ? '' : '/api'

return `${protocol}://${host}${portToUseInUrl}${apiPart}`
}
module.exports = getServerUrl
Loading

0 comments on commit 3b0b708

Please sign in to comment.