diff --git a/apps/notifications/src/email/notifications/components/notifications/Notification.tsx b/apps/notifications/src/email/notifications/components/notifications/Notification.tsx index 569b16c..f3452c2 100644 --- a/apps/notifications/src/email/notifications/components/notifications/Notification.tsx +++ b/apps/notifications/src/email/notifications/components/notifications/Notification.tsx @@ -578,6 +578,23 @@ const getTitle = (notification) => { ) } + case 'announcement': { + // Render the announcement heading (data.title) above the body. Older + // rows without a title fall back to no heading. + if (!notification.title) return null + return ( + + {notification.title} + + ) + } default: return null } diff --git a/apps/notifications/src/email/notifications/components/notifications/NotificationBody.tsx b/apps/notifications/src/email/notifications/components/notifications/NotificationBody.tsx index 0b33cf8..1b870ff 100644 --- a/apps/notifications/src/email/notifications/components/notifications/NotificationBody.tsx +++ b/apps/notifications/src/email/notifications/components/notifications/NotificationBody.tsx @@ -95,10 +95,22 @@ const OpenAudiusLink = () => ( ) +const DEFAULT_NOTIFICATION_LINK = + 'https://audius.co/feed?openNotifications=true' + +// Announcement rows carry a `route` (app path like `/rewards`, or a full +// audius.co URL already normalized to a path). Build an absolute link so the +// email CTA deep-links to the campaign target. Absent route → feed fallback. +const buildAnnouncementLink = (route?: string): string => { + if (!route) return DEFAULT_NOTIFICATION_LINK + if (route.startsWith('http')) return route + return `https://audius.co${route.startsWith('/') ? route : `/${route}`}` +} + const WrapLink = (props) => { return ( {props.children} @@ -127,7 +139,13 @@ const Body = (props) => { > - + { )} + {props.image_url && ( + + + + )} {hasMultiUser && (
+ +
!notificationsWithoutEmail.has(n.type) ) const notificationCount = validNotifications.length - const emailSubject = `${notificationCount} unread notification${ + let emailSubject = `${notificationCount} unread notification${ notificationCount > 1 ? 's' : '' } on Audius` + // A single announcement (e.g. a re-engagement campaign) gets its heading as + // the subject instead of the generic unread-notification count. Falls back + // to the generic subject when there's no heading or multiple notifications. + if (notificationCount === 1) { + const only = validNotifications[0] + const announcementTitle = + 'type' in only && + only.type === 'announcement' && + 'data' in only && + typeof only.data?.title === 'string' + ? only.data.title.trim() + : '' + if (announcementTitle.length > 0) { + emailSubject = announcementTitle + } + } if (notificationCount === 0) { logger.debug( `renderAndSendNotificationEmail | 0 notifications detected for user ${userId}, bypassing email` diff --git a/apps/notifications/src/processNotifications/mappers/announcement.ts b/apps/notifications/src/processNotifications/mappers/announcement.ts index 0cae713..69d1a50 100644 --- a/apps/notifications/src/processNotifications/mappers/announcement.ts +++ b/apps/notifications/src/processNotifications/mappers/announcement.ts @@ -69,7 +69,12 @@ export class Announcement extends BaseNotification return { type: this.notification.type, title: this.notification.data.title, - text: this.notification.data.short_description + text: this.notification.data.short_description, + // Surfaced in the email so the announcement heading, campaign artwork, + // and CTA deep-link render instead of being dropped. All optional — + // older announcement rows omit them and fall back gracefully. + route: this.notification.data.route, + image_url: this.notification.data.image_url } } @@ -81,13 +86,21 @@ export class Announcement extends BaseNotification this.identityDB, userIds ) + // Absent channels (older rows / non-dashboard callers) default to 'both'. + const channels = this.notification.data.notification_channels ?? 'both' + const sendPush = channels === 'push' || channels === 'both' + const sendEmail = channels === 'email' || channels === 'both' for (const userId of userIds) { - await this.broadcastPushNotificationAnnouncements( - userId, - userNotificationSettings, - isBrowserPushEnabled - ) - await this.broadcastEmailAnnouncements(userId, userNotificationSettings) + if (sendPush) { + await this.broadcastPushNotificationAnnouncements( + userId, + userNotificationSettings, + isBrowserPushEnabled + ) + } + if (sendEmail) { + await this.broadcastEmailAnnouncements(userId, userNotificationSettings) + } } } diff --git a/apps/notifications/src/server/routes/sendNotification.ts b/apps/notifications/src/server/routes/sendNotification.ts index 4cee809..e5c6198 100644 --- a/apps/notifications/src/server/routes/sendNotification.ts +++ b/apps/notifications/src/server/routes/sendNotification.ts @@ -1,8 +1,14 @@ import { Router, Request, Response } from 'express' import { Knex } from 'knex' import { logger } from '../../logger' +import { NotificationChannel } from '../../types/notifications' const containsHtml = (value: string): boolean => /<[^>]*>/.test(value) +const NOTIFICATION_CHANNELS: readonly NotificationChannel[] = [ + 'email', + 'push', + 'both' +] const normalizeRoute = (value: string): string | null => { const input = value.trim() if (!input) return null @@ -34,14 +40,32 @@ export function createSendNotificationRouter(discoveryDb: Knex): Router { res.status(401).json({ error: 'Unauthorized' }) return } - const { title, body, image_url, route, userIds, notification_campaign_id } = - req.body + const { + title, + body, + image_url, + route, + userIds, + notification_campaign_id, + notificationTypes + } = req.body if (!title || !body || !Array.isArray(userIds) || userIds.length === 0) { res.status(400).json({ error: 'Missing required fields: title, body, userIds (non-empty array)' }) return } + // Default to 'both' so existing callers that omit notificationTypes are unaffected. + const channels: NotificationChannel = + notificationTypes == null ? 'both' : notificationTypes + if (!NOTIFICATION_CHANNELS.includes(channels)) { + res.status(400).json({ + error: `Invalid notificationTypes. Use one of: ${NOTIFICATION_CHANNELS.join( + ', ' + )}` + }) + return + } try { const titleText = String(title).trim() @@ -102,7 +126,8 @@ export function createSendNotificationRouter(discoveryDb: Knex): Router { push_body: bodyText, ...(normalizedRoute ? { route: normalizedRoute } : {}), ...(imageUrlText ? { image_url: imageUrlText } : {}), - ...(campaignIdRaw ? { notification_campaign_id: campaignIdRaw } : {}) + ...(campaignIdRaw ? { notification_campaign_id: campaignIdRaw } : {}), + notification_channels: channels } }) logger.info( diff --git a/apps/notifications/src/types/notifications.ts b/apps/notifications/src/types/notifications.ts index 1303979..a7d86ac 100644 --- a/apps/notifications/src/types/notifications.ts +++ b/apps/notifications/src/types/notifications.ts @@ -248,6 +248,9 @@ export type TrendingUndergroundNotification = { time_range: string } +/** Which delivery channels an announcement should fire. Defaults to `both`. */ +export type NotificationChannel = 'email' | 'push' | 'both' + export type AnnouncementNotification = { title: string short_description: string @@ -258,6 +261,8 @@ export type AnnouncementNotification = { image_url?: string /** First-party campaign id (e.g. notifications-dashboard `announcements.id`). */ notification_campaign_id?: string + /** Delivery channels to fire. Absent is treated as `both` for back-compat. */ + notification_channels?: NotificationChannel } export type USDCPurchaseBuyerNotification = {