Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -578,6 +578,23 @@ const getTitle = (notification) => {
</span>
)
}
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 (
<span
className={'avenir notificationText'}
style={{
color: '#858199',
fontSize: '16px',
fontWeight: 'bold'
}}
>
{notification.title}
</span>
)
}
default:
return null
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -95,10 +95,22 @@ const OpenAudiusLink = () => (
</a>
)

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 (
<a
href="https://audius.co/feed?openNotifications=true"
href={props.href || DEFAULT_NOTIFICATION_LINK}
style={{ textDecoration: 'none' }}
>
{props.children}
Expand Down Expand Up @@ -127,7 +139,13 @@ const Body = (props) => {
>
<tr>
<td>
<WrapLink>
<WrapLink
href={
String(props.type).toLowerCase() === 'announcement'
? buildAnnouncementLink(props.route)
: undefined
}
>
<table
border="0"
cellPadding="0"
Expand Down Expand Up @@ -158,6 +176,27 @@ const Body = (props) => {
</td>
</tr>
)}
{props.image_url && (
<tr>
<td
colSpan={'12'}
style={{
padding: '12px 16px 0px'
}}
>
<img
src={props.image_url}
alt=""
style={{
width: '100%',
maxWidth: '364px',
borderRadius: '4px',
display: 'block'
}}
/>
</td>
</tr>
)}
{hasMultiUser && (
<tr>
<td
Expand Down
18 changes: 17 additions & 1 deletion apps/notifications/src/email/notifications/sendEmail.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,9 +59,25 @@ export const sendNotificationEmail = async ({
(n) => !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`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,12 @@ export class Announcement extends BaseNotification<AnnouncementNotificationRow>
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
}
}

Expand All @@ -81,13 +86,21 @@ export class Announcement extends BaseNotification<AnnouncementNotificationRow>
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)
}
}
}

Expand Down
31 changes: 28 additions & 3 deletions apps/notifications/src/server/routes/sendNotification.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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(
Expand Down
5 changes: 5 additions & 0 deletions apps/notifications/src/types/notifications.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 = {
Expand Down
Loading