From 5a7c063771f33295c5b3cbde7bb6759f1161cb6d Mon Sep 17 00:00:00 2001 From: Dimitrie Hoekstra Date: Fri, 21 Aug 2026 14:49:33 +0200 Subject: [PATCH 1/6] announcements: target specific teams, rich body, and a toast The Notifications Hub could only address whole team types with a plain text message whose only affordance was a card-wide link. Migrating a named set of teams onto a new product needs narrower targeting and a message that can explain itself. - audience: search teams by name and target them explicitly; the team type filter is ignored when specific teams are selected - body: optional markdown, a validated YouTube embed, and a button with its own label and url; the admin form previews exactly what is sent - video and button urls are normalised and validated server side, so the embed src is built from a known-good id rather than admin input - rich announcements render in a card that is not itself a link, because the body owns its links, video and button; plain announcements keep the existing card-wide behaviour - unread announcements also surface as a bottom-right toast, cleared when the notifications drawer is opened Also fixes the billing state audience filter, which only applied when a team type filter was set. --- forge/db/models/User.js | 19 +- forge/lib/announcements.js | 94 ++++++ forge/routes/api/admin.js | 58 +++- frontend/src/api/admin.js | 4 +- .../notifications/NotificationsDrawer.vue | 12 + .../components/notifications/Announcement.vue | 101 ++++++ .../announcements/AnnouncementBody.vue | 199 ++++++++++++ .../announcements/AnnouncementToasts.vue | 193 ++++++++++++ frontend/src/layouts/Platform.vue | 4 + frontend/src/pages/admin/NotificationsHub.vue | 288 +++++++++++++++--- test/unit/forge/lib/announcements_spec.js | 82 +++++ test/unit/forge/routes/api/admin_spec.js | 104 +++++++ 12 files changed, 1105 insertions(+), 53 deletions(-) create mode 100644 forge/lib/announcements.js create mode 100644 frontend/src/components/notifications/Announcement.vue create mode 100644 frontend/src/components/notifications/announcements/AnnouncementBody.vue create mode 100644 frontend/src/components/notifications/announcements/AnnouncementToasts.vue create mode 100644 test/unit/forge/lib/announcements_spec.js diff --git a/forge/db/models/User.js b/forge/db/models/User.js index 86f29680e5..905a3b8329 100644 --- a/forge/db/models/User.js +++ b/forge/db/models/User.js @@ -314,6 +314,7 @@ module.exports = { * @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 * @param {Array} options.teamTypes limit to teams of certain types + * @param {Array} options.teams limit to an explicit list of team ids * @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 */ @@ -349,15 +350,19 @@ module.exports = { } } } + if (options.teams) { + // Target an explicit set of teams, by raw id + query.include.include.where.id = { [Op.in]: options.teams } + } 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.billing) { + query.include.include.include = { + model: app.db.models.Subscription, + attributes: ['status'], + where: { + status: { [Op.in]: options.billing.map(opt => opt.toLowerCase()) } } } } diff --git a/forge/lib/announcements.js b/forge/lib/announcements.js new file mode 100644 index 0000000000..d17de38675 --- /dev/null +++ b/forge/lib/announcements.js @@ -0,0 +1,94 @@ +/** + * Helpers for platform announcements. + * + * Announcement bodies are authored by platform admins and rendered in the + * notifications drawer. Anything that ends up inside an iframe src is + * normalised here, server side, so the front-end never builds an embed URL + * out of free text. + */ + +const YOUTUBE_ID = /^[A-Za-z0-9_-]{11}$/ + +/** + * Turn a user-supplied video link into a structured, safe reference. + * + * Only YouTube is supported. The video id is extracted and validated, so the + * front-end can build the embed URL from a known-good id rather than from the + * string an admin typed. + * + * @param {string} value a YouTube watch, share or embed URL, or a bare video id + * @returns {{provider: string, id: string}|null} null when nothing usable was found + */ +function parseVideoReference (value) { + if (typeof value !== 'string') { + return null + } + const trimmed = value.trim() + if (!trimmed) { + return null + } + if (YOUTUBE_ID.test(trimmed)) { + return { provider: 'youtube', id: trimmed } + } + let url + try { + url = new URL(trimmed) + } catch (_err) { + return null + } + if (url.protocol !== 'https:' && url.protocol !== 'http:') { + return null + } + const host = url.hostname.replace(/^www\./, '') + let candidate = null + if (host === 'youtu.be') { + candidate = url.pathname.slice(1) + } else if (host === 'youtube.com' || host === 'm.youtube.com' || host === 'youtube-nocookie.com') { + if (url.pathname === '/watch') { + candidate = url.searchParams.get('v') + } else if (url.pathname.startsWith('/embed/')) { + candidate = url.pathname.slice('/embed/'.length) + } else if (url.pathname.startsWith('/shorts/')) { + candidate = url.pathname.slice('/shorts/'.length) + } else if (url.pathname.startsWith('/live/')) { + candidate = url.pathname.slice('/live/'.length) + } + } + if (candidate && YOUTUBE_ID.test(candidate)) { + return { provider: 'youtube', id: candidate } + } + return null +} + +/** + * Validate an admin-supplied call-to-action. + * + * @param {{label: string, url: string}} value + * @returns {{label: string, url: string}|null} null when incomplete or not an http(s) link + */ +function parseCallToAction (value) { + if (!value || typeof value !== 'object') { + return null + } + const label = typeof value.label === 'string' ? value.label.trim() : '' + const target = typeof value.url === 'string' ? value.url.trim() : '' + if (!label || !target) { + return null + } + let url + try { + url = new URL(target, 'https://placeholder.invalid') + } catch (_err) { + return null + } + const isRelative = target.startsWith('/') + if (!isRelative && url.protocol !== 'https:' && url.protocol !== 'http:') { + return null + } + return { label, url: target } +} + +module.exports = { + parseVideoReference, + parseCallToAction +} diff --git a/forge/routes/api/admin.js b/forge/routes/api/admin.js index b12b0b2486..d4942a86fd 100644 --- a/forge/routes/api/admin.js +++ b/forge/routes/api/admin.js @@ -1,5 +1,6 @@ const { Op } = require('sequelize') +const { parseCallToAction, parseVideoReference } = require('../../lib/announcements.js') const { Roles } = require('../../lib/roles.js') module.exports = async function (app) { @@ -453,17 +454,29 @@ module.exports = async function (app) { type: 'object', required: ['message', 'title', 'filter'], properties: { - message: { type: 'string' }, - title: { type: 'string' }, + message: { type: 'string', maxLength: 4000 }, + title: { type: 'string', maxLength: 120 }, filter: { type: 'object', properties: { - roles: { type: 'array', items: { type: 'number' } } + roles: { type: 'array', items: { type: 'number' } }, + teamTypes: { type: 'array', items: { type: 'string' } }, + teams: { type: 'array', items: { type: 'string' }, maxItems: 500 }, + billing: { type: 'array', items: { type: 'string' } } } }, mock: { type: 'boolean' }, to: { type: 'object' }, - url: { type: 'string' } + url: { type: 'string' }, + format: { type: 'string', enum: ['plain', 'markdown'] }, + video: { type: 'string', maxLength: 300 }, + cta: { + type: 'object', + properties: { + label: { type: 'string', maxLength: 40 }, + url: { type: 'string', maxLength: 500 } + } + } } }, response: { @@ -486,7 +499,10 @@ module.exports = async function (app) { filter, mock, to, - url + url, + format, + video, + cta } = request.body const recipientRoles = filter?.roles @@ -497,14 +513,30 @@ module.exports = async function (app) { if (filter?.teamTypes && filter.teamTypes.length > 0) { teamTypes = filter.teamTypes.map(app.db.models.TeamType.decodeHashid).flat() } + let teams + if (filter?.teams && filter.teams.length > 0) { + const decoded = filter.teams.map(hashid => app.db.models.Team.decodeHashid(hashid)).flat() + // decodeHashid is lenient - anything that is not a real id is rejected + // here rather than quietly narrowing the audience to nothing. + if (decoded.length !== filter.teams.length || decoded.some(id => !Number.isInteger(id) || id <= 0)) { + return reply.code(400).send({ code: 'bad_request', error: 'Invalid team provided.' }) + } + teams = decoded + } let billing if (filter?.billing && filter.billing.length > 0) { billing = filter.billing } + if (video && !parseVideoReference(video)) { + return reply.code(400).send({ code: 'bad_request', error: 'Unsupported video link. Provide a YouTube URL.' }) + } + if (cta && Object.keys(cta).length > 0 && !parseCallToAction(cta)) { + return reply.code(400).send({ code: 'bad_request', error: 'A button needs both a label and an http(s) or relative url.' }) + } 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, { teamTypes, billing, summary: true, count: true }) + const count = await app.db.models.User.byTeamRole(recipientRoles, { teamTypes, teams, billing, summary: true, count: true }) reply.send({ mock: true, recipientCount: count @@ -512,12 +544,22 @@ module.exports = async function (app) { return } - const recipients = await app.db.models.User.byTeamRole(recipientRoles, { teamTypes, billing, summary: true }) + const recipients = await app.db.models.User.byTeamRole(recipientRoles, { teamTypes, teams, billing, 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) const reference = `${uniqueId}:${titleSlug}` - const data = { title, message, ...(to && { to }), ...(url && { url }) } + const videoReference = parseVideoReference(video) + const callToAction = parseCallToAction(cta) + const data = { + title, + message, + ...(format === 'markdown' && { format }), + ...(videoReference && { video: videoReference }), + ...(callToAction && { cta: callToAction }), + ...(to && { to }), + ...(url && { url }) + } await app.notifications.sendBulk( recipients, notificationType, diff --git a/frontend/src/api/admin.js b/frontend/src/api/admin.js index 35cc208d3d..4d2c7fdf77 100644 --- a/frontend/src/api/admin.js +++ b/frontend/src/api/admin.js @@ -77,8 +77,8 @@ const getAnnouncementNotifications = async () => { }) } -const sendAnnouncementNotification = async ({ title, message, filter, mock, to, url }) => { - return client.post('/api/v1/admin/announcements', { message, title, filter, mock, to, url }) +const sendAnnouncementNotification = async ({ title, message, filter, mock, to, url, format, video, cta }) => { + return client.post('/api/v1/admin/announcements', { message, title, filter, mock, to, url, format, video, cta }) .then(res => { return res.data }) diff --git a/frontend/src/components/drawers/notifications/NotificationsDrawer.vue b/frontend/src/components/drawers/notifications/NotificationsDrawer.vue index f82180b80a..477e33eea3 100644 --- a/frontend/src/components/drawers/notifications/NotificationsDrawer.vue +++ b/frontend/src/components/drawers/notifications/NotificationsDrawer.vue @@ -80,6 +80,7 @@ import { markRaw } from 'vue' import userAPI from '../../../api/user.js' import alerts from '../../../services/alerts.js' +import AnnouncementNotification from '../../notifications/Announcement.vue' import GenericNotification from '../../notifications/Generic.vue' import TeamInvitationAcceptedNotification from '../../notifications/invitations/Accepted.vue' @@ -131,6 +132,13 @@ export default { this.closeRightDrawer() }, getNotificationsComponent (notification) { + if (notification.type === 'announcement') { + // Rich announcements own their click targets (links, video, + // button). A plain one stays a single card-wide link. + return this.isRichAnnouncement(notification) + ? markRaw(AnnouncementNotification) + : markRaw(GenericNotification) + } let comp = this.componentCache[notification.type] if (comp) { return comp @@ -151,6 +159,10 @@ export default { this.componentCache[notification.type] = comp return comp }, + isRichAnnouncement (notification) { + const data = notification.data || {} + return data.format === 'markdown' || !!data.video || !!data.cta + }, onSelected (notification) { this.selections.push(notification) }, diff --git a/frontend/src/components/notifications/Announcement.vue b/frontend/src/components/notifications/Announcement.vue new file mode 100644 index 0000000000..8ee12b722a --- /dev/null +++ b/frontend/src/components/notifications/Announcement.vue @@ -0,0 +1,101 @@ + + + + + diff --git a/frontend/src/components/notifications/announcements/AnnouncementBody.vue b/frontend/src/components/notifications/announcements/AnnouncementBody.vue new file mode 100644 index 0000000000..c4cfc7a585 --- /dev/null +++ b/frontend/src/components/notifications/announcements/AnnouncementBody.vue @@ -0,0 +1,199 @@ +