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 more filters for admin notification targeting #4843

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
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
29 changes: 26 additions & 3 deletions forge/db/models/User.js
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,7 @@ module.exports = {
this.hasMany(M.Invitation, { foreignKey: 'inviteeId' })
this.belongsTo(M.Team, { as: 'defaultTeam' })
},
finders: function (M) {
finders: function (M, app) {
return {
static: {
admins: async () => {
Expand Down Expand Up @@ -285,8 +285,11 @@ module.exports = {
* Get users with a particular role
* @param {Array} roles An array of valid user roles
* @param {Object} options Options
* @param {Boolean} options.count only return a count of results
* @param {Boolean} options.summary whether to return a limited user object that only contains id: default false
* @returns Array of users who have at least one of the specific roles
* @param {Array} options.teamTypes limit to teams of certain types
* @param {Array} options.billing array of billing states to include
* @returns Array of users who have at least one of the specific roles, or a count
*/
byTeamRole: async (roles = [], options) => {
options = {
Expand All @@ -309,7 +312,27 @@ module.exports = {
where,
include: {
model: M.TeamMember,
attributes: ['role']
attributes: ['role'],
include: {
model: M.Team,
attributes: ['suspended', 'TeamTypeId'],
where: {
// Never include suspended teams
suspended: false
}
}
}
}
if (options.teamTypes) {
query.include.include.where.TeamTypeId = { [Op.in]: options.teamTypes }
if (options.billing) {
query.include.include.include = {
model: app.db.models.Subscription,
attributes: ['status'],
where: {
status: { [Op.in]: options.billing.map(opt => opt.toLowerCase()) }
}
}
}
}
if (!options.count) {
Expand Down
12 changes: 10 additions & 2 deletions forge/routes/api/admin.js
Original file line number Diff line number Diff line change
Expand Up @@ -449,18 +449,26 @@ module.exports = async function (app) {
if (recipientRoles && !recipientRoles.every(value => Object.values(Roles).includes(value))) {
return reply.code(400).send({ code: 'bad_request', error: 'Invalid Role provided.' })
}
let teamTypes
if (filter?.teamTypes && filter.teamTypes.length > 0) {
teamTypes = filter.teamTypes.map(app.db.models.TeamType.decodeHashid).flat()
}
let billing
if (filter?.billing && filter.billing.length > 0) {
billing = filter.billing
}
if (mock) {
// If mock is sent, return an indication of how many users would receive this notification
// without actually sending them.
const count = await app.db.models.User.byTeamRole(recipientRoles, { summary: true, count: true })
const count = await app.db.models.User.byTeamRole(recipientRoles, { teamTypes, billing, summary: true, count: true })
reply.send({
mock: true,
recipientCount: count
})
return
}

const recipients = await app.db.models.User.byTeamRole(recipientRoles, { summary: true })
const recipients = await app.db.models.User.byTeamRole(recipientRoles, { teamTypes, summary: true })
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)
Expand Down
11 changes: 10 additions & 1 deletion frontend/src/composables/String.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,16 @@ export const isValidURL = (string) => {
export const capitalize = (string) => {
return string.charAt(0).toUpperCase() + string.slice(1)
}

/**
* Conditionally pluralize a string
* @param {String} stem the text to pluralize based on a count
* @param {Number} count the value to pluralize for
* @param {String} append (optional) what characters to add if pluralizing. Default: 's'
* @returns The pluralized string if count requires a plural
*/
export const pluralize = (stem, count, append = 's') => {
return stem + ((count === 1) ? '' : append)
}
/**
* @param {Date} date
* @returns {`${number}-${string}-${string}-${string}:${string}`}
Expand Down
154 changes: 127 additions & 27 deletions frontend/src/pages/admin/NotificationsHub.vue
Original file line number Diff line number Diff line change
Expand Up @@ -22,21 +22,53 @@
</FormRow>
</section>
<section>
<label class="block text-sm font-medium mb-1">Audience</label>
<FormHeading>Audience</FormHeading>
<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>
<FormHeading class="mt-4">User Roles:</FormHeading>
<div class="grid gap-1 grid-cols-2 items-middle">
<label
v-for="(role, $key) in roleIds"
:key="$key"
class="ff-checkbox text-sm"
: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>
</div>
<FormHeading class="mt-4">Team Types:</FormHeading>
<div class="grid gap-1 grid-cols-2 items-middle">
<label
v-for="teamType in teamTypes"
:key="teamType.id"
class="ff-checkbox text-sm"
:class="!teamType.active ? ['inactive-team'] : []"
:data-el="`audience-teamType-${teamType.id}`"
@keydown.space.prevent="toggleTeamType(teamType.id)"
>
<span ref="input" class="checkbox" :checked="form.teamTypes.includes(teamType.id)" tabindex="0" @keydown.space.prevent />
<input v-model="form.teamTypes" type="checkbox" :value="teamType.id" @keydown.space.prevent>
{{ teamType.name }}
</label>
</div>
<template v-if="features.billing">
<FormHeading class="mt-4">Billing State:</FormHeading>
<div class="grid gap-1 grid-cols-2 items-middle">
<label
v-for="(billingState, $key) in billingStates"
:key="$key"
class="ff-checkbox text-sm"
:data-el="`audience-billing-${billingState}`"
@keydown.space.prevent="toggleBillingState(billingState)"
>
<span ref="input" class="checkbox" :checked="form.billing.includes(billingState)" tabindex="0" @keydown.space.prevent />
<input v-model="form.billing" type="checkbox" :value="billingState" @keydown.space.prevent>
{{ billingState }}
</label>
</div>
</template>
</section>
</section>
<section class="actions">
Expand All @@ -50,78 +82,133 @@
</template>

<script>
import { mapState } from 'vuex'

import adminApi from '../../api/admin.js'
import teamTypesApi from '../../api/teamTypes.js'

import FormHeading from '../../components/FormHeading.vue'
import FormRow from '../../components/FormRow.vue'
import { pluralize } from '../../composables/String.js'
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 },
components: { FfButton, FormRow, FormHeading },
data () {
return {
form: {
title: '',
message: '',
url: '',
roles: [],
teamTypes: [],
billing: [],
externalUrl: true
},
teamTypes: [],
billingStates: [
'Active',
'Trial',
'Unmanaged',
'Canceled'
],
errors: {

}
}
},
computed: {
...mapState('account', ['features']),
roleIds () {
return Object.values(RoleNames).filter(r => r !== 'none').reverse().map(r => r[0].toUpperCase() + r.substring(1))
},
canSubmit () {
return this.form.title.length > 0 &&
this.form.message.length > 0 &&
this.form.roles.length > 0
this.form.roles.length > 0 &&
this.form.teamTypes.length > 0 &&
(!this.features.billing || this.form.billing.length > 0)
},
urlPlaceholder () {
return this.form.externalUrl ? 'https://flowfuse.com' : '{ name: "<component-name>", params: {id: "<id>"} }'
}
},
async created () {
const teamTypes = (await teamTypesApi.getTeamTypes(null, null, 'all')).types
this.teamTypes = teamTypes.map(tt => {
return {
order: tt.order,
id: tt.id,
name: tt.name,
active: tt.active
}
})
this.teamTypes.sort((A, B) => {
if (A.active === B.active) {
return A.order - B.order
} else if (A.active) {
return -1
} else {
return 1
}
})
},
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 })))
.then(mockRes => {
if (mockRes.recipientCount === 0) {
Dialog.show({
header: 'Platform Wide Announcement',
text: 'Your filters matched no users.',
confirmLabel: 'Cancel',
canBeCanceled: false
})
} else {
Dialog.show({
header: 'Platform Wide Announcement',
kind: 'danger',
text: `You are about to send an announcement to ${mockRes.recipientCount} ${pluralize('user', mockRes.recipientCount)}.`,
confirmLabel: 'Continue'
}, async () => this.sendAnnouncementNotification({ mock: false }))
}
})
},
sendAnnouncementNotification ({ mock = true }) {
const form = { ...this.form }
delete form.url

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

if (this.features.billing) {
payload.filter.billing = [...this.form.billing]
}
return adminApi.sendAnnouncementNotification(payload)
.then(res => {
if (!mock) {
alerts.emit(`Announcement sent to ${res.recipientCount} recipients.`, 'confirmation')
alerts.emit(`Announcement sent to ${res.recipientCount} ${pluralize('user', res.recipientCount)}.`, 'confirmation')
this.form.title = ''
this.form.message = ''
this.form.url = ''
this.form.roles = []
this.form.teamTypes = []
this.form.billing = []
}
return res
})
Expand All @@ -134,13 +221,26 @@ export default {
if (this.form.roles.includes(role)) {
this.form.roles = this.form.roles.filter(r => r !== role)
} else this.form.roles.push(role)
},
toggleTeamType (teamTypeId) {
if (this.form.teamTypes.includes(teamTypeId)) {
this.form.teamTypes = this.form.teamTypes.filter(r => r !== teamTypeId)
} else this.form.teamTypes.push(teamTypeId)
},
toggleBillingState (billingState) {
if (this.form.billing.includes(billingState)) {
this.form.billing = this.form.billing.filter(r => r !== billingState)
} else this.form.billing.push(billingState)
}

}
}
</script>

<style scoped lang="scss">
.inactive-team {
color: $ff-grey-400;
}
.clear-page-gutters {
margin: -1.75rem
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ describe('FlowForge - Notifications Hub', () => {
cy.get('[data-el="notification-external-url"] input').type('https://flowfuse.com')
cy.get('[data-action="submit"]').should('be.disabled')
cy.get('[data-el="audience-role-Owner"]').click()
cy.get('[data-action="submit"]').should('be.disabled')
cy.get('[data-el^="audience-teamType-"]').click()
cy.get('[data-action="submit"]').should('not.be.disabled')

cy.get('[data-el="platform-dialog"]').should('not.be.visible')
Expand All @@ -42,7 +44,7 @@ describe('FlowForge - Notifications Hub', () => {
cy.get('[data-el="platform-dialog"]').should('be.visible')

cy.get('[data-el="platform-dialog"] .ff-dialog-header').contains('Platform Wide Announcement')
cy.get('[data-el="platform-dialog"] .ff-dialog-content').contains('You are about to send an announcement to 2 recipients.')
cy.get('[data-el="platform-dialog"] .ff-dialog-content').contains('You are about to send an announcement to 2 users.')

cy.get('[data-action="dialog-cancel"]').click()
cy.get('[data-el="platform-dialog"]').should('not.be.visible')
Expand Down
Loading
Loading