import { IssueType, IssueTypeName } from '@server/constants/issue'; import { MediaType } from '@server/constants/media'; import { getRepository } from '@server/datasource'; import { User } from '@server/entity/User'; import { UserPushSubscription } from '@server/entity/UserPushSubscription'; import type { NotificationAgentConfig } from '@server/lib/settings'; import { getSettings, NotificationAgentKey } from '@server/lib/settings'; import logger from '@server/logger'; import webpush from 'web-push'; import { Notification, shouldSendAdminNotification } from '..'; import type { NotificationAgent, NotificationPayload } from './agent'; import { BaseAgent } from './agent'; interface PushNotificationPayload { notificationType: string; subject: string; message?: string; image?: string; actionUrl?: string; actionUrlTitle?: string; requestId?: number; } class WebPushAgent extends BaseAgent implements NotificationAgent { protected getSettings(): NotificationAgentConfig { if (this.settings) { return this.settings; } const settings = getSettings(); return settings.notifications.agents.webpush; } private getNotificationPayload( type: Notification, payload: NotificationPayload ): PushNotificationPayload { const mediaType = payload.media ? payload.media.mediaType === MediaType.MOVIE ? 'movie' : 'series' : undefined; const is4k = payload.request?.is4k; const issueType = payload.issue ? payload.issue.issueType !== IssueType.OTHER ? `${IssueTypeName[payload.issue.issueType].toLowerCase()} issue` : 'issue' : undefined; let message: string | undefined; switch (type) { case Notification.TEST_NOTIFICATION: message = payload.message; break; case Notification.MEDIA_AUTO_REQUESTED: message = `Automatically submitted a new ${ is4k ? '4K ' : '' }${mediaType} request.`; break; case Notification.MEDIA_APPROVED: message = `Your ${ is4k ? '4K ' : '' }${mediaType} request has been approved.`; break; case Notification.MEDIA_AUTO_APPROVED: message = `Automatically approved a new ${ is4k ? '4K ' : '' }${mediaType} request from ${ payload.request?.requestedBy.displayName }.`; break; case Notification.MEDIA_AVAILABLE: message = `Your ${ is4k ? '4K ' : '' }${mediaType} request is now available!`; break; case Notification.MEDIA_DECLINED: message = `Your ${is4k ? '4K ' : ''}${mediaType} request was declined.`; break; case Notification.MEDIA_FAILED: message = `Failed to process ${is4k ? '4K ' : ''}${mediaType} request.`; break; case Notification.MEDIA_PENDING: message = `Approval required for a new ${ is4k ? '4K ' : '' }${mediaType} request from ${ payload.request?.requestedBy.displayName }.`; break; case Notification.ISSUE_CREATED: message = `A new ${issueType} was reported by ${payload.issue?.createdBy.displayName}.`; break; case Notification.ISSUE_COMMENT: message = `${payload.comment?.user.displayName} commented on the ${issueType}.`; break; case Notification.ISSUE_RESOLVED: message = `The ${issueType} was marked as resolved by ${payload.issue?.modifiedBy?.displayName}!`; break; case Notification.ISSUE_REOPENED: message = `The ${issueType} was reopened by ${payload.issue?.modifiedBy?.displayName}.`; break; default: return { notificationType: Notification[type], subject: 'Unknown', }; } const actionUrl = payload.issue ? `/issues/${payload.issue.id}` : payload.media ? `/${payload.media.mediaType}/${payload.media.tmdbId}` : undefined; const actionUrlTitle = actionUrl ? `View ${payload.issue ? 'Issue' : 'Media'}` : undefined; return { notificationType: Notification[type], subject: payload.subject, message, image: payload.image, requestId: payload.request?.id, actionUrl, actionUrlTitle, }; } public shouldSend(): boolean { if (this.getSettings().enabled) { return true; } return false; } public async send( type: Notification, payload: NotificationPayload ): Promise { const userRepository = getRepository(User); const userPushSubRepository = getRepository(UserPushSubscription); const settings = getSettings(); const pushSubs: UserPushSubscription[] = []; const mainUser = await userRepository.findOne({ where: { id: 1 } }); if ( payload.notifyUser && // Check if user has webpush notifications enabled and fallback to true if undefined // since web push should default to true (payload.notifyUser.settings?.hasNotificationType( NotificationAgentKey.WEBPUSH, type ) ?? true) ) { const notifySubs = await userPushSubRepository.find({ where: { user: { id: payload.notifyUser.id } }, }); pushSubs.push(...notifySubs); } if (payload.notifyAdmin) { const users = await userRepository.find(); const manageUsers = users.filter( (user) => // Check if user has webpush notifications enabled and fallback to true if undefined // since web push should default to true (user.settings?.hasNotificationType( NotificationAgentKey.WEBPUSH, type ) ?? true) && shouldSendAdminNotification(type, user, payload) ); const allSubs = await userPushSubRepository .createQueryBuilder('pushSub') .leftJoinAndSelect('pushSub.user', 'user') .where('pushSub.userId IN (:users)', { users: manageUsers.map((user) => user.id), }) .getMany(); pushSubs.push(...allSubs); } if (mainUser && pushSubs.length > 0) { webpush.setVapidDetails( `mailto:${mainUser.email}`, settings.vapidPublic, settings.vapidPrivate ); const notificationPayload = Buffer.from( JSON.stringify(this.getNotificationPayload(type, payload)), 'utf-8' ); await Promise.all( pushSubs.map(async (sub) => { logger.debug('Sending web push notification', { label: 'Notifications', recipient: sub.user.displayName, type: Notification[type], subject: payload.subject, }); try { await webpush.sendNotification( { endpoint: sub.endpoint, keys: { auth: sub.auth, p256dh: sub.p256dh, }, }, notificationPayload ); } catch (e) { logger.error( 'Error sending web push notification; removing subscription', { label: 'Notifications', recipient: sub.user.displayName, type: Notification[type], subject: payload.subject, errorMessage: e.message, } ); // Failed to send notification so we need to remove the subscription userPushSubRepository.remove(sub); } }) ); } return true; } } export default WebPushAgent;