feat(notif): issue notifications (#2242)

* feat(notif): issue notifications

* refactor: dedupe test notification strings

* fix: webhook key parsing

* fix(notif): skip send for admin who requested on behalf of another user

* fix(notif): send comment notifs to admins when other admins reply

* fix(notif): also send resolved notifs to admins, and reopened notifs to issue creator

* fix: don't send duplicate notifications

* fix(lang): tweak notification description strings

* fix(notif): tweak Slack notification styling

* fix(notif): tweak Pushbullet & Telegram notification styling

* docs: reformat webhooks page

* fix(notif): add missing issue_type & issue_status variables to LunaSea notif payloads

* fix: explicitly attach media & issue objects where applicable

* fix(notif): correctly notify both notifyUser and managers where applicable

* fix: update default webhook payload for new installs

* fix(notif): add missing comment_message to LunaSea notif payload

* refactor(sw): simplify notificationclick event listener logic

* fix(notif): add missing event description for MEDIA_AVAILABLE notifications
pull/2254/head
TheCatLady 2 years ago committed by GitHub
parent 6245be1e10
commit c9ffac33f7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -24,23 +24,28 @@ Customize the JSON payload to suit your needs. Overseerr provides several [templ
### General
- `{{notification_type}}` The type of notification. (Ex. `MEDIA_PENDING` or `MEDIA_APPROVED`)
- `{{subject}}` The notification subject message. (For request notifications, this is the media title)
- `{{message}}` Notification message body. (For request notifications, this is the media's overview/synopsis)
- `{{image}}` Associated image with the request. (For request notifications, this is the media's poster)
| Variable | Value |
| ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
| `{{notification_type}}` | The type of notification (e.g. `MEDIA_PENDING` or `ISSUE_COMMENT`) |
| `{{event}}` | A friendly description of the notification event |
| `{{subject}}` | The notification subject (typically the media title) |
| `{{message}}` | The notification message body (the media overview/synopsis for request notifications; the issue description for issue notificatons) |
| `{{image}}` | The notification image (typically the media poster) |
### User
### Notify User
These variables are for the target recipient of the notification.
- `{{notifyuser_username}}` Target user's username.
- `{{notifyuser_email}}` Target user's email address.
- `{{notifyuser_avatar}}` Target user's avatar URL.
- `{{notifyuser_settings_discordId}}` Target user's Discord ID (if one is set).
- `{{notifyuser_settings_telegramChatId}}` Target user's Telegram Chat ID (if one is set).
| Variable | Value |
| ---------------------------------------- | ------------------------------------------------------------- |
| `{{notifyuser_username}}` | The target notification recipient's username |
| `{{notifyuser_email}}` | The target notification recipient's email address |
| `{{notifyuser_avatar}}` | The target notification recipient's avatar URL |
| `{{notifyuser_settings_discordId}}` | The target notification recipient's Discord ID (if set) |
| `{{notifyuser_settings_telegramChatId}}` | The target notification recipient's Telegram Chat ID (if set) |
{% hint style="info" %}
The `notifyuser` variables are not set for the following notification types, as they are intended for application administrators rather than end users:
The `notifyuser` variables are not defined for the following request notification types, as they are intended for application administrators rather than end users:
- Media Requested
- Media Automatically Approved
@ -59,28 +64,69 @@ If you would like to use the requesting user's information in your webhook, plea
The following variables must be used as a key in the JSON payload (e.g., `"{{extra}}": []`).
- `{{request}}` This object will be `null` if there is no relevant request object for the notification.
- `{{media}}` This object will be `null` if there is no relevant media object for the notification.
- `{{extra}}` This object will contain the "extra" array of additional data for certain notifications.
| Variable | Value |
| ------------- | ------------------------------------------------------------------------------------------------------------------------------ |
| `{{media}}` | The relevant media object |
| `{{request}}` | The relevant request object |
| `{{issue}}` | The relevant issue object |
| `{{comment}}` | The relevant issue comment object |
| `{{extra}}` | The "extra" array of additional data for certain notifications (e.g., season/episode numbers for series-related notifications) |
#### Media
These `{{media}}` special variables are only included in media-related notifications, such as requests.
The `{{media}}` will be `null` if there is no relevant media object for the notification.
- `{{media_type}}` Media type (`movie` or `tv`).
- `{{media_tmdbid}}` Media's TMDb ID.
- `{{media_imdbid}}` Media's IMDb ID.
- `{{media_tvdbid}}` Media's TVDB ID.
- `{{media_status}}` Media's availability status (`UNKNOWN`, `PENDING`, `PROCESSING`, `PARTIALLY_AVAILABLE`, or `AVAILABLE`).
- `{{media_status4k}}` Media's 4K availability status (`UNKNOWN`, `PENDING`, `PROCESSING`, `PARTIALLY_AVAILABLE`, or `AVAILABLE`)
These following special variables are only included in media-related notifications, such as requests.
| Variable | Value |
| -------------------- | -------------------------------------------------------------------------------------------------------------- |
| `{{media_type}}` | The media type (`movie` or `tv`) |
| `{{media_tmdbid}}` | The media's TMDb ID |
| `{{media_tvdbid}}` | The media's TheTVDB ID |
| `{{media_status}}` | The media's availability status (`UNKNOWN`, `PENDING`, `PROCESSING`, `PARTIALLY_AVAILABLE`, or `AVAILABLE`) |
| `{{media_status4k}}` | The media's 4K availability status (`UNKNOWN`, `PENDING`, `PROCESSING`, `PARTIALLY_AVAILABLE`, or `AVAILABLE`) |
#### Request
The `{{request}}` special variables are only included in request-related notifications.
The `{{request}}` will be `null` if there is no relevant media object for the notification.
The following special variables are only included in request-related notifications.
| Variable | Value |
| ----------------------------------------- | ----------------------------------------------- |
| `{{request_id}}` | The request ID |
| `{{requestedBy_username}}` | The requesting user's username |
| `{{requestedBy_email}}` | The requesting user's email address |
| `{{requestedBy_avatar}}` | The requesting user's avatar URL |
| `{{requestedBy_settings_discordId}}` | The requesting user's Discord ID (if set) |
| `{{requestedBy_settings_telegramChatId}}` | The requesting user's Telegram Chat ID (if set) |
#### Issue
The `{{issue}}` will be `null` if there is no relevant media object for the notification.
The following special variables are only included in issue-related notifications.
| Variable | Value |
| ---------------------------------------- | ----------------------------------------------- |
| `{{issue_id}}` | The issue ID |
| `{{reportedBy_username}}` | The requesting user's username |
| `{{reportedBy_email}}` | The requesting user's email address |
| `{{reportedBy_avatar}}` | The requesting user's avatar URL |
| `{{reportedBy_settings_discordId}}` | The requesting user's Discord ID (if set) |
| `{{reportedBy_settings_telegramChatId}}` | The requesting user's Telegram Chat ID (if set) |
#### Comment
The `{{comment}}` will be `null` if there is no relevant media object for the notification.
The following special variables are only included in issue comment-related notifications.
- `{{request_id}}` Request ID.
- `{{requestedBy_username}}` Requesting user's username.
- `{{requestedBy_email}}` Requesting user's email address.
- `{{requestedBy_avatar}}` Requesting user's avatar URL.
- `{{requestedBy_settings_discordId}}` Requesting user's Discord ID (if set).
- `{{requestedBy_settings_telegramChatId}}` Requesting user's Telegram Chat ID (if set).
| Variable | Value |
| ----------------------------------------- | ----------------------------------------------- |
| `{{comment_message}}` | The comment message |
| `{{commentedBy_username}}` | The commenting user's username |
| `{{commentedBy_email}}` | The commenting user's email address |
| `{{commentedBy_avatar}}` | The commenting user's avatar URL |
| `{{commentedBy_settings_discordId}}` | The commenting user's Discord ID (if set) |
| `{{commentedBy_settings_telegramChatId}}` | The commenting user's Telegram Chat ID (if set) |

@ -90,8 +90,8 @@ self.addEventListener('push', (event) => {
if (payload.actionUrl){
options.actions.push(
{
action: 'viewmedia',
title: 'View Media',
action: 'view',
title: payload.actionUrlTitle ?? 'View',
}
);
}
@ -119,21 +119,17 @@ self.addEventListener('notificationclick', (event) => {
event.notification.close();
if (event.action === 'viewmedia') {
clients.openWindow(notificationData.actionUrl);
} else if (event.action === 'approve') {
if (event.action === 'approve') {
fetch(`/api/v1/request/${notificationData.requestId}/approve`, {
method: 'POST',
});
clients.openWindow(notificationData.actionUrl);
} else if (event.action === 'decline') {
fetch(`/api/v1/request/${notificationData.requestId}/decline`, {
method: 'POST',
});
clients.openWindow(notificationData.actionUrl);
} else if (notificationData.actionUrl) {
}
if (notificationData.actionUrl) {
clients.openWindow(notificationData.actionUrl);
}
}, false);

@ -10,9 +10,9 @@ export enum IssueStatus {
RESOLVED = 2,
}
export const IssueTypeNames = {
export const IssueTypeName = {
[IssueType.AUDIO]: 'Audio',
[IssueType.VIDEO]: 'Video',
[IssueType.SUBTITLES]: 'Subtitles',
[IssueType.SUBTITLES]: 'Subtitle',
[IssueType.OTHER]: 'Other',
};

@ -142,6 +142,7 @@ export class MediaRequest {
if (this.type === MediaType.MOVIE) {
const movie = await tmdb.getMovie({ movieId: media.tmdbId });
notificationManager.sendNotification(Notification.MEDIA_PENDING, {
event: 'New Movie Request',
subject: `${movie.title}${
movie.release_date ? ` (${movie.release_date.slice(0, 4)})` : ''
}`,
@ -153,12 +154,14 @@ export class MediaRequest {
image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${movie.poster_path}`,
media,
request: this,
notifyAdmin: true,
});
}
if (this.type === MediaType.TV) {
const tv = await tmdb.getTvShow({ tvId: media.tmdbId });
notificationManager.sendNotification(Notification.MEDIA_PENDING, {
event: 'New Series Request',
subject: `${tv.name}${
tv.first_air_date ? ` (${tv.first_air_date.slice(0, 4)})` : ''
}`,
@ -171,13 +174,14 @@ export class MediaRequest {
media,
extra: [
{
name: 'Seasons',
name: 'Requested Seasons',
value: this.seasons
.map((season) => season.seasonNumber)
.join(', '),
},
],
request: this,
notifyAdmin: true,
});
}
}
@ -222,6 +226,13 @@ export class MediaRequest {
: Notification.MEDIA_APPROVED
: Notification.MEDIA_DECLINED,
{
event: `Movie Request ${
this.status === MediaRequestStatus.APPROVED
? autoApproved
? 'Automatically Approved'
: 'Approved'
: 'Declined'
}`,
subject: `${movie.title}${
movie.release_date ? ` (${movie.release_date.slice(0, 4)})` : ''
}`,
@ -231,6 +242,7 @@ export class MediaRequest {
omission: '…',
}),
image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${movie.poster_path}`,
notifyAdmin: autoApproved,
notifyUser: autoApproved ? undefined : this.requestedBy,
media,
request: this,
@ -245,6 +257,13 @@ export class MediaRequest {
: Notification.MEDIA_APPROVED
: Notification.MEDIA_DECLINED,
{
event: `Series Request ${
this.status === MediaRequestStatus.APPROVED
? autoApproved
? 'Automatically Approved'
: 'Approved'
: 'Declined'
}`,
subject: `${tv.name}${
tv.first_air_date ? ` (${tv.first_air_date.slice(0, 4)})` : ''
}`,
@ -254,11 +273,12 @@ export class MediaRequest {
omission: '…',
}),
image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${tv.poster_path}`,
notifyAdmin: autoApproved,
notifyUser: autoApproved ? undefined : this.requestedBy,
media,
extra: [
{
name: 'Seasons',
name: 'Requested Seasons',
value: this.seasons
.map((season) => season.seasonNumber)
.join(', '),
@ -508,6 +528,7 @@ export class MediaRequest {
);
notificationManager.sendNotification(Notification.MEDIA_FAILED, {
event: `Movie Request Failed`,
subject: `${movie.title}${
movie.release_date ? ` (${movie.release_date.slice(0, 4)})` : ''
}`,
@ -519,6 +540,7 @@ export class MediaRequest {
media,
image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${movie.poster_path}`,
request: this,
notifyAdmin: true,
});
});
logger.info('Sent request to Radarr', { label: 'Media Request' });
@ -722,6 +744,7 @@ export class MediaRequest {
);
notificationManager.sendNotification(Notification.MEDIA_FAILED, {
event: `Series Request Failed`,
subject: `${series.name}${
series.first_air_date
? ` (${series.first_air_date.slice(0, 4)})`
@ -736,13 +759,14 @@ export class MediaRequest {
media,
extra: [
{
name: 'Seasons',
name: 'Requested Seasons',
value: this.seasons
.map((season) => season.seasonNumber)
.join(', '),
},
],
request: this,
notifyAdmin: true,
});
});
logger.info('Sent request to Sonarr', { label: 'Media Request' });

@ -1,12 +1,15 @@
import { Notification } from '..';
import type Issue from '../../../entity/Issue';
import type Media from '../../../entity/Media';
import IssueComment from '../../../entity/IssueComment';
import Media from '../../../entity/Media';
import { MediaRequest } from '../../../entity/MediaRequest';
import { User } from '../../../entity/User';
import { NotificationAgentConfig } from '../../settings';
export interface NotificationPayload {
event?: string;
subject: string;
notifyAdmin: boolean;
notifyUser?: User;
media?: Media;
image?: string;
@ -14,6 +17,7 @@ export interface NotificationPayload {
extra?: { name: string; value: string }[];
request?: MediaRequest;
issue?: Issue;
comment?: IssueComment;
}
export abstract class BaseAgent<T extends NotificationAgentConfig> {

@ -1,11 +1,13 @@
import axios from 'axios';
import { getRepository } from 'typeorm';
import { hasNotificationType, Notification } from '..';
import { IssueStatus, IssueTypeNames } from '../../../constants/issue';
import { MediaType } from '../../../constants/media';
import {
hasNotificationType,
Notification,
shouldSendAdminNotification,
} from '..';
import { IssueStatus, IssueTypeName } from '../../../constants/issue';
import { User } from '../../../entity/User';
import logger from '../../../logger';
import { Permission } from '../../permissions';
import {
getSettings,
NotificationAgentDiscord,
@ -109,9 +111,9 @@ class DiscordAgent
type: Notification,
payload: NotificationPayload
): DiscordRichEmbed {
const settings = getSettings();
let color = EmbedColors.DARK_PURPLE;
const { applicationUrl } = getSettings().main;
let color = EmbedColors.DARK_PURPLE;
const fields: Field[] = [];
if (payload.request) {
@ -120,19 +122,55 @@ class DiscordAgent
value: payload.request.requestedBy.displayName,
inline: true,
});
}
// If payload has an issue attached, push issue specific fields
if (payload.issue) {
let status = '';
switch (type) {
case Notification.MEDIA_PENDING:
color = EmbedColors.ORANGE;
status = 'Pending Approval';
break;
case Notification.MEDIA_APPROVED:
case Notification.MEDIA_AUTO_APPROVED:
color = EmbedColors.PURPLE;
status = 'Processing';
break;
case Notification.MEDIA_AVAILABLE:
color = EmbedColors.GREEN;
status = 'Available';
break;
case Notification.MEDIA_DECLINED:
color = EmbedColors.RED;
status = 'Declined';
break;
case Notification.MEDIA_FAILED:
color = EmbedColors.RED;
status = 'Failed';
break;
}
if (status) {
fields.push({
name: 'Request Status',
value: status,
inline: true,
});
}
} else if (payload.comment) {
fields.push({
name: `Comment from ${payload.comment.user.displayName}`,
value: payload.comment.message,
inline: false,
});
} else if (payload.issue) {
fields.push(
{
name: 'Created By',
name: 'Reported By',
value: payload.issue.createdBy.displayName,
inline: true,
},
{
name: 'Issue Type',
value: IssueTypeNames[payload.issue.issueType],
value: IssueTypeName[payload.issue.issueType],
inline: true,
},
{
@ -143,85 +181,35 @@ class DiscordAgent
}
);
if (payload.issue.media.mediaType === MediaType.TV) {
fields.push({
name: 'Affected Season',
value:
payload.issue.problemSeason > 0
? `Season ${payload.issue.problemSeason}`
: 'All Seasons',
});
if (payload.issue.problemSeason > 0) {
fields.push({
name: 'Affected Episode',
value:
payload.issue.problemEpisode > 0
? `Episode ${payload.issue.problemEpisode}`
: 'All Episodes',
});
}
switch (type) {
case Notification.ISSUE_CREATED:
case Notification.ISSUE_REOPENED:
color = EmbedColors.RED;
break;
case Notification.ISSUE_COMMENT:
color = EmbedColors.ORANGE;
break;
case Notification.ISSUE_RESOLVED:
color = EmbedColors.GREEN;
break;
}
}
switch (type) {
case Notification.MEDIA_PENDING:
color = EmbedColors.ORANGE;
fields.push({
name: 'Status',
value: 'Pending Approval',
inline: true,
});
break;
case Notification.MEDIA_APPROVED:
case Notification.MEDIA_AUTO_APPROVED:
color = EmbedColors.PURPLE;
fields.push({
name: 'Status',
value: 'Processing',
inline: true,
});
break;
case Notification.MEDIA_AVAILABLE:
color = EmbedColors.GREEN;
fields.push({
name: 'Status',
value: 'Available',
inline: true,
});
break;
case Notification.MEDIA_DECLINED:
color = EmbedColors.RED;
fields.push({
name: 'Status',
value: 'Declined',
inline: true,
});
break;
case Notification.MEDIA_FAILED:
color = EmbedColors.RED;
fields.push({
name: 'Status',
value: 'Failed',
inline: true,
});
break;
case Notification.ISSUE_CREATED:
case Notification.ISSUE_COMMENT:
case Notification.ISSUE_RESOLVED:
color = EmbedColors.ORANGE;
if (payload.issue && payload.issue.status === IssueStatus.RESOLVED) {
color = EmbedColors.GREEN;
}
break;
for (const extra of payload.extra ?? []) {
fields.push({
name: extra.name,
value: extra.value,
inline: true,
});
}
const url =
settings.main.applicationUrl && payload.media
? `${settings.main.applicationUrl}/${payload.media.mediaType}/${payload.media.tmdbId}`
: undefined;
const url = applicationUrl
? payload.issue
? `${applicationUrl}/issue/${payload.issue.id}`
: payload.media
? `${applicationUrl}/${payload.media.mediaType}/${payload.media.tmdbId}`
: undefined
: undefined;
return {
title: payload.subject,
@ -229,18 +217,12 @@ class DiscordAgent
description: payload.message,
color,
timestamp: new Date().toISOString(),
author: {
name: settings.main.applicationTitle,
url: settings.main.applicationUrl,
},
fields: [
...fields,
// If we have extra data, map it to fields for discord notifications
...(payload.extra ?? []).map((extra) => ({
name: extra.name,
value: extra.value,
})),
],
author: payload.event
? {
name: payload.event,
}
: undefined,
fields,
thumbnail: {
url: payload.image,
},
@ -273,54 +255,53 @@ class DiscordAgent
subject: payload.subject,
});
let content = undefined;
const userMentions: string[] = [];
try {
if (payload.notifyUser) {
// Mention user who submitted the request
if (
payload.notifyUser.settings?.hasNotificationType(
NotificationAgentKey.DISCORD,
type
) &&
payload.notifyUser.settings?.discordId
payload.notifyUser.settings.discordId
) {
content = `<@${payload.notifyUser.settings.discordId}>`;
userMentions.push(`<@${payload.notifyUser.settings.discordId}>`);
}
} else {
// Mention all users with the Manage Requests permission
}
if (payload.notifyAdmin) {
const userRepository = getRepository(User);
const users = await userRepository.find();
content = users
.filter(
(user) =>
user.hasPermission(Permission.MANAGE_REQUESTS) &&
user.settings?.hasNotificationType(
NotificationAgentKey.DISCORD,
type
) &&
user.settings?.discordId &&
// Check if it's the user's own auto-approved request
(type !== Notification.MEDIA_AUTO_APPROVED ||
user.id !== payload.request?.requestedBy.id)
)
.map((user) => `<@${user.settings?.discordId}>`)
.join(' ');
userMentions.push(
...users
.filter(
(user) =>
user.settings?.hasNotificationType(
NotificationAgentKey.DISCORD,
type
) &&
user.settings.discordId &&
shouldSendAdminNotification(type, user, payload)
)
.map((user) => `<@${user.settings?.discordId}>`)
);
}
await axios.post(settings.options.webhookUrl, {
username: settings.options.botUsername,
username: settings.options.botUsername
? settings.options.botUsername
: getSettings().main.applicationTitle,
avatar_url: settings.options.botAvatarUrl,
embeds: [this.buildEmbed(type, payload)],
content,
content: userMentions.join(' '),
} as DiscordWebhookPayload);
return true;
} catch (e) {
logger.error('Error sending Discord notification', {
label: 'Notifications',
mentions: content,
type: Notification[type],
subject: payload.subject,
errorMessage: e.message,

@ -1,12 +1,12 @@
import { EmailOptions } from 'email-templates';
import path from 'path';
import { getRepository } from 'typeorm';
import { Notification } from '..';
import { Notification, shouldSendAdminNotification } from '..';
import { IssueType, IssueTypeName } from '../../../constants/issue';
import { MediaType } from '../../../constants/media';
import { User } from '../../../entity/User';
import logger from '../../../logger';
import PreparedEmail from '../../email';
import { Permission } from '../../permissions';
import {
getSettings,
NotificationAgentEmail,
@ -67,59 +67,34 @@ class EmailAgent
};
}
if (payload.media) {
let requestType = '';
const mediaType = payload.media
? payload.media.mediaType === MediaType.MOVIE
? 'movie'
: 'series'
: undefined;
if (payload.request) {
let body = '';
switch (type) {
case Notification.MEDIA_PENDING:
requestType = `New ${
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
} Request`;
body = `A user has requested a new ${
payload.media?.mediaType === MediaType.TV ? 'series' : 'movie'
}!`;
body = `A new request for the following ${mediaType} is pending approval:`;
break;
case Notification.MEDIA_APPROVED:
requestType = `${
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
} Request Approved`;
body = `Your request for the following ${
payload.media?.mediaType === MediaType.TV ? 'series' : 'movie'
} has been approved:`;
body = `Your request for the following ${mediaType} has been approved:`;
break;
case Notification.MEDIA_AUTO_APPROVED:
requestType = `${
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
} Request Automatically Approved`;
body = `A new request for the following ${
payload.media?.mediaType === MediaType.TV ? 'series' : 'movie'
} has been automatically approved:`;
body = `A new request for the following ${mediaType} has been automatically approved:`;
break;
case Notification.MEDIA_AVAILABLE:
requestType = `${
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
} Now Available`;
body = `The following ${
payload.media?.mediaType === MediaType.TV ? 'series' : 'movie'
} you requested is now available!`;
body = `Your request for the following ${mediaType} is now available:`;
break;
case Notification.MEDIA_DECLINED:
requestType = `${
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
} Request Declined`;
body = `Your request for the following ${
payload.media?.mediaType === MediaType.TV ? 'series' : 'movie'
} was declined:`;
body = `Your request for the following ${mediaType} was declined:`;
break;
case Notification.MEDIA_FAILED:
requestType = `Failed ${
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
} Request`;
body = `A new request for the following ${
payload.media?.mediaType === MediaType.TV ? 'series' : 'movie'
} could not be added to ${
payload.media?.mediaType === MediaType.TV ? 'Sonarr' : 'Radarr'
body = `A request for the following ${mediaType} failed to be added to ${
payload.media?.mediaType === MediaType.MOVIE ? 'Radarr' : 'Sonarr'
}:`;
break;
}
@ -133,14 +108,13 @@ class EmailAgent
to: recipientEmail,
},
locals: {
requestType,
event: payload.event,
body,
mediaName: payload.subject,
mediaPlot: payload.message,
mediaExtra: payload.extra ?? [],
imageUrl: payload.image,
timestamp: new Date().toTimeString(),
requestedBy: payload.request?.requestedBy.displayName,
requestedBy: payload.request.requestedBy.displayName,
actionUrl: applicationUrl
? `${applicationUrl}/${payload.media?.mediaType}/${payload.media?.tmdbId}`
: undefined,
@ -150,6 +124,52 @@ class EmailAgent
recipientEmail,
},
};
} else if (payload.issue) {
const issueType =
payload.issue && payload.issue.issueType !== IssueType.OTHER
? `${IssueTypeName[payload.issue.issueType].toLowerCase()} issue`
: 'issue';
let body = '';
switch (type) {
case Notification.ISSUE_CREATED:
body = `A new ${issueType} has been reported by ${payload.issue.createdBy.displayName} for the ${mediaType} ${payload.subject}:`;
break;
case Notification.ISSUE_COMMENT:
body = `${payload.comment?.user.displayName} commented on the ${issueType} for the ${mediaType} ${payload.subject}:`;
break;
case Notification.ISSUE_RESOLVED:
body = `The ${issueType} for the ${mediaType} ${payload.subject} was marked as resolved by ${payload.issue.modifiedBy?.displayName}!`;
break;
case Notification.ISSUE_REOPENED:
body = `The ${issueType} for the ${mediaType} ${payload.subject} was reopened by ${payload.issue.modifiedBy?.displayName}.`;
break;
}
return {
template: path.join(__dirname, '../../../templates/email/media-issue'),
message: {
to: recipientEmail,
},
locals: {
event: payload.event,
body,
issueDescription: payload.message,
issueComment: payload.comment?.message,
mediaName: payload.subject,
extra: payload.extra ?? [],
imageUrl: payload.image,
timestamp: new Date().toTimeString(),
actionUrl: applicationUrl
? `${applicationUrl}/issue/${payload.issue.id}`
: undefined,
applicationUrl,
applicationTitle,
recipientName,
recipientEmail,
},
};
}
return undefined;
@ -160,7 +180,6 @@ class EmailAgent
payload: NotificationPayload
): Promise<boolean> {
if (payload.notifyUser) {
// Send notification to the user who submitted the request
if (
!payload.notifyUser.settings ||
// Check if user has email notifications enabled and fallback to true if undefined
@ -203,8 +222,9 @@ class EmailAgent
return false;
}
}
} else {
// Send notifications to all users with the Manage Requests permission
}
if (payload.notifyAdmin) {
const userRepository = getRepository(User);
const users = await userRepository.find();
@ -212,7 +232,6 @@ class EmailAgent
users
.filter(
(user) =>
user.hasPermission(Permission.MANAGE_REQUESTS) &&
(!user.settings ||
// Check if user has email notifications enabled and fallback to true if undefined
// since email should default to true
@ -221,9 +240,7 @@ class EmailAgent
type
) ??
true)) &&
// Check if it's the user's own auto-approved request
(type !== Notification.MEDIA_AUTO_APPROVED ||
user.id !== payload.request?.requestedBy.id)
shouldSendAdminNotification(type, user, payload)
)
.map(async (user) => {
logger.debug('Sending email notification', {

@ -1,5 +1,6 @@
import axios from 'axios';
import { hasNotificationType, Notification } from '..';
import { IssueStatus, IssueType } from '../../../constants/issue';
import { MediaStatus } from '../../../constants/media';
import logger from '../../../logger';
import { getSettings, NotificationAgentLunaSea } from '../../settings';
@ -22,17 +23,17 @@ class LunaSeaAgent
private buildPayload(type: Notification, payload: NotificationPayload) {
return {
notification_type: Notification[type],
event: payload.event,
subject: payload.subject,
message: payload.message,
image: payload.image ?? null,
email: payload.notifyUser?.email,
username: payload.notifyUser?.username,
username: payload.notifyUser?.displayName,
avatar: payload.notifyUser?.avatar,
media: payload.media
? {
media_type: payload.media.mediaType,
tmdbId: payload.media.tmdbId,
imdbId: payload.media.imdbId,
tvdbId: payload.media.tvdbId,
status: MediaStatus[payload.media.status],
status4k: MediaStatus[payload.media.status4k],
@ -47,6 +48,24 @@ class LunaSeaAgent
requestedBy_avatar: payload.request.requestedBy.avatar,
}
: null,
issue: payload.issue
? {
issue_id: payload.issue.id,
issue_type: IssueType[payload.issue.issueType],
issue_status: IssueStatus[payload.issue.status],
createdBy_email: payload.issue.createdBy.email,
createdBy_username: payload.issue.createdBy.displayName,
createdBy_avatar: payload.issue.createdBy.avatar,
}
: null,
comment: payload.comment
? {
comment_message: payload.comment.message,
commentedBy_email: payload.comment.user.email,
commentedBy_username: payload.comment.user.displayName,
commentedBy_avatar: payload.comment.user.avatar,
}
: null,
};
}

@ -1,10 +1,13 @@
import axios from 'axios';
import { getRepository } from 'typeorm';
import { hasNotificationType, Notification } from '..';
import { MediaType } from '../../../constants/media';
import {
hasNotificationType,
Notification,
shouldSendAdminNotification,
} from '..';
import { IssueStatus, IssueTypeName } from '../../../constants/issue';
import { User } from '../../../entity/User';
import logger from '../../../logger';
import { Permission } from '../../permissions';
import {
getSettings,
NotificationAgentKey,
@ -40,94 +43,55 @@ class PushbulletAgent
type: Notification,
payload: NotificationPayload
): PushbulletPayload {
let messageTitle = '';
let message = '';
const title = payload.subject;
const plot = payload.message;
const username = payload.request?.requestedBy.displayName;
const title = payload.event
? `${payload.event} - ${payload.subject}`
: payload.subject;
let body = payload.message ?? '';
if (payload.request) {
body += `\n\nRequested By: ${payload.request.requestedBy.displayName}`;
let status = '';
switch (type) {
case Notification.MEDIA_PENDING:
status = 'Pending Approval';
break;
case Notification.MEDIA_APPROVED:
case Notification.MEDIA_AUTO_APPROVED:
status = 'Processing';
break;
case Notification.MEDIA_AVAILABLE:
status = 'Available';
break;
case Notification.MEDIA_DECLINED:
status = 'Declined';
break;
case Notification.MEDIA_FAILED:
status = 'Failed';
break;
}
switch (type) {
case Notification.MEDIA_PENDING:
messageTitle = `New ${
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
} Request`;
message += `${title}`;
if (plot) {
message += `\n\n${plot}`;
}
message += `\n\nRequested By: ${username}`;
message += `\nStatus: Pending Approval`;
break;
case Notification.MEDIA_APPROVED:
messageTitle = `${
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
} Request Approved`;
message += `${title}`;
if (plot) {
message += `\n\n${plot}`;
}
message += `\n\nRequested By: ${username}`;
message += `\nStatus: Processing`;
break;
case Notification.MEDIA_AUTO_APPROVED:
messageTitle = `${
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
} Request Automatically Approved`;
message += `${title}`;
if (plot) {
message += `\n\n${plot}`;
}
message += `\n\nRequested By: ${username}`;
message += `\nStatus: Processing`;
break;
case Notification.MEDIA_AVAILABLE:
messageTitle = `${
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
} Now Available`;
message += `${title}`;
if (plot) {
message += `\n\n${plot}`;
}
message += `\n\nRequested By: ${username}`;
message += `\nStatus: Available`;
break;
case Notification.MEDIA_DECLINED:
messageTitle = `${
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
} Request Declined`;
message += `${title}`;
if (plot) {
message += `\n\n${plot}`;
}
message += `\n\nRequested By: ${username}`;
message += `\nStatus: Declined`;
break;
case Notification.MEDIA_FAILED:
messageTitle = `Failed ${
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
} Request`;
message += `${title}`;
if (plot) {
message += `\n\n${plot}`;
}
message += `\n\nRequested By: ${username}`;
message += `\nStatus: Failed`;
break;
case Notification.TEST_NOTIFICATION:
messageTitle = 'Test Notification';
message += `${plot}`;
break;
if (status) {
body += `\nRequest Status: ${status}`;
}
} else if (payload.comment) {
body += `\n\nComment from ${payload.comment.user.displayName}:\n${payload.comment.message}`;
} else if (payload.issue) {
body += `\n\nReported By: ${payload.issue.createdBy.displayName}`;
body += `\nIssue Type: ${IssueTypeName[payload.issue.issueType]}`;
body += `\nIssue Status: ${
payload.issue.status === IssueStatus.OPEN ? 'Open' : 'Resolved'
}`;
}
for (const extra of payload.extra ?? []) {
message += `\n${extra.name}: ${extra.value}`;
body += `\n${extra.name}: ${extra.value}`;
}
return {
type: 'note',
title: messageTitle,
body: message,
title,
body,
};
}
@ -171,7 +135,6 @@ class PushbulletAgent
}
if (payload.notifyUser) {
// Send notification to the user who submitted the request
if (
payload.notifyUser.settings?.hasNotificationType(
NotificationAgentKey.PUSHBULLET,
@ -207,8 +170,9 @@ class PushbulletAgent
return false;
}
}
} else {
// Send notifications to all users with the Manage Requests permission
}
if (payload.notifyAdmin) {
const userRepository = getRepository(User);
const users = await userRepository.find();
@ -216,14 +180,10 @@ class PushbulletAgent
users
.filter(
(user) =>
user.hasPermission(Permission.MANAGE_REQUESTS) &&
user.settings?.hasNotificationType(
NotificationAgentKey.PUSHBULLET,
type
) &&
// Check if it's the user's own auto-approved request
(type !== Notification.MEDIA_AUTO_APPROVED ||
user.id !== payload.request?.requestedBy.id)
) && shouldSendAdminNotification(type, user, payload)
)
.map(async (user) => {
if (

@ -1,10 +1,13 @@
import axios from 'axios';
import { getRepository } from 'typeorm';
import { hasNotificationType, Notification } from '..';
import { MediaType } from '../../../constants/media';
import {
hasNotificationType,
Notification,
shouldSendAdminNotification,
} from '..';
import { IssueStatus, IssueTypeName } from '../../../constants/issue';
import { User } from '../../../entity/User';
import logger from '../../../logger';
import { Permission } from '../../permissions';
import {
getSettings,
NotificationAgentKey,
@ -45,103 +48,77 @@ class PushoverAgent
type: Notification,
payload: NotificationPayload
): Partial<PushoverPayload> {
const settings = getSettings();
let messageTitle = '';
let message = '';
let url: string | undefined;
let url_title: string | undefined;
const { applicationUrl, applicationTitle } = getSettings().main;
const title = payload.event ?? payload.subject;
let message = payload.event ? `<b>${payload.subject}</b>` : '';
let priority = 0;
const title = payload.subject;
const plot = payload.message;
const username = payload.request?.requestedBy.displayName;
if (payload.message) {
message += `<small>${message ? '\n' : ''}${payload.message}</small>`;
}
switch (type) {
case Notification.MEDIA_PENDING:
messageTitle = `New ${
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
} Request`;
message += `<b>${title}</b>`;
if (plot) {
message += `<small>\n${plot}</small>`;
}
message += `<small>\n\n<b>Requested By</b>\n${username}</small>`;
message += `<small>\n\n<b>Status</b>\nPending Approval</small>`;
break;
case Notification.MEDIA_APPROVED:
messageTitle = `${
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
} Request Approved`;
message += `<b>${title}</b>`;
if (plot) {
message += `<small>\n${plot}</small>`;
}
message += `<small>\n\n<b>Requested By</b>\n${username}</small>`;
message += `<small>\n\n<b>Status</b>\nProcessing</small>`;
break;
case Notification.MEDIA_AUTO_APPROVED:
messageTitle = `${
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
} Request Automatically Approved`;
message += `<b>${title}</b>`;
if (plot) {
message += `<small>\n${plot}</small>`;
}
message += `<small>\n\n<b>Requested By</b>\n${username}</small>`;
message += `<small>\n\n<b>Status</b>\nProcessing</small>`;
break;
case Notification.MEDIA_AVAILABLE:
messageTitle = `${
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
} Now Available`;
message += `<b>${title}</b>`;
if (plot) {
message += `<small>\n${plot}</small>`;
}
message += `<small>\n\n<b>Requested By</b>\n${username}</small>`;
message += `<small>\n\n<b>Status</b>\nAvailable</small>`;
break;
case Notification.MEDIA_DECLINED:
messageTitle = `${
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
} Request Declined`;
message += `<b>${title}</b>`;
if (plot) {
message += `<small>\n${plot}</small>`;
}
message += `<small>\n\n<b>Requested By</b>\n${username}</small>`;
message += `<small>\n\n<b>Status</b>\nDeclined</small>`;
priority = 1;
break;
case Notification.MEDIA_FAILED:
messageTitle = `Failed ${
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
} Request`;
message += `<b>${title}</b>`;
if (plot) {
message += `<small>\n${plot}</small>`;
}
message += `<small>\n\n<b>Requested By</b>\n${username}</small>`;
message += `<small>\n\n<b>Status</b>\nFailed</small>`;
if (payload.request) {
message += `<small>\n\n<b>Requested By:</b> ${payload.request.requestedBy.displayName}</small>`;
let status = '';
switch (type) {
case Notification.MEDIA_PENDING:
status = 'Pending Approval';
break;
case Notification.MEDIA_APPROVED:
case Notification.MEDIA_AUTO_APPROVED:
status = 'Processing';
break;
case Notification.MEDIA_AVAILABLE:
status = 'Available';
break;
case Notification.MEDIA_DECLINED:
status = 'Declined';
priority = 1;
break;
case Notification.MEDIA_FAILED:
status = 'Failed';
priority = 1;
break;
}
if (status) {
message += `<small>\n<b>Request Status:</b> ${status}</small>`;
}
} else if (payload.comment) {
message += `<small>\n\n<b>Comment from ${payload.comment.user.displayName}:</b> ${payload.comment.message}</small>`;
} else if (payload.issue) {
message += `<small>\n\n<b>Reported By:</b> ${payload.issue.createdBy.displayName}</small>`;
message += `<small>\n<b>Issue Type:</b> ${
IssueTypeName[payload.issue.issueType]
}</small>`;
message += `<small>\n<b>Issue Status:</b> ${
payload.issue.status === IssueStatus.OPEN ? 'Open' : 'Resolved'
}</small>`;
if (type === Notification.ISSUE_CREATED) {
priority = 1;
break;
case Notification.TEST_NOTIFICATION:
messageTitle = 'Test Notification';
message += `<small>${plot}</small>`;
break;
}
}
for (const extra of payload.extra ?? []) {
message += `<small>\n\n<b>${extra.name}</b>\n${extra.value}</small>`;
message += `<small>\n<b>${extra.name}:</b> ${extra.value}</small>`;
}
if (settings.main.applicationUrl && payload.media) {
url = `${settings.main.applicationUrl}/${payload.media.mediaType}/${payload.media.tmdbId}`;
url_title = `Open in ${settings.main.applicationTitle}`;
}
const url = applicationUrl
? payload.issue
? `${applicationUrl}/issue/${payload.issue.id}`
: payload.media
? `${applicationUrl}/${payload.media.mediaType}/${payload.media.tmdbId}`
: undefined
: undefined;
const url_title = url
? `View ${payload.issue ? 'Issue' : 'Media'} in ${applicationTitle}`
: undefined;
return {
title: messageTitle,
title,
message,
url,
url_title,
@ -191,7 +168,6 @@ class PushoverAgent
}
if (payload.notifyUser) {
// Send notification to the user who submitted the request
if (
payload.notifyUser.settings?.hasNotificationType(
NotificationAgentKey.PUSHOVER,
@ -230,8 +206,9 @@ class PushoverAgent
return false;
}
}
} else {
// Send notifications to all users with the Manage Requests permission
}
if (payload.notifyAdmin) {
const userRepository = getRepository(User);
const users = await userRepository.find();
@ -239,14 +216,10 @@ class PushoverAgent
users
.filter(
(user) =>
user.hasPermission(Permission.MANAGE_REQUESTS) &&
user.settings?.hasNotificationType(
NotificationAgentKey.PUSHOVER,
type
) &&
// Check if it's the user's own auto-approved request
(type !== Notification.MEDIA_AUTO_APPROVED ||
user.id !== payload.request?.requestedBy.id)
) && shouldSendAdminNotification(type, user, payload)
)
.map(async (user) => {
if (

@ -1,6 +1,6 @@
import axios from 'axios';
import { hasNotificationType, Notification } from '..';
import { MediaType } from '../../../constants/media';
import { IssueStatus, IssueTypeName } from '../../../constants/issue';
import logger from '../../../logger';
import { getSettings, NotificationAgentSlack } from '../../settings';
import { BaseAgent, NotificationAgent, NotificationPayload } from './agent';
@ -19,9 +19,10 @@ interface TextItem {
interface Element {
type: 'button';
text?: TextItem;
value: string;
url: string;
action_id: 'button-action';
action_id: string;
url?: string;
value?: string;
style?: 'primary' | 'danger';
}
interface EmbedBlock {
@ -34,7 +35,7 @@ interface EmbedBlock {
image_url: string;
alt_text: string;
};
elements?: Element[];
elements?: (Element | TextItem)[];
}
interface SlackBlockEmbed {
@ -59,9 +60,7 @@ class SlackAgent
type: Notification,
payload: NotificationPayload
): SlackBlockEmbed {
const settings = getSettings();
let header = '';
let actionUrl: string | undefined;
const { applicationUrl, applicationTitle } = getSettings().main;
const fields: EmbedField[] = [];
@ -70,66 +69,55 @@ class SlackAgent
type: 'mrkdwn',
text: `*Requested By*\n${payload.request.requestedBy.displayName}`,
});
}
switch (type) {
case Notification.MEDIA_PENDING:
header = `New ${
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
} Request`;
fields.push({
type: 'mrkdwn',
text: '*Status*\nPending Approval',
});
break;
case Notification.MEDIA_APPROVED:
header = `${
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
} Request Approved`;
let status = '';
switch (type) {
case Notification.MEDIA_PENDING:
status = 'Pending Approval';
break;
case Notification.MEDIA_APPROVED:
case Notification.MEDIA_AUTO_APPROVED:
status = 'Processing';
break;
case Notification.MEDIA_AVAILABLE:
status = 'Available';
break;
case Notification.MEDIA_DECLINED:
status = 'Declined';
break;
case Notification.MEDIA_FAILED:
status = 'Failed';
break;
}
if (status) {
fields.push({
type: 'mrkdwn',
text: '*Status*\nProcessing',
text: `*Request Status*\n${status}`,
});
break;
case Notification.MEDIA_AUTO_APPROVED:
header = `${
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
} Request Automatically Approved`;
fields.push({
type: 'mrkdwn',
text: '*Status*\nProcessing',
});
break;
case Notification.MEDIA_AVAILABLE:
header = `${
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
} Now Available`;
fields.push({
}
} else if (payload.comment) {
fields.push({
type: 'mrkdwn',
text: `*Comment from ${payload.comment.user.displayName}*\n${payload.comment.message}`,
});
} else if (payload.issue) {
fields.push(
{
type: 'mrkdwn',
text: '*Status*\nAvailable',
});
break;
case Notification.MEDIA_DECLINED:
header = `${
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
} Request Declined`;
fields.push({
text: `*Reported By*\n${payload.issue.createdBy.displayName}`,
},
{
type: 'mrkdwn',
text: '*Status*\nDeclined',
});
break;
case Notification.MEDIA_FAILED:
header = `Failed ${
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
} Request`;
fields.push({
text: `*Issue Type*\n${IssueTypeName[payload.issue.issueType]}`,
},
{
type: 'mrkdwn',
text: '*Status*\nFailed',
});
break;
case Notification.TEST_NOTIFICATION:
header = 'Test Notification';
break;
text: `*Issue Status*\n${
payload.issue.status === IssueStatus.OPEN ? 'Open' : 'Resolved'
}`,
}
);
}
for (const extra of payload.extra ?? []) {
@ -139,30 +127,28 @@ class SlackAgent
});
}
if (settings.main.applicationUrl && payload.media) {
actionUrl = `${settings.main.applicationUrl}/${payload.media?.mediaType}/${payload.media?.tmdbId}`;
}
const blocks: EmbedBlock[] = [
{
type: 'header',
text: {
type: 'plain_text',
text: header,
},
},
];
const blocks: EmbedBlock[] = [];
if (type !== Notification.TEST_NOTIFICATION) {
if (payload.event) {
blocks.push({
type: 'section',
text: {
type: 'mrkdwn',
text: `*${payload.subject}*`,
},
type: 'context',
elements: [
{
type: 'mrkdwn',
text: `*${payload.event}*`,
},
],
});
}
blocks.push({
type: 'header',
text: {
type: 'plain_text',
text: payload.subject,
},
});
if (payload.message) {
blocks.push({
type: 'section',
@ -183,30 +169,31 @@ class SlackAgent
if (fields.length > 0) {
blocks.push({
type: 'section',
fields: [
...fields,
...(payload.extra ?? []).map(
(extra): EmbedField => ({
type: 'mrkdwn',
text: `*${extra.name}*\n${extra.value}`,
})
),
],
fields,
});
}
if (actionUrl) {
const url = applicationUrl
? payload.issue
? `${applicationUrl}/issue/${payload.issue.id}`
: payload.media
? `${applicationUrl}/${payload.media.mediaType}/${payload.media.tmdbId}`
: undefined
: undefined;
if (url) {
blocks.push({
type: 'actions',
elements: [
{
action_id: 'button-action',
action_id: 'open-in-overseerr',
type: 'button',
url: actionUrl,
value: 'open_overseerr',
url,
text: {
type: 'plain_text',
text: `Open in ${settings.main.applicationTitle}`,
text: `View ${
payload.issue ? 'Issue' : 'Media'
} in ${applicationTitle}`,
},
},
],

@ -1,10 +1,13 @@
import axios from 'axios';
import { getRepository } from 'typeorm';
import { hasNotificationType, Notification } from '..';
import { MediaType } from '../../../constants/media';
import {
hasNotificationType,
Notification,
shouldSendAdminNotification,
} from '..';
import { IssueStatus, IssueTypeName } from '../../../constants/issue';
import { User } from '../../../entity/User';
import logger from '../../../logger';
import { Permission } from '../../permissions';
import {
getSettings,
NotificationAgentKey,
@ -61,95 +64,74 @@ class TelegramAgent
type: Notification,
payload: NotificationPayload
): Partial<TelegramMessagePayload | TelegramPhotoPayload> {
const settings = getSettings();
let message = '';
const title = this.escapeText(payload.subject);
const plot = this.escapeText(payload.message);
const user = this.escapeText(payload.request?.requestedBy.displayName);
const applicationTitle = this.escapeText(settings.main.applicationTitle);
const { applicationUrl, applicationTitle } = getSettings().main;
/* eslint-disable no-useless-escape */
switch (type) {
case Notification.MEDIA_PENDING:
message += `\*New ${
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
} Request\*`;
message += `\n\n\*${title}\*`;
if (plot) {
message += `\n${plot}`;
}
message += `\n\n\*Requested By\*\n${user}`;
message += `\n\n\*Status\*\nPending Approval`;
break;
case Notification.MEDIA_APPROVED:
message += `\*${
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
} Request Approved\*`;
message += `\n\n\*${title}\*`;
if (plot) {
message += `\n${plot}`;
}
message += `\n\n\*Requested By\*\n${user}`;
message += `\n\n\*Status\*\nProcessing`;
break;
case Notification.MEDIA_AUTO_APPROVED:
message += `\*${
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
} Request Automatically Approved\*`;
message += `\n\n\*${title}\*`;
if (plot) {
message += `\n${plot}`;
}
message += `\n\n\*Requested By\*\n${user}`;
message += `\n\n\*Status\*\nProcessing`;
break;
case Notification.MEDIA_AVAILABLE:
message += `\*${
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
} Now Available\*`;
message += `\n\n\*${title}\*`;
if (plot) {
message += `\n${plot}`;
}
message += `\n\n\*Requested By\*\n${user}`;
message += `\n\n\*Status\*\nAvailable`;
break;
case Notification.MEDIA_DECLINED:
message += `\*${
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
} Request Declined\*`;
message += `\n\n\*${title}\*`;
if (plot) {
message += `\n${plot}`;
}
message += `\n\n\*Requested By\*\n${user}`;
message += `\n\n\*Status\*\nDeclined`;
break;
case Notification.MEDIA_FAILED:
message += `\*Failed ${
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
} Request\*`;
message += `\n\n\*${title}\*`;
if (plot) {
message += `\n${plot}`;
}
message += `\n\n\*Requested By\*\n${user}`;
message += `\n\n\*Status\*\nFailed`;
break;
case Notification.TEST_NOTIFICATION:
message += `\*Test Notification\*`;
message += `\n\n${plot}`;
break;
let message = `\*${this.escapeText(
payload.event ? `${payload.event} - ${payload.subject}` : payload.subject
)}\*`;
if (payload.message) {
message += `\n${this.escapeText(payload.message)}`;
}
if (payload.request) {
message += `\n\n\*Requested By:\* ${this.escapeText(
payload.request?.requestedBy.displayName
)}`;
let status = '';
switch (type) {
case Notification.MEDIA_PENDING:
status = 'Pending Approval';
break;
case Notification.MEDIA_APPROVED:
case Notification.MEDIA_AUTO_APPROVED:
status = 'Processing';
break;
case Notification.MEDIA_AVAILABLE:
status = 'Available';
break;
case Notification.MEDIA_DECLINED:
status = 'Declined';
break;
case Notification.MEDIA_FAILED:
status = 'Failed';
break;
}
if (status) {
message += `\n\*Request Status:\* ${status}`;
}
} else if (payload.comment) {
message += `\n\n\*Comment from ${this.escapeText(
payload.comment.user.displayName
)}:\* ${this.escapeText(payload.comment.message)}`;
} else if (payload.issue) {
message += `\n\n\*Reported By:\* ${this.escapeText(
payload.issue.createdBy.displayName
)}`;
message += `\n\*Issue Type:\* ${IssueTypeName[payload.issue.issueType]}`;
message += `\n\*Issue Status:\* ${
payload.issue.status === IssueStatus.OPEN ? 'Open' : 'Resolved'
}`;
}
for (const extra of payload.extra ?? []) {
message += `\n\n\*${extra.name}\*\n${extra.value}`;
message += `\n\*${extra.name}:\* ${extra.value}`;
}
if (settings.main.applicationUrl && payload.media) {
const actionUrl = `${settings.main.applicationUrl}/${payload.media.mediaType}/${payload.media.tmdbId}`;
message += `\n\n\[Open in ${applicationTitle}\]\(${actionUrl}\)`;
const url = applicationUrl
? payload.issue
? `${applicationUrl}/issue/${payload.issue.id}`
: payload.media
? `${applicationUrl}/${payload.media.mediaType}/${payload.media.tmdbId}`
: undefined
: undefined;
if (url) {
message += `\n\n\[View ${
payload.issue ? 'Issue' : 'Media'
} in ${this.escapeText(applicationTitle)}\]\(${url}\)`;
}
/* eslint-enable */
@ -206,7 +188,6 @@ class TelegramAgent
}
if (payload.notifyUser) {
// Send notification to the user who submitted the request
if (
payload.notifyUser.settings?.hasNotificationType(
NotificationAgentKey.TELEGRAM,
@ -242,8 +223,9 @@ class TelegramAgent
return false;
}
}
} else {
// Send notifications to all users with the Manage Requests permission
}
if (payload.notifyAdmin) {
const userRepository = getRepository(User);
const users = await userRepository.find();
@ -251,14 +233,10 @@ class TelegramAgent
users
.filter(
(user) =>
user.hasPermission(Permission.MANAGE_REQUESTS) &&
user.settings?.hasNotificationType(
NotificationAgentKey.TELEGRAM,
type
) &&
// Check if it's the user's own auto-approved request
(type !== Notification.MEDIA_AUTO_APPROVED ||
user.id !== payload.request?.requestedBy.id)
) && shouldSendAdminNotification(type, user, payload)
)
.map(async (user) => {
if (

@ -1,6 +1,7 @@
import axios from 'axios';
import { get } from 'lodash';
import { hasNotificationType, Notification } from '..';
import { IssueStatus, IssueType } from '../../../constants/issue';
import { MediaStatus } from '../../../constants/media';
import logger from '../../../logger';
import { getSettings, NotificationAgentWebhook } from '../../settings';
@ -13,6 +14,7 @@ type KeyMapFunction = (
const KeyMap: Record<string, string | KeyMapFunction> = {
notification_type: (_payload, type) => Notification[type],
event: 'event',
subject: 'subject',
message: 'message',
image: 'image',
@ -22,13 +24,12 @@ const KeyMap: Record<string, string | KeyMapFunction> = {
notifyuser_settings_discordId: 'notifyUser.settings.discordId',
notifyuser_settings_telegramChatId: 'notifyUser.settings.telegramChatId',
media_tmdbid: 'media.tmdbId',
media_imdbid: 'media.imdbId',
media_tvdbid: 'media.tvdbId',
media_type: 'media.mediaType',
media_status: (payload) =>
payload.media?.status ? MediaStatus[payload.media?.status] : '',
payload.media ? MediaStatus[payload.media.status] : '',
media_status4k: (payload) =>
payload.media?.status ? MediaStatus[payload.media?.status4k] : '',
payload.media ? MediaStatus[payload.media.status4k] : '',
request_id: 'request.id',
requestedBy_username: 'request.requestedBy.displayName',
requestedBy_email: 'request.requestedBy.email',
@ -36,6 +37,22 @@ const KeyMap: Record<string, string | KeyMapFunction> = {
requestedBy_settings_discordId: 'request.requestedBy.settings.discordId',
requestedBy_settings_telegramChatId:
'request.requestedBy.settings.telegramChatId',
issue_id: 'issue.id',
issue_type: (payload) =>
payload.issue ? IssueType[payload.issue.issueType] : '',
issue_status: (payload) =>
payload.issue ? IssueStatus[payload.issue.status] : '',
reportedBy_username: 'issue.createdBy.displayName',
reportedBy_email: 'issue.createdBy.email',
reportedBy_avatar: 'issue.createdBy.avatar',
reportedBy_settings_discordId: 'issue.createdBy.settings.discordId',
reportedBy_settings_telegramChatId: 'issue.createdBy.settings.telegramChatId',
comment_message: 'comment.message',
commentedBy_username: 'comment.user.displayName',
commentedBy_email: 'comment.user.email',
commentedBy_avatar: 'comment.user.avatar',
commentedBy_settings_discordId: 'comment.user.settings.discordId',
commentedBy_settings_telegramChatId: 'comment.user.settings.telegramChatId',
};
class WebhookAgent
@ -78,6 +95,22 @@ class WebhookAgent
}
delete finalPayload[key];
key = 'request';
} else if (key === '{{issue}}') {
if (payload.issue) {
finalPayload.issue = finalPayload[key];
} else {
finalPayload.issue = null;
}
delete finalPayload[key];
key = 'issue';
} else if (key === '{{comment}}') {
if (payload.comment) {
finalPayload.comment = finalPayload[key];
} else {
finalPayload.comment = null;
}
delete finalPayload[key];
key = 'comment';
}
if (typeof finalPayload[key] === 'string') {

@ -1,11 +1,11 @@
import { getRepository } from 'typeorm';
import webpush from 'web-push';
import { Notification } from '..';
import { Notification, shouldSendAdminNotification } from '..';
import { IssueType, IssueTypeName } from '../../../constants/issue';
import { MediaType } from '../../../constants/media';
import { User } from '../../../entity/User';
import { UserPushSubscription } from '../../../entity/UserPushSubscription';
import logger from '../../../logger';
import { Permission } from '../../permissions';
import {
getSettings,
NotificationAgentConfig,
@ -15,12 +15,11 @@ import { BaseAgent, NotificationAgent, NotificationPayload } from './agent';
interface PushNotificationPayload {
notificationType: string;
mediaType?: 'movie' | 'tv';
tmdbId?: number;
subject: string;
message?: string;
image?: string;
actionUrl?: string;
actionUrlTitle?: string;
requestId?: number;
}
@ -42,97 +41,79 @@ class WebPushAgent
type: Notification,
payload: NotificationPayload
): PushNotificationPayload {
const mediaType = payload.media
? payload.media.mediaType === MediaType.MOVIE
? 'movie'
: 'series'
: undefined;
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:
return {
notificationType: Notification[type],
subject: payload.subject,
message: payload.message,
};
message = payload.message;
break;
case Notification.MEDIA_APPROVED:
return {
notificationType: Notification[type],
subject: payload.subject,
message: `Your ${
payload.media?.mediaType === MediaType.MOVIE ? 'movie' : 'series'
} request has been approved.`,
image: payload.image,
mediaType: payload.media?.mediaType,
tmdbId: payload.media?.tmdbId,
requestId: payload.request?.id,
actionUrl: `/${payload.media?.mediaType}/${payload.media?.tmdbId}`,
};
message = `Your ${mediaType} request has been approved.`;
break;
case Notification.MEDIA_AUTO_APPROVED:
return {
notificationType: Notification[type],
subject: payload.subject,
message: `Automatically approved a new ${
payload.media?.mediaType === MediaType.MOVIE ? 'movie' : 'series'
} request from ${payload.request?.requestedBy.displayName}.`,
image: payload.image,
mediaType: payload.media?.mediaType,
tmdbId: payload.media?.tmdbId,
requestId: payload.request?.id,
actionUrl: `/${payload.media?.mediaType}/${payload.media?.tmdbId}`,
};
message = `Automatically approved a new ${mediaType} request from ${payload.request?.requestedBy.displayName}.`;
break;
case Notification.MEDIA_AVAILABLE:
return {
notificationType: Notification[type],
subject: payload.subject,
message: `Your ${
payload.media?.mediaType === MediaType.MOVIE ? 'movie' : 'series'
} request is now available!`,
image: payload.image,
mediaType: payload.media?.mediaType,
tmdbId: payload.media?.tmdbId,
requestId: payload.request?.id,
actionUrl: `/${payload.media?.mediaType}/${payload.media?.tmdbId}`,
};
message = `Your ${mediaType} request is now available!`;
break;
case Notification.MEDIA_DECLINED:
return {
notificationType: Notification[type],
subject: payload.subject,
message: `Your ${
payload.media?.mediaType === MediaType.MOVIE ? 'movie' : 'series'
} request was declined.`,
image: payload.image,
mediaType: payload.media?.mediaType,
tmdbId: payload.media?.tmdbId,
requestId: payload.request?.id,
actionUrl: `/${payload.media?.mediaType}/${payload.media?.tmdbId}`,
};
message = `Your ${mediaType} request was declined.`;
break;
case Notification.MEDIA_FAILED:
return {
notificationType: Notification[type],
subject: payload.subject,
message: `Failed to process ${
payload.media?.mediaType === MediaType.MOVIE ? 'movie' : 'series'
} request.`,
image: payload.image,
mediaType: payload.media?.mediaType,
tmdbId: payload.media?.tmdbId,
requestId: payload.request?.id,
actionUrl: `/${payload.media?.mediaType}/${payload.media?.tmdbId}`,
};
message = `Failed to process ${mediaType} request.`;
break;
case Notification.MEDIA_PENDING:
return {
notificationType: Notification[type],
subject: payload.subject,
message: `Approval required for new ${
payload.media?.mediaType === MediaType.MOVIE ? 'movie' : 'series'
} request from ${payload.request?.requestedBy.displayName}.`,
image: payload.image,
mediaType: payload.media?.mediaType,
tmdbId: payload.media?.tmdbId,
requestId: payload.request?.id,
actionUrl: `/${payload.media?.mediaType}/${payload.media?.tmdbId}`,
};
message = `Approval required for a new ${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
? `/issue/${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 {
@ -151,7 +132,7 @@ class WebPushAgent
const userPushSubRepository = getRepository(UserPushSubscription);
const settings = getSettings();
let pushSubs: UserPushSubscription[] = [];
const pushSubs: UserPushSubscription[] = [];
const mainUser = await userRepository.findOne({ where: { id: 1 } });
@ -169,13 +150,14 @@ class WebPushAgent
where: { user: payload.notifyUser.id },
});
pushSubs = notifySubs;
} else if (!payload.notifyUser) {
pushSubs.push(...notifySubs);
}
if (payload.notifyAdmin) {
const users = await userRepository.find();
const manageUsers = users.filter(
(user) =>
user.hasPermission(Permission.MANAGE_REQUESTS) &&
// Check if user has webpush notifications enabled and fallback to true if undefined
// since web push should default to true
(user.settings?.hasNotificationType(
@ -183,9 +165,7 @@ class WebPushAgent
type
) ??
true) &&
// Check if it's the user's own auto-approved request
(type !== Notification.MEDIA_AUTO_APPROVED ||
user.id !== payload.request?.requestedBy.id)
shouldSendAdminNotification(type, user, payload)
);
const allSubs = await userPushSubRepository
@ -196,7 +176,7 @@ class WebPushAgent
})
.getMany();
pushSubs = allSubs;
pushSubs.push(...allSubs);
}
if (mainUser && pushSubs.length > 0) {

@ -1,4 +1,6 @@
import { User } from '../../entity/User';
import logger from '../../logger';
import { Permission } from '../permissions';
import type { NotificationAgent, NotificationPayload } from './agents/agent';
export enum Notification {
@ -13,6 +15,7 @@ export enum Notification {
ISSUE_CREATED = 256,
ISSUE_COMMENT = 512,
ISSUE_RESOLVED = 1024,
ISSUE_REOPENED = 2048,
}
export const hasNotificationType = (
@ -41,6 +44,50 @@ export const hasNotificationType = (
return !!(value & total);
};
export const getAdminPermission = (type: Notification): Permission => {
switch (type) {
case Notification.MEDIA_PENDING:
case Notification.MEDIA_APPROVED:
case Notification.MEDIA_AVAILABLE:
case Notification.MEDIA_FAILED:
case Notification.MEDIA_DECLINED:
case Notification.MEDIA_AUTO_APPROVED:
return Permission.MANAGE_REQUESTS;
case Notification.ISSUE_CREATED:
case Notification.ISSUE_COMMENT:
case Notification.ISSUE_RESOLVED:
case Notification.ISSUE_REOPENED:
return Permission.MANAGE_ISSUES;
default:
return Permission.ADMIN;
}
};
export const shouldSendAdminNotification = (
type: Notification,
user: User,
payload: NotificationPayload
): boolean => {
return (
user.id !== payload.notifyUser?.id &&
user.hasPermission(getAdminPermission(type)) &&
// Check if the user submitted this request (on behalf of themself OR another user)
(type !== Notification.MEDIA_AUTO_APPROVED ||
user.id !==
(payload.request?.modifiedBy ?? payload.request?.requestedBy)?.id) &&
// Check if the user created this issue
(type !== Notification.ISSUE_CREATED ||
user.id !== payload.issue?.createdBy.id) &&
// Check if the user submitted this issue comment
(type !== Notification.ISSUE_COMMENT ||
user.id !== payload.comment?.user.id) &&
// Check if the user resolved/reopened this issue
((type !== Notification.ISSUE_RESOLVED &&
type !== Notification.ISSUE_REOPENED) ||
user.id !== payload.issue?.modifiedBy?.id)
);
};
class NotificationManager {
private activeAgents: NotificationAgent[] = [];

@ -350,7 +350,7 @@ class Settings {
options: {
webhookUrl: '',
jsonPayload:
'IntcbiAgICBcIm5vdGlmaWNhdGlvbl90eXBlXCI6IFwie3tub3RpZmljYXRpb25fdHlwZX19XCIsXG4gICAgXCJzdWJqZWN0XCI6IFwie3tzdWJqZWN0fX1cIixcbiAgICBcIm1lc3NhZ2VcIjogXCJ7e21lc3NhZ2V9fVwiLFxuICAgIFwiaW1hZ2VcIjogXCJ7e2ltYWdlfX1cIixcbiAgICBcImVtYWlsXCI6IFwie3tub3RpZnl1c2VyX2VtYWlsfX1cIixcbiAgICBcInVzZXJuYW1lXCI6IFwie3tub3RpZnl1c2VyX3VzZXJuYW1lfX1cIixcbiAgICBcImF2YXRhclwiOiBcInt7bm90aWZ5dXNlcl9hdmF0YXJ9fVwiLFxuICAgIFwie3ttZWRpYX19XCI6IHtcbiAgICAgICAgXCJtZWRpYV90eXBlXCI6IFwie3ttZWRpYV90eXBlfX1cIixcbiAgICAgICAgXCJ0bWRiSWRcIjogXCJ7e21lZGlhX3RtZGJpZH19XCIsXG4gICAgICAgIFwiaW1kYklkXCI6IFwie3ttZWRpYV9pbWRiaWR9fVwiLFxuICAgICAgICBcInR2ZGJJZFwiOiBcInt7bWVkaWFfdHZkYmlkfX1cIixcbiAgICAgICAgXCJzdGF0dXNcIjogXCJ7e21lZGlhX3N0YXR1c319XCIsXG4gICAgICAgIFwic3RhdHVzNGtcIjogXCJ7e21lZGlhX3N0YXR1czRrfX1cIlxuICAgIH0sXG4gICAgXCJ7e2V4dHJhfX1cIjogW10sXG4gICAgXCJ7e3JlcXVlc3R9fVwiOiB7XG4gICAgICAgIFwicmVxdWVzdF9pZFwiOiBcInt7cmVxdWVzdF9pZH19XCIsXG4gICAgICAgIFwicmVxdWVzdGVkQnlfZW1haWxcIjogXCJ7e3JlcXVlc3RlZEJ5X2VtYWlsfX1cIixcbiAgICAgICAgXCJyZXF1ZXN0ZWRCeV91c2VybmFtZVwiOiBcInt7cmVxdWVzdGVkQnlfdXNlcm5hbWV9fVwiLFxuICAgICAgICBcInJlcXVlc3RlZEJ5X2F2YXRhclwiOiBcInt7cmVxdWVzdGVkQnlfYXZhdGFyfX1cIlxuICAgIH1cbn0i',
'IntcbiAgICBcIm5vdGlmaWNhdGlvbl90eXBlXCI6IFwie3tub3RpZmljYXRpb25fdHlwZX19XCIsXG4gICAgXCJldmVudFwiOiBcInt7ZXZlbnR9fVwiLFxuICAgIFwic3ViamVjdFwiOiBcInt7c3ViamVjdH19XCIsXG4gICAgXCJtZXNzYWdlXCI6IFwie3ttZXNzYWdlfX1cIixcbiAgICBcImltYWdlXCI6IFwie3tpbWFnZX19XCIsXG4gICAgXCJ7e21lZGlhfX1cIjoge1xuICAgICAgICBcIm1lZGlhX3R5cGVcIjogXCJ7e21lZGlhX3R5cGV9fVwiLFxuICAgICAgICBcInRtZGJJZFwiOiBcInt7bWVkaWFfdG1kYmlkfX1cIixcbiAgICAgICAgXCJ0dmRiSWRcIjogXCJ7e21lZGlhX3R2ZGJpZH19XCIsXG4gICAgICAgIFwic3RhdHVzXCI6IFwie3ttZWRpYV9zdGF0dXN9fVwiLFxuICAgICAgICBcInN0YXR1czRrXCI6IFwie3ttZWRpYV9zdGF0dXM0a319XCJcbiAgICB9LFxuICAgIFwie3tyZXF1ZXN0fX1cIjoge1xuICAgICAgICBcInJlcXVlc3RfaWRcIjogXCJ7e3JlcXVlc3RfaWR9fVwiLFxuICAgICAgICBcInJlcXVlc3RlZEJ5X2VtYWlsXCI6IFwie3tyZXF1ZXN0ZWRCeV9lbWFpbH19XCIsXG4gICAgICAgIFwicmVxdWVzdGVkQnlfdXNlcm5hbWVcIjogXCJ7e3JlcXVlc3RlZEJ5X3VzZXJuYW1lfX1cIixcbiAgICAgICAgXCJyZXF1ZXN0ZWRCeV9hdmF0YXJcIjogXCJ7e3JlcXVlc3RlZEJ5X2F2YXRhcn19XCJcbiAgICB9LFxuICAgIFwie3tpc3N1ZX19XCI6IHtcbiAgICAgICAgXCJpc3N1ZV9pZFwiOiBcInt7aXNzdWVfaWR9fVwiLFxuICAgICAgICBcImlzc3VlX3R5cGVcIjogXCJ7e2lzc3VlX3R5cGV9fVwiLFxuICAgICAgICBcImlzc3VlX3N0YXR1c1wiOiBcInt7aXNzdWVfc3RhdHVzfX1cIixcbiAgICAgICAgXCJyZXBvcnRlZEJ5X2VtYWlsXCI6IFwie3tyZXBvcnRlZEJ5X2VtYWlsfX1cIixcbiAgICAgICAgXCJyZXBvcnRlZEJ5X3VzZXJuYW1lXCI6IFwie3tyZXBvcnRlZEJ5X3VzZXJuYW1lfX1cIixcbiAgICAgICAgXCJyZXBvcnRlZEJ5X2F2YXRhclwiOiBcInt7cmVwb3J0ZWRCeV9hdmF0YXJ9fVwiXG4gICAgfSxcbiAgICBcInt7Y29tbWVudH19XCI6IHtcbiAgICAgICAgXCJjb21tZW50X21lc3NhZ2VcIjogXCJ7e2NvbW1lbnRfbWVzc2FnZX19XCIsXG4gICAgICAgIFwiY29tbWVudGVkQnlfZW1haWxcIjogXCJ7e2NvbW1lbnRlZEJ5X2VtYWlsfX1cIixcbiAgICAgICAgXCJjb21tZW50ZWRCeV91c2VybmFtZVwiOiBcInt7Y29tbWVudGVkQnlfdXNlcm5hbWV9fVwiLFxuICAgICAgICBcImNvbW1lbnRlZEJ5X2F2YXRhclwiOiBcInt7Y29tbWVudGVkQnlfYXZhdGFyfX1cIlxuICAgIH0sXG4gICAgXCJ7e2V4dHJhfX1cIjogW11cbn0i',
},
},
webpush: {

@ -277,6 +277,7 @@ issueRoutes.post<{ issueId: string; status: string }, Issue>(
}
issue.status = newStatus;
issue.modifiedBy = req.user;
await issueRepository.save(issue);

@ -1,5 +1,7 @@
import { Router } from 'express';
import { User } from '../../entity/User';
import { Notification } from '../../lib/notifications';
import { NotificationAgent } from '../../lib/notifications/agents/agent';
import DiscordAgent from '../../lib/notifications/agents/discord';
import EmailAgent from '../../lib/notifications/agents/email';
import LunaSeaAgent from '../../lib/notifications/agents/lunasea';
@ -13,6 +15,14 @@ import { getSettings } from '../../lib/settings';
const notificationRoutes = Router();
const sendTestNotification = async (agent: NotificationAgent, user: User) =>
await agent.send(Notification.TEST_NOTIFICATION, {
notifyAdmin: false,
notifyUser: user,
subject: 'Test Notification',
message: 'Check check, 1, 2, 3. Are we coming in clear?',
});
notificationRoutes.get('/discord', (_req, res) => {
const settings = getSettings();
@ -37,14 +47,7 @@ notificationRoutes.post('/discord/test', async (req, res, next) => {
}
const discordAgent = new DiscordAgent(req.body);
if (
await discordAgent.send(Notification.TEST_NOTIFICATION, {
notifyUser: req.user,
subject: 'Test Notification',
message:
'This is a test notification! Check check, 1, 2, 3. Are we coming in clear?',
})
) {
if (await sendTestNotification(discordAgent, req.user)) {
return res.status(204).send();
} else {
return next({
@ -78,14 +81,7 @@ notificationRoutes.post('/slack/test', async (req, res, next) => {
}
const slackAgent = new SlackAgent(req.body);
if (
await slackAgent.send(Notification.TEST_NOTIFICATION, {
notifyUser: req.user,
subject: 'Test Notification',
message:
'This is a test notification! Check check, 1, 2, 3. Are we coming in clear?',
})
) {
if (await sendTestNotification(slackAgent, req.user)) {
return res.status(204).send();
} else {
return next({
@ -119,14 +115,7 @@ notificationRoutes.post('/telegram/test', async (req, res, next) => {
}
const telegramAgent = new TelegramAgent(req.body);
if (
await telegramAgent.send(Notification.TEST_NOTIFICATION, {
notifyUser: req.user,
subject: 'Test Notification',
message:
'This is a test notification! Check check, 1, 2, 3. Are we coming in clear?',
})
) {
if (await sendTestNotification(telegramAgent, req.user)) {
return res.status(204).send();
} else {
return next({
@ -160,14 +149,7 @@ notificationRoutes.post('/pushbullet/test', async (req, res, next) => {
}
const pushbulletAgent = new PushbulletAgent(req.body);
if (
await pushbulletAgent.send(Notification.TEST_NOTIFICATION, {
notifyUser: req.user,
subject: 'Test Notification',
message:
'This is a test notification! Check check, 1, 2, 3. Are we coming in clear?',
})
) {
if (await sendTestNotification(pushbulletAgent, req.user)) {
return res.status(204).send();
} else {
return next({
@ -201,14 +183,7 @@ notificationRoutes.post('/pushover/test', async (req, res, next) => {
}
const pushoverAgent = new PushoverAgent(req.body);
if (
await pushoverAgent.send(Notification.TEST_NOTIFICATION, {
notifyUser: req.user,
subject: 'Test Notification',
message:
'This is a test notification! Check check, 1, 2, 3. Are we coming in clear?',
})
) {
if (await sendTestNotification(pushoverAgent, req.user)) {
return res.status(204).send();
} else {
return next({
@ -242,14 +217,7 @@ notificationRoutes.post('/email/test', async (req, res, next) => {
}
const emailAgent = new EmailAgent(req.body);
if (
await emailAgent.send(Notification.TEST_NOTIFICATION, {
notifyUser: req.user,
subject: 'Test Notification',
message:
'This is a test notification! Check check, 1, 2, 3. Are we coming in clear?',
})
) {
if (await sendTestNotification(emailAgent, req.user)) {
return res.status(204).send();
} else {
return next({
@ -283,14 +251,7 @@ notificationRoutes.post('/webpush/test', async (req, res, next) => {
}
const webpushAgent = new WebPushAgent(req.body);
if (
await webpushAgent.send(Notification.TEST_NOTIFICATION, {
notifyUser: req.user,
subject: 'Test Notification',
message:
'This is a test notification! Check check, 1, 2, 3. Are we coming in clear?',
})
) {
if (await sendTestNotification(webpushAgent, req.user)) {
return res.status(204).send();
} else {
return next({
@ -369,14 +330,7 @@ notificationRoutes.post('/webhook/test', async (req, res, next) => {
};
const webhookAgent = new WebhookAgent(testBody);
if (
await webhookAgent.send(Notification.TEST_NOTIFICATION, {
notifyUser: req.user,
subject: 'Test Notification',
message:
'This is a test notification! Check check, 1, 2, 3. Are we coming in clear?',
})
) {
if (await sendTestNotification(webhookAgent, req.user)) {
return res.status(204).send();
} else {
return next({
@ -413,14 +367,7 @@ notificationRoutes.post('/lunasea/test', async (req, res, next) => {
}
const lunaseaAgent = new LunaSeaAgent(req.body);
if (
await lunaseaAgent.send(Notification.TEST_NOTIFICATION, {
notifyUser: req.user,
subject: 'Test Notification',
message:
'This is a test notification! Check check, 1, 2, 3. Are we coming in clear?',
})
) {
if (await sendTestNotification(lunaseaAgent, req.user)) {
return res.status(204).send();
} else {
return next({

@ -1,3 +1,4 @@
import { sortBy } from 'lodash';
import {
EntitySubscriberInterface,
EventSubscriber,
@ -5,9 +6,12 @@ import {
InsertEvent,
} from 'typeorm';
import TheMovieDb from '../api/themoviedb';
import { IssueType, IssueTypeName } from '../constants/issue';
import { MediaType } from '../constants/media';
import IssueComment from '../entity/IssueComment';
import Media from '../entity/Media';
import notificationManager, { Notification } from '../lib/notifications';
import { Permission } from '../lib/permissions';
@EventSubscriber()
export class IssueCommentSubscriber
@ -18,41 +22,67 @@ export class IssueCommentSubscriber
}
private async sendIssueCommentNotification(entity: IssueComment) {
const issueCommentRepository = getRepository(IssueComment);
let title: string;
let image: string;
const tmdb = new TheMovieDb();
const issuecomment = await issueCommentRepository.findOne({
where: { id: entity.id },
relations: ['issue'],
});
const issue = issuecomment?.issue;
const issue = (
await getRepository(IssueComment).findOne({
where: { id: entity.id },
relations: ['issue'],
})
)?.issue;
if (!issue) {
return;
}
if (issue.media.mediaType === MediaType.MOVIE) {
const movie = await tmdb.getMovie({ movieId: issue.media.tmdbId });
const media = await getRepository(Media).findOne({
where: { id: issue.media.id },
});
if (!media) {
return;
}
title = movie.title;
if (media.mediaType === MediaType.MOVIE) {
const movie = await tmdb.getMovie({ movieId: media.tmdbId });
title = `${movie.title}${
movie.release_date ? ` (${movie.release_date.slice(0, 4)})` : ''
}`;
image = `https://image.tmdb.org/t/p/w600_and_h900_bestv2${movie.poster_path}`;
} else {
const tvshow = await tmdb.getTvShow({ tvId: issue.media.tmdbId });
const tvshow = await tmdb.getTvShow({ tvId: media.tmdbId });
title = tvshow.name;
title = `${tvshow.name}${
tvshow.first_air_date ? ` (${tvshow.first_air_date.slice(0, 4)})` : ''
}`;
image = `https://image.tmdb.org/t/p/w600_and_h900_bestv2${tvshow.poster_path}`;
}
notificationManager.sendNotification(Notification.ISSUE_COMMENT, {
subject: `New Issue Comment: ${title}`,
message: entity.message,
issue,
image,
notifyUser:
issue.createdBy.id !== entity.user.id ? issue.createdBy : undefined,
});
const [firstComment] = sortBy(issue.comments, 'id');
if (entity.id !== firstComment.id) {
// Send notifications to all issue managers
notificationManager.sendNotification(Notification.ISSUE_COMMENT, {
event: `New Comment on ${
issue.issueType !== IssueType.OTHER
? `${IssueTypeName[issue.issueType]} `
: ''
}Issue`,
subject: title,
message: firstComment.message,
comment: entity,
issue,
media,
image,
notifyAdmin: true,
notifyUser:
!issue.createdBy.hasPermission(Permission.MANAGE_ISSUES) &&
issue.createdBy.id !== entity.user.id
? issue.createdBy
: undefined,
});
}
}
public afterInsert(event: InsertEvent<IssueComment>): void {

@ -1,12 +1,16 @@
import { sortBy } from 'lodash';
import {
EntitySubscriberInterface,
EventSubscriber,
InsertEvent,
UpdateEvent,
} from 'typeorm';
import TheMovieDb from '../api/themoviedb';
import { IssueStatus, IssueType, IssueTypeName } from '../constants/issue';
import { MediaType } from '../constants/media';
import Issue from '../entity/Issue';
import notificationManager, { Notification } from '../lib/notifications';
import { Permission } from '../lib/permissions';
@EventSubscriber()
export class IssueSubscriber implements EntitySubscriberInterface<Issue> {
@ -14,29 +18,75 @@ export class IssueSubscriber implements EntitySubscriberInterface<Issue> {
return Issue;
}
private async sendIssueCreatedNotification(entity: Issue) {
private async sendIssueNotification(entity: Issue, type: Notification) {
let title: string;
let image: string;
const tmdb = new TheMovieDb();
if (entity.media.mediaType === MediaType.MOVIE) {
const movie = await tmdb.getMovie({ movieId: entity.media.tmdbId });
title = movie.title;
title = `${movie.title}${
movie.release_date ? ` (${movie.release_date.slice(0, 4)})` : ''
}`;
image = `https://image.tmdb.org/t/p/w600_and_h900_bestv2${movie.poster_path}`;
} else {
const tvshow = await tmdb.getTvShow({ tvId: entity.media.tmdbId });
title = tvshow.name;
title = `${tvshow.name}${
tvshow.first_air_date ? ` (${tvshow.first_air_date.slice(0, 4)})` : ''
}`;
image = `https://image.tmdb.org/t/p/w600_and_h900_bestv2${tvshow.poster_path}`;
}
const [firstComment] = entity.comments;
const [firstComment] = sortBy(entity.comments, 'id');
const extra: { name: string; value: string }[] = [];
notificationManager.sendNotification(Notification.ISSUE_CREATED, {
if (entity.media.mediaType === MediaType.TV && entity.problemSeason > 0) {
extra.push({
name: 'Affected Season',
value: entity.problemSeason.toString(),
});
if (entity.problemEpisode > 0) {
extra.push({
name: 'Affected Episode',
value: entity.problemEpisode.toString(),
});
}
}
notificationManager.sendNotification(type, {
event:
type === Notification.ISSUE_CREATED
? `New ${
entity.issueType !== IssueType.OTHER
? `${IssueTypeName[entity.issueType]} `
: ''
}Issue Reported`
: type === Notification.ISSUE_RESOLVED
? `${
entity.issueType !== IssueType.OTHER
? `${IssueTypeName[entity.issueType]} `
: ''
}Issue Resolved`
: `${
entity.issueType !== IssueType.OTHER
? `${IssueTypeName[entity.issueType]} `
: ''
}Issue Reopened`,
subject: title,
message: firstComment.message,
issue: entity,
media: entity.media,
image,
extra,
notifyAdmin: true,
notifyUser:
!entity.createdBy.hasPermission(Permission.MANAGE_ISSUES) &&
(type === Notification.ISSUE_RESOLVED ||
type === Notification.ISSUE_REOPENED)
? entity.createdBy
: undefined,
});
}
@ -45,6 +95,30 @@ export class IssueSubscriber implements EntitySubscriberInterface<Issue> {
return;
}
this.sendIssueCreatedNotification(event.entity);
this.sendIssueNotification(event.entity, Notification.ISSUE_CREATED);
}
public beforeUpdate(event: UpdateEvent<Issue>): void {
if (!event.entity) {
return;
}
if (
event.entity.status === IssueStatus.RESOLVED &&
event.databaseEntity.status !== IssueStatus.RESOLVED
) {
this.sendIssueNotification(
event.entity as Issue,
Notification.ISSUE_RESOLVED
);
} else if (
event.entity.status === IssueStatus.OPEN &&
event.databaseEntity.status !== IssueStatus.OPEN
) {
this.sendIssueNotification(
event.entity as Issue,
Notification.ISSUE_REOPENED
);
}
}
}

@ -31,6 +31,8 @@ export class MediaSubscriber implements EntitySubscriberInterface<Media> {
relatedRequests.forEach((request) => {
notificationManager.sendNotification(Notification.MEDIA_AVAILABLE, {
event: 'Movie Now Available',
notifyAdmin: false,
notifyUser: request.requestedBy,
subject: `${movie.title}${
movie.release_date ? ` (${movie.release_date.slice(0, 4)})` : ''
@ -42,7 +44,7 @@ export class MediaSubscriber implements EntitySubscriberInterface<Media> {
}),
media: entity,
image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${movie.poster_path}`,
request: request,
request,
});
});
}
@ -91,6 +93,7 @@ export class MediaSubscriber implements EntitySubscriberInterface<Media> {
);
const tv = await tmdb.getTvShow({ tvId: entity.tmdbId });
notificationManager.sendNotification(Notification.MEDIA_AVAILABLE, {
event: 'Series Now Available',
subject: `${tv.name}${
tv.first_air_date ? ` (${tv.first_air_date.slice(0, 4)})` : ''
}`,
@ -99,18 +102,19 @@ export class MediaSubscriber implements EntitySubscriberInterface<Media> {
separator: /\s/,
omission: '…',
}),
notifyAdmin: false,
notifyUser: request.requestedBy,
image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${tv.poster_path}`,
media: entity,
extra: [
{
name: 'Seasons',
name: 'Requested Seasons',
value: request.seasons
.map((season) => season.seasonNumber)
.join(', '),
},
],
request: request,
request,
});
}
}

@ -0,0 +1,53 @@
doctype html
head
meta(charset='utf-8')
meta(name='x-apple-disable-message-reformatting')
meta(http-equiv='x-ua-compatible' content='ie=edge')
meta(name='viewport' content='width=device-width, initial-scale=1')
meta(name='format-detection' content='telephone=no, date=no, address=no, email=no')
link(href='https://fonts.googleapis.com/css2?family=Inter:wght@100..900&display=swap' rel='stylesheet' media='screen')
style.
.title:hover * {
text-decoration: underline;
}
@media only screen and (max-width:600px) {
table {
font-size: 20px !important;
width: 100% !important;
}
}
div(style='display: block; background-color: #111827; padding: 2.5rem 0;')
table(style='margin: 0 auto; font-family: Inter, Arial, sans-serif; color: #fff; font-size: 16px; width: 26rem;')
tr
td(style="text-align: center;")
if applicationUrl
a(href=applicationUrl style='margin: 0 1rem;')
img(src=applicationUrl +'/logo_full.png' style='width: 26rem; image-rendering: crisp-edges; image-rendering: -webkit-optimize-contrast;')
else
div(style='margin: 0 1rem 2.5rem; font-size: 3em; font-weight: 700;')
| #{applicationTitle}
if recipientName !== recipientEmail
tr
td(style='text-align: center;')
div(style='margin: 1rem 0 0; font-size: 1.25em;')
| Hi, #{recipientName.replace(/\.|@/g, ((x) => x + '\ufeff'))}!
tr
td(style='text-align: center;')
div(style='margin: 1rem 0 0; font-size: 1.25em;')
| #{body}
if issueComment
tr
td(style='text-align: center;')
div(style='margin: 1rem 0 0; font-size: 1.25em;')
| #{issueComment}
else if issueDescription
tr
td(style='text-align: center;')
div(style='margin: 1rem 0 0; font-size: 1.25em;')
| #{issueDescription}
if actionUrl
tr
td
a(href=actionUrl style='display: block; margin: 1.5rem 3rem 0; text-decoration: none; font-size: 1.0em; line-height: 2.25em;')
span(style='padding: 0.2rem; font-weight: 500; text-align: center; border-radius: 10px; background-color: rgb(99,102,241); color: #fff; display: block; border: 1px solid rgba(255,255,255,0.2);')
| View Issue in #{applicationTitle}

@ -0,0 +1 @@
!= `${event} - ${mediaName} [${applicationTitle}]`

@ -66,4 +66,4 @@ div(style='display: block; background-color: #111827; padding: 2.5rem 0;')
td
a(href=actionUrl style='display: block; margin: 1.5rem 3rem 0; text-decoration: none; font-size: 1.0em; line-height: 2.25em;')
span(style='padding: 0.2rem; font-weight: 500; text-align: center; border-radius: 10px; background-color: rgb(99,102,241); color: #fff; display: block; border: 1px solid rgba(255,255,255,0.2);')
| Open in #{applicationTitle}
| View Media in #{applicationTitle}

@ -1 +1 @@
!= `${requestType} - ${mediaName} [${applicationTitle}]`
!= `${event} - ${mediaName} [${applicationTitle}]`

@ -4,7 +4,7 @@ import { IssueType } from '../../../server/constants/issue';
const messages = defineMessages({
issueAudio: 'Audio',
issueVideo: 'Video',
issueSubtitles: 'Subtitles',
issueSubtitles: 'Subtitle',
issueOther: 'Other',
});

@ -44,12 +44,21 @@ const messages = defineMessages({
issuecommentDescription:
'Send notifications when issues receive new comments.',
userissuecommentDescription:
'Get notified when your issues receive new comments.',
'Get notified when issues you reported receive new comments.',
adminissuecommentDescription:
'Get notified when issues receive new comments.',
'Get notified when other users comment on issues.',
issueresolved: 'Issue Resolved',
issueresolvedDescription: 'Send notifications when issues are resolved.',
userissueresolvedDescription: 'Get notified when your issues are resolved.',
userissueresolvedDescription:
'Get notified when issues you reported are resolved.',
adminissueresolvedDescription:
'Get notified when issues are resolved by other users.',
issuereopened: 'Issue Reopened',
issuereopenedDescription: 'Send notifications when issues are reopened.',
userissuereopenedDescription:
'Get notified when issues you reported are reopened.',
adminissuereopenedDescription:
'Get notified when issues are reopened by other users.',
});
export const hasNotificationType = (
@ -90,6 +99,7 @@ export enum Notification {
ISSUE_CREATED = 256,
ISSUE_COMMENT = 512,
ISSUE_RESOLVED = 1024,
ISSUE_REOPENED = 2048,
}
export const ALL_NOTIFICATIONS = Object.values(Notification)
@ -287,7 +297,9 @@ const NotificationTypeSelector: React.FC<NotificationTypeSelectorProps> = ({
name: intl.formatMessage(messages.issueresolved),
description: intl.formatMessage(
user
? messages.userissueresolvedDescription
? hasPermission(Permission.MANAGE_ISSUES)
? messages.adminissueresolvedDescription
: messages.userissueresolvedDescription
: messages.issueresolvedDescription
),
value: Notification.ISSUE_RESOLVED,
@ -296,7 +308,27 @@ const NotificationTypeSelector: React.FC<NotificationTypeSelectorProps> = ({
!hasPermission([Permission.MANAGE_ISSUES, Permission.CREATE_ISSUES], {
type: 'or',
}),
hasNotifyUser: true,
hasNotifyUser:
!user || hasPermission(Permission.MANAGE_ISSUES) ? false : true,
},
{
id: 'issue-reopened',
name: intl.formatMessage(messages.issuereopened),
description: intl.formatMessage(
user
? hasPermission(Permission.MANAGE_ISSUES)
? messages.adminissuereopenedDescription
: messages.userissuereopenedDescription
: messages.issuereopenedDescription
),
value: Notification.ISSUE_REOPENED,
hidden:
user &&
!hasPermission([Permission.MANAGE_ISSUES, Permission.CREATE_ISSUES], {
type: 'or',
}),
hasNotifyUser:
!user || hasPermission(Permission.MANAGE_ISSUES) ? false : true,
},
];

@ -6,6 +6,7 @@ import { defineMessages, useIntl } from 'react-intl';
import { useToasts } from 'react-toast-notifications';
import useSWR from 'swr';
import * as Yup from 'yup';
import useSettings from '../../../hooks/useSettings';
import globalMessages from '../../../i18n/globalMessages';
import Button from '../../Common/Button';
import LoadingSpinner from '../../Common/LoadingSpinner';
@ -29,6 +30,7 @@ const messages = defineMessages({
const NotificationsDiscord: React.FC = () => {
const intl = useIntl();
const settings = useSettings();
const { addToast, removeToast } = useToasts();
const [isTesting, setIsTesting] = useState(false);
const { data, error, revalidate } = useSWR(
@ -195,7 +197,12 @@ const NotificationsDiscord: React.FC = () => {
</label>
<div className="form-input">
<div className="form-input-field">
<Field id="botUsername" name="botUsername" type="text" />
<Field
id="botUsername"
name="botUsername"
type="text"
placeholder={settings.currentSettings.applicationTitle}
/>
</div>
{errors.botUsername && touched.botUsername && (
<div className="error">{errors.botUsername}</div>

@ -18,27 +18,38 @@ const JSONEditor = dynamic(() => import('../../../JSONEditor'), { ssr: false });
const defaultPayload = {
notification_type: '{{notification_type}}',
event: '{{event}}',
subject: '{{subject}}',
message: '{{message}}',
image: '{{image}}',
email: '{{notifyuser_email}}',
username: '{{notifyuser_username}}',
avatar: '{{notifyuser_avatar}}',
'{{media}}': {
media_type: '{{media_type}}',
tmdbId: '{{media_tmdbid}}',
imdbId: '{{media_imdbid}}',
tvdbId: '{{media_tvdbid}}',
status: '{{media_status}}',
status4k: '{{media_status4k}}',
},
'{{extra}}': [],
'{{request}}': {
request_id: '{{request_id}}',
requestedBy_email: '{{requestedBy_email}}',
requestedBy_username: '{{requestedBy_username}}',
requestedBy_avatar: '{{requestedBy_avatar}}',
},
'{{issue}}': {
issue_id: '{{issue_id}}',
issue_type: '{{issue_type}}',
issue_status: '{{issue_status}}',
reportedBy_email: '{{reportedBy_email}}',
reportedBy_username: '{{reportedBy_username}}',
reportedBy_avatar: '{{reportedBy_avatar}}',
},
'{{comment}}': {
comment_message: '{{comment_message}}',
commentedBy_email: '{{commentedBy_email}}',
commentedBy_username: '{{commentedBy_username}}',
commentedBy_avatar: '{{commentedBy_avatar}}',
},
'{{extra}}': [],
};
const messages = defineMessages({

@ -102,7 +102,7 @@
"components.IssueModal.CreateIssueModal.whatswrong": "What's wrong?",
"components.IssueModal.issueAudio": "Audio",
"components.IssueModal.issueOther": "Other",
"components.IssueModal.issueSubtitles": "Subtitles",
"components.IssueModal.issueSubtitles": "Subtitle",
"components.IssueModal.issueVideo": "Video",
"components.LanguageSelector.languageServerDefault": "Default ({language})",
"components.LanguageSelector.originalLanguageDefault": "All Languages",
@ -169,11 +169,15 @@
"components.MovieDetails.studio": "{studioCount, plural, one {Studio} other {Studios}}",
"components.MovieDetails.viewfullcrew": "View Full Crew",
"components.MovieDetails.watchtrailer": "Watch Trailer",
"components.NotificationTypeSelector.adminissuecommentDescription": "Get notified when issues receive new comments.",
"components.NotificationTypeSelector.adminissuecommentDescription": "Get notified when other users comment on issues.",
"components.NotificationTypeSelector.adminissuereopenedDescription": "Get notified when issues are reopened by other users.",
"components.NotificationTypeSelector.adminissueresolvedDescription": "Get notified when issues are resolved by other users.",
"components.NotificationTypeSelector.issuecomment": "Issue Comment",
"components.NotificationTypeSelector.issuecommentDescription": "Send notifications when issues receive new comments.",
"components.NotificationTypeSelector.issuecreated": "Issue Reported",
"components.NotificationTypeSelector.issuecreatedDescription": "Send notifications when issues are reported.",
"components.NotificationTypeSelector.issuereopened": "Issue Reopened",
"components.NotificationTypeSelector.issuereopenedDescription": "Send notifications when issues are reopened.",
"components.NotificationTypeSelector.issueresolved": "Issue Resolved",
"components.NotificationTypeSelector.issueresolvedDescription": "Send notifications when issues are resolved.",
"components.NotificationTypeSelector.mediaAutoApproved": "Media Automatically Approved",
@ -189,9 +193,10 @@
"components.NotificationTypeSelector.mediarequested": "Media Requested",
"components.NotificationTypeSelector.mediarequestedDescription": "Send notifications when users submit new media requests which require approval.",
"components.NotificationTypeSelector.notificationTypes": "Notification Types",
"components.NotificationTypeSelector.userissuecommentDescription": "Get notified when your issues receive new comments.",
"components.NotificationTypeSelector.userissuecommentDescription": "Get notified when issues you reported receive new comments.",
"components.NotificationTypeSelector.userissuecreatedDescription": "Get notified when other users report issues.",
"components.NotificationTypeSelector.userissueresolvedDescription": "Get notified when your issues are resolved.",
"components.NotificationTypeSelector.userissuereopenedDescription": "Get notified when issues you reported are reopened.",
"components.NotificationTypeSelector.userissueresolvedDescription": "Get notified when issues you reported are resolved.",
"components.NotificationTypeSelector.usermediaAutoApprovedDescription": "Get notified when other users submit new media requests which are automatically approved.",
"components.NotificationTypeSelector.usermediaapprovedDescription": "Get notified when your media requests are approved.",
"components.NotificationTypeSelector.usermediaavailableDescription": "Get notified when your media requests become available.",

Loading…
Cancel
Save