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 the ability to emit platform wide notifications #4637

Merged
merged 24 commits into from
Nov 6, 2024
Merged
Show file tree
Hide file tree
Changes from 20 commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
bb2f11d
Add a new notifications hub admin page component and related menu entry
cstns Oct 9, 2024
b71828d
frontend api call scaffolding
cstns Oct 10, 2024
4880c35
Merge remote-tracking branch 'origin/main' into platform-wide-notific…
cstns Oct 11, 2024
d9ebc97
naive implementation of backend API
cstns Oct 11, 2024
c2f43d8
add admin ui to send an announcement
cstns Oct 11, 2024
797c566
add the ability to send a mock announcement request and form validati…
cstns Oct 11, 2024
02dd06c
change text key to message to comply with notification payload
cstns Oct 11, 2024
a4aed86
Merge remote-tracking branch 'origin/main' into platform-wide-notific…
cstns Oct 14, 2024
59dd18d
add url to notification
cstns Oct 14, 2024
865149f
add url params to notifications backend
cstns Oct 14, 2024
a7dcc0a
reformat
cstns Oct 14, 2024
6aeb4f0
fix the byTeamRole query to include admins
cstns Oct 14, 2024
8df4115
prevent mark notification as read api call if notification already read
cstns Oct 14, 2024
e67d4c1
qf instance crash notification not redirecting
cstns Oct 14, 2024
6aa276a
Merge remote-tracking branch 'origin/main' into platform-wide-notific…
cstns Oct 14, 2024
25665a5
fix admin menu entry data attr
cstns Oct 14, 2024
14d7cb1
add e2e tests for the admin notification-hub + locators
cstns Oct 14, 2024
14cf1b1
add e2e tests for the notification pill
cstns Oct 14, 2024
a5c9e04
remove the external url selector forcing external url for platform wi…
cstns Oct 21, 2024
fcf5bbf
Merge branch 'main' into platform-wide-notifications
cstns Oct 24, 2024
cfd9c50
Refactor to send notifications in batches of 200
knolleary Nov 5, 2024
cd4b824
Cosmetic improvement on the notification role selection
knolleary Nov 5, 2024
949de2f
Fix ui tests following cosmetic change
knolleary Nov 5, 2024
4ea0309
Add unit tests for admin notifications api
knolleary Nov 6, 2024
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
17 changes: 17 additions & 0 deletions forge/db/models/User.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
*/
const { DataTypes, Op, fn, col, where } = require('sequelize')

const { Roles } = require('../../lib/roles.js')
const { hash, generateUserAvatar, buildPaginationSearchClause } = require('../utils')

module.exports = {
Expand Down Expand Up @@ -279,6 +280,22 @@ module.exports = {
count,
users: rows
}
},
byTeamRole: async (roles = []) => {
const includesAdmins = roles.includes(Roles.Admin)

return M.User.findAll({
where: {
[Op.or]: [
includesAdmins ? { admin: 1 } : {},
{ '$TeamMembers.role$': { [Op.in]: roles } }
]
},
include: {
model: M.TeamMember,
attributes: ['role']
}
})
}
},
instance: {
Expand Down
1 change: 1 addition & 0 deletions forge/lib/permissions.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ const Permissions = {
'user:edit': { description: 'Edit User Information', role: Roles.Admin, self: true },
'user:delete': { description: 'Delete User', role: Roles.Admin, self: true },
'user:team:list': { description: 'List a Users teams', role: Roles.Admin, self: true },
'user:announcements:manage': { description: 'Manage platform wide announcements', role: Roles.Admin },
// Team Scoped Actions
'team:create': { description: 'Create Team' },
'team:list': { description: 'List Teams', role: Roles.Admin },
Expand Down
90 changes: 90 additions & 0 deletions forge/routes/api/admin.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
const { Op } = require('sequelize')

const { Roles } = require('../../lib/roles.js')

module.exports = async function (app) {
async function getStats () {
const userCount = await app.db.models.User.count({ attributes: ['admin'], group: 'admin' })
Expand Down Expand Up @@ -392,4 +394,92 @@ module.exports = async function (app) {
await app.db.controllers.AccessToken.removePlatformStatisticsToken()
reply.send({ status: 'okay' })
})

// announcements
app.get('/announcements', {
preHandler: app.needsPermission('user:announcements:manage'),
schema: {
summary: 'Get platform wide announcements',
tags: ['Platform', 'Notifications', 'Announcements'],
response: {
200: {
type: 'object',
properties: {}
},
'4xx': {
$ref: 'APIError'
}
}
}
}, async (request, reply) => {
// tbd
})

app.post('/announcements', {
preHandler: app.needsPermission('user:announcements:manage'),
schema: {
summary: 'Send platform wide announcements',
tags: ['Platform', 'Notifications', 'Announcements'],
body: {
type: 'object',
required: ['message', 'title', 'recipientRoles'],
properties: {
message: { type: 'string' },
title: { type: 'string' },
recipientRoles: { type: 'array', items: { type: 'number' } },
mock: { type: 'boolean' },
to: { type: 'object' },
url: { type: 'string' }
}
},
response: {
200: {
type: 'object',
properties: {
recipientCount: { type: 'number' }
}
},
'4xx': {
$ref: 'APIError'
}
}
}
}, async (request, reply) => {
const {
title,
message,
recipientRoles,
mock,
to,
url
} = request.body

if (!recipientRoles.every(value => Object.values(Roles).includes(value))) {
return reply.code(400).send({ code: 'bad_request', error: 'Invalid Role provided.' })
}

const recipients = await app.db.models.User.byTeamRole(recipientRoles)
const notificationType = 'announcement'
const titleSlug = title.replace(/[^a-zA-Z0-9-]/g, '-').toLowerCase()
const uniqueId = Date.now().toString(36) + Math.random().toString(36).substring(2)
const reference = `${uniqueId}:${titleSlug}`

const data = { title, message, recipientRoles, ...(to && { to }), ...(url && { url }) }

if (!mock || mock === false) {
for (const recipient of recipients) {
await app.notifications.send(
recipient,
notificationType,
data,
reference,
{ upsert: false }
)
}
}

reply.send({
recipientCount: recipients.length
})
})
}
20 changes: 19 additions & 1 deletion frontend/src/api/admin.js
Original file line number Diff line number Diff line change
Expand Up @@ -51,11 +51,27 @@ const generateStatsAccessToken = async () => {
return res.data
})
}

