diff --git a/server/entity/MediaRequest.ts b/server/entity/MediaRequest.ts index ba67ab7b..0307b336 100644 --- a/server/entity/MediaRequest.ts +++ b/server/entity/MediaRequest.ts @@ -460,6 +460,9 @@ export class MediaRequest { @Column({ default: false }) public isAutoRequest: boolean; + @Column({ nullable: true, length: 140 }) + public adminMessage?: string; + constructor(init?: Partial) { Object.assign(this, init); } diff --git a/server/lib/notifications/agents/agent.ts b/server/lib/notifications/agents/agent.ts index d2b0b165..4198e7dd 100644 --- a/server/lib/notifications/agents/agent.ts +++ b/server/lib/notifications/agents/agent.ts @@ -19,6 +19,7 @@ export interface NotificationPayload { request?: MediaRequest; issue?: Issue; comment?: IssueComment; + adminMessage?: string; } export abstract class BaseAgent { diff --git a/server/lib/notifications/agents/discord.ts b/server/lib/notifications/agents/discord.ts index 67a278bf..31351244 100644 --- a/server/lib/notifications/agents/discord.ts +++ b/server/lib/notifications/agents/discord.ts @@ -153,6 +153,14 @@ class DiscordAgent inline: true, }); } + + if (payload.request.adminMessage) { + fields.push({ + name: 'Admin Message', + value: payload.request.adminMessage, + inline: true, + }); + } } else if (payload.comment) { fields.push({ name: `Comment from ${payload.comment.user.displayName}`, diff --git a/server/lib/notifications/agents/webpush.ts b/server/lib/notifications/agents/webpush.ts index 275a77e8..b616274f 100644 --- a/server/lib/notifications/agents/webpush.ts +++ b/server/lib/notifications/agents/webpush.ts @@ -45,6 +45,9 @@ class WebPushAgent : 'series' : undefined; const is4k = payload.request?.is4k; + const adminMessage = payload.adminMessage + ? payload.adminMessage + : undefined; const issueType = payload.issue ? payload.issue.issueType !== IssueType.OTHER @@ -80,7 +83,9 @@ class WebPushAgent }${mediaType} request is now available!`; break; case Notification.MEDIA_DECLINED: - message = `Your ${is4k ? '4K ' : ''}${mediaType} request was declined.`; + message = `Your ${is4k ? '4K ' : ''}${mediaType} request was declined${ + adminMessage ? `: ${adminMessage}` : '.' + }.`; break; case Notification.MEDIA_FAILED: message = `Failed to process ${is4k ? '4K ' : ''}${mediaType} request.`; diff --git a/server/migration/1702596723101-AddAdminMessage.ts b/server/migration/1702596723101-AddAdminMessage.ts new file mode 100644 index 00000000..4a9b8792 --- /dev/null +++ b/server/migration/1702596723101-AddAdminMessage.ts @@ -0,0 +1,17 @@ +import type { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddAdminMessage1702596723101 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE media_requests + ADD adminMessage VARCHAR(140) + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE media_requests + DROP COLUMN adminMessage + `); + } +} diff --git a/server/routes/request.ts b/server/routes/request.ts index 83c05b48..51d47ab2 100644 --- a/server/routes/request.ts +++ b/server/routes/request.ts @@ -538,6 +538,12 @@ requestRoutes.post<{ request.status = newStatus; request.modifiedBy = req.user; + + const adminMessage = req.body.adminMessage; + if (adminMessage) { + request.adminMessage = adminMessage; + } + await requestRepository.save(request); return res.status(200).json(request); diff --git a/server/templates/email/media-request/html.pug b/server/templates/email/media-request/html.pug index 334095df..42af1086 100644 --- a/server/templates/email/media-request/html.pug +++ b/server/templates/email/media-request/html.pug @@ -61,6 +61,10 @@ div(style='display: block; background-color: #111827; padding: 2.5rem 0;') td(style='font-size: .85em; color: #9ca3af; line-height: 1em; vertical-align: bottom; margin-right: 1rem') span | #{timestamp} + if adminMessage + tr + td(style='font-size: 1.0em; color: #9ca3af; line-height: 1.5em; vertical-align: top; margin-right: 1rem') + | #{adminMessage} if actionUrl tr td diff --git a/src/components/DeclineRequestModal/index.tsx b/src/components/DeclineRequestModal/index.tsx new file mode 100644 index 00000000..8b294235 --- /dev/null +++ b/src/components/DeclineRequestModal/index.tsx @@ -0,0 +1,111 @@ +import Modal from '@app/components/Common/Modal'; +import globalMessages from '@app/i18n/globalMessages'; +import { Transition } from '@headlessui/react'; +import type { MovieDetails } from '@server/models/Movie'; +import { Field, Form, Formik } from 'formik'; +import React from 'react'; +import { useIntl } from 'react-intl'; +import useSWR from 'swr'; +import * as Yup from 'yup'; + +interface DeclineRequestModalProps { + show: boolean; + tmdbId: number; + onDecline: (declineMessage: string) => void; + onCancel?: () => void; +} + +const validationSchema = Yup.object().shape({ + declineMessage: Yup.string().max(140, 'Message is too long'), +}); + +const DeclineRequestModal = ({ + show, + tmdbId, + onDecline, + onCancel, +}: DeclineRequestModalProps) => { + const intl = useIntl(); + const { data, error } = useSWR(`/api/v1/movie/${tmdbId}`, { + revalidateOnMount: true, + }); + const [characterCount, setCharacterCount] = React.useState(0); + const handleCancel = () => { + setCharacterCount(0); + if (onCancel) { + onCancel(); + } + }; + + return ( + + { + setSubmitting(true); + onDecline(values.declineMessage); + }} + > + {({ errors, touched, handleSubmit, setFieldValue, values }) => { + const handleInputChange = ( + event: React.ChangeEvent + ) => { + const { value } = event.target; + setFieldValue('declineMessage', value); + setCharacterCount(value.length); + }; + + return ( + { + handleSubmit(); + }} + okText={intl.formatMessage(globalMessages.decline)} + okButtonType="danger" + cancelText={intl.formatMessage(globalMessages.cancel)} + backdrop={`https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/${data?.backdropPath}`} + > +
+ + {errors.declineMessage && touched.declineMessage ? ( +
+ {errors.declineMessage} +
+ ) : null} +
140 ? 'text-red-500' : 'text-gray-300' + }`} + > + {characterCount}/140 +
+ +
+ ); + }} +
+
+ ); +}; + +export default DeclineRequestModal; diff --git a/src/components/RequestList/RequestItem/index.tsx b/src/components/RequestList/RequestItem/index.tsx index a42483ab..a0fcfb0a 100644 --- a/src/components/RequestList/RequestItem/index.tsx +++ b/src/components/RequestList/RequestItem/index.tsx @@ -2,6 +2,7 @@ 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 DeclineRequestModal from '@app/components/DeclineRequestModal'; import RequestModal from '@app/components/RequestModal'; import StatusBadge from '@app/components/StatusBadge'; import useDeepLinks from '@app/hooks/useDeepLinks'; @@ -283,6 +284,7 @@ const RequestItem = ({ request, revalidateList }: RequestItemProps) => { const intl = useIntl(); const { user, hasPermission } = useUser(); const [showEditModal, setShowEditModal] = useState(false); + const [showDeclineModal, setShowDeclineModal] = useState(false); const url = request.type === 'movie' ? `/api/v1/movie/${request.media.tmdbId}` @@ -306,6 +308,16 @@ const RequestItem = ({ request, revalidateList }: RequestItemProps) => { const [isRetrying, setRetrying] = useState(false); + const declineRequest = async (declineMessage: string) => { + const response = await axios.post(`/api/v1/request/${request.id}/decline`, { + adminMessage: declineMessage, + }); + + if (response) { + revalidate(); + } + }; + const modifyRequest = async (type: 'approve' | 'decline') => { const response = await axios.post(`/api/v1/request/${request.id}/${type}`); @@ -375,7 +387,16 @@ const RequestItem = ({ request, revalidateList }: RequestItemProps) => { setShowEditModal(false); }} /> -
+ { + declineRequest(declineMessage); + setShowDeclineModal(false); + }} + onCancel={() => setShowDeclineModal(false)} + /> +
{title.backdropPath && (
{
)} + {requestData.adminMessage && ( +
+ + {requestData.status === MediaRequestStatus.DECLINED && ( + + "{requestData.adminMessage}" + + )} + +
+ )}
@@ -648,7 +680,7 @@ const RequestItem = ({ request, revalidateList }: RequestItemProps) => {