diff --git a/seerr-api.yml b/seerr-api.yml index 6d58dd3cbf..2bc41df19b 100644 --- a/seerr-api.yml +++ b/seerr-api.yml @@ -1271,6 +1271,8 @@ components: type: boolean example: false description: If true, this request will not count against the user's quota. Requires MANAGE_REQUESTS permission. + declineReason: + type: string required: - id - status @@ -6518,6 +6520,9 @@ paths: userId: type: number nullable: true + declineReason: + type: string + nullable: true required: - mediaType responses: @@ -6591,6 +6596,16 @@ paths: schema: type: string enum: [approve, decline] + requestBody: + required: false + content: + application/json: + schema: + type: object + properties: + declineReason: + type: string + nullable: true responses: '200': description: Request status changed diff --git a/server/entity/MediaRequest.ts b/server/entity/MediaRequest.ts index 60681f8f06..85b4ec6e72 100644 --- a/server/entity/MediaRequest.ts +++ b/server/entity/MediaRequest.ts @@ -553,6 +553,9 @@ export class MediaRequest { @Index() public status: MediaRequestStatus; + @Column({ type: 'text', nullable: true }) + public declineReason?: string; + @ManyToOne(() => Media, (media) => media.requests, { eager: true, onDelete: 'CASCADE', diff --git a/server/i18n/globalMessages.ts b/server/i18n/globalMessages.ts index 38f53f0df2..a9a01cad94 100644 --- a/server/i18n/globalMessages.ts +++ b/server/i18n/globalMessages.ts @@ -21,6 +21,7 @@ const globalMessages = defineMessages('notifications.common', { series: 'series', issue: 'issue', issueTypeName: '{type} issue', + declineReason: 'Decline Reason', }); export default globalMessages; diff --git a/server/i18n/locale/en.json b/server/i18n/locale/en.json index 459ed5fada..d94aa63c5c 100644 --- a/server/i18n/locale/en.json +++ b/server/i18n/locale/en.json @@ -9,6 +9,8 @@ "notifications.agents.email.availableRequest4k": "Your request for the following {mediaType} in 4K is now available:", "notifications.agents.email.declinedRequest": "Your request for the following {mediaType} was declined:", "notifications.agents.email.declinedRequest4k": "Your request for the following {mediaType} in 4K was declined:", + "notifications.agents.email.declinedRequest4kReason": "Your request for the following {mediaType} in 4K was declined because: {reason}", + "notifications.agents.email.declinedRequestReason": "Your request for the following {mediaType} was declined because: {reason}", "notifications.agents.email.failedRequest": "A request for the following {mediaType} failed to be added to {service}:", "notifications.agents.email.failedRequest4k": "A request for the following {mediaType} in 4K failed to be added to {service}:", "notifications.agents.email.issue": "issue", @@ -24,6 +26,7 @@ "notifications.agents.webpush.autoRequested": "Automatically submitted a new {quality}{mediaType} request.", "notifications.agents.webpush.available": "Your {quality}{mediaType} request is now available!", "notifications.agents.webpush.declined": "Your {quality}{mediaType} request was declined.", + "notifications.agents.webpush.declinedWithReason": "Your {quality}{mediaType} request was declined because: {reason}.", "notifications.agents.webpush.failed": "Failed to process {quality}{mediaType} request.", "notifications.agents.webpush.issueComment": "{userName} commented on the {issueType}.", "notifications.agents.webpush.issueCreated": "A new {issueType} was reported by {userName}.", @@ -34,6 +37,7 @@ "notifications.agents.webpush.viewMedia": "View Media", "notifications.common.available": "Available", "notifications.common.commentFrom": "Comment from {userName}", + "notifications.common.declineReason": "Decline Reason", "notifications.common.declined": "Declined", "notifications.common.failed": "Failed", "notifications.common.issue": "issue", diff --git a/server/lib/notifications/agents/discord.ts b/server/lib/notifications/agents/discord.ts index 81da3c4c40..ad3c217521 100644 --- a/server/lib/notifications/agents/discord.ts +++ b/server/lib/notifications/agents/discord.ts @@ -142,6 +142,17 @@ class DiscordAgent value: status, inline: true, }); + if ( + type === Notification.MEDIA_DECLINED && + payload.request && + payload.request.declineReason + ) { + fields.push({ + name: intl.formatMessage(globalMessages.declineReason), + value: payload.request.declineReason, + inline: false, + }); + } } } else if (payload.comment) { fields.push({ diff --git a/server/lib/notifications/agents/email.ts b/server/lib/notifications/agents/email.ts index 4037aa6ad8..32b6ef69d8 100644 --- a/server/lib/notifications/agents/email.ts +++ b/server/lib/notifications/agents/email.ts @@ -45,6 +45,10 @@ const messages = defineMessages('notifications.agents.email', { declinedRequest: 'Your request for the following {mediaType} was declined:', declinedRequest4k: 'Your request for the following {mediaType} in 4K was declined:', + declinedRequestReason: + 'Your request for the following {mediaType} was declined because: {reason}', + declinedRequest4kReason: + 'Your request for the following {mediaType} in 4K was declined because: {reason}', failedRequest: 'A request for the following {mediaType} failed to be added to {service}:', failedRequest4k: @@ -165,10 +169,19 @@ class EmailAgent ); break; case Notification.MEDIA_DECLINED: - body = intl.formatMessage( - is4k ? messages.declinedRequest4k : messages.declinedRequest, - { mediaType } - ); + if (!payload.request.declineReason) { + body = intl.formatMessage( + is4k ? messages.declinedRequest4k : messages.declinedRequest, + { mediaType } + ); + } else { + body = intl.formatMessage( + is4k + ? messages.declinedRequest4kReason + : messages.declinedRequestReason, + { mediaType, reason: payload.request.declineReason } + ); + } break; case Notification.MEDIA_FAILED: body = intl.formatMessage( diff --git a/server/lib/notifications/agents/gotify.ts b/server/lib/notifications/agents/gotify.ts index 0470e156e0..c5cb045c93 100644 --- a/server/lib/notifications/agents/gotify.ts +++ b/server/lib/notifications/agents/gotify.ts @@ -85,6 +85,13 @@ class GotifyAgent if (status) { message += `\n**${intl.formatMessage(globalMessages.requestStatus)}:** ${status} `; + if ( + type === Notification.MEDIA_DECLINED && + payload.request && + payload.request.declineReason + ) { + message += `\n**${intl.formatMessage(globalMessages.declineReason)}**\n${payload.request.declineReason}`; + } } } else if (payload.comment) { message += `\n${intl.formatMessage(globalMessages.commentFrom, { userName: payload.comment.user.displayName })}:\n${payload.comment.message} `; diff --git a/server/lib/notifications/agents/ntfy.ts b/server/lib/notifications/agents/ntfy.ts index fae329a007..a2e48f5c68 100644 --- a/server/lib/notifications/agents/ntfy.ts +++ b/server/lib/notifications/agents/ntfy.ts @@ -67,6 +67,13 @@ class NtfyAgent if (status) { message += `\n**${intl.formatMessage(globalMessages.requestStatus)}:** ${status}`; + if ( + type === Notification.MEDIA_DECLINED && + payload.request && + payload.request.declineReason + ) { + message += `\n**${intl.formatMessage(globalMessages.declineReason)}**\n${this.escapeMarkdown(payload.request.declineReason)}`; + } } } else if (payload.comment) { message += `\n**${this.escapeMarkdown( diff --git a/server/lib/notifications/agents/pushbullet.ts b/server/lib/notifications/agents/pushbullet.ts index 971c0b3d84..c6221bdc48 100644 --- a/server/lib/notifications/agents/pushbullet.ts +++ b/server/lib/notifications/agents/pushbullet.ts @@ -84,6 +84,13 @@ class PushbulletAgent if (status) { body += `\n${intl.formatMessage(globalMessages.requestStatus)}: ${status}`; + if ( + type === Notification.MEDIA_DECLINED && + payload.request && + payload.request.declineReason + ) { + body += `\n${intl.formatMessage(globalMessages.declineReason)}: ${payload.request.declineReason}`; + } } } else if (payload.comment) { body += `\n\n${intl.formatMessage(globalMessages.commentFrom, { userName: payload.comment.user.displayName })}:\n${payload.comment.message}`; diff --git a/server/lib/notifications/agents/pushover.ts b/server/lib/notifications/agents/pushover.ts index 065401dc49..d963f4df9d 100644 --- a/server/lib/notifications/agents/pushover.ts +++ b/server/lib/notifications/agents/pushover.ts @@ -138,6 +138,14 @@ class PushoverAgent if (status) { message += `\n${intl.formatMessage(globalMessages.requestStatus)}: ${status}`; + + if ( + type === Notification.MEDIA_DECLINED && + payload.request && + payload.request.declineReason + ) { + message += `\n\n${intl.formatMessage(globalMessages.declineReason)}: ${payload.request.declineReason}`; + } } } else if (payload.comment) { message += `\n\n${intl.formatMessage(globalMessages.commentFrom, { userName: payload.comment.user.displayName })}: ${payload.comment.message}`; diff --git a/server/lib/notifications/agents/slack.ts b/server/lib/notifications/agents/slack.ts index 33320ff5f1..6e2a608208 100644 --- a/server/lib/notifications/agents/slack.ts +++ b/server/lib/notifications/agents/slack.ts @@ -103,6 +103,17 @@ class SlackAgent type: 'mrkdwn', text: `*${intl.formatMessage(globalMessages.requestStatus)}*\n${status}`, }); + + if ( + type === Notification.MEDIA_DECLINED && + payload.request && + payload.request.declineReason + ) { + fields.push({ + type: 'mrkdwn', + text: `*${intl.formatMessage(globalMessages.declineReason)}*\n${payload.request.declineReason}`, + }); + } } } else if (payload.comment) { fields.push({ diff --git a/server/lib/notifications/agents/telegram.ts b/server/lib/notifications/agents/telegram.ts index 6d1bf4d83a..810805f7b7 100644 --- a/server/lib/notifications/agents/telegram.ts +++ b/server/lib/notifications/agents/telegram.ts @@ -117,6 +117,16 @@ class TelegramAgent message += `\n\*${this.escapeText( intl.formatMessage(globalMessages.requestStatus) )}:\* ${this.escapeText(status)}`; + + if ( + type === Notification.MEDIA_DECLINED && + payload.request && + payload.request.declineReason + ) { + message += `\n\n\*${this.escapeText( + intl.formatMessage(globalMessages.declineReason) + )}:\* ${this.escapeText(payload.request.declineReason)}`; + } } } else if (payload.comment) { message += `\n\n\*${this.escapeText( diff --git a/server/lib/notifications/agents/webpush.ts b/server/lib/notifications/agents/webpush.ts index 8586dd2960..23078abde6 100644 --- a/server/lib/notifications/agents/webpush.ts +++ b/server/lib/notifications/agents/webpush.ts @@ -22,6 +22,8 @@ const messages = defineMessages('notifications.agents.webpush', { 'Automatically approved a new {quality}{mediaType} request from {userName}.', available: 'Your {quality}{mediaType} request is now available!', declined: 'Your {quality}{mediaType} request was declined.', + declinedWithReason: + 'Your {quality}{mediaType} request was declined because: {reason}.', failed: 'Failed to process {quality}{mediaType} request.', pending: 'Approval required for a new {quality}{mediaType} request from {userName}.', @@ -123,10 +125,18 @@ class WebPushAgent }); break; case Notification.MEDIA_DECLINED: - message = intl.formatMessage(messages.declined, { - quality, - mediaType, - }); + if (payload.request && payload.request?.declineReason) { + message = intl.formatMessage(messages.declinedWithReason, { + quality, + mediaType, + reason: payload.request.declineReason, + }); + } else { + message = intl.formatMessage(messages.declined, { + quality, + mediaType, + }); + } break; case Notification.MEDIA_FAILED: message = intl.formatMessage(messages.failed, { diff --git a/server/migration/postgres/1781992427522-AddRequestDeclinedMessage.ts b/server/migration/postgres/1781992427522-AddRequestDeclinedMessage.ts new file mode 100644 index 0000000000..b634655fe7 --- /dev/null +++ b/server/migration/postgres/1781992427522-AddRequestDeclinedMessage.ts @@ -0,0 +1,17 @@ +import type { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddRequestDeclinedMessage1781992427522 implements MigrationInterface { + name = 'AddRequestDeclinedMessage1781992427522'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "media_request" ADD "declineReason" text` + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "media_request" DROP COLUMN "declineReason"` + ); + } +} diff --git a/server/migration/sqlite/1781992375456-AddRequestDeclinedMessage.ts b/server/migration/sqlite/1781992375456-AddRequestDeclinedMessage.ts new file mode 100644 index 0000000000..1bf8172aa9 --- /dev/null +++ b/server/migration/sqlite/1781992375456-AddRequestDeclinedMessage.ts @@ -0,0 +1,119 @@ +import type { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddRequestDeclinedMessage1781992375456 implements MigrationInterface { + name = 'AddRequestDeclinedMessage1781992375456'; + + 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_6427d07d9a171a3a1ab87480005" UNIQUE ("endpoint", "userId"), CONSTRAINT "UQ_f90ab5a4ed54905a4bb51a7148b" UNIQUE ("auth"), 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(`DROP INDEX "IDX_4c696e8ed36ae34fe18abe59d2"`); + await queryRunner.query(`DROP INDEX "IDX_a1aa713f41c99e9d10c48da75a"`); + await queryRunner.query(`DROP INDEX "IDX_6997bee94720f1ecb7f3113709"`); + await queryRunner.query(`DROP INDEX "IDX_f4fc4efa14c3ba2b29c4525fa1"`); + await queryRunner.query( + `CREATE TABLE "temporary_media_request" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "status" integer NOT NULL, "createdAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "updatedAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "type" varchar NOT NULL, "mediaId" integer, "requestedById" integer, "modifiedById" integer, "is4k" boolean NOT NULL DEFAULT (0), "serverId" integer, "profileId" integer, "rootFolder" varchar, "languageProfileId" integer, "tags" text, "isAutoRequest" boolean NOT NULL DEFAULT (0), "ignoreQuota" boolean NOT NULL DEFAULT (0), "declineReason" text, CONSTRAINT "FK_f4fc4efa14c3ba2b29c4525fa15" FOREIGN KEY ("modifiedById") REFERENCES "user" ("id") ON DELETE SET NULL ON UPDATE NO ACTION, CONSTRAINT "FK_6997bee94720f1ecb7f31137095" FOREIGN KEY ("requestedById") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_a1aa713f41c99e9d10c48da75a0" FOREIGN KEY ("mediaId") REFERENCES "media" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` + ); + await queryRunner.query( + `INSERT INTO "temporary_media_request"("id", "status", "createdAt", "updatedAt", "type", "mediaId", "requestedById", "modifiedById", "is4k", "serverId", "profileId", "rootFolder", "languageProfileId", "tags", "isAutoRequest", "ignoreQuota") SELECT "id", "status", "createdAt", "updatedAt", "type", "mediaId", "requestedById", "modifiedById", "is4k", "serverId", "profileId", "rootFolder", "languageProfileId", "tags", "isAutoRequest", "ignoreQuota" FROM "media_request"` + ); + await queryRunner.query(`DROP TABLE "media_request"`); + await queryRunner.query( + `ALTER TABLE "temporary_media_request" RENAME TO "media_request"` + ); + await queryRunner.query( + `CREATE INDEX "IDX_4c696e8ed36ae34fe18abe59d2" ON "media_request" ("status") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_a1aa713f41c99e9d10c48da75a" ON "media_request" ("mediaId") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_6997bee94720f1ecb7f3113709" ON "media_request" ("requestedById") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_f4fc4efa14c3ba2b29c4525fa1" ON "media_request" ("modifiedById") ` + ); + 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_6427d07d9a171a3a1ab87480005" UNIQUE ("endpoint", "userId"), CONSTRAINT "UQ_f90ab5a4ed54905a4bb51a7148b" UNIQUE ("auth"), 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_6427d07d9a171a3a1ab87480005" UNIQUE ("endpoint", "userId"), CONSTRAINT "UQ_f90ab5a4ed54905a4bb51a7148b" UNIQUE ("auth"), 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(`DROP INDEX "IDX_f4fc4efa14c3ba2b29c4525fa1"`); + await queryRunner.query(`DROP INDEX "IDX_6997bee94720f1ecb7f3113709"`); + await queryRunner.query(`DROP INDEX "IDX_a1aa713f41c99e9d10c48da75a"`); + await queryRunner.query(`DROP INDEX "IDX_4c696e8ed36ae34fe18abe59d2"`); + await queryRunner.query( + `ALTER TABLE "media_request" RENAME TO "temporary_media_request"` + ); + await queryRunner.query( + `CREATE TABLE "media_request" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "status" integer NOT NULL, "createdAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "updatedAt" datetime NOT NULL DEFAULT (CURRENT_TIMESTAMP), "type" varchar NOT NULL, "mediaId" integer, "requestedById" integer, "modifiedById" integer, "is4k" boolean NOT NULL DEFAULT (0), "serverId" integer, "profileId" integer, "rootFolder" varchar, "languageProfileId" integer, "tags" text, "isAutoRequest" boolean NOT NULL DEFAULT (0), "ignoreQuota" boolean NOT NULL DEFAULT (0), CONSTRAINT "FK_f4fc4efa14c3ba2b29c4525fa15" FOREIGN KEY ("modifiedById") REFERENCES "user" ("id") ON DELETE SET NULL ON UPDATE NO ACTION, CONSTRAINT "FK_6997bee94720f1ecb7f31137095" FOREIGN KEY ("requestedById") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, CONSTRAINT "FK_a1aa713f41c99e9d10c48da75a0" FOREIGN KEY ("mediaId") REFERENCES "media" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)` + ); + await queryRunner.query( + `INSERT INTO "media_request"("id", "status", "createdAt", "updatedAt", "type", "mediaId", "requestedById", "modifiedById", "is4k", "serverId", "profileId", "rootFolder", "languageProfileId", "tags", "isAutoRequest", "ignoreQuota") SELECT "id", "status", "createdAt", "updatedAt", "type", "mediaId", "requestedById", "modifiedById", "is4k", "serverId", "profileId", "rootFolder", "languageProfileId", "tags", "isAutoRequest", "ignoreQuota" FROM "temporary_media_request"` + ); + await queryRunner.query(`DROP TABLE "temporary_media_request"`); + await queryRunner.query( + `CREATE INDEX "IDX_f4fc4efa14c3ba2b29c4525fa1" ON "media_request" ("modifiedById") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_6997bee94720f1ecb7f3113709" ON "media_request" ("requestedById") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_a1aa713f41c99e9d10c48da75a" ON "media_request" ("mediaId") ` + ); + await queryRunner.query( + `CREATE INDEX "IDX_4c696e8ed36ae34fe18abe59d2" ON "media_request" ("status") ` + ); + 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_6427d07d9a171a3a1ab87480005" UNIQUE ("endpoint", "userId"), CONSTRAINT "UQ_f90ab5a4ed54905a4bb51a7148b" UNIQUE ("auth"), 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/request.ts b/server/routes/request.ts index fafa90692e..27c650bee4 100644 --- a/server/routes/request.ts +++ b/server/routes/request.ts @@ -675,6 +675,7 @@ requestRoutes.post<{ relations: { requestedBy: true, modifiedBy: true }, }); + const declineReason: string = req.body ? req.body.declineReason : ''; let newStatus: MediaRequestStatus; switch (req.params.status) { @@ -689,6 +690,11 @@ requestRoutes.post<{ break; } + if (req.params.status === 'decline' && declineReason) { + request.declineReason = declineReason.trim().substring(0, 500); + } else { + request.declineReason = ''; + } request.status = newStatus; request.modifiedBy = req.user; await requestRepository.save(request); diff --git a/src/components/RequestButton/index.tsx b/src/components/RequestButton/index.tsx index 55b14c6c96..8f28657c4e 100644 --- a/src/components/RequestButton/index.tsx +++ b/src/components/RequestButton/index.tsx @@ -1,5 +1,6 @@ import ButtonWithDropdown from '@app/components/Common/ButtonWithDropdown'; import RequestModal from '@app/components/RequestModal'; +import DeclineRequestModal from '@app/components/RequestModal/DeclineRequestModal'; import useSettings from '@app/hooks/useSettings'; import { Permission, useUser } from '@app/hooks/useUser'; import globalMessages from '@app/i18n/globalMessages'; @@ -66,6 +67,7 @@ const RequestButton = ({ const { user, hasPermission } = useUser(); const [showRequestModal, setShowRequestModal] = useState(false); const [showRequest4kModal, setShowRequest4kModal] = useState(false); + const [showDeclineCommentModal, setShowDeclineCommentModal] = useState(false); const [editRequest, setEditRequest] = useState(false); // All pending requests @@ -75,6 +77,7 @@ const RequestButton = ({ const active4kRequests = media?.requests.filter( (request) => request.status === MediaRequestStatus.PENDING && request.is4k ); + const [declineTargets, setDeclineTargets] = useState([]); // Current user's pending request, or the first pending request const activeRequest = useMemo(() => { @@ -161,7 +164,8 @@ const RequestButton = ({ id: 'decline-request', text: intl.formatMessage(messages.declinerequest), action: () => { - modifyRequest(activeRequest, 'decline'); + setDeclineTargets([activeRequest]); + setShowDeclineCommentModal(true); }, svg: , } @@ -189,7 +193,8 @@ const RequestButton = ({ requestCount: activeRequests.length, }), action: () => { - modifyRequests(activeRequests, 'decline'); + setShowDeclineCommentModal(true); + setDeclineTargets(activeRequests); }, svg: , } @@ -231,7 +236,8 @@ const RequestButton = ({ id: 'decline-4k-request', text: intl.formatMessage(messages.declinerequest4k), action: () => { - modifyRequest(active4kRequest, 'decline'); + setShowDeclineCommentModal(true); + setDeclineTargets([active4kRequest]); }, svg: , } @@ -259,7 +265,8 @@ const RequestButton = ({ requestCount: active4kRequests.length, }), action: () => { - modifyRequests(active4kRequests, 'decline'); + setShowDeclineCommentModal(true); + setDeclineTargets(active4kRequests); }, svg: , } @@ -391,6 +398,23 @@ const RequestButton = ({ }} onCancel={() => setShowRequest4kModal(false)} /> + {declineTargets.length > 0 && ( + { + setShowDeclineCommentModal(false); + setDeclineTargets([]); + }} + onComplete={() => { + onUpdate(); + mutate('/api/v1/request/count'); + setShowDeclineCommentModal(false); + setDeclineTargets([]); + }} + /> + )} diff --git a/src/components/RequestCard/index.tsx b/src/components/RequestCard/index.tsx index a71842cdf1..5eb4451675 100644 --- a/src/components/RequestCard/index.tsx +++ b/src/components/RequestCard/index.tsx @@ -4,6 +4,7 @@ import Button from '@app/components/Common/Button'; import CachedImage from '@app/components/Common/CachedImage'; import Tooltip from '@app/components/Common/Tooltip'; import RequestModal from '@app/components/RequestModal'; +import DeclineRequestModal from '@app/components/RequestModal/DeclineRequestModal'; import StatusBadge from '@app/components/StatusBadge'; import useDeepLinks from '@app/hooks/useDeepLinks'; import useToasts from '@app/hooks/useToasts'; @@ -231,6 +232,7 @@ const RequestCard = ({ request, onTitleData }: RequestCardProps) => { 'approve' | 'decline' | null >(null); const [showEditModal, setShowEditModal] = useState(false); + const [showDeclineRequestModal, setShowDeclineRequestModal] = useState(false); const url = request.type === 'movie' ? `/api/v1/movie/${request.media.tmdbId}` @@ -341,6 +343,22 @@ const RequestCard = ({ request, onTitleData }: RequestCardProps) => { setShowEditModal(false); }} /> + setShowDeclineRequestModal(false)} + onError={() => { + addToast(intl.formatMessage(messages.failedmodify), { + autoDismiss: true, + appearance: 'error', + }); + }} + onComplete={() => { + revalidate(); + setShowDeclineRequestModal(false); + }} + />
{ /> )}
+ {requestData.declineReason && ( + +
+ + {intl.formatMessage(globalMessages.declinemessage)} + + + {requestData.declineReason} + +
+
+ )}
{requestData.status === MediaRequestStatus.FAILED && hasPermission(Permission.MANAGE_REQUESTS) && ( @@ -537,8 +571,8 @@ const RequestCard = ({ request, onTitleData }: RequestCardProps) => { buttonType="danger" buttonSize="sm" className="hidden sm:block" - onClick={() => modifyRequest('decline')} disabled={updatingType !== null} + onClick={() => setShowDeclineRequestModal(true)} > {updatingType === 'decline' ? : } {intl.formatMessage(globalMessages.decline)} @@ -550,8 +584,8 @@ const RequestCard = ({ request, onTitleData }: RequestCardProps) => { buttonType="danger" buttonSize="sm" className="sm:hidden" - onClick={() => modifyRequest('decline')} disabled={updatingType !== null} + onClick={() => setShowDeclineRequestModal(true)} > {updatingType === 'decline' ? ( diff --git a/src/components/RequestList/RequestItem/index.tsx b/src/components/RequestList/RequestItem/index.tsx index 9a7d2de52c..f6f52eeca7 100644 --- a/src/components/RequestList/RequestItem/index.tsx +++ b/src/components/RequestList/RequestItem/index.tsx @@ -3,7 +3,9 @@ import Badge from '@app/components/Common/Badge'; import Button from '@app/components/Common/Button'; import CachedImage from '@app/components/Common/CachedImage'; import ConfirmButton from '@app/components/Common/ConfirmButton'; +import Tooltip from '@app/components/Common/Tooltip'; import RequestModal from '@app/components/RequestModal'; +import DeclineRequestModal from '@app/components/RequestModal/DeclineRequestModal'; import StatusBadge from '@app/components/StatusBadge'; import useDeepLinks from '@app/hooks/useDeepLinks'; import useToasts from '@app/hooks/useToasts'; @@ -48,6 +50,7 @@ const messages = defineMessages('components.RequestList.RequestItem', { unknowntitle: 'Unknown Title', removearr: 'Remove from {arr}', profileName: 'Profile', + declineReason: 'Reason', }); const isMovie = (movie: MovieDetails | TvDetails): movie is MovieDetails => { @@ -327,6 +330,7 @@ const RequestItem = ({ request, revalidateList }: RequestItemProps) => { const [updatingType, setUpdatingType] = useState< 'approve' | 'decline' | null >(null); + const [showDeclineRequestModal, setShowDeclineRequestModal] = useState(false); const modifyRequest = async (type: 'approve' | 'decline') => { setUpdatingType(type); @@ -416,6 +420,23 @@ const RequestItem = ({ request, revalidateList }: RequestItemProps) => { setShowEditModal(false); }} /> + setShowDeclineRequestModal(false)} + onError={() => { + addToast(intl.formatMessage(messages.failedmodify), { + autoDismiss: true, + appearance: 'error', + }); + }} + onComplete={() => { + revalidate(); + mutate('/api/v1/request/count'); + setShowDeclineRequestModal(false); + }} + />
{title.backdropPath && (
@@ -669,6 +690,22 @@ const RequestItem = ({ request, revalidateList }: RequestItemProps) => {
)} + {requestData.declineReason && ( + +
+ + {intl.formatMessage(messages.declineReason)} + + + {requestData.declineReason} + +
+
+ )}
@@ -736,8 +773,8 @@ const RequestItem = ({ request, revalidateList }: RequestItemProps) => { + } + +
+ + + ); + }} + + } + + + ); +}; + +export default DeclineRequestModal; diff --git a/src/i18n/globalMessages.ts b/src/i18n/globalMessages.ts index a150afa992..1b4e10f50d 100644 --- a/src/i18n/globalMessages.ts +++ b/src/i18n/globalMessages.ts @@ -43,6 +43,7 @@ const globalMessages = defineMessages('i18n', { next: 'Next', previous: 'Previous', status: 'Status', + declinemessage: 'Message', all: 'All', experimental: 'Experimental', advanced: 'Advanced', diff --git a/src/i18n/locale/en.json b/src/i18n/locale/en.json index 21c9c8af16..3288528249 100644 --- a/src/i18n/locale/en.json +++ b/src/i18n/locale/en.json @@ -21,6 +21,11 @@ "components.CollectionDetails.removefromblocklistpartialcount": "{removeLabel} ({count, plural, one {# movie} other {# movies}})", "components.CollectionDetails.requestcollection": "Request Collection", "components.CollectionDetails.requestcollection4k": "Request Collection in 4K", + "components.DeclineRequestModal.cancelButtonLabel": "Cancel", + "components.DeclineRequestModal.declineButtonLabel": "Decline Request", + "components.DeclineRequestModal.declinePlaceholder": "Optionally leave a reason for declining the request.", + "components.DeclineRequestModal.leaveComment": "Leave a comment for {username}'s request.", + "components.DeclineRequestModal.reasonLabel": "Reason for declining?", "components.Discover.CreateSlider.addSlider": "Add Slider", "components.Discover.CreateSlider.addcustomslider": "Create Custom Slider", "components.Discover.CreateSlider.addfail": "Failed to create new slider.", @@ -508,6 +513,7 @@ "components.RequestCard.tvdbid": "TheTVDB ID", "components.RequestCard.unknowntitle": "Unknown Title", "components.RequestList.RequestItem.cancelRequest": "Cancel Request", + "components.RequestList.RequestItem.declineReason": "Reason", "components.RequestList.RequestItem.deleterequest": "Delete Request", "components.RequestList.RequestItem.editrequest": "Edit Request", "components.RequestList.RequestItem.failedmodify": "Something went wrong while modifying the request.", @@ -1313,7 +1319,7 @@ "components.Setup.librarieserror": "Validation failed. Please toggle the libraries again to continue.", "components.Setup.servertype": "Choose Server Type", "components.Setup.setup": "Setup", - "components.Setup.signin": "Sign In", + "components.Setup.signin": "Sign in to your account", "components.Setup.signinMessage": "Get started by signing in", "components.Setup.signinWithEmby": "Enter your Emby details", "components.Setup.signinWithJellyfin": "Enter your Jellyfin details", @@ -1621,6 +1627,7 @@ "i18n.completed": "Completed", "i18n.decline": "Decline", "i18n.declined": "Declined", + "i18n.declinemessage": "Message", "i18n.delete": "Delete", "i18n.deleted": "Deleted", "i18n.deleting": "Deleting…",