const deleteStatsAccessToken = async () => {
return client.delete('/api/v1/admin/stats-token').then(res => {
return res.data
})
}

const getAnnouncementNotifications = async () => {
return client.get('/api/v1/admin/announcements')
.then(res => {
return res.data
})
}

const sendAnnouncementNotification = async ({ title, message, recipientRoles, mock, to, url }) => {
return client.post('/api/v1/admin/announcements', { message, title, recipientRoles, mock, to, url })
.then(res => {
return res.data
})
}

/**
* Calls api routes in admin.js
* See [routes/api/admin.js](../../../forge/routes/api/admin.js)
Expand All @@ -67,5 +83,7 @@ export default {
getInvitations,
getPlatformAuditLog,
generateStatsAccessToken,
deleteStatsAccessToken
deleteStatsAccessToken,
getAnnouncementNotifications,
sendAnnouncementNotification
}
23 changes: 18 additions & 5 deletions frontend/src/components/notifications/Generic.vue
Original file line number Diff line number Diff line change
Expand Up @@ -60,16 +60,29 @@ export default {
},
computed: {
to () {
if (typeof this.notification.data?.to === 'object') { return this.notification.data.to }
if (typeof this.notification.data?.to === 'string') { return { path: this.notification.data.to } }
if (typeof this.notification.data?.url === 'string') { return { url: this.notification.data.url } }
if (this.notification.data?.instance?.id) {
switch (true) {
case this.notification.data?.to && typeof this.notification.data?.to === 'object':
return this.notification.data.to

case this.notification.data?.to && typeof this.notification.data?.to === 'string':
try {
return JSON.parse(this.notification.data?.to)
} catch (e) {
return { path: this.notification.data.to }
}

case typeof this.notification.data?.instance?.id === 'string':
return {
name: 'instance-overview',
params: { id: this.notification.data.instance.id }
}

case typeof this.notification.data?.url === 'string':
return { url: this.notification.data.url }

default:
return null // no link
}
return null // no link
},
notificationData () {
const event = this.knownEvents[this.notification.type] || {}
Expand Down
10 changes: 8 additions & 2 deletions frontend/src/components/notifications/Notification.vue
Original file line number Diff line number Diff line change
Expand Up @@ -72,14 +72,20 @@ export default {
...mapActions('ux', ['closeRightDrawer']),
go (to) {
this.closeRightDrawer()
this.notification.read = true
userApi.markNotificationRead(this.notification.id)
this.markAsRead()

if (to?.url) {
// Handle external links
window.open(to.url, '_blank').focus()
} else if (to?.name || to?.path) {
this.$router.push(to)
}
},
markAsRead () {
if (!this.notification.read) {
this.notification.read = true
userApi.markNotificationRead(this.notification.id)
}
}
}
}
Expand Down
145 changes: 145 additions & 0 deletions frontend/src/pages/admin/NotificationsHub.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
<template>
<div class="clear-page-gutters">
<div class="ff-instance-header">
<ff-page-header title="Notifications Hub" />
</div>
<div class="px-3 py-3 md:px-6 md:py-6">
<form class="flex flex-col gap-5" data-el="notification-form" @submit.prevent>
<section class="flex gap-10">
<section>
<FormRow v-model="form.title" type="input" placeholder="Title" class="mb-5" data-el="notification-title">
Announcement Title
<template #description>Enter a concise title for your announcement.</template>
</FormRow>
<FormRow v-model="form.message" class="mb-5" data-el="notification-message">
Announcement Text
<template #description>Provide the details of your announcement.</template>
<template #input><textarea v-model="form.message" class="w-full max-h-80 min-h-40" rows="4" /></template>
</FormRow>
<FormRow v-model="form.url" type="input" :placeholder="urlPlaceholder" class="mb-5" data-el="notification-external-url">
URL Link
<template #description>Provide an url where users will be redirected when they click on the notification.</template>
</FormRow>
</section>
<section>
<label class="block text-sm font-medium mb-1">Audience</label>
<div class="ff-description mb-2 space-y-1">Select the audience of your announcement.</div>

<label class="block text-sm font-medium mb-2">By User Roles</label>
<label
v-for="(role, $key) in roleIds"
:key="$key"
class="ff-checkbox mb-2"
:data-el="`audience-role-${role}`"
@keydown.space.prevent="toggleRole(role)"
>
<span ref="input" class="checkbox" :checked="form.roles.includes(role)" tabindex="0" @keydown.space.prevent />
<input v-model="form.roles" type="checkbox" :value="role" @keydown.space.prevent>
{{ role }}
</label>
</section>
</section>
<section class="actions">
<ff-button :disabled="!canSubmit" data-action="submit" @click.stop.prevent="submitForm">
Send Announcement
</ff-button>
</section>
</form>
</div>
</div>
</template>

<script>
import adminApi from '../../api/admin.js'
import FormRow from '../../components/FormRow.vue'
import alerts from '../../services/alerts.js'
import Dialog from '../../services/dialog.js'
import FfButton from '../../ui-components/components/Button.vue'
import { RoleNames, Roles } from '../../utils/roles.js'

export default {
name: 'NotificationsHub',
components: { FfButton, FormRow },
data () {
return {
form: {
title: '',
message: '',
url: '',
roles: [],
externalUrl: true
},
errors: {

}
}
},
computed: {
roleIds () {
return Object.values(RoleNames).filter(r => r !== 'none')
},
canSubmit () {
return this.form.title.length > 0 &&
this.form.message.length > 0 &&
this.form.roles.length > 0
},
urlPlaceholder () {
return this.form.externalUrl ? 'https://flowfuse.com' : '{ name: "<component-name>", params: {id: "<id>"} }'
}
},
methods: {
getAnnouncements () {
return adminApi.getAnnouncementNotifications()
.then(res => console.info(res))
},
submitForm () {
return this.sendAnnouncementNotification({ mock: true })
.then(mockRes => Dialog.show({
header: 'Platform Wide Announcement',
kind: 'danger',
text: `You are about to send an announcement to ${mockRes.recipientCount} recipients.`,
confirmLabel: 'Continue'
}, async () => this.sendAnnouncementNotification({ mock: false })))
},
sendAnnouncementNotification ({ mock = true }) {
const form = { ...this.form }
delete form.url

const payload = {
mock,
...form,
recipientRoles: this.form.roles.map(r => Roles[r]),
...(this.form.externalUrl ? { url: this.form.url } : { to: JSON.parse(this.form.url) })
}

return adminApi.sendAnnouncementNotification(payload)
.then(res => {
if (!mock) {
alerts.emit(`Announcement sent to ${res.recipientCount} recipients.`, 'confirmation')
this.form.title = ''
this.form.message = ''
this.form.url = ''
this.form.roles = []
}
return res
})
.catch(err => {
alerts.emit('Something went wrong', 'warning')
console.warn(err)
})
},
toggleRole (role) {
if (this.form.roles.includes(role)) {
this.form.roles = this.form.roles.filter(r => r !== role)
} else this.form.roles.push(role)
}

}
}
</script>

<style scoped lang="scss">
.clear-page-gutters {
margin: -1.75rem
}
</style>
Loading
Loading