From 23814274c41a5ff7fbd0914a8ce3781d25f6154d Mon Sep 17 00:00:00 2001 From: JackW <53652452+JackW6809@users.noreply.github.com> Date: Tue, 17 Feb 2026 23:47:46 +0000 Subject: [PATCH 1/3] feat(notifications): added apprise support Added in support for the Apprise platform BREAKING CHANGE: Databse Migration, Changing POST and GET requests Closes #2270 --- seerr-api.yml | 69 +++++ server/entity/UserSettings.ts | 4 + server/index.ts | 2 + .../interfaces/api/userSettingsInterfaces.ts | 6 + server/lib/notifications/agents/apprise.ts | 159 +++++++++++ server/lib/settings/index.ts | 19 ++ .../1771353857398-notification-apprise.ts | 27 ++ .../1771353842932-notification-apprise.ts | 87 ++++++ server/routes/settings/notifications.ts | 35 +++ server/routes/user/usersettings.ts | 9 + .../Notifications/NotificationsApprise.tsx | 258 ++++++++++++++++++ .../Settings/SettingsNotifications.tsx | 12 + .../UserNotificationsApprise.tsx | 166 +++++++++++ .../UserNotificationsDiscord.tsx | 1 + .../UserNotificationsEmail.tsx | 1 + .../UserNotificationsPushbullet.tsx | 1 + .../UserNotificationsPushover.tsx | 1 + .../UserNotificationsTelegram.tsx | 1 + .../UserNotificationsWebPush/index.tsx | 1 + .../UserNotificationSettings/index.tsx | 14 +- src/i18n/locale/en.json | 13 + .../settings/notifications/apprise.tsx | 16 ++ src/pages/settings/notifications/apprise.tsx | 19 ++ .../settings/notifications/apprise.tsx | 19 ++ 24 files changed, 939 insertions(+), 1 deletion(-) create mode 100644 server/lib/notifications/agents/apprise.ts create mode 100644 server/migration/postgres/1771353857398-notification-apprise.ts create mode 100644 server/migration/sqlite/1771353842932-notification-apprise.ts create mode 100644 src/components/Settings/Notifications/NotificationsApprise.tsx create mode 100644 src/components/UserProfile/UserSettings/UserNotificationSettings/UserNotificationsApprise.tsx create mode 100644 src/pages/profile/settings/notifications/apprise.tsx create mode 100644 src/pages/settings/notifications/apprise.tsx create mode 100644 src/pages/users/[userId]/settings/notifications/apprise.tsx diff --git a/seerr-api.yml b/seerr-api.yml index 99ef16cc3c..cbaa0add45 100644 --- a/seerr-api.yml +++ b/seerr-api.yml @@ -148,6 +148,8 @@ components: email: type: string example: 'user@example.com' + appriseTags: + type: string discordId: type: string nullable: true @@ -1388,6 +1390,22 @@ components: results: type: number example: 100 + AppriseSettings: + type: object + properties: + enabled: + type: boolean + example: false + types: + type: number + example: 2 + options: + type: object + properties: + url: + type: string + apiToken: + type: string DiscordSettings: type: object properties: @@ -1939,6 +1957,11 @@ components: pgpKey: type: string nullable: true + appriseEnabled: + type: boolean + appriseEnabledTypes: + type: number + nullable: true discordEnabled: type: boolean discordEnabledTypes: @@ -3307,6 +3330,52 @@ paths: responses: '204': description: Test notification attempted + /settings/notifications/apprise: + get: + summary: Get Apprise notification settings + description: Returns current Apprise notification settings in a JSON object. + tags: + - settings + responses: + '200': + description: Returned Apprise settings + content: + application/json: + schema: + $ref: '#/components/schemas/AppriseSettings' + post: + summary: Update Apprise notification settings + description: Updates Apprise notification settings with the provided values. + tags: + - settings + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/AppriseSettings' + responses: + '200': + description: 'Values were successfully updated' + content: + application/json: + schema: + $ref: '#/components/schemas/AppriseSettings' + /settings/notifications/apprise/test: + post: + summary: Test Apprise settings + description: Sends a test notification to the Apprise agent. + tags: + - settings + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/AppriseSettings' + responses: + '204': + description: Test notification attempted /settings/notifications/discord: get: summary: Get Discord notification settings diff --git a/server/entity/UserSettings.ts b/server/entity/UserSettings.ts index 82671fe3b3..b33b07de2d 100644 --- a/server/entity/UserSettings.ts +++ b/server/entity/UserSettings.ts @@ -42,6 +42,9 @@ export class UserSettings { @Column({ nullable: true }) public pgpKey?: string; + @Column({ nullable: true }) + public appriseTags?: string; + @Column({ nullable: true }) public discordId?: string; @@ -79,6 +82,7 @@ export class UserSettings { from: (value: string | null): Partial => { const defaultTypes = { email: ALL_NOTIFICATIONS, + apprise: 0, discord: 0, pushbullet: 0, pushover: 0, diff --git a/server/index.ts b/server/index.ts index 1ee4722c3d..58df0ed989 100644 --- a/server/index.ts +++ b/server/index.ts @@ -6,6 +6,7 @@ import { Session } from '@server/entity/Session'; import { User } from '@server/entity/User'; import { startJobs } from '@server/job/schedule'; import notificationManager from '@server/lib/notifications'; +import AppriseAgent from '@server/lib/notifications/agents/apprise'; import DiscordAgent from '@server/lib/notifications/agents/discord'; import EmailAgent from '@server/lib/notifications/agents/email'; import GotifyAgent from '@server/lib/notifications/agents/gotify'; @@ -126,6 +127,7 @@ app // Register Notification Agents notificationManager.registerAgents([ + new AppriseAgent(), new DiscordAgent(), new EmailAgent(), new GotifyAgent(), diff --git a/server/interfaces/api/userSettingsInterfaces.ts b/server/interfaces/api/userSettingsInterfaces.ts index 327764618e..6bc8c9424f 100644 --- a/server/interfaces/api/userSettingsInterfaces.ts +++ b/server/interfaces/api/userSettingsInterfaces.ts @@ -3,6 +3,7 @@ import type { NotificationAgentKey } from '@server/lib/settings'; export interface UserSettingsGeneralResponse { username?: string; email?: string; + appriseTags?: string; discordId?: string; locale?: string; discoverRegion?: string; @@ -24,6 +25,11 @@ export type NotificationAgentTypes = Record; export interface UserSettingsNotificationsResponse { emailEnabled?: boolean; pgpKey?: string; + appriseEnabled?: boolean; + appriseEnabledTypes?: number; + appriseURL?: string; + appriseAPIToken?: string; + appriseTags?: string; discordEnabled?: boolean; discordEnabledTypes?: number; discordId?: string; diff --git a/server/lib/notifications/agents/apprise.ts b/server/lib/notifications/agents/apprise.ts new file mode 100644 index 0000000000..15034c5a16 --- /dev/null +++ b/server/lib/notifications/agents/apprise.ts @@ -0,0 +1,159 @@ +import { IssueStatus, IssueTypeName } from '@server/constants/issue'; +import type { NotificationAgentApprise } from '@server/lib/settings'; +import { getSettings } from '@server/lib/settings'; +import logger from '@server/logger'; +import { + getAvailableMediaServerName, + getAvailableMediaServerUrl, +} from '@server/utils/mediaServerHelper'; +import axios from 'axios'; +import { Notification, hasNotificationType } from '..'; +import type { NotificationAgent, NotificationPayload } from './agent'; +import { BaseAgent } from './agent'; + +interface AppriseOptions { + format: 'text' | 'markdown' | 'html' | 'ignore'; + tags?: string; + apiToken?: string; + title?: string; + body?: string; + type: 'info' | 'success' | 'warning' | 'failure'; +} + +class AppriseAgent + extends BaseAgent + implements NotificationAgent +{ + protected getSettings(): NotificationAgentApprise { + if (this.settings) { + return this.settings; + } + + const settings = getSettings(); + + return settings.notifications.agents.apprise; + } + + public shouldSend(): boolean { + const settings = this.getSettings(); + + return !!(settings.enabled && settings.options.url); + } + + private buildRequest( + type: Notification, + payload: NotificationPayload + ): AppriseOptions { + const settings = getSettings(); + const { applicationUrl, applicationTitle, mediaServerType } = settings.main; + const mediaServerName = getAvailableMediaServerName(mediaServerType); + const mediaServerUrl = getAvailableMediaServerUrl(payload); + + let title = ''; + let body = ''; + if (payload.event) { + title += `**${payload.event}**`; + body += `${payload.subject}\n\n`; + } else { + title += `**${payload.subject}**`; + body += `${payload.message}\n\n`; + } + + title += ` [[${applicationTitle}]](${applicationUrl})`; + + if (payload.request) { + body += `Requested By\n${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) { + body += `\n\nRequest Status\n${status}`; + } + } else if (payload.comment) { + body += `\n\nComment From ${payload.comment.user.displayName}\n${payload.comment}`; + } else if (payload.issue) { + body += `\n\nReported By\n${payload.issue.createdBy.displayName}\n\nIssue Type\n${IssueTypeName[payload.issue.issueType]}\n\nIssue Status\n${payload.issue.status === IssueStatus.OPEN ? 'Open' : 'Resolved'}`; + } + + const url = applicationUrl + ? payload.issue + ? `${applicationUrl}/issues/${payload.issue.id}` + : payload.media + ? `${applicationUrl}/${payload.media.mediaType}/${payload.media.tmdbId}` + : undefined + : undefined; + + if (url) { + body += `\n\nView ${ + payload.issue ? 'Issue' : 'Media' + } in [${applicationTitle}](${url})`; + } + + if (mediaServerUrl) { + body += `\n\nPlay on [${mediaServerName}](${mediaServerUrl})`; + } + + return { + title: title, + body: body, + format: 'markdown', + tags: payload.notifyUser?.settings?.appriseTags || 'all', + type: 'info', + }; + } + public async send( + type: Notification, + payload: NotificationPayload + ): Promise { + const settings = this.getSettings(); + + if ( + !payload.notifySystem || + !hasNotificationType(type, settings.types ?? 0) + ) { + return true; + } + + logger.debug('Sending Apprise notification', { + label: 'Notifications', + type: Notification[type], + subject: payload.subject, + }); + + try { + await axios.post(settings.options.url, this.buildRequest(type, payload)); + + return true; + } catch (e) { + logger.error('Error sending Apprise notification', { + label: 'Notifications', + type: Notification[type], + subject: payload.subject, + errorMessage: e.message, + response: e?.response?.data, + }); + + return false; + } + } +} + +export default AppriseAgent; diff --git a/server/lib/settings/index.ts b/server/lib/settings/index.ts index 7057cf2b01..86ff772dad 100644 --- a/server/lib/settings/index.ts +++ b/server/lib/settings/index.ts @@ -229,6 +229,14 @@ export interface NotificationAgentSlack extends NotificationAgentConfig { }; } +export interface NotificationAgentApprise extends NotificationAgentConfig { + options: { + url: string; + apiToken?: string; + tags?: string; + }; +} + export interface NotificationAgentEmail extends NotificationAgentConfig { options: { userEmailRequired: boolean; @@ -304,6 +312,7 @@ export interface NotificationAgentNtfy extends NotificationAgentConfig { } export enum NotificationAgentKey { + APPRISE = 'apprise', DISCORD = 'discord', EMAIL = 'email', GOTIFY = 'gotify', @@ -317,6 +326,7 @@ export enum NotificationAgentKey { } interface NotificationAgents { + apprise: NotificationAgentApprise; discord: NotificationAgentDiscord; email: NotificationAgentEmail; gotify: NotificationAgentGotify; @@ -536,6 +546,15 @@ class Settings { priority: 3, }, }, + apprise: { + enabled: false, + embedPoster: false, + types: 0, + options: { + url: '', + apiToken: '', + }, + }, }, }, jobs: { diff --git a/server/migration/postgres/1771353857398-notification-apprise.ts b/server/migration/postgres/1771353857398-notification-apprise.ts new file mode 100644 index 0000000000..4be21b1789 --- /dev/null +++ b/server/migration/postgres/1771353857398-notification-apprise.ts @@ -0,0 +1,27 @@ +import type { MigrationInterface, QueryRunner } from 'typeorm'; + +export class NotificationApprise1771353857398 implements MigrationInterface { + name = 'NotificationApprise1771353857398'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "user_settings" ADD "appriseTags" character varying` + ); + await queryRunner.query( + `CREATE SEQUENCE IF NOT EXISTS "blocklist_id_seq" OWNED BY "blocklist"."id"` + ); + await queryRunner.query( + `ALTER TABLE "blocklist" ALTER COLUMN "id" SET DEFAULT nextval('"blocklist_id_seq"')` + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "blocklist" ALTER COLUMN "id" DROP DEFAULT` + ); + await queryRunner.query(`DROP SEQUENCE "blocklist_id_seq"`); + await queryRunner.query( + `ALTER TABLE "user_settings" DROP COLUMN "appriseTags"` + ); + } +} diff --git a/server/migration/sqlite/1771353842932-notification-apprise.ts b/server/migration/sqlite/1771353842932-notification-apprise.ts new file mode 100644 index 0000000000..8eb90d7caf --- /dev/null +++ b/server/migration/sqlite/1771353842932-notification-apprise.ts @@ -0,0 +1,87 @@ +import type { MigrationInterface, QueryRunner } from 'typeorm'; + +export class NotificationApprise1771353842932 implements MigrationInterface { + name = 'NotificationApprise1771353842932'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX "IDX_03f7958328e311761b0de675fb"`); + await queryRunner.query( + `CREATE TABLE "temporary_user_push_subscription" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "endpoint" varchar NOT NULL, "p256dh" varchar NOT NULL, "auth" varchar NOT NULL, "userId" integer, "userAgent" varchar, "createdAt" datetime DEFAULT (CURRENT_TIMESTAMP), CONSTRAINT "UQ_f90ab5a4ed54905a4bb51a7148b" UNIQUE ("auth"), CONSTRAINT "UQ_6427d07d9a171a3a1ab87480005" UNIQUE ("endpoint", "userId"), CONSTRAINT "FK_03f7958328e311761b0de675fbe" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` + ); + await queryRunner.query( + `INSERT INTO "temporary_user_push_subscription"("id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt") SELECT "id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt" FROM "user_push_subscription"` + ); + await queryRunner.query(`DROP TABLE "user_push_subscription"`); + await queryRunner.query( + `ALTER TABLE "temporary_user_push_subscription" RENAME TO "user_push_subscription"` + ); + await queryRunner.query( + `CREATE INDEX "IDX_03f7958328e311761b0de675fb" ON "user_push_subscription" ("userId") ` + ); + await queryRunner.query( + `CREATE TABLE "temporary_user_settings" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "locale" varchar NOT NULL DEFAULT (''), "discoverRegion" varchar, "streamingRegion" varchar, "originalLanguage" varchar, "pgpKey" varchar, "discordId" varchar, "pushbulletAccessToken" varchar, "pushoverApplicationToken" varchar, "pushoverUserKey" varchar, "pushoverSound" varchar, "telegramChatId" varchar, "telegramSendSilently" boolean, "watchlistSyncMovies" boolean, "watchlistSyncTv" boolean, "notificationTypes" text, "userId" integer, "telegramMessageThreadId" varchar, "appriseTags" varchar, CONSTRAINT "REL_986a2b6d3c05eb4091bb8066f7" UNIQUE ("userId"), CONSTRAINT "FK_986a2b6d3c05eb4091bb8066f78" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` + ); + await queryRunner.query( + `INSERT INTO "temporary_user_settings"("id", "locale", "discoverRegion", "streamingRegion", "originalLanguage", "pgpKey", "discordId", "pushbulletAccessToken", "pushoverApplicationToken", "pushoverUserKey", "pushoverSound", "telegramChatId", "telegramSendSilently", "watchlistSyncMovies", "watchlistSyncTv", "notificationTypes", "userId", "telegramMessageThreadId") SELECT "id", "locale", "discoverRegion", "streamingRegion", "originalLanguage", "pgpKey", "discordId", "pushbulletAccessToken", "pushoverApplicationToken", "pushoverUserKey", "pushoverSound", "telegramChatId", "telegramSendSilently", "watchlistSyncMovies", "watchlistSyncTv", "notificationTypes", "userId", "telegramMessageThreadId" FROM "user_settings"` + ); + await queryRunner.query(`DROP TABLE "user_settings"`); + await queryRunner.query( + `ALTER TABLE "temporary_user_settings" RENAME TO "user_settings"` + ); + await queryRunner.query(`DROP INDEX "IDX_03f7958328e311761b0de675fb"`); + await queryRunner.query( + `CREATE TABLE "temporary_user_push_subscription" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "endpoint" varchar NOT NULL, "p256dh" varchar NOT NULL, "auth" varchar NOT NULL, "userId" integer, "userAgent" varchar, "createdAt" datetime DEFAULT (CURRENT_TIMESTAMP), CONSTRAINT "UQ_f90ab5a4ed54905a4bb51a7148b" UNIQUE ("auth"), CONSTRAINT "UQ_6427d07d9a171a3a1ab87480005" UNIQUE ("endpoint", "userId"), CONSTRAINT "FK_03f7958328e311761b0de675fbe" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` + ); + await queryRunner.query( + `INSERT INTO "temporary_user_push_subscription"("id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt") SELECT "id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt" FROM "user_push_subscription"` + ); + await queryRunner.query(`DROP TABLE "user_push_subscription"`); + await queryRunner.query( + `ALTER TABLE "temporary_user_push_subscription" RENAME TO "user_push_subscription"` + ); + await queryRunner.query( + `CREATE INDEX "IDX_03f7958328e311761b0de675fb" ON "user_push_subscription" ("userId") ` + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX "IDX_03f7958328e311761b0de675fb"`); + await queryRunner.query( + `ALTER TABLE "user_push_subscription" RENAME TO "temporary_user_push_subscription"` + ); + await queryRunner.query( + `CREATE TABLE "user_push_subscription" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "endpoint" varchar NOT NULL, "p256dh" varchar NOT NULL, "auth" varchar NOT NULL, "userId" integer, "userAgent" varchar, "createdAt" datetime DEFAULT (CURRENT_TIMESTAMP), CONSTRAINT "UQ_f90ab5a4ed54905a4bb51a7148b" UNIQUE ("auth"), CONSTRAINT "UQ_6427d07d9a171a3a1ab87480005" UNIQUE ("endpoint", "userId"), CONSTRAINT "FK_03f7958328e311761b0de675fbe" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` + ); + await queryRunner.query( + `INSERT INTO "user_push_subscription"("id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt") SELECT "id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt" FROM "temporary_user_push_subscription"` + ); + await queryRunner.query(`DROP TABLE "temporary_user_push_subscription"`); + await queryRunner.query( + `CREATE INDEX "IDX_03f7958328e311761b0de675fb" ON "user_push_subscription" ("userId") ` + ); + await queryRunner.query( + `ALTER TABLE "user_settings" RENAME TO "temporary_user_settings"` + ); + await queryRunner.query( + `CREATE TABLE "user_settings" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "locale" varchar NOT NULL DEFAULT (''), "discoverRegion" varchar, "streamingRegion" varchar, "originalLanguage" varchar, "pgpKey" varchar, "discordId" varchar, "pushbulletAccessToken" varchar, "pushoverApplicationToken" varchar, "pushoverUserKey" varchar, "pushoverSound" varchar, "telegramChatId" varchar, "telegramSendSilently" boolean, "watchlistSyncMovies" boolean, "watchlistSyncTv" boolean, "notificationTypes" text, "userId" integer, "telegramMessageThreadId" varchar, CONSTRAINT "REL_986a2b6d3c05eb4091bb8066f7" UNIQUE ("userId"), CONSTRAINT "FK_986a2b6d3c05eb4091bb8066f78" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` + ); + await queryRunner.query( + `INSERT INTO "user_settings"("id", "locale", "discoverRegion", "streamingRegion", "originalLanguage", "pgpKey", "discordId", "pushbulletAccessToken", "pushoverApplicationToken", "pushoverUserKey", "pushoverSound", "telegramChatId", "telegramSendSilently", "watchlistSyncMovies", "watchlistSyncTv", "notificationTypes", "userId", "telegramMessageThreadId") SELECT "id", "locale", "discoverRegion", "streamingRegion", "originalLanguage", "pgpKey", "discordId", "pushbulletAccessToken", "pushoverApplicationToken", "pushoverUserKey", "pushoverSound", "telegramChatId", "telegramSendSilently", "watchlistSyncMovies", "watchlistSyncTv", "notificationTypes", "userId", "telegramMessageThreadId" FROM "temporary_user_settings"` + ); + await queryRunner.query(`DROP TABLE "temporary_user_settings"`); + await queryRunner.query(`DROP INDEX "IDX_03f7958328e311761b0de675fb"`); + await queryRunner.query( + `ALTER TABLE "user_push_subscription" RENAME TO "temporary_user_push_subscription"` + ); + await queryRunner.query( + `CREATE TABLE "user_push_subscription" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "endpoint" varchar NOT NULL, "p256dh" varchar NOT NULL, "auth" varchar NOT NULL, "userId" integer, "userAgent" varchar, "createdAt" datetime DEFAULT (CURRENT_TIMESTAMP), CONSTRAINT "UQ_f90ab5a4ed54905a4bb51a7148b" UNIQUE ("auth"), CONSTRAINT "UQ_6427d07d9a171a3a1ab87480005" UNIQUE ("endpoint", "userId"), CONSTRAINT "FK_03f7958328e311761b0de675fbe" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` + ); + await queryRunner.query( + `INSERT INTO "user_push_subscription"("id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt") SELECT "id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt" FROM "temporary_user_push_subscription"` + ); + await queryRunner.query(`DROP TABLE "temporary_user_push_subscription"`); + await queryRunner.query( + `CREATE INDEX "IDX_03f7958328e311761b0de675fb" ON "user_push_subscription" ("userId") ` + ); + } +} diff --git a/server/routes/settings/notifications.ts b/server/routes/settings/notifications.ts index 52cc2ee0fc..f712b86aff 100644 --- a/server/routes/settings/notifications.ts +++ b/server/routes/settings/notifications.ts @@ -1,6 +1,7 @@ import type { User } from '@server/entity/User'; import { Notification } from '@server/lib/notifications'; import type { NotificationAgent } from '@server/lib/notifications/agents/agent'; +import AppriseAgent from '@server/lib/notifications/agents/apprise'; import DiscordAgent from '@server/lib/notifications/agents/discord'; import EmailAgent from '@server/lib/notifications/agents/email'; import GotifyAgent from '@server/lib/notifications/agents/gotify'; @@ -25,6 +26,40 @@ const sendTestNotification = async (agent: NotificationAgent, user: User) => message: 'Check check, 1, 2, 3. Are we coming in clear?', }); +notificationRoutes.get('/apprise', (_req, res) => { + const settings = getSettings(); + + res.status(200).json(settings.notifications.agents.apprise); +}); + +notificationRoutes.post('/apprise', async (req, res) => { + const settings = getSettings(); + + settings.notifications.agents.apprise = req.body; + await settings.save(); + + res.status(200).json(settings.notifications.agents.apprise); +}); + +notificationRoutes.post('/apprise/test', async (req, res, next) => { + if (!req.user) { + return next({ + status: 500, + message: 'User information is missing from the request.', + }); + } + + const appriseAgent = new AppriseAgent(req.body); + if (await sendTestNotification(appriseAgent, req.user)) { + return res.status(204).send(); + } else { + return next({ + status: 500, + message: 'Failed to send Apprise notification.', + }); + } +}); + notificationRoutes.get('/discord', (_req, res) => { const settings = getSettings(); diff --git a/server/routes/user/usersettings.ts b/server/routes/user/usersettings.ts index bd5af746f5..65953a4246 100644 --- a/server/routes/user/usersettings.ts +++ b/server/routes/user/usersettings.ts @@ -47,6 +47,7 @@ userSettingsRoutes.get<{ id: string }, UserSettingsGeneralResponse>( return res.status(200).json({ username: user.username, + appriseTags: user.settings?.appriseTags, email: user.email, discordId: user.settings?.discordId, locale: user.settings?.locale, @@ -536,6 +537,11 @@ userSettingsRoutes.get<{ id: string }, UserSettingsNotificationsResponse>( return res.status(200).json({ emailEnabled: settings.email.enabled, + appriseTags: user.settings?.appriseTags, + appriseEnabled: settings?.apprise.enabled, + appriseEnabledTypes: settings?.apprise.enabled + ? settings.apprise.types + : 0, pgpKey: user.settings?.pgpKey, discordEnabled: settings?.discord.enabled && settings.discord.options.enableMentions, @@ -588,6 +594,7 @@ userSettingsRoutes.post<{ id: string }, UserSettingsNotificationsResponse>( if (!user.settings) { user.settings = new UserSettings({ user: req.user, + appriseTags: req.body.appriseTags, pgpKey: req.body.pgpKey, discordId: req.body.discordId, pushbulletAccessToken: req.body.pushbulletAccessToken, @@ -600,6 +607,7 @@ userSettingsRoutes.post<{ id: string }, UserSettingsNotificationsResponse>( }); } else { user.settings.pgpKey = req.body.pgpKey; + user.settings.appriseTags = req.body.appriseTags; user.settings.discordId = req.body.discordId; user.settings.pushbulletAccessToken = req.body.pushbulletAccessToken; user.settings.pushoverApplicationToken = @@ -621,6 +629,7 @@ userSettingsRoutes.post<{ id: string }, UserSettingsNotificationsResponse>( return res.status(200).json({ pgpKey: user.settings.pgpKey, + appriseTags: user.settings?.appriseTags, discordId: user.settings.discordId, pushbulletAccessToken: user.settings.pushbulletAccessToken, pushoverApplicationToken: user.settings.pushoverApplicationToken, diff --git a/src/components/Settings/Notifications/NotificationsApprise.tsx b/src/components/Settings/Notifications/NotificationsApprise.tsx new file mode 100644 index 0000000000..6701fee45f --- /dev/null +++ b/src/components/Settings/Notifications/NotificationsApprise.tsx @@ -0,0 +1,258 @@ +import Button from '@app/components/Common/Button'; +import LoadingSpinner from '@app/components/Common/LoadingSpinner'; +import NotificationTypeSelector from '@app/components/NotificationTypeSelector'; +import globalMessages from '@app/i18n/globalMessages'; +import defineMessages from '@app/utils/defineMessages'; +import { ArrowDownOnSquareIcon, BeakerIcon } from '@heroicons/react/24/outline'; +import axios from 'axios'; +import { Field, Form, Formik } from 'formik'; +import { useState } from 'react'; +import { useIntl } from 'react-intl'; +import { useToasts } from 'react-toast-notifications'; +import useSWR from 'swr'; +import * as Yup from 'yup'; + +const messages = defineMessages('components.Settings.Notifications', { + agentenabled: 'Enable Agent', + url: 'Apprise URL', + appriseURLTip: 'The URL to your Apprise instance', + apiToken: 'Apprise API Token', + apprisesettingssaved: 'Apprise notification settings saved successfully!', + apprisesettingsfailed: 'Apprise notification settings failed to save.', + toastAppriseTestSending: 'Sending Apprise test notification…', + toastAppriseTestSuccess: 'Apprise test notification sent!', + toastAppriseTestFailed: 'Apprise test notification failed to send.', + validationUrl: 'You must provide a valid URL', + validationTypes: 'You must select at least one notification type', +}); + +const NotificationsApprise = () => { + const intl = useIntl(); + const { addToast, removeToast } = useToasts(); + const [isTesting, setIsTesting] = useState(false); + const { + data, + error, + mutate: revalidate, + } = useSWR('/api/v1/settings/notifications/apprise'); + + const NotificationsAppriseSchema = Yup.object().shape({ + url: Yup.string() + .when('enabled', { + is: true, + then: Yup.string() + .nullable() + .required(intl.formatMessage(messages.validationUrl)), + otherwise: Yup.string().nullable(), + }) + .url(intl.formatMessage(messages.validationUrl)), + }); + + if (!data && !error) { + return ; + } + + return ( + { + try { + await axios.post('/api/v1/settings/notifications/apprise', { + enabled: values.enabled, + types: values.types, + options: { + url: values.url, + apiToken: values.apiToken, + }, + }); + + addToast(intl.formatMessage(messages.apprisesettingssaved), { + appearance: 'success', + autoDismiss: true, + }); + } catch (e) { + addToast(intl.formatMessage(messages.apprisesettingsfailed), { + appearance: 'error', + autoDismiss: true, + }); + } finally { + revalidate(); + } + }} + > + {({ + errors, + touched, + isSubmitting, + values, + isValid, + setFieldValue, + setFieldTouched, + }) => { + const testSettings = async () => { + setIsTesting(true); + let toastId: string | undefined; + try { + addToast( + intl.formatMessage(messages.toastAppriseTestSending), + { + autoDismiss: false, + appearance: 'info', + }, + (id) => { + toastId = id; + } + ); + await axios.post('/api/v1/settings/notifications/apprise/test', { + enabled: true, + types: values.types, + options: { + url: values.url, + apiToken: values.apiToken, + }, + }); + + if (toastId) { + removeToast(toastId); + } + addToast(intl.formatMessage(messages.toastAppriseTestSuccess), { + autoDismiss: true, + appearance: 'success', + }); + } catch (e) { + if (toastId) { + removeToast(toastId); + } + addToast(intl.formatMessage(messages.toastAppriseTestFailed), { + autoDismiss: true, + appearance: 'error', + }); + } finally { + setIsTesting(false); + } + }; + + return ( +
+
+ +
+ +
+
+
+ +
+
+ +
+ {errors.url && + touched.url && + typeof errors.url === 'string' && ( +
{errors.url}
+ )} +
+
+
+ +
+
+ +
+ {errors.apiToken && + touched.apiToken && + typeof errors.apiToken === 'string' && ( +
{errors.apiToken}
+ )} +
+
+ { + setFieldValue('types', newTypes); + setFieldTouched('types'); + + if (newTypes) { + setFieldValue('enabled', true); + } + }} + error={ + values.enabled && !values.types && touched.types + ? intl.formatMessage(messages.validationTypes) + : undefined + } + /> +
+
+ + + + + + +
+
+ + ); + }} +
+ ); +}; + +export default NotificationsApprise; diff --git a/src/components/Settings/SettingsNotifications.tsx b/src/components/Settings/SettingsNotifications.tsx index 564e4c7343..cc04ef95a0 100644 --- a/src/components/Settings/SettingsNotifications.tsx +++ b/src/components/Settings/SettingsNotifications.tsx @@ -21,6 +21,7 @@ const messages = defineMessages('components.Settings', { email: 'Email', webhook: 'Webhook', webpush: 'Web Push', + apprise: 'Apprise', }); type SettingsNotificationsProps = { @@ -141,6 +142,17 @@ const SettingsNotifications = ({ children }: SettingsNotificationsProps) => { route: '/settings/notifications/webhook', regex: /^\/settings\/notifications\/webhook/, }, + { + text: intl.formatMessage(messages.apprise), + content: ( + + + {intl.formatMessage(messages.apprise)} + + ), + route: '/settings/notifications/apprise', + regex: /^\/settings\/notifications\/apprise/, + }, ]; return ( diff --git a/src/components/UserProfile/UserSettings/UserNotificationSettings/UserNotificationsApprise.tsx b/src/components/UserProfile/UserSettings/UserNotificationSettings/UserNotificationsApprise.tsx new file mode 100644 index 0000000000..2457405665 --- /dev/null +++ b/src/components/UserProfile/UserSettings/UserNotificationSettings/UserNotificationsApprise.tsx @@ -0,0 +1,166 @@ +import Button from '@app/components/Common/Button'; +import LoadingSpinner from '@app/components/Common/LoadingSpinner'; +import NotificationTypeSelector from '@app/components/NotificationTypeSelector'; +import { useUser } from '@app/hooks/useUser'; +import globalMessages from '@app/i18n/globalMessages'; +import defineMessages from '@app/utils/defineMessages'; +import { ArrowDownOnSquareIcon } from '@heroicons/react/24/outline'; +import type { UserSettingsNotificationsResponse } from '@server/interfaces/api/userSettingsInterfaces'; +import axios from 'axios'; +import { Field, Form, Formik } from 'formik'; +import { useRouter } from 'next/router'; +import { useIntl } from 'react-intl'; +import { useToasts } from 'react-toast-notifications'; +import useSWR from 'swr'; +import * as Yup from 'yup'; + +const messages = defineMessages( + 'components.UserProfile.UserSettings.UserNotificationSettings', + { + apprisesettingssaved: 'Apprise notification settings saved successfully!', + apprisesettingsfailed: 'Apprise notification settings failed to save.', + appriseTags: 'Apprise Tags', + appriseTagsTip: + 'The tag(s) that lines up to what yo have configure in your Apprise instance', + } +); + +const UserNotificationsApprise = () => { + const intl = useIntl(); + const { addToast } = useToasts(); + const router = useRouter(); + const { user } = useUser({ id: Number(router.query.userId) }); + const { + data, + error, + mutate: revalidate, + } = useSWR( + user ? `/api/v1/user/${user?.id}/settings/notifications` : null + ); + + const UserNotificationsAppriseSchema = Yup.object().shape({ + appriseTags: Yup.string().when('types', { + is: (types: string) => !!types, + then: Yup.string() + .nullable() + .required(intl.formatMessage(messages.appriseTags)), + otherwise: Yup.string().nullable(), + }), + }); + + if (!data && !error) { + return ; + } + + return ( + { + try { + await axios.post(`/api/v1/user/${user?.id}/settings/notifications`, { + pgpKey: data?.pgpKey, + appriseTags: values.appriseTags, + discordId: data?.discordId, + pushbulletAccessToken: data?.pushbulletAccessToken, + pushoverApplicationToken: data?.pushoverApplicationToken, + pushoverUserKey: data?.pushoverUserKey, + telegramChatId: data?.telegramChatId, + telegramSendSilently: data?.telegramSendSilently, + notificationTypes: { + apprise: values.types, + }, + }); + addToast(intl.formatMessage(messages.apprisesettingssaved), { + appearance: 'success', + autoDismiss: true, + }); + } catch (e) { + addToast(intl.formatMessage(messages.apprisesettingsfailed), { + appearance: 'error', + autoDismiss: true, + }); + } finally { + revalidate(); + } + }} + > + {({ + errors, + touched, + isSubmitting, + isValid, + values, + setFieldValue, + setFieldTouched, + }) => { + return ( +
+
+ +
+
+ +
+ {errors.appriseTags && + touched.appriseTags && + typeof errors.appriseTags === 'object' && ( +
{errors.appriseTags}
+ )} +
+
+ { + setFieldValue('types', newTypes); + setFieldTouched('types'); + }} + error={ + errors.types && touched.types + ? (errors.types as string) + : undefined + } + /> +
+
+ + + +
+
+ + ); + }} +
+ ); +}; + +export default UserNotificationsApprise; diff --git a/src/components/UserProfile/UserSettings/UserNotificationSettings/UserNotificationsDiscord.tsx b/src/components/UserProfile/UserSettings/UserNotificationSettings/UserNotificationsDiscord.tsx index 01650ac0f5..0ba3f09a26 100644 --- a/src/components/UserProfile/UserSettings/UserNotificationSettings/UserNotificationsDiscord.tsx +++ b/src/components/UserProfile/UserSettings/UserNotificationSettings/UserNotificationsDiscord.tsx @@ -70,6 +70,7 @@ const UserNotificationsDiscord = () => { try { await axios.post(`/api/v1/user/${user?.id}/settings/notifications`, { pgpKey: data?.pgpKey, + appriseTags: data?.appriseTags, discordId: values.discordId, pushbulletAccessToken: data?.pushbulletAccessToken, pushoverApplicationToken: data?.pushoverApplicationToken, diff --git a/src/components/UserProfile/UserSettings/UserNotificationSettings/UserNotificationsEmail.tsx b/src/components/UserProfile/UserSettings/UserNotificationSettings/UserNotificationsEmail.tsx index c1196d5acf..f654bf11c3 100644 --- a/src/components/UserProfile/UserSettings/UserNotificationSettings/UserNotificationsEmail.tsx +++ b/src/components/UserProfile/UserSettings/UserNotificationSettings/UserNotificationsEmail.tsx @@ -69,6 +69,7 @@ const UserEmailSettings = () => { try { await axios.post(`/api/v1/user/${user?.id}/settings/notifications`, { pgpKey: values.pgpKey, + appriseTags: data?.appriseTags, discordId: data?.discordId, pushbulletAccessToken: data?.pushbulletAccessToken, pushoverApplicationToken: data?.pushoverApplicationToken, diff --git a/src/components/UserProfile/UserSettings/UserNotificationSettings/UserNotificationsPushbullet.tsx b/src/components/UserProfile/UserSettings/UserNotificationSettings/UserNotificationsPushbullet.tsx index 0a18ad25b9..d1891c4fc3 100644 --- a/src/components/UserProfile/UserSettings/UserNotificationSettings/UserNotificationsPushbullet.tsx +++ b/src/components/UserProfile/UserSettings/UserNotificationSettings/UserNotificationsPushbullet.tsx @@ -67,6 +67,7 @@ const UserPushbulletSettings = () => { try { await axios.post(`/api/v1/user/${user?.id}/settings/notifications`, { pgpKey: data?.pgpKey, + appriseTags: data?.appriseTags, discordId: data?.discordId, pushbulletAccessToken: values.pushbulletAccessToken, pushoverApplicationToken: data?.pushoverApplicationToken, diff --git a/src/components/UserProfile/UserSettings/UserNotificationSettings/UserNotificationsPushover.tsx b/src/components/UserProfile/UserSettings/UserNotificationSettings/UserNotificationsPushover.tsx index 7ab9204ded..437cd893c5 100644 --- a/src/components/UserProfile/UserSettings/UserNotificationSettings/UserNotificationsPushover.tsx +++ b/src/components/UserProfile/UserSettings/UserNotificationSettings/UserNotificationsPushover.tsx @@ -99,6 +99,7 @@ const UserPushoverSettings = () => { try { await axios.post(`/api/v1/user/${user?.id}/settings/notifications`, { pgpKey: data?.pgpKey, + appriseTags: data?.appriseTags, discordId: data?.discordId, pushbulletAccessToken: data?.pushbulletAccessToken, pushoverApplicationToken: values.pushoverApplicationToken, diff --git a/src/components/UserProfile/UserSettings/UserNotificationSettings/UserNotificationsTelegram.tsx b/src/components/UserProfile/UserSettings/UserNotificationSettings/UserNotificationsTelegram.tsx index 7d006b6769..7e94a81cda 100644 --- a/src/components/UserProfile/UserSettings/UserNotificationSettings/UserNotificationsTelegram.tsx +++ b/src/components/UserProfile/UserSettings/UserNotificationSettings/UserNotificationsTelegram.tsx @@ -93,6 +93,7 @@ const UserTelegramSettings = () => { try { await axios.post(`/api/v1/user/${user?.id}/settings/notifications`, { pgpKey: data?.pgpKey, + appriseTags: data?.appriseTags, discordId: data?.discordId, pushbulletAccessToken: data?.pushbulletAccessToken, pushoverApplicationToken: data?.pushoverApplicationToken, diff --git a/src/components/UserProfile/UserSettings/UserNotificationSettings/UserNotificationsWebPush/index.tsx b/src/components/UserProfile/UserSettings/UserNotificationSettings/UserNotificationsWebPush/index.tsx index d534c0c262..86118bea03 100644 --- a/src/components/UserProfile/UserSettings/UserNotificationSettings/UserNotificationsWebPush/index.tsx +++ b/src/components/UserProfile/UserSettings/UserNotificationSettings/UserNotificationsWebPush/index.tsx @@ -256,6 +256,7 @@ const UserWebPushSettings = () => { `/api/v1/user/${user?.id}/settings/notifications`, { pgpKey: data?.pgpKey, + appriseTags: data?.appriseTags, discordId: data?.discordId, pushbulletAccessToken: data?.pushbulletAccessToken, pushoverApplicationToken: data?.pushoverApplicationToken, diff --git a/src/components/UserProfile/UserSettings/UserNotificationSettings/index.tsx b/src/components/UserProfile/UserSettings/UserNotificationSettings/index.tsx index a57a3f5d5a..ecd7336518 100644 --- a/src/components/UserProfile/UserSettings/UserNotificationSettings/index.tsx +++ b/src/components/UserProfile/UserSettings/UserNotificationSettings/index.tsx @@ -10,7 +10,7 @@ import { useUser } from '@app/hooks/useUser'; import globalMessages from '@app/i18n/globalMessages'; import Error from '@app/pages/_error'; import defineMessages from '@app/utils/defineMessages'; -import { CloudIcon, EnvelopeIcon } from '@heroicons/react/24/solid'; +import { BoltIcon, CloudIcon, EnvelopeIcon } from '@heroicons/react/24/solid'; import type { UserSettingsNotificationsResponse } from '@server/interfaces/api/userSettingsInterfaces'; import { useRouter } from 'next/router'; import { useIntl } from 'react-intl'; @@ -111,6 +111,18 @@ const UserNotificationSettings = ({ regex: /\/settings\/notifications\/telegram/, hidden: !data?.telegramEnabled || !data?.telegramBotUsername, }, + { + text: 'Apprise', + content: ( + + + Apprise + + ), + route: '/settings/notifications/apprise', + regex: /\/settings\/notifications\/apprise/, + hidden: !data?.appriseEnabled, + }, ]; settingsRoutes.forEach((settingsRoute) => { diff --git a/src/i18n/locale/en.json b/src/i18n/locale/en.json index 06ad3fecbd..f861bf6bc8 100644 --- a/src/i18n/locale/en.json +++ b/src/i18n/locale/en.json @@ -721,6 +721,10 @@ "components.Settings.Notifications.NotificationsWebPush.webpushsettingssaved": "Web push notification settings saved successfully!", "components.Settings.Notifications.agentenabled": "Enable Agent", "components.Settings.Notifications.allowselfsigned": "Allow Self-Signed Certificates", + "components.Settings.Notifications.apiToken": "Apprise API Token", + "components.Settings.Notifications.appriseURLTip": "The URL to your Apprise instance", + "components.Settings.Notifications.apprisesettingsfailed": "Apprise notification settings failed to save.", + "components.Settings.Notifications.apprisesettingssaved": "Apprise notification settings saved successfully!", "components.Settings.Notifications.authPass": "SMTP Password", "components.Settings.Notifications.authUser": "SMTP Username", "components.Settings.Notifications.botAPI": "Bot Authorization Token", @@ -756,6 +760,9 @@ "components.Settings.Notifications.smtpPort": "SMTP Port", "components.Settings.Notifications.telegramsettingsfailed": "Telegram notification settings failed to save.", "components.Settings.Notifications.telegramsettingssaved": "Telegram notification settings saved successfully!", + "components.Settings.Notifications.toastAppriseTestFailed": "Apprise test notification failed to send.", + "components.Settings.Notifications.toastAppriseTestSending": "Sending Apprise test notification…", + "components.Settings.Notifications.toastAppriseTestSuccess": "Apprise test notification sent!", "components.Settings.Notifications.toastDiscordTestFailed": "Discord test notification failed to send.", "components.Settings.Notifications.toastDiscordTestSending": "Sending Discord test notification…", "components.Settings.Notifications.toastDiscordTestSuccess": "Discord test notification sent!", @@ -765,6 +772,7 @@ "components.Settings.Notifications.toastTelegramTestFailed": "Telegram test notification failed to send.", "components.Settings.Notifications.toastTelegramTestSending": "Sending Telegram test notification…", "components.Settings.Notifications.toastTelegramTestSuccess": "Telegram test notification sent!", + "components.Settings.Notifications.url": "Apprise URL", "components.Settings.Notifications.userEmailRequired": "Require user email", "components.Settings.Notifications.validationBotAPIRequired": "You must provide a bot authorization token", "components.Settings.Notifications.validationChatIdRequired": "You must provide a valid chat ID", @@ -1127,6 +1135,7 @@ "components.Settings.allChosenProvidersAreOperational": "All chosen metadata providers are operational", "components.Settings.animeMetadataProvider": "Anime metadata provider", "components.Settings.apiKey": "API key", + "components.Settings.apprise": "Apprise", "components.Settings.blocklistedTagImportInstructions": "Paste blocklist tag configuration below.", "components.Settings.blocklistedTagImportTitle": "Import Blocklisted Tag Configuration", "components.Settings.blocklistedTagsText": "Blocklisted Tags", @@ -1478,6 +1487,10 @@ "components.UserProfile.UserSettings.UserNotificationSettings.UserNotificationsWebPush.webpushhasbeenenabled": "Web push has been enabled.", "components.UserProfile.UserSettings.UserNotificationSettings.UserNotificationsWebPush.webpushsettingsfailed": "Web push notification settings failed to save.", "components.UserProfile.UserSettings.UserNotificationSettings.UserNotificationsWebPush.webpushsettingssaved": "Web push notification settings saved successfully!", + "components.UserProfile.UserSettings.UserNotificationSettings.appriseTags": "Apprise Tags", + "components.UserProfile.UserSettings.UserNotificationSettings.appriseTagsTip": "The tag(s) that lines up to what yo have configure in your Apprise instance", + "components.UserProfile.UserSettings.UserNotificationSettings.apprisesettingsfailed": "Apprise notification settings failed to save.", + "components.UserProfile.UserSettings.UserNotificationSettings.apprisesettingssaved": "Apprise notification settings saved successfully!", "components.UserProfile.UserSettings.UserNotificationSettings.deviceDefault": "Device Default", "components.UserProfile.UserSettings.UserNotificationSettings.discordId": "User ID", "components.UserProfile.UserSettings.UserNotificationSettings.discordIdTip": "The multi-digit ID number associated with your user account", diff --git a/src/pages/profile/settings/notifications/apprise.tsx b/src/pages/profile/settings/notifications/apprise.tsx new file mode 100644 index 0000000000..5151ae3c77 --- /dev/null +++ b/src/pages/profile/settings/notifications/apprise.tsx @@ -0,0 +1,16 @@ +import UserSettings from '@app/components/UserProfile/UserSettings'; +import UserNotificationSettings from '@app/components/UserProfile/UserSettings/UserNotificationSettings'; +import UserNotificationsApprise from '@app/components/UserProfile/UserSettings/UserNotificationSettings/UserNotificationsApprise'; +import type { NextPage } from 'next'; + +const NotificationsPage: NextPage = () => { + return ( + + + + + + ); +}; + +export default NotificationsPage; diff --git a/src/pages/settings/notifications/apprise.tsx b/src/pages/settings/notifications/apprise.tsx new file mode 100644 index 0000000000..2ab65a4248 --- /dev/null +++ b/src/pages/settings/notifications/apprise.tsx @@ -0,0 +1,19 @@ +import NotificationsApprise from '@app/components/Settings/Notifications/NotificationsApprise'; +import SettingsLayout from '@app/components/Settings/SettingsLayout'; +import SettingsNotifications from '@app/components/Settings/SettingsNotifications'; +import useRouteGuard from '@app/hooks/useRouteGuard'; +import { Permission } from '@app/hooks/useUser'; +import type { NextPage } from 'next'; + +const NotificationsPage: NextPage = () => { + useRouteGuard(Permission.ADMIN); + return ( + + + + + + ); +}; + +export default NotificationsPage; diff --git a/src/pages/users/[userId]/settings/notifications/apprise.tsx b/src/pages/users/[userId]/settings/notifications/apprise.tsx new file mode 100644 index 0000000000..807203bc8e --- /dev/null +++ b/src/pages/users/[userId]/settings/notifications/apprise.tsx @@ -0,0 +1,19 @@ +import UserSettings from '@app/components/UserProfile/UserSettings'; +import UserNotificationSettings from '@app/components/UserProfile/UserSettings/UserNotificationSettings'; +import UserNotificationsApprise from '@app/components/UserProfile/UserSettings/UserNotificationSettings/UserNotificationsApprise'; +import useRouteGuard from '@app/hooks/useRouteGuard'; +import { Permission } from '@app/hooks/useUser'; +import type { NextPage } from 'next'; + +const NotificationsPage: NextPage = () => { + useRouteGuard(Permission.MANAGE_USERS); + return ( + + + + + + ); +}; + +export default NotificationsPage; From 20b15c6e6d0e1919ab37434f9458ccfa884380d5 Mon Sep 17 00:00:00 2001 From: JackW <53652452+JackW6809@users.noreply.github.com> Date: Sat, 28 Feb 2026 20:21:35 +0000 Subject: [PATCH 2/3] feat(notifications): added apprise notifications Added Apprise Notifcation Agent, this will close an open issue BREAKING CHANGE: Database Migration implements #2270 --- seerr-api.yml | 10 + server/entity/UserSettings.ts | 3 + .../interfaces/api/userSettingsInterfaces.ts | 2 + server/lib/notifications/agents/apprise.ts | 33 ++- .../1771353857398-notification-apprise.ts | 27 -- .../1771353842932-notification-apprise.ts | 87 ------- server/routes/user/usersettings.ts | 7 +- server/utils/mediaServerHelper.ts | 47 ++++ .../UserNotificationsApprise.tsx | 243 +++++++++++++++--- .../UserNotificationsDiscord.tsx | 1 + .../UserNotificationsEmail.tsx | 1 + .../UserNotificationsPushbullet.tsx | 1 + .../UserNotificationsPushover.tsx | 1 + .../UserNotificationsTelegram.tsx | 1 + .../UserNotificationsWebPush/index.tsx | 1 + .../UserNotificationSettings/index.tsx | 5 +- src/i18n/locale/en.json | 7 +- 17 files changed, 317 insertions(+), 160 deletions(-) delete mode 100644 server/migration/postgres/1771353857398-notification-apprise.ts delete mode 100644 server/migration/sqlite/1771353842932-notification-apprise.ts create mode 100644 server/utils/mediaServerHelper.ts diff --git a/seerr-api.yml b/seerr-api.yml index cbaa0add45..366888842a 100644 --- a/seerr-api.yml +++ b/seerr-api.yml @@ -150,6 +150,8 @@ components: example: 'user@example.com' appriseTags: type: string + appriseStatelessURL: + type: string discordId: type: string nullable: true @@ -1962,6 +1964,12 @@ components: appriseEnabledTypes: type: number nullable: true + appriseTags: + type: string + nullable: true + appriseStatelessURL: + type: string + nullable: true discordEnabled: type: boolean discordEnabledTypes: @@ -1999,6 +2007,8 @@ components: NotificationAgentTypes: type: object properties: + apprise: + type: number discord: type: number email: diff --git a/server/entity/UserSettings.ts b/server/entity/UserSettings.ts index b33b07de2d..acaa968d85 100644 --- a/server/entity/UserSettings.ts +++ b/server/entity/UserSettings.ts @@ -45,6 +45,9 @@ export class UserSettings { @Column({ nullable: true }) public appriseTags?: string; + @Column({ nullable: true }) + public appriseStatelessURL?: string; + @Column({ nullable: true }) public discordId?: string; diff --git a/server/interfaces/api/userSettingsInterfaces.ts b/server/interfaces/api/userSettingsInterfaces.ts index 6bc8c9424f..16c53f78ff 100644 --- a/server/interfaces/api/userSettingsInterfaces.ts +++ b/server/interfaces/api/userSettingsInterfaces.ts @@ -4,6 +4,7 @@ export interface UserSettingsGeneralResponse { username?: string; email?: string; appriseTags?: string; + appriseStatelessURL?: string; discordId?: string; locale?: string; discoverRegion?: string; @@ -30,6 +31,7 @@ export interface UserSettingsNotificationsResponse { appriseURL?: string; appriseAPIToken?: string; appriseTags?: string; + appriseStatelessURL?: string; discordEnabled?: boolean; discordEnabledTypes?: number; discordId?: string; diff --git a/server/lib/notifications/agents/apprise.ts b/server/lib/notifications/agents/apprise.ts index 15034c5a16..b6da3d5f3d 100644 --- a/server/lib/notifications/agents/apprise.ts +++ b/server/lib/notifications/agents/apprise.ts @@ -17,6 +17,7 @@ interface AppriseOptions { apiToken?: string; title?: string; body?: string; + urls?: string; type: 'info' | 'success' | 'warning' | 'failure'; } @@ -59,7 +60,11 @@ class AppriseAgent body += `${payload.message}\n\n`; } - title += ` [[${applicationTitle}]](${applicationUrl})`; + if (applicationUrl) { + title += ` [[${applicationTitle}]](${applicationUrl})`; + } else { + title += ` [${applicationTitle}]`; + } if (payload.request) { body += `Requested By\n${payload.request.requestedBy.displayName}`; @@ -88,7 +93,7 @@ class AppriseAgent body += `\n\nRequest Status\n${status}`; } } else if (payload.comment) { - body += `\n\nComment From ${payload.comment.user.displayName}\n${payload.comment}`; + body += `\n\nComment From ${payload.comment.user.displayName}\n${payload.comment.message}`; } else if (payload.issue) { body += `\n\nReported By\n${payload.issue.createdBy.displayName}\n\nIssue Type\n${IssueTypeName[payload.issue.issueType]}\n\nIssue Status\n${payload.issue.status === IssueStatus.OPEN ? 'Open' : 'Resolved'}`; } @@ -111,13 +116,23 @@ class AppriseAgent body += `\n\nPlay on [${mediaServerName}](${mediaServerUrl})`; } - return { - title: title, - body: body, - format: 'markdown', - tags: payload.notifyUser?.settings?.appriseTags || 'all', - type: 'info', - }; + if (payload.notifyUser?.settings?.appriseStatelessURL) { + return { + title: title, + body: body, + format: 'markdown', + urls: payload.notifyUser?.settings?.appriseStatelessURL, + type: 'info', + }; + } else { + return { + title: title, + body: body, + format: 'markdown', + tags: payload.notifyUser?.settings?.appriseTags || 'all', + type: 'info', + }; + } } public async send( type: Notification, diff --git a/server/migration/postgres/1771353857398-notification-apprise.ts b/server/migration/postgres/1771353857398-notification-apprise.ts deleted file mode 100644 index 4be21b1789..0000000000 --- a/server/migration/postgres/1771353857398-notification-apprise.ts +++ /dev/null @@ -1,27 +0,0 @@ -import type { MigrationInterface, QueryRunner } from 'typeorm'; - -export class NotificationApprise1771353857398 implements MigrationInterface { - name = 'NotificationApprise1771353857398'; - - public async up(queryRunner: QueryRunner): Promise { - await queryRunner.query( - `ALTER TABLE "user_settings" ADD "appriseTags" character varying` - ); - await queryRunner.query( - `CREATE SEQUENCE IF NOT EXISTS "blocklist_id_seq" OWNED BY "blocklist"."id"` - ); - await queryRunner.query( - `ALTER TABLE "blocklist" ALTER COLUMN "id" SET DEFAULT nextval('"blocklist_id_seq"')` - ); - } - - public async down(queryRunner: QueryRunner): Promise { - await queryRunner.query( - `ALTER TABLE "blocklist" ALTER COLUMN "id" DROP DEFAULT` - ); - await queryRunner.query(`DROP SEQUENCE "blocklist_id_seq"`); - await queryRunner.query( - `ALTER TABLE "user_settings" DROP COLUMN "appriseTags"` - ); - } -} diff --git a/server/migration/sqlite/1771353842932-notification-apprise.ts b/server/migration/sqlite/1771353842932-notification-apprise.ts deleted file mode 100644 index 8eb90d7caf..0000000000 --- a/server/migration/sqlite/1771353842932-notification-apprise.ts +++ /dev/null @@ -1,87 +0,0 @@ -import type { MigrationInterface, QueryRunner } from 'typeorm'; - -export class NotificationApprise1771353842932 implements MigrationInterface { - name = 'NotificationApprise1771353842932'; - - public async up(queryRunner: QueryRunner): Promise { - await queryRunner.query(`DROP INDEX "IDX_03f7958328e311761b0de675fb"`); - await queryRunner.query( - `CREATE TABLE "temporary_user_push_subscription" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "endpoint" varchar NOT NULL, "p256dh" varchar NOT NULL, "auth" varchar NOT NULL, "userId" integer, "userAgent" varchar, "createdAt" datetime DEFAULT (CURRENT_TIMESTAMP), CONSTRAINT "UQ_f90ab5a4ed54905a4bb51a7148b" UNIQUE ("auth"), CONSTRAINT "UQ_6427d07d9a171a3a1ab87480005" UNIQUE ("endpoint", "userId"), CONSTRAINT "FK_03f7958328e311761b0de675fbe" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` - ); - await queryRunner.query( - `INSERT INTO "temporary_user_push_subscription"("id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt") SELECT "id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt" FROM "user_push_subscription"` - ); - await queryRunner.query(`DROP TABLE "user_push_subscription"`); - await queryRunner.query( - `ALTER TABLE "temporary_user_push_subscription" RENAME TO "user_push_subscription"` - ); - await queryRunner.query( - `CREATE INDEX "IDX_03f7958328e311761b0de675fb" ON "user_push_subscription" ("userId") ` - ); - await queryRunner.query( - `CREATE TABLE "temporary_user_settings" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "locale" varchar NOT NULL DEFAULT (''), "discoverRegion" varchar, "streamingRegion" varchar, "originalLanguage" varchar, "pgpKey" varchar, "discordId" varchar, "pushbulletAccessToken" varchar, "pushoverApplicationToken" varchar, "pushoverUserKey" varchar, "pushoverSound" varchar, "telegramChatId" varchar, "telegramSendSilently" boolean, "watchlistSyncMovies" boolean, "watchlistSyncTv" boolean, "notificationTypes" text, "userId" integer, "telegramMessageThreadId" varchar, "appriseTags" varchar, CONSTRAINT "REL_986a2b6d3c05eb4091bb8066f7" UNIQUE ("userId"), CONSTRAINT "FK_986a2b6d3c05eb4091bb8066f78" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` - ); - await queryRunner.query( - `INSERT INTO "temporary_user_settings"("id", "locale", "discoverRegion", "streamingRegion", "originalLanguage", "pgpKey", "discordId", "pushbulletAccessToken", "pushoverApplicationToken", "pushoverUserKey", "pushoverSound", "telegramChatId", "telegramSendSilently", "watchlistSyncMovies", "watchlistSyncTv", "notificationTypes", "userId", "telegramMessageThreadId") SELECT "id", "locale", "discoverRegion", "streamingRegion", "originalLanguage", "pgpKey", "discordId", "pushbulletAccessToken", "pushoverApplicationToken", "pushoverUserKey", "pushoverSound", "telegramChatId", "telegramSendSilently", "watchlistSyncMovies", "watchlistSyncTv", "notificationTypes", "userId", "telegramMessageThreadId" FROM "user_settings"` - ); - await queryRunner.query(`DROP TABLE "user_settings"`); - await queryRunner.query( - `ALTER TABLE "temporary_user_settings" RENAME TO "user_settings"` - ); - await queryRunner.query(`DROP INDEX "IDX_03f7958328e311761b0de675fb"`); - await queryRunner.query( - `CREATE TABLE "temporary_user_push_subscription" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "endpoint" varchar NOT NULL, "p256dh" varchar NOT NULL, "auth" varchar NOT NULL, "userId" integer, "userAgent" varchar, "createdAt" datetime DEFAULT (CURRENT_TIMESTAMP), CONSTRAINT "UQ_f90ab5a4ed54905a4bb51a7148b" UNIQUE ("auth"), CONSTRAINT "UQ_6427d07d9a171a3a1ab87480005" UNIQUE ("endpoint", "userId"), CONSTRAINT "FK_03f7958328e311761b0de675fbe" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` - ); - await queryRunner.query( - `INSERT INTO "temporary_user_push_subscription"("id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt") SELECT "id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt" FROM "user_push_subscription"` - ); - await queryRunner.query(`DROP TABLE "user_push_subscription"`); - await queryRunner.query( - `ALTER TABLE "temporary_user_push_subscription" RENAME TO "user_push_subscription"` - ); - await queryRunner.query( - `CREATE INDEX "IDX_03f7958328e311761b0de675fb" ON "user_push_subscription" ("userId") ` - ); - } - - public async down(queryRunner: QueryRunner): Promise { - await queryRunner.query(`DROP INDEX "IDX_03f7958328e311761b0de675fb"`); - await queryRunner.query( - `ALTER TABLE "user_push_subscription" RENAME TO "temporary_user_push_subscription"` - ); - await queryRunner.query( - `CREATE TABLE "user_push_subscription" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "endpoint" varchar NOT NULL, "p256dh" varchar NOT NULL, "auth" varchar NOT NULL, "userId" integer, "userAgent" varchar, "createdAt" datetime DEFAULT (CURRENT_TIMESTAMP), CONSTRAINT "UQ_f90ab5a4ed54905a4bb51a7148b" UNIQUE ("auth"), CONSTRAINT "UQ_6427d07d9a171a3a1ab87480005" UNIQUE ("endpoint", "userId"), CONSTRAINT "FK_03f7958328e311761b0de675fbe" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` - ); - await queryRunner.query( - `INSERT INTO "user_push_subscription"("id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt") SELECT "id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt" FROM "temporary_user_push_subscription"` - ); - await queryRunner.query(`DROP TABLE "temporary_user_push_subscription"`); - await queryRunner.query( - `CREATE INDEX "IDX_03f7958328e311761b0de675fb" ON "user_push_subscription" ("userId") ` - ); - await queryRunner.query( - `ALTER TABLE "user_settings" RENAME TO "temporary_user_settings"` - ); - await queryRunner.query( - `CREATE TABLE "user_settings" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "locale" varchar NOT NULL DEFAULT (''), "discoverRegion" varchar, "streamingRegion" varchar, "originalLanguage" varchar, "pgpKey" varchar, "discordId" varchar, "pushbulletAccessToken" varchar, "pushoverApplicationToken" varchar, "pushoverUserKey" varchar, "pushoverSound" varchar, "telegramChatId" varchar, "telegramSendSilently" boolean, "watchlistSyncMovies" boolean, "watchlistSyncTv" boolean, "notificationTypes" text, "userId" integer, "telegramMessageThreadId" varchar, CONSTRAINT "REL_986a2b6d3c05eb4091bb8066f7" UNIQUE ("userId"), CONSTRAINT "FK_986a2b6d3c05eb4091bb8066f78" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` - ); - await queryRunner.query( - `INSERT INTO "user_settings"("id", "locale", "discoverRegion", "streamingRegion", "originalLanguage", "pgpKey", "discordId", "pushbulletAccessToken", "pushoverApplicationToken", "pushoverUserKey", "pushoverSound", "telegramChatId", "telegramSendSilently", "watchlistSyncMovies", "watchlistSyncTv", "notificationTypes", "userId", "telegramMessageThreadId") SELECT "id", "locale", "discoverRegion", "streamingRegion", "originalLanguage", "pgpKey", "discordId", "pushbulletAccessToken", "pushoverApplicationToken", "pushoverUserKey", "pushoverSound", "telegramChatId", "telegramSendSilently", "watchlistSyncMovies", "watchlistSyncTv", "notificationTypes", "userId", "telegramMessageThreadId" FROM "temporary_user_settings"` - ); - await queryRunner.query(`DROP TABLE "temporary_user_settings"`); - await queryRunner.query(`DROP INDEX "IDX_03f7958328e311761b0de675fb"`); - await queryRunner.query( - `ALTER TABLE "user_push_subscription" RENAME TO "temporary_user_push_subscription"` - ); - await queryRunner.query( - `CREATE TABLE "user_push_subscription" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "endpoint" varchar NOT NULL, "p256dh" varchar NOT NULL, "auth" varchar NOT NULL, "userId" integer, "userAgent" varchar, "createdAt" datetime DEFAULT (CURRENT_TIMESTAMP), CONSTRAINT "UQ_f90ab5a4ed54905a4bb51a7148b" UNIQUE ("auth"), CONSTRAINT "UQ_6427d07d9a171a3a1ab87480005" UNIQUE ("endpoint", "userId"), CONSTRAINT "FK_03f7958328e311761b0de675fbe" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` - ); - await queryRunner.query( - `INSERT INTO "user_push_subscription"("id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt") SELECT "id", "endpoint", "p256dh", "auth", "userId", "userAgent", "createdAt" FROM "temporary_user_push_subscription"` - ); - await queryRunner.query(`DROP TABLE "temporary_user_push_subscription"`); - await queryRunner.query( - `CREATE INDEX "IDX_03f7958328e311761b0de675fb" ON "user_push_subscription" ("userId") ` - ); - } -} diff --git a/server/routes/user/usersettings.ts b/server/routes/user/usersettings.ts index 65953a4246..6dea4db0d7 100644 --- a/server/routes/user/usersettings.ts +++ b/server/routes/user/usersettings.ts @@ -48,6 +48,7 @@ userSettingsRoutes.get<{ id: string }, UserSettingsGeneralResponse>( return res.status(200).json({ username: user.username, appriseTags: user.settings?.appriseTags, + appriseStatelessURL: user.settings?.appriseStatelessURL, email: user.email, discordId: user.settings?.discordId, locale: user.settings?.locale, @@ -538,6 +539,7 @@ userSettingsRoutes.get<{ id: string }, UserSettingsNotificationsResponse>( return res.status(200).json({ emailEnabled: settings.email.enabled, appriseTags: user.settings?.appriseTags, + appriseStatelessURL: user.settings?.appriseStatelessURL, appriseEnabled: settings?.apprise.enabled, appriseEnabledTypes: settings?.apprise.enabled ? settings.apprise.types @@ -595,6 +597,7 @@ userSettingsRoutes.post<{ id: string }, UserSettingsNotificationsResponse>( user.settings = new UserSettings({ user: req.user, appriseTags: req.body.appriseTags, + appriseStatelessURL: req.body.appriseStatelessURL, pgpKey: req.body.pgpKey, discordId: req.body.discordId, pushbulletAccessToken: req.body.pushbulletAccessToken, @@ -608,6 +611,7 @@ userSettingsRoutes.post<{ id: string }, UserSettingsNotificationsResponse>( } else { user.settings.pgpKey = req.body.pgpKey; user.settings.appriseTags = req.body.appriseTags; + user.settings.appriseStatelessURL = req.body.appriseStatelessURL; user.settings.discordId = req.body.discordId; user.settings.pushbulletAccessToken = req.body.pushbulletAccessToken; user.settings.pushoverApplicationToken = @@ -625,11 +629,12 @@ userSettingsRoutes.post<{ id: string }, UserSettingsNotificationsResponse>( ); } - userRepository.save(user); + await userRepository.save(user); return res.status(200).json({ pgpKey: user.settings.pgpKey, appriseTags: user.settings?.appriseTags, + appriseStatelessURL: user.settings?.appriseStatelessURL, discordId: user.settings.discordId, pushbulletAccessToken: user.settings.pushbulletAccessToken, pushoverApplicationToken: user.settings.pushoverApplicationToken, diff --git a/server/utils/mediaServerHelper.ts b/server/utils/mediaServerHelper.ts new file mode 100644 index 0000000000..0622332cc6 --- /dev/null +++ b/server/utils/mediaServerHelper.ts @@ -0,0 +1,47 @@ +import { MediaServerType } from '@server/constants/server'; + +/** + * Returns the display name of the media server being used in the seerr installation + * If neither Emby nor Plex is detected it falls back to Jellyfin + * @param currentMediaServer The currently configured media server type from settings + * @returns Human readable media server name + */ +export function getAvailableMediaServerName( + currentMediaServer: MediaServerType +): string { + if (currentMediaServer === MediaServerType.EMBY) { + return 'Emby'; + } + + if (currentMediaServer === MediaServerType.PLEX) { + return 'Plex'; + } + + return 'Jellyfin'; +} + +/** + * This function returns the URL of the media directly in the media server + * Used later on in the email as a button to send directly to + * + * Prefers the 4K URL when the request is marked as 4K and prefers the + * standard URL otherwise. If the preferred URL is unavailable, it falls + * back to the alternative resolution URL to ensure a playable link is + * returned wherever possible. + * + * @param payload Notification payload containing media and request information + * @returns Media server URL or undefined if it's unavailable + */ +export function getAvailableMediaServerUrl(payload: { + request?: { is4k?: boolean }; + media?: { + mediaUrl?: string; + mediaUrl4k?: string; + }; +}): string | undefined { + const wants4k = payload.request?.is4k; + const url4k = payload.media?.mediaUrl4k; + const url = payload.media?.mediaUrl; + + return (wants4k ? (url4k ?? url) : (url ?? url4k)) || undefined; +} diff --git a/src/components/UserProfile/UserSettings/UserNotificationSettings/UserNotificationsApprise.tsx b/src/components/UserProfile/UserSettings/UserNotificationSettings/UserNotificationsApprise.tsx index 2457405665..24bf1f835c 100644 --- a/src/components/UserProfile/UserSettings/UserNotificationSettings/UserNotificationsApprise.tsx +++ b/src/components/UserProfile/UserSettings/UserNotificationSettings/UserNotificationsApprise.tsx @@ -1,5 +1,6 @@ import Button from '@app/components/Common/Button'; import LoadingSpinner from '@app/components/Common/LoadingSpinner'; +import SensitiveInput from '@app/components/Common/SensitiveInput'; import NotificationTypeSelector from '@app/components/NotificationTypeSelector'; import { useUser } from '@app/hooks/useUser'; import globalMessages from '@app/i18n/globalMessages'; @@ -21,7 +22,14 @@ const messages = defineMessages( apprisesettingsfailed: 'Apprise notification settings failed to save.', appriseTags: 'Apprise Tags', appriseTagsTip: - 'The tag(s) that lines up to what yo have configure in your Apprise instance', + 'The tags that have been configured to send the desired notifications to Apprise', + appriseStatelessURL: 'Apprise Stateless URL', + appriseStatelessURLTip: + 'The stateless URL used to send notifications to Apprise', + validationAppriseOneRequired: 'Enter Apprise Tag(s) or a stateless URL', + validationAppriseExclusive: + 'You can only set Apprise Tag(s) OR a stateless URL (not both)', + validationAppriseTypesRequired: 'Select at least one notification type', } ); @@ -39,13 +47,71 @@ const UserNotificationsApprise = () => { ); const UserNotificationsAppriseSchema = Yup.object().shape({ - appriseTags: Yup.string().when('types', { - is: (types: string) => !!types, - then: Yup.string() - .nullable() - .required(intl.formatMessage(messages.appriseTags)), - otherwise: Yup.string().nullable(), - }), + appriseTags: Yup.string() + .transform((v) => (typeof v === 'string' ? v.trim() : v)) + .nullable() + .when('types', { + is: (types: unknown) => Number(types) > 0, + then: Yup.string() + .nullable() + .test( + 'tags-or-url-required', + intl.formatMessage(messages.validationAppriseOneRequired), + function (value) { + const tags = (value ?? '').toString().trim(); + const url = (this.parent?.appriseStatelessURL ?? '') + .toString() + .trim(); + return !!(tags || url); + } + ) + .test( + 'tags-xor-url', + intl.formatMessage(messages.validationAppriseExclusive), + function (value) { + const tags = (value ?? '').toString().trim(); + const url = (this.parent?.appriseStatelessURL ?? '') + .toString() + .trim(); + + return !(tags && url); + } + ), + otherwise: Yup.string().nullable(), + }), + + appriseStatelessURL: Yup.string() + .transform((v) => (typeof v === 'string' ? v.trim() : v)) + .nullable() + .when('types', { + is: (types: unknown) => Number(types) > 0, + then: Yup.string() + .nullable() + .test( + 'url-xor-tags', + intl.formatMessage(messages.validationAppriseExclusive), + function (value) { + const url = (value ?? '').toString().trim(); + const tags = (this.parent?.appriseTags ?? '').toString().trim(); + + return !(url && tags); + } + ), + otherwise: Yup.string().nullable(), + }), + + types: Yup.number().test( + 'types-required-if-configured', + intl.formatMessage(messages.validationAppriseTypesRequired), + function (value) { + const tags = (this.parent?.appriseTags ?? '').toString().trim(); + const url = (this.parent?.appriseStatelessURL ?? '').toString().trim(); + if (tags || url) { + return Number(value) > 0; + } + return true; + } + ), }); if (!data && !error) { @@ -56,6 +122,7 @@ const UserNotificationsApprise = () => { { await axios.post(`/api/v1/user/${user?.id}/settings/notifications`, { pgpKey: data?.pgpKey, appriseTags: values.appriseTags, + appriseStatelessURL: values.appriseStatelessURL, discordId: data?.discordId, pushbulletAccessToken: data?.pushbulletAccessToken, pushoverApplicationToken: data?.pushoverApplicationToken, @@ -99,38 +167,147 @@ const UserNotificationsApprise = () => { values, setFieldValue, setFieldTouched, + setFieldError, + validateForm, }) => { + const hasTags = !!(values.appriseTags ?? '').toString().trim(); + const hasUrl = !!(values.appriseStatelessURL ?? '').toString().trim(); + return ( -
-
- -
-
- + + {(!hasUrl || hasTags) && ( +
+ +
+
+ + {({ field }: { field: { name: string; value: any } }) => ( + { + const next = e.target.value; + const newUrl = next.trim() + ? '' + : (values.appriseStatelessURL ?? ''); + setFieldValue('appriseTags', next, false); + if (next.trim()) + setFieldValue('appriseStatelessURL', '', false); + validateForm({ + ...values, + appriseTags: next, + appriseStatelessURL: newUrl, + }); + }} + onBlur={() => setFieldTouched('appriseTags', true)} + /> + )} + +
+ {errors.appriseTags && + touched.appriseTags && + typeof errors.appriseTags === 'string' && ( +
{errors.appriseTags}
+ )}
- {errors.appriseTags && - touched.appriseTags && - typeof errors.appriseTags === 'object' && ( -
{errors.appriseTags}
- )}
-
+ )} + {(!hasTags || hasUrl) && ( +
+ +
+
+ + {({ field }: { field: { name: string; value: any } }) => ( + { + const next = e.target.value; + const newTags = next.trim() + ? '' + : (values.appriseTags ?? ''); + setFieldValue('appriseStatelessURL', next, false); + if (next.trim()) + setFieldValue('appriseTags', '', false); + validateForm({ + ...values, + appriseStatelessURL: next, + appriseTags: newTags, + }); + }} + onBlur={() => + setFieldTouched('appriseStatelessURL', true) + } + /> + )} + +
+ {errors.appriseStatelessURL && + touched.appriseStatelessURL && + typeof errors.appriseStatelessURL === 'string' && ( +
{errors.appriseStatelessURL}
+ )} +
+
+ )} + {(hasTags || hasUrl) && ( +
+
+ + {hasTags + ? 'Using Apprise Tags. Clear the Tags field to use a Stateless URL instead.' + : 'Using a Stateless URL. Clear the URL field to use Apprise Tags instead.'} + +
+
+ )} { - setFieldValue('types', newTypes); - setFieldTouched('types'); + onUpdate={async (newTypes) => { + const normalizedTypes = Number(newTypes) || 0; + + // Update without immediate validation, then validate once with final state + setFieldValue('types', normalizedTypes, false); + setFieldTouched('types', true, false); + + if (normalizedTypes === 0) { + setFieldError('appriseTags', undefined); + setFieldError('appriseStatelessURL', undefined); + setFieldTouched('appriseTags', false, false); + setFieldTouched('appriseStatelessURL', false, false); + } + + await validateForm({ + ...values, + types: normalizedTypes, + }); }} error={ errors.types && touched.types diff --git a/src/components/UserProfile/UserSettings/UserNotificationSettings/UserNotificationsDiscord.tsx b/src/components/UserProfile/UserSettings/UserNotificationSettings/UserNotificationsDiscord.tsx index 0ba3f09a26..c1a0206548 100644 --- a/src/components/UserProfile/UserSettings/UserNotificationSettings/UserNotificationsDiscord.tsx +++ b/src/components/UserProfile/UserSettings/UserNotificationSettings/UserNotificationsDiscord.tsx @@ -71,6 +71,7 @@ const UserNotificationsDiscord = () => { await axios.post(`/api/v1/user/${user?.id}/settings/notifications`, { pgpKey: data?.pgpKey, appriseTags: data?.appriseTags, + appriseStatelessURL: data?.appriseStatelessURL, discordId: values.discordId, pushbulletAccessToken: data?.pushbulletAccessToken, pushoverApplicationToken: data?.pushoverApplicationToken, diff --git a/src/components/UserProfile/UserSettings/UserNotificationSettings/UserNotificationsEmail.tsx b/src/components/UserProfile/UserSettings/UserNotificationSettings/UserNotificationsEmail.tsx index f654bf11c3..2544b1cbf4 100644 --- a/src/components/UserProfile/UserSettings/UserNotificationSettings/UserNotificationsEmail.tsx +++ b/src/components/UserProfile/UserSettings/UserNotificationSettings/UserNotificationsEmail.tsx @@ -70,6 +70,7 @@ const UserEmailSettings = () => { await axios.post(`/api/v1/user/${user?.id}/settings/notifications`, { pgpKey: values.pgpKey, appriseTags: data?.appriseTags, + appriseStatelessURL: data?.appriseStatelessURL, discordId: data?.discordId, pushbulletAccessToken: data?.pushbulletAccessToken, pushoverApplicationToken: data?.pushoverApplicationToken, diff --git a/src/components/UserProfile/UserSettings/UserNotificationSettings/UserNotificationsPushbullet.tsx b/src/components/UserProfile/UserSettings/UserNotificationSettings/UserNotificationsPushbullet.tsx index d1891c4fc3..54d7c3b8bc 100644 --- a/src/components/UserProfile/UserSettings/UserNotificationSettings/UserNotificationsPushbullet.tsx +++ b/src/components/UserProfile/UserSettings/UserNotificationSettings/UserNotificationsPushbullet.tsx @@ -68,6 +68,7 @@ const UserPushbulletSettings = () => { await axios.post(`/api/v1/user/${user?.id}/settings/notifications`, { pgpKey: data?.pgpKey, appriseTags: data?.appriseTags, + appriseStatelessURL: data?.appriseStatelessURL, discordId: data?.discordId, pushbulletAccessToken: values.pushbulletAccessToken, pushoverApplicationToken: data?.pushoverApplicationToken, diff --git a/src/components/UserProfile/UserSettings/UserNotificationSettings/UserNotificationsPushover.tsx b/src/components/UserProfile/UserSettings/UserNotificationSettings/UserNotificationsPushover.tsx index 437cd893c5..1119201774 100644 --- a/src/components/UserProfile/UserSettings/UserNotificationSettings/UserNotificationsPushover.tsx +++ b/src/components/UserProfile/UserSettings/UserNotificationSettings/UserNotificationsPushover.tsx @@ -100,6 +100,7 @@ const UserPushoverSettings = () => { await axios.post(`/api/v1/user/${user?.id}/settings/notifications`, { pgpKey: data?.pgpKey, appriseTags: data?.appriseTags, + appriseStatelessURL: data?.appriseStatelessURL, discordId: data?.discordId, pushbulletAccessToken: data?.pushbulletAccessToken, pushoverApplicationToken: values.pushoverApplicationToken, diff --git a/src/components/UserProfile/UserSettings/UserNotificationSettings/UserNotificationsTelegram.tsx b/src/components/UserProfile/UserSettings/UserNotificationSettings/UserNotificationsTelegram.tsx index 7e94a81cda..63cd8ac362 100644 --- a/src/components/UserProfile/UserSettings/UserNotificationSettings/UserNotificationsTelegram.tsx +++ b/src/components/UserProfile/UserSettings/UserNotificationSettings/UserNotificationsTelegram.tsx @@ -94,6 +94,7 @@ const UserTelegramSettings = () => { await axios.post(`/api/v1/user/${user?.id}/settings/notifications`, { pgpKey: data?.pgpKey, appriseTags: data?.appriseTags, + appriseStatelessURL: data?.appriseStatelessURL, discordId: data?.discordId, pushbulletAccessToken: data?.pushbulletAccessToken, pushoverApplicationToken: data?.pushoverApplicationToken, diff --git a/src/components/UserProfile/UserSettings/UserNotificationSettings/UserNotificationsWebPush/index.tsx b/src/components/UserProfile/UserSettings/UserNotificationSettings/UserNotificationsWebPush/index.tsx index 86118bea03..4ddd9d2aaf 100644 --- a/src/components/UserProfile/UserSettings/UserNotificationSettings/UserNotificationsWebPush/index.tsx +++ b/src/components/UserProfile/UserSettings/UserNotificationSettings/UserNotificationsWebPush/index.tsx @@ -257,6 +257,7 @@ const UserWebPushSettings = () => { { pgpKey: data?.pgpKey, appriseTags: data?.appriseTags, + appriseStatelessURL: data?.appriseStatelessURL, discordId: data?.discordId, pushbulletAccessToken: data?.pushbulletAccessToken, pushoverApplicationToken: data?.pushoverApplicationToken, diff --git a/src/components/UserProfile/UserSettings/UserNotificationSettings/index.tsx b/src/components/UserProfile/UserSettings/UserNotificationSettings/index.tsx index ecd7336518..5d3e0f231c 100644 --- a/src/components/UserProfile/UserSettings/UserNotificationSettings/index.tsx +++ b/src/components/UserProfile/UserSettings/UserNotificationSettings/index.tsx @@ -6,7 +6,7 @@ import LoadingSpinner from '@app/components/Common/LoadingSpinner'; import PageTitle from '@app/components/Common/PageTitle'; import type { SettingsRoute } from '@app/components/Common/SettingsTabs'; import SettingsTabs from '@app/components/Common/SettingsTabs'; -import { useUser } from '@app/hooks/useUser'; +import { Permission, useUser } from '@app/hooks/useUser'; import globalMessages from '@app/i18n/globalMessages'; import Error from '@app/pages/_error'; import defineMessages from '@app/utils/defineMessages'; @@ -36,6 +36,7 @@ const UserNotificationSettings = ({ const intl = useIntl(); const router = useRouter(); const { user } = useUser({ id: Number(router.query.userId) }); + const { hasPermission } = useUser(); const { data, error } = useSWR( user ? `/api/v1/user/${user?.id}/settings/notifications` : null ); @@ -121,7 +122,7 @@ const UserNotificationSettings = ({ ), route: '/settings/notifications/apprise', regex: /\/settings\/notifications\/apprise/, - hidden: !data?.appriseEnabled, + hidden: !data?.appriseEnabled || !hasPermission(Permission.ADMIN), }, ]; diff --git a/src/i18n/locale/en.json b/src/i18n/locale/en.json index f861bf6bc8..e3c8ec445e 100644 --- a/src/i18n/locale/en.json +++ b/src/i18n/locale/en.json @@ -1487,8 +1487,10 @@ "components.UserProfile.UserSettings.UserNotificationSettings.UserNotificationsWebPush.webpushhasbeenenabled": "Web push has been enabled.", "components.UserProfile.UserSettings.UserNotificationSettings.UserNotificationsWebPush.webpushsettingsfailed": "Web push notification settings failed to save.", "components.UserProfile.UserSettings.UserNotificationSettings.UserNotificationsWebPush.webpushsettingssaved": "Web push notification settings saved successfully!", + "components.UserProfile.UserSettings.UserNotificationSettings.appriseStatelessURL": "Apprise Stateless URL", + "components.UserProfile.UserSettings.UserNotificationSettings.appriseStatelessURLTip": "The stateless URL used to send notifications to Apprise", "components.UserProfile.UserSettings.UserNotificationSettings.appriseTags": "Apprise Tags", - "components.UserProfile.UserSettings.UserNotificationSettings.appriseTagsTip": "The tag(s) that lines up to what yo have configure in your Apprise instance", + "components.UserProfile.UserSettings.UserNotificationSettings.appriseTagsTip": "The tags that have been configured to send the desired notifications to Apprise", "components.UserProfile.UserSettings.UserNotificationSettings.apprisesettingsfailed": "Apprise notification settings failed to save.", "components.UserProfile.UserSettings.UserNotificationSettings.apprisesettingssaved": "Apprise notification settings saved successfully!", "components.UserProfile.UserSettings.UserNotificationSettings.deviceDefault": "Device Default", @@ -1522,6 +1524,9 @@ "components.UserProfile.UserSettings.UserNotificationSettings.telegramMessageThreadIdTip": "If your group-chat has topics enabled, you can specify a thread/topic's ID here", "components.UserProfile.UserSettings.UserNotificationSettings.telegramsettingsfailed": "Telegram notification settings failed to save.", "components.UserProfile.UserSettings.UserNotificationSettings.telegramsettingssaved": "Telegram notification settings saved successfully!", + "components.UserProfile.UserSettings.UserNotificationSettings.validationAppriseExclusive": "You can only set Apprise Tag(s) OR a stateless URL (not both)", + "components.UserProfile.UserSettings.UserNotificationSettings.validationAppriseOneRequired": "Enter Apprise Tag(s) or a stateless URL", + "components.UserProfile.UserSettings.UserNotificationSettings.validationAppriseTypesRequired": "Select at least one notification type", "components.UserProfile.UserSettings.UserNotificationSettings.validationDiscordId": "You must provide a valid user ID", "components.UserProfile.UserSettings.UserNotificationSettings.validationPgpPublicKey": "You must provide a valid PGP public key", "components.UserProfile.UserSettings.UserNotificationSettings.validationPushbulletAccessToken": "You must provide an access token", From 22f640dea84601227a4eb97c294886fc55691825 Mon Sep 17 00:00:00 2001 From: JackW <53652452+JackW6809@users.noreply.github.com> Date: Sun, 1 Mar 2026 11:00:33 +0000 Subject: [PATCH 3/3] fix(usernotificationsapprise.tsx): fix Typecast Linting was complaning about casting, updated casting to be inline with what Formik expects --- .../UserNotificationsApprise.tsx | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/src/components/UserProfile/UserSettings/UserNotificationSettings/UserNotificationsApprise.tsx b/src/components/UserProfile/UserSettings/UserNotificationSettings/UserNotificationsApprise.tsx index 24bf1f835c..0d7e0ed266 100644 --- a/src/components/UserProfile/UserSettings/UserNotificationSettings/UserNotificationsApprise.tsx +++ b/src/components/UserProfile/UserSettings/UserNotificationSettings/UserNotificationsApprise.tsx @@ -191,7 +191,14 @@ const UserNotificationsApprise = () => {
- {({ field }: { field: { name: string; value: any } }) => ( + {({ + field, + }: { + field: { + name: string; + value: string | null | undefined; + }; + }) => ( {
- {({ field }: { field: { name: string; value: any } }) => ( + {({ + field, + }: { + field: { + name: string; + value: string | null | undefined; + }; + }) => (