Add notification system with admin UI and backend

Introduces a full notification system with support for Discord and Pushover backends, event triggers, and message formatting. Adds backend services, processors, and API endpoints for managing notifications, as well as a new Notifications tab in the admin settings UI. Updates documentation, database schema, and tests to cover notification features and approval workflow improvements. Also changes project license from MIT to AGPL v3.
This commit is contained in:
kikootwo
2026-01-21 15:28:23 -05:00
parent ac2ad8aac2
commit dc7e557694
51 changed files with 5065 additions and 264 deletions
+657 -17
View File
@@ -1,21 +1,661 @@
MIT License
GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 2007
Copyright (c) 2024 ReadMeABook
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
Preamble
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
The GNU Affero General Public License is a free, copyleft license for
software and other kinds of works, specifically designed to ensure
cooperation with the community in the case of network server software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
our General Public Licenses are intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
Developers that use our General Public Licenses protect your rights
with two steps: (1) assert copyright on the software, and (2) offer
you this License which gives you legal permission to copy, distribute
and/or modify the software.
A secondary benefit of defending all users' freedom is that
improvements made in alternate versions of the program, if they
receive widespread use, become available for other developers to
incorporate. Many developers of free software are heartened and
encouraged by the resulting cooperation. However, in the case of
software used on network servers, this result may fail to come about.
The GNU General Public License permits making a modified version and
letting the public access it on a server without ever releasing its
source code to the public.
The GNU Affero General Public License is designed specifically to
ensure that, in such cases, the modified source code becomes available
to the community. It requires the operator of a network server to
provide the source code of the modified version running there to the
users of that server. Therefore, public use of a modified version, on
a publicly accessible server, gives the public access to the source
code of the modified version.
An older license, called the Affero General Public License and
published by Affero, was designed to accomplish similar goals. This is
a different license, not a version of the Affero GPL, but Affero has
released a new version of the Affero GPL which permits relicensing under
this license.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU Affero General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Remote Network Interaction; Use with the GNU General Public License.
Notwithstanding any other provision of this License, if you modify the
Program, your modified version must prominently offer all users
interacting with it remotely through a computer network (if your version
supports such interaction) an opportunity to receive the Corresponding
Source of your version by providing access to the Corresponding Source
from a network server at no charge, through some standard or customary
means of facilitating copying of software. This Corresponding Source
shall include the Corresponding Source for any work covered by version 3
of the GNU General Public License that is incorporated pursuant to the
following paragraph.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the work with which it is combined will remain governed by version
3 of the GNU General Public License.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU Affero General Public License from time to time. Such new versions
will be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU Affero General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU Affero General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU Affero General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If your software can interact with users remotely through a computer
network, you should also make sure that it provides a way for users to
get its source. For example, if your program is a web application, its
interface could display a "Source" link that leads users to an archive
of the code. There are many ways you could offer source, and different
solutions will be better for different programs; see section 13 for the
specific requirements.
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU AGPL, see
<https://www.gnu.org/licenses/>.
+61 -115
View File
@@ -1,43 +1,55 @@
# ReadMeABook
<div align="center">
**[HERO SCREENSHOT PLACEHOLDER: Full-width hero image showing the main dashboard with recent requests, BookDate swipe interface preview, and library stats - something that looks modern and shows off the UI]**
![RMAB_hero.png](screenshots/RMAB_hero.png)
An audiobook automation system that connects your Plex library to torrent and Usenet indexers. Request audiobooks through a web interface and they're automatically downloaded, organized, and imported into your Plex library.
### Audiobook automation for Plex and Audiobookshelf
## What It Does
<div align="center">
If you're running Plex with an audiobook library, you know the drill: search for torrents or NZBs manually, download them, move files to the right folder, wait for Plex to scan. ReadMeABook automates all of that.
[![Ko-Fi](https://img.shields.io/badge/Ko--fi-F16061?style=for-the-badge&logo=ko-fi&logoColor=white)](https://ko-fi.com/kikootwo)
[![GitHub Sponsors](https://img.shields.io/github/sponsors/kikootwo?style=for-the-badge&logo=github&logoColor=white&label=Sponsor&color=EA4AAA)](https://github.com/sponsors/kikootwo)
[![Build Status](https://img.shields.io/github/actions/workflow/status/kikootwo/readmeabook/build-unified-image.yml?branch=main&style=for-the-badge&logo=github&label=Build)](https://github.com/kikootwo/readmeabook/actions/workflows/build-unified-image.yml)
[![Tests](https://img.shields.io/github/actions/workflow/status/kikootwo/readmeabook/run-tests.yml?branch=main&style=for-the-badge&logo=github&label=Tests)](https://github.com/kikootwo/readmeabook/actions/workflows/run-tests.yml)
[![Docker Pulls](https://img.shields.io/docker/pulls/kikootwo/readmeabook?style=for-the-badge&logo=docker&logoColor=white)](https://github.com/kikootwo/readmeabook/pkgs/container/readmeabook)
[![License](https://img.shields.io/badge/License-AGPL%20v3-blue.svg?style=for-the-badge)](https://www.gnu.org/licenses/agpl-3.0)
[![GitHub Stars](https://img.shields.io/github/stars/kikootwo/readmeabook?style=for-the-badge&logo=github)](https://github.com/kikootwo/readmeabook/stargazers)
[![Discord](https://img.shields.io/discord/1450562177277755464?style=for-the-badge&logo=discord&logoColor=white&label=Discord)](https://discord.gg/kaw6jKbKts)
It works like the *arr apps (Sonarr, Radarr) but for audiobooks. Connect it to Prowlarr for searching, qBittorrent or SABnzbd for downloading, and Plex for your library. Request a book and everything else happens automatically.
</div>
There's also BookDate - an AI-powered recommendation system that suggests audiobooks based on what you already own. Think Tinder but for books. Swipe right to request, left to skip.
*Radarr/Sonarr + Overseerr for audiobooks, all in one*
[Features](#features) • [Setup](#setup) • [Screenshots](#screenshots) • [Discord](#community)
</div>
---
## What is this?
You run Plex or Audiobookshelf with audiobooks. You want more audiobooks. You search indexers, download torrents or NZBs, organize files, wait for your server to scan. ReadMeABook does all of that automatically.
Request a book → Prowlarr searches → qBittorrent or SABnzbd downloads → Files organized → Library imports. Done.
Also includes BookDate: AI recommendations with a Tinder-style swipe interface. Swipe right to request.
User friendly audible-backed searches, multi-file chapter merging, e-book sidecar support, OIDC OAuth, admin approval workflows, and more.
## Features
- **Plex Integration** - OAuth login, automatic library scanning, fuzzy matching
- **Torrent Support** - qBittorrent and Transmission clients
- **Usenet Support** - SABnzbd for NZB downloads
- **Prowlarr Integration** - Search both torrents and Usenet indexers
- **Request Management** - Track downloads from search to completion
- **BookDate Recommendations** - AI-powered suggestions with swipe interface (OpenAI/Claude)
- **Chapter Merging** - Automatically combine multi-file downloads into single M4B with chapter markers
- **E-book Sidecar** - Optional e-book downloads from Anna's Archive
- **Multi-User Support** - Role-based access (admin/user), request approval system
- **Setup Wizard** - 9-step guided configuration with connection testing
- **Plex** or **Audiobookshelf**
- **Torrents** via qBittorrent
- **Usenet** via SABnzbd
- **Prowlarr** for indexer search (torrents + NZBs)
- **BookDate**: AI recommendations (OpenAI/Claude/Local) with swipe interface
- **Chapter merging**: Multi-file downloads → single M4B with chapters
- **E-book sidecar**: Optional EPUB/PDF downloads from Shadow Library
- **Request approval**: Admin approval workflow for multi-user setups
- **Setup wizard**: Step-by-step guided config with connection testing
## Screenshots
## Setup
**[SCREENSHOT PLACEHOLDER: Dashboard page showing active requests with status badges (Searching/Downloading/Completed) and recent activity]**
**[SCREENSHOT PLACEHOLDER: BookDate interface with the card stack showing a book cover, AI reasoning, and the swipe gesture indicators]**
**[SCREENSHOT PLACEHOLDER: Settings page showing the Prowlarr/qBittorrent/SABnzbd configuration form with connection test buttons]**
## Quick Start
Prerequisites: Docker, Plex Media Server, and either qBittorrent or SABnzbd. Prowlarr is highly recommended for searching indexers.
### Docker Compose
**Prerequisites:** Docker, Plex or Audiobookshelf, qBittorrent or SABnzbd, Prowlarr
```yaml
services:
@@ -50,110 +62,44 @@ services:
volumes:
- ./config:/app/config
- ./cache:/app/cache
- ./downloads:/downloads
- ./media:/media
- ./downloads:/downloads # Your download client's path
- ./media:/media # Your audiobook library
- ./pgdata:/var/lib/postgresql/data
- ./redis:/var/lib/redis
environment:
# Optional - set to your user/group ID for proper file permissions
PUID: 1000
PGID: 1000
# Required if accessing from outside localhost (for Plex OAuth)
PUBLIC_URL: "https://audiobooks.example.com"
PUID: 1000 # Optional: your user ID
PGID: 1000 # Optional: your group ID
PUBLIC_URL: "https://audiobooks.example.com" # Required for OAuth
```
Update the volume paths to match your setup:
- `/downloads` should point to your download client's directory
- `/media` should point to your Plex audiobook library
Start it:
```bash
docker compose up -d
```
Navigate to http://localhost:3030 and run through the setup wizard.
Open http://localhost:3030 and follow the setup wizard.
### Initial Configuration
## Screenshots
The setup wizard walks you through:
1. Admin account creation
2. Plex server connection and library selection
3. Prowlarr configuration (API key and indexer selection)
4. Download client setup (qBittorrent or SABnzbd)
5. Path configuration with validation
6. Optional BookDate AI recommendations
7. Review and finalization
<img WIDTH="720" alt="image" src="screenshots/HOMEPAGE.png" />
<img WIDTH="720" alt="image" src="screenshots/ADMIN.png" />
<img WIDTH="720" alt="image" src="screenshots/BOOKDATE.png" />
After setup, the first library scan runs automatically. You can start requesting audiobooks immediately.
## Community
## How It Works
Join the Discord: https://discord.gg/kaw6jKbKts
**Request Flow:**
1. Search for an audiobook (pulls metadata from Audible)
2. Submit a request
3. Prowlarr searches your configured indexers (torrents and/or NZBs)
4. Best result is selected based on seeders, file size, and quality
5. Download starts in qBittorrent or SABnzbd
6. Files are monitored until completion
7. Multi-file audiobooks are optionally merged into M4B with chapter markers
8. Files are organized into your Plex library using configurable folder templates
9. Plex scans and matches the audiobook
10. Request marked as complete
**BookDate Flow:**
1. Configure AI provider in settings (OpenAI or Claude)
2. Set your library scope (full library, rated books only, or pick favorites)
3. AI analyzes your library and suggests recommendations
4. Swipe right to request, left to skip, up to dismiss
5. Requests are processed like normal requests
The system runs background jobs for library scanning, Audible metadata refresh, and request status checks. Everything is logged and visible in the admin dashboard.
## Architecture
Built with Next.js, PostgreSQL, and Redis. The container includes all three services in a single unified image. Background jobs are handled by Bull queues with Redis.
Authentication uses Plex OAuth, so users log in with their existing Plex accounts. The first user automatically becomes an admin. Admins can enable a request approval system if needed.
## Configuration
All configuration happens in the web UI. The only environment variables you might need are:
- `PUBLIC_URL` - Required for OAuth redirects if not using localhost
- `PUID`/`PGID` - Optional, for file permission mapping
- `LOG_LEVEL` - Optional, defaults to `info`
Secrets (JWT keys, database password, encryption keys) are auto-generated on first run and persisted to `/app/config/.secrets`.
## Advanced Features
**Chapter Merging:** When enabled, multi-file audiobook downloads (separate MP3/M4A files per chapter) are automatically merged into a single M4B file with proper chapter markers. Works for both M4A (fast, codec copy) and MP3 (re-encoded to AAC).
**E-book Sidecar:** Optionally downloads e-books from Anna's Archive to accompany audiobooks. Files are placed in the same folder as the audiobook. Supports EPUB, PDF, MOBI, and AZW3 formats.
**Request Approval:** Admins can enable a request approval system where user requests must be approved before processing. Useful for managing indexer limits or controlling library growth.
**Remote Path Mapping:** If your download client runs on a different machine or container, path mapping ensures ReadMeABook can find completed downloads.
## Contributing
Found a bug? Want to add a feature? Pull requests are welcome. The codebase is documented in the `documentation/` directory.
If you want to discuss ideas or get help, join the Discord: **[DISCORD LINK PLACEHOLDER]**
Feature and fix Contributions are highly welcome. Documentation in `documentation/` if you want to contribute. Discord is a great place to ask questions!
## Support
If this project is useful to you, the best way to support it is:
- Star the repo
- Share it with others who might find it useful
- Contribute code, documentation, or bug reports
If you find this project useful, consider supporting development via [GitHub Sponsors]()
Financial contributions are not expected or necessary. This project exists because I wanted it for myself, and sharing it costs nothing extra.
## License
MIT - See LICENSE file
If you'd like to support but cannot sponsor, a simple star on the GitHub repo is also greatly appreciated!
---
Built for people who want their audiobook library to just work.
<div align="center">
**AGPL v3 License**
</div>
+5
View File
@@ -62,6 +62,11 @@
- **LOG_LEVEL configuration** → [backend/services/logging.md](backend/services/logging.md)
- **Job-aware database persistence** → [backend/services/logging.md](backend/services/logging.md)
## Notifications
- **Notification backends (Discord, Pushover)** → [backend/services/notifications.md](backend/services/notifications.md)
- **Event types, triggers, message formatting** → [backend/services/notifications.md](backend/services/notifications.md)
- **Notification settings UI** → [settings-pages.md](settings-pages.md)
## Frontend Components
- **Component catalog (cards, badges, forms)** → [frontend/components.md](frontend/components.md)
- **RequestCard, StatusBadge, ProgressBar** → [frontend/components.md](frontend/components.md)
+156 -14
View File
@@ -3,7 +3,7 @@
**Status:** ✅ Implemented | Admin approval workflow for user requests with global & per-user auto-approve controls
## Overview
Allows admins to review and approve/deny user requests before they are processed. Supports global auto-approve toggle and per-user auto-approve overrides.
Allows admins to review and approve/deny user requests before they are processed. Supports global auto-approve toggle and per-user auto-approve overrides. Interactive search requests store pre-selected torrents when approval is required.
## Key Details
@@ -21,20 +21,107 @@ Allows admins to review and approve/deny user requests before they are processed
- `false` = Always require approval for this user
### Approval Logic
**When user creates request:**
**When user creates request (automatic search via POST /api/requests):**
1. Check `User.autoApproveRequests`:
- If `true` → Set status to 'pending', trigger search job
- If `false` → Set status to 'awaiting_approval', wait for admin
- If `true` → Set status to 'pending', trigger search job, send approved notification
- If `false` → Set status to 'awaiting_approval', wait for admin, send pending notification
- If `null` → Check global `auto_approve_requests` setting
- If 'true' → Auto-approve (status: 'pending')
- Otherwise → Require approval (status: 'awaiting_approval')
- If 'true' → Auto-approve (status: 'pending', send approved notification)
- Otherwise → Require approval (status: 'awaiting_approval', send pending notification)
**When user creates request with pre-selected torrent (interactive search):**
- **Via POST /api/audiobooks/request-with-torrent** (book detail page):
1. Check approval requirements (same logic as above)
2. If approval needed → Set status to 'awaiting_approval', store torrent in `selectedTorrent`, send pending notification
3. If auto-approved → Set status to 'downloading', start download immediately, send approved notification
- **Via POST /api/requests/{id}/select-torrent** (existing request):
1. Check if request already in 'awaiting_approval' status → Block with 403 error
2. Check approval requirements based on CURRENT settings
3. If approval needed → Set status to 'awaiting_approval', store torrent in `selectedTorrent`, send pending notification
4. If auto-approved → Set status to 'downloading', start download immediately, send approved notification
**Admin approval actions:**
- **Approve** → Change status to 'pending', trigger search job
- **Deny** → Change status to 'denied', no further processing
- **Approve:**
- If request has `selectedTorrent` → Download that specific torrent (clear `selectedTorrent` field)
- If no `selectedTorrent` → Trigger automatic search job (status: 'pending')
- Send approved notification
- **Deny:** → Change status to 'denied', no further processing
## API Endpoints
### POST /api/audiobooks/request-with-torrent
Create request with pre-selected torrent (book detail page interactive search)
**Auth:** User or Admin
**Request:**
```json
{
"audiobook": { /* audiobook metadata */ },
"torrent": { /* selected torrent data */ }
}
```
**Approval Check:**
- Checks approval requirements
- If needed → Status 'awaiting_approval', stores torrent, sends pending notification
- If auto-approved → Status 'downloading', starts download, sends approved notification
**Response (awaiting approval):**
```json
{
"success": true,
"request": { /* request with status: 'awaiting_approval' */ },
"message": "Request submitted for admin approval"
}
```
**Response (auto-approved):**
```json
{
"success": true,
"request": { /* request with status: 'downloading' */ }
}
```
### POST /api/requests/[id]/select-torrent
Select torrent for existing request (request page interactive search)
**Auth:** User (owner) or Admin
**Request:**
```json
{
"torrent": { /* selected torrent data */ }
}
```
**Approval Check:**
- Blocks if already in 'awaiting_approval' status
- Re-checks approval requirements based on CURRENT settings
- If needed → Status 'awaiting_approval', stores torrent, sends pending notification
- If auto-approved → Status 'downloading', starts download, sends approved notification
**Response (awaiting approval):**
```json
{
"success": true,
"request": { /* request with status: 'awaiting_approval' */ },
"message": "Request submitted for admin approval"
}
```
**Response (auto-approved):**
```json
{
"success": true,
"request": { /* request with status: 'downloading' */ },
"message": "Torrent download initiated"
}
```
### GET /api/admin/requests/pending-approval
Fetch all requests with status 'awaiting_approval'
@@ -76,12 +163,31 @@ Approve or deny a specific request
}
```
**Response (approve):**
**Approval Logic:**
- If request has `selectedTorrent`:
- Downloads that specific torrent directly (status: 'downloading')
- Clears `selectedTorrent` field after use
- Message: "Request approved and download started with pre-selected torrent"
- If no `selectedTorrent`:
- Triggers automatic search job (status: 'pending')
- Message: "Request approved and search job triggered"
- Both send approved notification
**Response (approve with pre-selected torrent):**
```json
{
"success": true,
"message": "Request approved and download started with pre-selected torrent",
"request": { /* full request object with status: 'downloading' */ }
}
```
**Response (approve without pre-selected torrent):**
```json
{
"success": true,
"message": "Request approved and search job triggered",
"request": { /* full request object */ }
"request": { /* full request object with status: 'pending' */ }
}
```
@@ -90,7 +196,7 @@ Approve or deny a specific request
{
"success": true,
"message": "Request denied",
"request": { /* full request object */ }
"request": { /* full request object with status: 'denied' */ }
}
```
@@ -188,10 +294,28 @@ Update user (includes autoApproveRequests field)
- **denied** → Red badge with X icon
- All other statuses → Existing badge colors
## Security
**Interactive Search Approval Enforcement:**
- All interactive search flows (request-with-torrent, select-torrent) check approval requirements
- If approval needed, torrent is stored in `selectedTorrent` field and request enters 'awaiting_approval' status
- Admin sees exact torrent user selected when reviewing approval
- Upon approval, admin approves THAT specific torrent (no re-search)
**Settings Change Protection:**
- `select-torrent` endpoint re-checks approval requirements based on CURRENT settings
- Prevents bypass: User with auto-approve enabled creates request → Admin disables auto-approve → User tries to download
- If settings changed, torrent is stored and request enters approval queue
**Notification Timing:**
- Automatic search: Notification sent immediately on request creation
- Interactive search (auto-approved): Notification sent when torrent selected and download starts
- Interactive search (approval needed): Pending notification sent immediately, approved notification sent on admin approval
## Database Schema
### User Table
```
```prisma
autoApproveRequests: Boolean (nullable, default null)
- null: Use global setting
- true: Always auto-approve
@@ -199,17 +323,35 @@ autoApproveRequests: Boolean (nullable, default null)
```
### Request Table
```
```prisma
status: Enum (includes 'awaiting_approval', 'denied')
selectedTorrent: Json (nullable)
- Stores pre-selected torrent data from interactive search
- Set when approval needed, cleared after admin approval
- Contains: guid, title, size, seeders, indexer, downloadUrl, format, etc.
```
### Configuration Table
```
```prisma
key: 'auto_approve_requests'
value: 'true' | 'false' (string)
```
## Fixed Issues ✅
**1. BookDate Requests Bypass Approval System**
- Issue: Requests created through BookDate (right swipe) bypassed approval system entirely
- Security Impact: Critical - allowed users to bypass admin approval controls
- Cause: BookDate swipe route created requests with hardcoded 'pending' status, no approval checks, no notifications
- Fix: Implemented full approval logic in BookDate swipe route (same as POST /api/requests)
- Checks user.autoApproveRequests and global auto_approve_requests setting
- Sets correct status ('awaiting_approval' or 'pending')
- Sends appropriate notifications (request_pending_approval or request_approved)
- Only triggers search job if auto-approved
- Files updated: `src/app/api/bookdate/swipe/route.ts:124-217`, `tests/api/bookdate.routes.test.ts:470-648`
## Related
- [Admin Dashboard](../admin-dashboard.md) - Dashboard UI features
- [Database Schema](../backend/database.md) - User and Request tables
- [Settings Pages](../settings-pages.md) - Global settings management
- [BookDate Feature](../features/bookdate.md) - AI recommendations (Fixed Issues #9)
@@ -0,0 +1,182 @@
# Notification System
**Status:** ✅ Implemented | Extensible notification system with Discord and Pushover support
## Overview
Sends notifications for audiobook request events (pending approval, approved, available, error) to configured backends. Non-blocking, atomic per-backend failure handling. Proper notification timing for all request flows including interactive search.
## Key Details
- **Backends:** Discord (webhooks), Pushover (API)
- **Events:** request_pending_approval, request_approved, request_available, request_error
- **Encryption:** AES-256-GCM for sensitive config (webhook URLs, API keys)
- **Delivery:** Async via Bull job queue (priority 5)
- **Failure Handling:** Non-blocking, Promise.allSettled (one backend fails, others succeed)
## Database Schema
```prisma
model NotificationBackend {
id String @id @default(uuid())
type String // 'discord' | 'pushover'
name String // User-friendly label
config Json // Encrypted sensitive values
events Json // Array of subscribed events
enabled Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
```
## Event Types
| Event | Trigger | Notification Sent When |
|-------|---------|------------------------|
| request_pending_approval | User creates request | Request needs admin approval |
| request_approved | Admin approves OR auto-approval | Request approved (manual or auto) |
| request_available | Plex/ABS scan completes | Audiobook available in library |
| request_error | Download/import fails | Request failed at any stage |
## Notification Triggers
**Request Creation (POST /api/requests)**
- Automatic search, approval needed: `status === 'awaiting_approval'` → request_pending_approval
- Automatic search, auto-approved: `status === 'pending'` → request_approved
- Interactive search: NO notification yet (deferred until torrent selection)
**BookDate Swipe (POST /api/bookdate/swipe)**
- Right swipe, approval needed: `status === 'awaiting_approval'` → request_pending_approval
- Right swipe, auto-approved: `status === 'pending'` → request_approved
**Request with Pre-Selected Torrent (POST /api/audiobooks/request-with-torrent)**
- Approval needed: `status === 'awaiting_approval'` → request_pending_approval
- Auto-approved: `status === 'downloading'` → request_approved
**Torrent Selection for Existing Request (POST /api/requests/[id]/select-torrent)**
- Approval needed: `status === 'awaiting_approval'` → request_pending_approval
- Auto-approved: `status === 'downloading'` → request_approved
**Admin Approval (POST /api/admin/requests/[id]/approve)**
- Approve (with or without pre-selected torrent): After job triggered → request_approved
- Deny: No notification
**Request Available (processors: scan-plex, plex-recently-added)**
- After `status: 'available'` update → request_available
- Includes user info in query (plexUsername)
**Request Error (processors: monitor-download, organize-files)**
- After `status: 'failed'` or `status: 'warn'` update → request_error
- Includes error message in payload
## Configuration Encryption
**Encrypted Values:**
- Discord: `webhookUrl`
- Pushover: `userKey`, `appToken`
**Pattern:** `iv:authTag:encryptedData` (base64)
**Masking:** Sensitive values returned as `••••••••` in API responses
**Preservation:** Masked values preserved on update (if value === '••••••••', use existing encrypted value)
## Message Formatting
**Discord (Rich Embeds):**
- Color-coded by event (yellow=pending, green=approved, blue=available, red=error)
- Fields: Title, Author, Requested By, Error (if applicable)
- Footer: Request ID
- Timestamp: Event time
**Pushover (Plain Text with Emojis):**
- Emojis: 📬 📬 🎉 ❌
- Priority: Normal (0) for pending/approved, High (1) for available/error
- Format: Event title + book details + user + error (if applicable)
## API Endpoints
**GET /api/admin/notifications**
- Returns all backends (sensitive values masked)
**POST /api/admin/notifications**
- Create backend (encrypts sensitive values)
- Body: `{type, name, config, events, enabled}`
**GET /api/admin/notifications/[id]**
- Get single backend (sensitive values masked)
**PUT /api/admin/notifications/[id]**
- Update backend (preserves masked values, encrypts new values)
**DELETE /api/admin/notifications/[id]**
- Delete backend
**POST /api/admin/notifications/test**
- Test notification (synchronous, not via job queue)
- Body: `{type, config}` (plaintext for testing)
- Sends test payload: "The Hitchhiker's Guide to the Galaxy" by Douglas Adams
## UI Components
**NotificationsTab (src/app/admin/settings/tabs/NotificationsTab)**
- Type selector cards (Discord: indigo "D", Pushover: blue "P")
- Configured backends grid (3 columns)
- Backend cards: type icon, name, enabled status, event count, edit/delete actions
- Modal: type-specific forms, event checkboxes, enable toggle, test button
**Modal Features:**
- Type-first selection (user clicks "Add Discord" or "Add Pushover")
- Password inputs for sensitive values
- Event subscription checkboxes (4 events, default: available + error)
- Test button (sends synchronous test notification)
- Save button (validates and creates/updates backend)
## Job Queue Integration
**Job Type:** `send_notification` (priority 5, concurrency 5)
**Payload:**
```typescript
{
jobId?: string,
event: string,
requestId: string,
title: string,
author: string,
userName: string,
message?: string,
timestamp: Date
}
```
**Processor:** `src/lib/processors/send-notification.processor.ts`
- Calls NotificationService.sendNotification()
- Non-blocking error handling (logs but doesn't throw)
**Queue Method:** `addNotificationJob(event, requestId, title, author, userName, message?)`
## Extensibility
**Adding New Backend (e.g., Email):**
1. Add 'email' to NotificationBackendType enum
2. Create EmailConfig interface
3. Add encryption logic for smtpPassword
4. Implement sendEmail() method in NotificationService
5. Add email card to type selector (green "E" badge)
6. Add email form fields to modal
**Adding New Event (e.g., download_complete):**
1. Add 'download_complete' to NotificationEvent enum
2. Add to event labels in UI
3. Add trigger point in processor
4. Add message formatting in Discord/Pushover formatters
## Tech Stack
- Bull (job queue)
- Node.js crypto (AES-256-GCM encryption)
- Discord webhooks, Pushover API
- React (UI), Tailwind CSS (styling)
## Related
- [Job Queue System](jobs.md)
- [Config Encryption](config.md)
- [Settings Pages](../../settings-pages.md)
+24
View File
@@ -425,6 +425,30 @@ Personalized audiobook discovery using OpenAI/Claude APIs. Admin configures AI p
- Added tests to verify 401 from external provider returns 400 to client
- Files updated: `src/app/api/bookdate/test-connection/route.ts:190-197,382-389`, `tests/api/bookdate-test-connection.routes.test.ts:254-294`
**9. BookDate Requests Bypass Approval System**
- Issue: Requests created through BookDate (right swipe) bypass the approval system entirely
- User Experience: "BookDate requests don't go through approval even when approval is required, and I don't get any notifications about them"
- Security Impact: Critical - allows users to bypass admin approval controls
- Cause: BookDate swipe route created requests directly without checking approval requirements
- `src/app/api/bookdate/swipe/route.ts:124-146` hardcoded status as 'pending'
- Did not check user.autoApproveRequests or global auto_approve_requests setting
- Did not send any notifications (pending approval or approved)
- Immediately triggered search job regardless of approval status
- Contrast with POST /api/requests which properly implements approval logic
- Fix: Implement full approval logic in BookDate swipe route (same as POST /api/requests)
- Fetch user with autoApproveRequests setting
- Check approval requirements: user setting → global setting → default (true)
- Set status: 'awaiting_approval' if approval needed, 'pending' if auto-approved
- Send appropriate notification: request_pending_approval or request_approved
- Only trigger search job if auto-approved (not if awaiting approval)
- Admins always auto-approve (role === 'admin')
- Files updated: `src/app/api/bookdate/swipe/route.ts:124-217`, `tests/api/bookdate.routes.test.ts:470-648`
- Tests added:
- Admin user auto-approves (status: 'pending', sends approved notification, triggers search)
- User with autoApproveRequests=false requires approval (status: 'awaiting_approval', sends pending notification, no search)
- User with autoApproveRequests=true auto-approves (status: 'pending', sends approved notification, triggers search)
- User with autoApproveRequests=null checks global setting
## Related
- Full requirements: [features/bookdate-prd.md](bookdate-prd.md)
+44 -17
View File
@@ -88,35 +88,54 @@ This prevents issues where category retains old save path after user changes `do
**Use Case:** qBittorrent runs on different machine/container with different filesystem perspective.
**Example Scenario:**
- qBittorrent reports: `/remote/mnt/d/done/Audiobook.Name`
- ReadMeABook needs: `/downloads/Audiobook.Name`
- Mapping: Remote `/remote/mnt/d/done` Local `/downloads`
- qBittorrent on Windows expects: `F:\Docker\downloads\completed\books`
- ReadMeABook inside Docker sees: `/downloads`
- Mapping: Remote `F:\Docker\downloads\completed\books` Local `/downloads`
**Configuration:**
1. Admin Settings → Download Client → Enable Remote Path Mapping
2. Enter remote path (as reported by qBittorrent)
3. Enter local path (accessible to ReadMeABook)
2. Enter remote path (as qBittorrent sees it, e.g., `F:\Docker\downloads\completed\books`)
3. Enter local path (as RMAB sees it, e.g., `/downloads`)
4. Test connection validates local path exists
5. Save settings
**Bidirectional Path Mapping:**
**1. Outgoing (RMAB → qBittorrent):** When adding torrents
- RMAB's download path: `/downloads`
- Translated to qBit's path: `F:\Docker\downloads\completed\books`
- Applied in `qbittorrent.service.ts` via `PathMapper.reverseTransform()`
- Ensures qBittorrent knows where to save files
**2. Incoming (qBittorrent → RMAB):** When processing completed downloads
- qBit reports: `F:\Docker\downloads\completed\books\Audiobook.Name`
- Translated to RMAB's path: `/downloads/Audiobook.Name`
- Applied in `monitor-download.processor.ts` via `PathMapper.transform()`
- Applied in `retry-failed-imports.processor.ts` for failed imports
- Ensures RMAB can find and organize files
**Implementation:**
- `PathMapper` utility (`src/lib/utils/path-mapper.ts`) handles transformation
- Applied in `monitor-download.processor.ts` when download completes
- Applied in `retry-failed-imports.processor.ts` for failed imports
- `transform()`: Remote → Local (qBit → RMAB)
- `reverseTransform()`: Local → Remote (RMAB → qBit)
- Uses simple prefix replacement with path normalization
- Graceful fallback: if path doesn't match remote prefix, returns unchanged
- Preserves Windows backslashes when translating to Windows paths
- Graceful fallback: if path doesn't match prefix, returns unchanged
**Path Transformation Examples:**
**Path Transformation:**
```typescript
// Input from qBittorrent
qbPath = "/remote/mnt/d/done/Audiobook.Name"
// Config
remotePath = "/remote/mnt/d/done"
// Outgoing: RMAB → qBittorrent (when adding torrent)
localPath = "/downloads"
config = { remotePath: "F:\\Docker\\downloads\\completed\\books", localPath: "/downloads" }
remotePath = PathMapper.reverseTransform(localPath, config)
// Result: "F:\Docker\downloads\completed\books"
// Output (used for file organization)
organizePath = "/downloads/Audiobook.Name"
// Incoming: qBittorrent → RMAB (when processing completion)
qbPath = "F:\\Docker\\downloads\\completed\\books\\Audiobook.Name"
config = { remotePath: "F:\\Docker\\downloads\\completed\\books", localPath: "/downloads" }
organizePath = PathMapper.transform(qbPath, config)
// Result: "/downloads/Audiobook.Name"
```
**Validation:**
@@ -126,9 +145,10 @@ organizePath = "/downloads/Audiobook.Name"
**Behavior:**
- Mapping only applies when enabled
- If path doesn't start with remote prefix, returns original (logs warning)
- If path doesn't start with expected prefix, returns original (logs warning)
- Path normalization handles trailing slashes, backslashes, redundant separators
- Works with both `content_path` and constructed `save_path + name`
- Preserves native path separators (important for Windows)
## Data Models
@@ -188,6 +208,13 @@ type TorrentState = 'downloading' | 'uploading' | 'stalledDL' |
- Applied to axios client instance and all standalone requests
- Works transparently with or without reverse proxy
- Compatible with popular seedbox providers (seedit4.me, etc.)
**14. Remote path mapping not applied when adding torrents** - When qBittorrent runs locally (e.g., Windows) and RMAB runs in Docker, savepath sent to qBittorrent was not translated. qBittorrent received `/downloads` (RMAB's path) but expected `F:\Docker\downloads\completed\books` (Windows path), causing "Invalid path" errors. Fixed by:
- Added `PathMapper.reverseTransform()` for bidirectional path mapping (local → remote)
- Applied in `qbittorrent.service.ts` when setting savepath for torrents
- Preserves Windows backslashes when translating to Windows paths
- Path mapping now works in both directions: outgoing (RMAB → qBit) and incoming (qBit → RMAB)
- Service constructor accepts `PathMappingConfig` parameter
- Singleton loads path mapping config from database
## Tech Stack
+36
View File
@@ -70,6 +70,7 @@ src/app/admin/settings/
4. **Download Client** - Type, URL, credentials (masked)
5. **Paths** - Download + media directories, audiobook organization template, metadata tagging toggle, chapter merging toggle
6. **BookDate** - AI provider, API key (encrypted), model selection, library scope, custom prompt, swipe history
7. **Notifications** - Multiple backends (Discord, Pushover), event subscriptions, test functionality
## Audible Region
@@ -300,3 +301,38 @@ src/app/admin/settings/
- Allow saving indexer config changes without re-testing connection
- Button text adapts: "Test Connection" vs "Refresh Indexers"
- Behavior: Natural workflow - see current settings, modify indexers, save immediately
## Notifications
**Purpose:** Configure notification backends to receive alerts for audiobook request events.
**Configuration:**
- Multiple backends per type (Discord, Pushover)
- Per-backend event subscriptions (4 events)
- Encrypted sensitive values (webhook URLs, API keys)
- Enable/disable toggle per backend
**UI (NotificationsTab):**
- Type selector cards: Discord (indigo "D"), Pushover (blue "P")
- Grid layout for configured backends (3 columns)
- Card shows: type icon, name, enabled status, event count
- Edit/delete actions per card
**Modal (NotificationConfigModal):**
- Type-specific forms (Discord: webhook/username/avatar, Pushover: keys/priority)
- Event subscription checkboxes (4 events)
- Enable/disable toggle
- Test button (sends sample notification)
- Password masking for sensitive values
**Event Types:**
- Request Pending Approval - Admin approval required
- Request Approved - Approved (manual or auto)
- Request Available - Available in library
- Request Error - Failed at any stage
**Validation:**
- Name required
- Discord: webhook URL required
- Pushover: user key + app token required
- At least one event selected
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "requests" ADD COLUMN "selected_torrent" JSONB;
+15
View File
@@ -210,6 +210,7 @@ model Request {
progress Int @default(0) // 0-100
priority Int @default(0)
errorMessage String? @map("error_message") @db.Text
selectedTorrent Json? @map("selected_torrent") // Pre-selected torrent from interactive search (stored when awaiting approval)
searchAttempts Int @default(0) @map("search_attempts")
downloadAttempts Int @default(0) @map("download_attempts")
importAttempts Int @default(0) @map("import_attempts")
@@ -420,3 +421,17 @@ model BookDateSwipe {
@@index([recommendationId])
@@map("bookdate_swipes")
}
model NotificationBackend {
id String @id @default(uuid())
type String // 'discord' | 'pushover' | 'email' | 'slack' | 'telegram' | 'webhook'
name String // User-friendly label
config Json // Type-specific config (encrypted sensitive values)
events Json @default("[]") // Array of event strings
enabled Boolean @default(true)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@index([enabled])
@@map("notification_backends")
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 917 KiB

+1
View File
@@ -212,4 +212,5 @@ export const getTabs = (backendMode: 'plex' | 'audiobookshelf') => [
{ id: 'paths' as const, label: 'Paths', icon: '📁' },
{ id: 'ebook' as const, label: 'E-book Sidecar', icon: '📖' },
{ id: 'bookdate' as const, label: 'BookDate', icon: '📚' },
{ id: 'notifications' as const, label: 'Notifications', icon: '🔔' },
];
+1 -1
View File
@@ -226,4 +226,4 @@ export interface BookDateModel {
/**
* Tab identifier type
*/
export type SettingsTab = 'library' | 'auth' | 'prowlarr' | 'download' | 'paths' | 'ebook' | 'bookdate';
export type SettingsTab = 'library' | 'auth' | 'prowlarr' | 'download' | 'paths' | 'ebook' | 'bookdate' | 'notifications';
+5 -1
View File
@@ -22,6 +22,7 @@ import { DownloadTab } from './tabs/DownloadTab/DownloadTab';
import { PathsTab } from './tabs/PathsTab/PathsTab';
import { EbookTab } from './tabs/EbookTab/EbookTab';
import { BookDateTab } from './tabs/BookDateTab/BookDateTab';
import { NotificationsTab } from './tabs/NotificationsTab';
// Types and Helpers
import type { Settings, SettingsTab, IndexerConfig, SavedIndexerConfig, Message } from './lib/types';
@@ -328,8 +329,11 @@ export default function AdminSettings() {
/>
)}
{/* Notifications Tab */}
{activeTab === 'notifications' && <NotificationsTab />}
{/* Save Button (only for tabs that save through main page) */}
{activeTab !== 'ebook' && activeTab !== 'bookdate' && (
{activeTab !== 'ebook' && activeTab !== 'bookdate' && activeTab !== 'notifications' && (
<div className="mt-8 flex gap-4">
<Button
onClick={saveSettings}
@@ -0,0 +1,455 @@
'use client';
import { useState, useEffect } from 'react';
import { RMABLogger } from '@/lib/utils/logger';
import { fetchWithAuth } from '@/lib/utils/api';
const logger = RMABLogger.create('NotificationsTab');
interface NotificationBackend {
id: string;
type: string;
name: string;
config: Record<string, any>;
events: string[];
enabled: boolean;
createdAt: string;
updatedAt: string;
}
interface ModalState {
isOpen: boolean;
mode: 'add' | 'edit';
selectedType?: string;
backend?: NotificationBackend;
}
const typeColors: Record<string, string> = {
discord: 'bg-indigo-500',
pushover: 'bg-blue-500',
email: 'bg-green-500',
slack: 'bg-purple-500',
telegram: 'bg-sky-500',
webhook: 'bg-gray-500',
};
const eventLabels: Record<string, string> = {
request_pending_approval: 'Request Pending Approval',
request_approved: 'Request Approved',
request_available: 'Audiobook Available',
request_error: 'Request Error',
};
export function NotificationsTab() {
const [backends, setBackends] = useState<NotificationBackend[]>([]);
const [loading, setLoading] = useState(true);
const [modalState, setModalState] = useState<ModalState>({
isOpen: false,
mode: 'add',
});
const [formData, setFormData] = useState<any>({
name: '',
config: {},
events: ['request_available', 'request_error'],
enabled: true,
});
const [testResult, setTestResult] = useState<{ success: boolean; message: string } | null>(null);
const [isTesting, setIsTesting] = useState(false);
const [isSaving, setIsSaving] = useState(false);
useEffect(() => {
fetchBackends();
}, []);
const fetchBackends = async () => {
try {
setLoading(true);
const response = await fetchWithAuth('/api/admin/notifications');
if (response.ok) {
const data = await response.json();
if (data.success) {
setBackends(data.backends);
} else {
logger.error('Failed to fetch backends', { error: data.error });
}
} else {
logger.error('Failed to fetch backends', { status: response.status });
}
} catch (error) {
logger.error('Failed to fetch backends', { error: error instanceof Error ? error.message : String(error) });
} finally {
setLoading(false);
}
};
const openAddModal = (type: string) => {
setModalState({ isOpen: true, mode: 'add', selectedType: type });
setFormData({
name: `${type.charAt(0).toUpperCase() + type.slice(1)} Notifications`,
config: type === 'discord' ? { webhookUrl: '', username: 'ReadMeABook', avatarUrl: '' } : { userKey: '', appToken: '', device: '', priority: 0 },
events: ['request_available', 'request_error'],
enabled: true,
});
setTestResult(null);
};
const openEditModal = (backend: NotificationBackend) => {
setModalState({ isOpen: true, mode: 'edit', selectedType: backend.type, backend });
setFormData({
name: backend.name,
config: backend.config,
events: backend.events,
enabled: backend.enabled,
});
setTestResult(null);
};
const closeModal = () => {
setModalState({ isOpen: false, mode: 'add' });
setTestResult(null);
};
const handleTest = async () => {
if (!modalState.selectedType) return;
try {
setIsTesting(true);
setTestResult(null);
const response = await fetchWithAuth('/api/admin/notifications/test', {
method: 'POST',
body: JSON.stringify({
type: modalState.selectedType,
config: formData.config,
}),
});
const data = await response.json();
if (response.ok && data.success) {
setTestResult({ success: true, message: 'Test notification sent successfully!' });
} else {
setTestResult({ success: false, message: data.message || 'Failed to send test notification' });
}
} catch (error) {
setTestResult({ success: false, message: error instanceof Error ? error.message : 'Unknown error' });
} finally {
setIsTesting(false);
}
};
const handleSave = async () => {
if (!modalState.selectedType) return;
try {
setIsSaving(true);
const url = modalState.mode === 'add' ? '/api/admin/notifications' : `/api/admin/notifications/${modalState.backend?.id}`;
const method = modalState.mode === 'add' ? 'POST' : 'PUT';
const response = await fetchWithAuth(url, {
method,
body: JSON.stringify({
type: modalState.selectedType,
...formData,
}),
});
const data = await response.json();
if (response.ok && data.success) {
await fetchBackends();
closeModal();
} else {
setTestResult({ success: false, message: data.message || 'Failed to save backend' });
}
} catch (error) {
setTestResult({ success: false, message: error instanceof Error ? error.message : 'Unknown error' });
} finally {
setIsSaving(false);
}
};
const handleDelete = async (id: string) => {
if (!confirm('Are you sure you want to delete this notification backend?')) return;
try {
const response = await fetchWithAuth(`/api/admin/notifications/${id}`, {
method: 'DELETE',
});
if (response.ok) {
const data = await response.json();
if (data.success) {
await fetchBackends();
}
}
} catch (error) {
logger.error('Failed to delete backend', { error: error instanceof Error ? error.message : String(error) });
}
};
return (
<div className="space-y-6">
{/* Header */}
<div>
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">Notifications</h2>
<p className="mt-2 text-sm text-gray-600 dark:text-gray-400">
Configure notification backends to receive alerts for audiobook request events.
</p>
</div>
{/* Type Selector */}
<div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">Add Notification Backend</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<button
onClick={() => openAddModal('discord')}
className="flex items-center p-4 bg-white dark:bg-gray-800 rounded-lg border-2 border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600 transition-colors"
>
<div className="flex-shrink-0 w-12 h-12 bg-indigo-500 rounded-lg flex items-center justify-center text-white font-bold text-2xl">
D
</div>
<div className="ml-4 text-left">
<div className="font-semibold text-gray-900 dark:text-white">Discord</div>
<div className="text-sm text-gray-600 dark:text-gray-400">Send notifications via Discord webhook</div>
</div>
</button>
<button
onClick={() => openAddModal('pushover')}
className="flex items-center p-4 bg-white dark:bg-gray-800 rounded-lg border-2 border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600 transition-colors"
>
<div className="flex-shrink-0 w-12 h-12 bg-blue-500 rounded-lg flex items-center justify-center text-white font-bold text-2xl">
P
</div>
<div className="ml-4 text-left">
<div className="font-semibold text-gray-900 dark:text-white">Pushover</div>
<div className="text-sm text-gray-600 dark:text-gray-400">Send notifications via Pushover API</div>
</div>
</button>
</div>
</div>
{/* Configured Backends */}
<div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">Configured Backends</h3>
{loading ? (
<p className="text-gray-600 dark:text-gray-400">Loading...</p>
) : backends.length === 0 ? (
<p className="text-gray-600 dark:text-gray-400">No notification backends configured.</p>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{backends.map((backend) => (
<div key={backend.id} className="bg-white dark:bg-gray-800 rounded-lg shadow-md border border-gray-200 dark:border-gray-700 p-4 hover:shadow-lg transition-shadow">
<div className="flex items-start justify-between mb-3">
<div className="flex items-center space-x-3">
<div className={`w-10 h-10 ${typeColors[backend.type]} rounded-lg flex items-center justify-center text-white font-bold`}>
{backend.type.charAt(0).toUpperCase()}
</div>
<div>
<div className="font-semibold text-gray-900 dark:text-white truncate">{backend.name}</div>
<div className="text-xs text-gray-500 dark:text-gray-400 capitalize">{backend.type}</div>
</div>
</div>
</div>
<div className="space-y-2 mb-3">
<div className={`inline-block px-2 py-1 rounded text-xs ${backend.enabled ? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200' : 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300'}`}>
{backend.enabled ? 'Enabled' : 'Disabled'}
</div>
<div className="text-sm text-gray-600 dark:text-gray-400">
{backend.events.length} {backend.events.length === 1 ? 'event' : 'events'} subscribed
</div>
</div>
<div className="flex space-x-2">
<button
onClick={() => openEditModal(backend)}
className="flex-1 px-3 py-1.5 text-sm bg-blue-600 hover:bg-blue-700 text-white rounded transition-colors"
>
Edit
</button>
<button
onClick={() => handleDelete(backend.id)}
className="flex-1 px-3 py-1.5 text-sm bg-red-600 hover:bg-red-700 text-white rounded transition-colors"
>
Delete
</button>
</div>
</div>
))}
</div>
)}
</div>
{/* Modal */}
{modalState.isOpen && modalState.selectedType && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-2xl w-full max-h-[90vh] overflow-y-auto">
<div className="p-6">
<div className="flex items-center justify-between mb-6">
<h3 className="text-xl font-bold text-gray-900 dark:text-white">
{modalState.mode === 'add' ? 'Add' : 'Edit'} {modalState.selectedType.charAt(0).toUpperCase() + modalState.selectedType.slice(1)} Notification
</h3>
<button onClick={closeModal} className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200">
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div className="space-y-4">
{/* Name */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Name</label>
<input
type="text"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
placeholder="e.g., Discord - Admins"
/>
</div>
{/* Config Fields */}
{modalState.selectedType === 'discord' && (
<>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Webhook URL *</label>
<input
type="text"
value={formData.config.webhookUrl}
onChange={(e) => setFormData({ ...formData, config: { ...formData.config, webhookUrl: e.target.value } })}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
placeholder="https://discord.com/api/webhooks/..."
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Username (optional)</label>
<input
type="text"
value={formData.config.username}
onChange={(e) => setFormData({ ...formData, config: { ...formData.config, username: e.target.value } })}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
placeholder="ReadMeABook"
/>
</div>
</>
)}
{modalState.selectedType === 'pushover' && (
<>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">User Key *</label>
<input
type="text"
value={formData.config.userKey}
onChange={(e) => setFormData({ ...formData, config: { ...formData.config, userKey: e.target.value } })}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
placeholder="Your Pushover user key"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">App Token *</label>
<input
type="text"
value={formData.config.appToken}
onChange={(e) => setFormData({ ...formData, config: { ...formData.config, appToken: e.target.value } })}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
placeholder="Your Pushover app token"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Priority</label>
<select
value={formData.config.priority}
onChange={(e) => setFormData({ ...formData, config: { ...formData.config, priority: Number(e.target.value) } })}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
>
<option value="-2">Lowest</option>
<option value="-1">Low</option>
<option value="0">Normal</option>
<option value="1">High</option>
<option value="2">Emergency</option>
</select>
</div>
</>
)}
{/* Events */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Subscribe to Events *</label>
<div className="space-y-2">
{Object.entries(eventLabels).map(([event, label]) => (
<label key={event} className="flex items-center space-x-2 p-3 border border-gray-300 dark:border-gray-600 rounded-lg cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700">
<input
type="checkbox"
checked={formData.events.includes(event)}
onChange={(e) => {
if (e.target.checked) {
setFormData({ ...formData, events: [...formData.events, event] });
} else {
setFormData({ ...formData, events: formData.events.filter((e: string) => e !== event) });
}
}}
className="rounded"
/>
<span className="text-sm text-gray-900 dark:text-white">{label}</span>
</label>
))}
</div>
</div>
{/* Enabled Toggle */}
<div>
<label className="flex items-center space-x-2 cursor-pointer">
<input
type="checkbox"
checked={formData.enabled}
onChange={(e) => setFormData({ ...formData, enabled: e.target.checked })}
className="rounded"
/>
<span className="text-sm text-gray-900 dark:text-white">Enable this notification backend</span>
</label>
</div>
{/* Test Result */}
{testResult && (
<div className={`p-3 rounded-lg ${testResult.success ? 'bg-green-100 dark:bg-green-900 border border-green-300 dark:border-green-700 text-green-800 dark:text-green-200' : 'bg-red-100 dark:bg-red-900 border border-red-300 dark:border-red-700 text-red-800 dark:text-red-200'}`}>
{testResult.message}
</div>
)}
{/* Actions */}
<div className="flex justify-between pt-4 border-t border-gray-200 dark:border-gray-700">
<button
onClick={handleTest}
disabled={isTesting}
className="px-4 py-2 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50"
>
{isTesting ? 'Testing...' : 'Send Test'}
</button>
<div className="flex space-x-2">
<button
onClick={closeModal}
className="px-4 py-2 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700"
>
Cancel
</button>
<button
onClick={handleSave}
disabled={isSaving}
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg disabled:opacity-50"
>
{isSaving ? 'Saving...' : (modalState.mode === 'add' ? 'Add Backend' : 'Save Changes')}
</button>
</div>
</div>
</div>
</div>
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1 @@
export { NotificationsTab } from './NotificationsTab';
@@ -0,0 +1,226 @@
/**
* Component: Notification Backend Individual API
* Documentation: documentation/backend/services/notifications.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
import { prisma } from '@/lib/db';
import { getNotificationService, NotificationBackendType } from '@/lib/services/notification.service';
import { RMABLogger } from '@/lib/utils/logger';
import { z } from 'zod';
const logger = RMABLogger.create('API.Admin.Notifications.Id');
const UpdateBackendSchema = z.object({
name: z.string().min(1).optional(),
config: z.record(z.any()).optional(),
events: z.array(z.enum(['request_pending_approval', 'request_approved', 'request_available', 'request_error'])).min(1).optional(),
enabled: z.boolean().optional(),
});
/**
* GET /api/admin/notifications/[id]
* Get single notification backend (sensitive values masked)
*/
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
return requireAdmin(req, async () => {
try {
const { id } = await params;
const backend = await prisma.notificationBackend.findUnique({
where: { id },
});
if (!backend) {
return NextResponse.json(
{ error: 'NotFound', message: 'Notification backend not found' },
{ status: 404 }
);
}
const notificationService = getNotificationService();
// Mask sensitive config values
return NextResponse.json({
success: true,
backend: {
...backend,
config: notificationService.maskConfig(backend.type as NotificationBackendType, backend.config),
},
});
} catch (error) {
logger.error('Failed to fetch notification backend', {
error: error instanceof Error ? error.message : String(error),
});
return NextResponse.json(
{
error: 'FetchError',
message: 'Failed to fetch notification backend',
},
{ status: 500 }
);
}
});
});
}
/**
* PUT /api/admin/notifications/[id]
* Update notification backend
*/
export async function PUT(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
return requireAdmin(req, async () => {
try {
const { id } = await params;
const body = await request.json();
const updates = UpdateBackendSchema.parse(body);
// Get existing backend
const existing = await prisma.notificationBackend.findUnique({
where: { id },
});
if (!existing) {
return NextResponse.json(
{ error: 'NotFound', message: 'Notification backend not found' },
{ status: 404 }
);
}
const notificationService = getNotificationService();
// Handle config updates (preserve masked values, encrypt new values)
let finalConfig = existing.config;
if (updates.config) {
const existingConfig = existing.config as any;
const updatedConfig = updates.config as any;
// Check if masked values need to be preserved
Object.keys(updatedConfig).forEach((key) => {
if (updatedConfig[key] === '••••••••') {
// Preserve existing encrypted value
updatedConfig[key] = existingConfig[key];
}
});
// Encrypt new/changed values
finalConfig = notificationService.encryptConfig(existing.type as NotificationBackendType, updatedConfig);
}
// Update backend
const updateData: any = {};
if (updates.name) updateData.name = updates.name;
if (updates.config) updateData.config = finalConfig;
if (updates.events) updateData.events = updates.events;
if (updates.enabled !== undefined) updateData.enabled = updates.enabled;
const updated = await prisma.notificationBackend.update({
where: { id },
data: updateData,
});
logger.info(`Updated notification backend: ${updated.name}`, {
backendId: id,
adminId: req.user?.sub,
});
// Return with masked values
return NextResponse.json({
success: true,
backend: {
...updated,
config: notificationService.maskConfig(updated.type as NotificationBackendType, updated.config),
},
});
} catch (error) {
logger.error('Failed to update notification backend', {
error: error instanceof Error ? error.message : String(error),
});
if (error instanceof z.ZodError) {
return NextResponse.json(
{
error: 'ValidationError',
details: error.errors,
},
{ status: 400 }
);
}
return NextResponse.json(
{
error: 'UpdateError',
message: 'Failed to update notification backend',
},
{ status: 500 }
);
}
});
});
}
/**
* DELETE /api/admin/notifications/[id]
* Delete notification backend
*/
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
return requireAdmin(req, async () => {
try {
const { id } = await params;
// Check if backend exists
const backend = await prisma.notificationBackend.findUnique({
where: { id },
});
if (!backend) {
return NextResponse.json(
{ error: 'NotFound', message: 'Notification backend not found' },
{ status: 404 }
);
}
// Delete backend
await prisma.notificationBackend.delete({
where: { id },
});
logger.info(`Deleted notification backend: ${backend.name}`, {
backendId: id,
adminId: req.user?.sub,
});
return NextResponse.json({
success: true,
message: 'Notification backend deleted',
});
} catch (error) {
logger.error('Failed to delete notification backend', {
error: error instanceof Error ? error.message : String(error),
});
return NextResponse.json(
{
error: 'DeleteError',
message: 'Failed to delete notification backend',
},
{ status: 500 }
);
}
});
});
}
+129
View File
@@ -0,0 +1,129 @@
/**
* Component: Notification Backend API
* Documentation: documentation/backend/services/notifications.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
import { prisma } from '@/lib/db';
import { getNotificationService, NotificationBackendType } from '@/lib/services/notification.service';
import { RMABLogger } from '@/lib/utils/logger';
import { z } from 'zod';
const logger = RMABLogger.create('API.Admin.Notifications');
const CreateBackendSchema = z.object({
type: z.enum(['discord', 'pushover', 'email', 'slack', 'telegram', 'webhook']),
name: z.string().min(1),
config: z.record(z.any()),
events: z.array(z.enum(['request_pending_approval', 'request_approved', 'request_available', 'request_error'])).min(1),
enabled: z.boolean().default(true),
});
/**
* GET /api/admin/notifications
* List all notification backends (sensitive values masked)
*/
export async function GET(request: NextRequest) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
return requireAdmin(req, async () => {
try {
const backends = await prisma.notificationBackend.findMany({
orderBy: { createdAt: 'desc' },
});
const notificationService = getNotificationService();
// Mask sensitive config values
const maskedBackends = backends.map((backend) => ({
...backend,
config: notificationService.maskConfig(backend.type as NotificationBackendType, backend.config),
}));
return NextResponse.json({
success: true,
backends: maskedBackends,
});
} catch (error) {
logger.error('Failed to fetch notification backends', {
error: error instanceof Error ? error.message : String(error),
});
return NextResponse.json(
{
error: 'FetchError',
message: 'Failed to fetch notification backends',
},
{ status: 500 }
);
}
});
});
}
/**
* POST /api/admin/notifications
* Create new notification backend
*/
export async function POST(request: NextRequest) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
return requireAdmin(req, async () => {
try {
const body = await request.json();
const { type, name, config, events, enabled } = CreateBackendSchema.parse(body);
const notificationService = getNotificationService();
// Encrypt sensitive config values
const encryptedConfig = notificationService.encryptConfig(type, config);
// Create backend
const backend = await prisma.notificationBackend.create({
data: {
type,
name,
config: encryptedConfig,
events,
enabled,
},
});
logger.info(`Created notification backend: ${name} (${type})`, {
backendId: backend.id,
adminId: req.user?.sub,
});
// Return with masked values
return NextResponse.json({
success: true,
backend: {
...backend,
config: notificationService.maskConfig(type, backend.config),
},
}, { status: 201 });
} catch (error) {
logger.error('Failed to create notification backend', {
error: error instanceof Error ? error.message : String(error),
});
if (error instanceof z.ZodError) {
return NextResponse.json(
{
error: 'ValidationError',
details: error.errors,
},
{ status: 400 }
);
}
return NextResponse.json(
{
error: 'CreateError',
message: 'Failed to create notification backend',
},
{ status: 500 }
);
}
});
});
}
@@ -0,0 +1,97 @@
/**
* Component: Notification Test API
* Documentation: documentation/backend/services/notifications.md
*/
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
import { getNotificationService, NotificationBackendType, NotificationPayload } from '@/lib/services/notification.service';
import { RMABLogger } from '@/lib/utils/logger';
import { z } from 'zod';
const logger = RMABLogger.create('API.Admin.Notifications.Test');
const TestNotificationSchema = z.object({
type: z.enum(['discord', 'pushover', 'email', 'slack', 'telegram', 'webhook']),
config: z.record(z.any()),
});
/**
* POST /api/admin/notifications/test
* Test notification with provided config (synchronous)
*/
export async function POST(request: NextRequest) {
return requireAuth(request, async (req: AuthenticatedRequest) => {
return requireAdmin(req, async () => {
try {
const body = await request.json();
const { type, config } = TestNotificationSchema.parse(body);
const notificationService = getNotificationService();
// Encrypt config values
const encryptedConfig = notificationService.encryptConfig(type, config);
// Create test payload
const testPayload: NotificationPayload = {
event: 'request_available',
requestId: 'test-request-id',
title: "The Hitchhiker's Guide to the Galaxy",
author: 'Douglas Adams',
userName: 'Test User',
timestamp: new Date(),
};
// Send test notification synchronously (not via job queue)
try {
// Call sendToBackend directly
await (notificationService as any).sendToBackend(type, encryptedConfig, testPayload);
logger.info(`Test notification sent successfully for ${type}`, {
adminId: req.user?.sub,
});
return NextResponse.json({
success: true,
message: 'Test notification sent successfully',
});
} catch (notificationError) {
logger.error(`Test notification failed for ${type}`, {
error: notificationError instanceof Error ? notificationError.message : String(notificationError),
adminId: req.user?.sub,
});
return NextResponse.json(
{
error: 'NotificationError',
message: notificationError instanceof Error ? notificationError.message : 'Failed to send test notification',
},
{ status: 400 }
);
}
} catch (error) {
logger.error('Failed to test notification', {
error: error instanceof Error ? error.message : String(error),
});
if (error instanceof z.ZodError) {
return NextResponse.json(
{
error: 'ValidationError',
details: error.errors,
},
{ status: 400 }
);
}
return NextResponse.json(
{
error: 'TestError',
message: 'Failed to test notification',
},
{ status: 500 }
);
}
});
});
}
+112 -31
View File
@@ -75,42 +75,123 @@ export async function POST(
// Update request based on action
if (action === 'approve') {
// Approve: Change status to 'pending' and trigger search job
const updatedRequest = await prisma.request.update({
where: { id },
data: { status: 'pending' },
include: {
audiobook: true,
user: {
select: {
id: true,
plexUsername: true,
const jobQueue = getJobQueueService();
// Check if request has a pre-selected torrent (from interactive search)
if (existingRequest.selectedTorrent) {
// User pre-selected a specific torrent - download that torrent directly
logger.info(`Request ${id} has pre-selected torrent, starting download`, {
requestId: id,
userId: existingRequest.userId,
adminId: req.user.sub,
});
// Trigger download job with pre-selected torrent
await jobQueue.addDownloadJob(
existingRequest.id,
{
id: existingRequest.audiobook.id,
title: existingRequest.audiobook.title,
author: existingRequest.audiobook.author,
},
existingRequest.selectedTorrent as any
);
// Update status to 'downloading' and clear selectedTorrent
const updatedRequest = await prisma.request.update({
where: { id },
data: {
status: 'downloading',
selectedTorrent: null as any, // Clear after use
},
include: {
audiobook: true,
user: {
select: {
id: true,
plexUsername: true,
},
},
},
},
});
});
// Trigger search job
const jobQueue = getJobQueueService();
await jobQueue.addSearchJob(updatedRequest.id, {
id: updatedRequest.audiobook.id,
title: updatedRequest.audiobook.title,
author: updatedRequest.audiobook.author,
asin: updatedRequest.audiobook.audibleAsin || undefined,
});
// Send notification for manual approval
await jobQueue.addNotificationJob(
'request_approved',
updatedRequest.id,
existingRequest.audiobook.title,
existingRequest.audiobook.author,
existingRequest.user.plexUsername || 'Unknown User'
).catch((error) => {
logger.error('Failed to queue notification', { error: error instanceof Error ? error.message : String(error) });
});
logger.info(`Request ${id} approved by admin ${req.user.sub}`, {
requestId: id,
userId: updatedRequest.userId,
audiobookTitle: updatedRequest.audiobook.title,
adminId: req.user.sub,
});
logger.info(`Request ${id} approved by admin ${req.user.sub}, downloading pre-selected torrent`, {
requestId: id,
userId: updatedRequest.userId,
audiobookTitle: existingRequest.audiobook.title,
adminId: req.user.sub,
});
return NextResponse.json({
success: true,
message: 'Request approved and search job triggered',
request: updatedRequest,
});
return NextResponse.json({
success: true,
message: 'Request approved and download started with pre-selected torrent',
request: updatedRequest,
});
} else {
// No pre-selected torrent - use automatic search
logger.info(`Request ${id} using automatic search`, {
requestId: id,
userId: existingRequest.userId,
adminId: req.user.sub,
});
const updatedRequest = await prisma.request.update({
where: { id },
data: { status: 'pending' },
include: {
audiobook: true,
user: {
select: {
id: true,
plexUsername: true,
},
},
},
});
// Trigger search job
await jobQueue.addSearchJob(updatedRequest.id, {
id: updatedRequest.audiobook.id,
title: updatedRequest.audiobook.title,
author: updatedRequest.audiobook.author,
asin: updatedRequest.audiobook.audibleAsin || undefined,
});
// Send notification for manual approval
await jobQueue.addNotificationJob(
'request_approved',
updatedRequest.id,
updatedRequest.audiobook.title,
updatedRequest.audiobook.author,
updatedRequest.user.plexUsername || 'Unknown User'
).catch((error) => {
logger.error('Failed to queue notification', { error: error instanceof Error ? error.message : String(error) });
});
logger.info(`Request ${id} approved by admin ${req.user.sub}`, {
requestId: id,
userId: updatedRequest.userId,
audiobookTitle: updatedRequest.audiobook.title,
adminId: req.user.sub,
});
return NextResponse.json({
success: true,
message: 'Request approved and search job triggered',
request: updatedRequest,
});
}
} else {
// Deny: Change status to 'denied'
const updatedRequest = await prisma.request.update({
@@ -193,43 +193,141 @@ export async function POST(request: NextRequest) {
});
}
// Create request with downloading status
const newRequest = await prisma.request.create({
data: {
userId: req.user.id,
audiobookId: audiobookRecord.id,
status: 'downloading',
progress: 0,
},
include: {
audiobook: true,
user: {
select: {
id: true,
plexUsername: true,
},
},
// Check if request needs approval
let needsApproval = false;
// Fetch user with autoApproveRequests setting
const user = await prisma.user.findUnique({
where: { id: req.user.id },
select: {
role: true,
autoApproveRequests: true,
plexUsername: true,
},
});
// Queue download job with the selected torrent
if (!user) {
return NextResponse.json(
{ error: 'UserNotFound', message: 'User not found' },
{ status: 404 }
);
}
// Determine if approval is needed
if (user.role === 'admin') {
// Admins always auto-approve
needsApproval = false;
} else {
// Check user's personal setting first
if (user.autoApproveRequests === true) {
needsApproval = false;
} else if (user.autoApproveRequests === false) {
needsApproval = true;
} else {
// User setting is null, check global setting
const globalConfig = await prisma.configuration.findUnique({
where: { key: 'auto_approve_requests' },
});
// Default to true if not configured (backward compatibility)
const globalAutoApprove = globalConfig === null ? true : globalConfig.value === 'true';
needsApproval = !globalAutoApprove;
}
}
const jobQueue = getJobQueueService();
await jobQueue.addDownloadJob(
newRequest.id,
{
id: audiobookRecord.id,
title: audiobookRecord.title,
author: audiobookRecord.author,
},
torrent
);
logger.info(`Queued download monitor job for request ${newRequest.id}`);
if (needsApproval) {
// Create request with awaiting_approval status and store selected torrent
logger.info('Request requires approval, storing selected torrent', { userId: req.user.id });
return NextResponse.json({
success: true,
request: newRequest,
}, { status: 201 });
const newRequest = await prisma.request.create({
data: {
userId: req.user.id,
audiobookId: audiobookRecord.id,
status: 'awaiting_approval',
progress: 0,
selectedTorrent: torrent as any, // Store the selected torrent for later
},
include: {
audiobook: true,
user: {
select: {
id: true,
plexUsername: true,
},
},
},
});
// Send pending approval notification
await jobQueue.addNotificationJob(
'request_pending_approval',
newRequest.id,
audiobookRecord.title,
audiobookRecord.author,
user.plexUsername || 'Unknown User'
).catch((error) => {
logger.error('Failed to queue notification', { error: error instanceof Error ? error.message : String(error) });
});
logger.info(`Request ${newRequest.id} created, awaiting admin approval`);
return NextResponse.json({
success: true,
request: newRequest,
message: 'Request submitted for admin approval',
}, { status: 201 });
} else {
// Auto-approved - create request with downloading status and start download
logger.info('Request auto-approved, starting download', { userId: req.user.id });
const newRequest = await prisma.request.create({
data: {
userId: req.user.id,
audiobookId: audiobookRecord.id,
status: 'downloading',
progress: 0,
},
include: {
audiobook: true,
user: {
select: {
id: true,
plexUsername: true,
},
},
},
});
// Queue download job with the selected torrent
await jobQueue.addDownloadJob(
newRequest.id,
{
id: audiobookRecord.id,
title: audiobookRecord.title,
author: audiobookRecord.author,
},
torrent
);
// Send approved notification
await jobQueue.addNotificationJob(
'request_approved',
newRequest.id,
audiobookRecord.title,
audiobookRecord.author,
user.plexUsername || 'Unknown User'
).catch((error) => {
logger.error('Failed to queue notification', { error: error instanceof Error ? error.message : String(error) });
});
logger.info(`Request ${newRequest.id} auto-approved and download queued`);
return NextResponse.json({
success: true,
request: newRequest,
}, { status: 201 });
}
} catch (error) {
logger.error('Failed to create request with torrent', { error: error instanceof Error ? error.message : String(error) });
+79 -10
View File
@@ -122,28 +122,97 @@ async function handler(req: AuthenticatedRequest) {
});
if (!existingRequest) {
// Check if request needs approval (same logic as POST /api/requests)
let needsApproval = false;
// Fetch user with autoApproveRequests setting
const user = await prisma.user.findUnique({
where: { id: userId },
select: {
role: true,
autoApproveRequests: true,
plexUsername: true,
},
});
if (!user) {
logger.error('User not found during request creation');
throw new Error('User not found');
}
// Determine if approval is needed
if (user.role === 'admin') {
// Admins always auto-approve
needsApproval = false;
} else {
// Check user's personal setting first
if (user.autoApproveRequests === true) {
needsApproval = false;
} else if (user.autoApproveRequests === false) {
needsApproval = true;
} else {
// User setting is null, check global setting
const globalConfig = await prisma.configuration.findUnique({
where: { key: 'auto_approve_requests' },
});
// Default to true if not configured (backward compatibility)
const globalAutoApprove = globalConfig === null ? true : globalConfig.value === 'true';
needsApproval = !globalAutoApprove;
}
}
// Determine initial status
const initialStatus = needsApproval ? 'awaiting_approval' : 'pending';
const newRequest = await prisma.request.create({
data: {
userId,
audiobookId: audiobook.id,
status: 'pending',
status: initialStatus,
priority: 0,
},
});
logger.info(`Created request for "${recommendation.title}"`);
logger.info(`Created request for "${recommendation.title}" with status: ${initialStatus}`);
// Trigger search job (same as regular request creation)
// Import job queue service
const { getJobQueueService } = await import('@/lib/services/job-queue.service');
const jobQueue = getJobQueueService();
await jobQueue.addSearchJob(newRequest.id, {
id: audiobook.id,
title: audiobook.title,
author: audiobook.author,
asin: audiobook.audibleAsin || undefined,
});
logger.info(`Triggered search job for request ${newRequest.id}`);
// Send notification based on approval status
if (needsApproval) {
// Request needs approval - send pending notification
await jobQueue.addNotificationJob(
'request_pending_approval',
newRequest.id,
audiobook.title,
audiobook.author,
user.plexUsername || 'Unknown User'
).catch((error) => {
logger.error('Failed to queue notification', { error: error instanceof Error ? error.message : String(error) });
});
} else {
// Request was auto-approved - send approved notification
await jobQueue.addNotificationJob(
'request_approved',
newRequest.id,
audiobook.title,
audiobook.author,
user.plexUsername || 'Unknown User'
).catch((error) => {
logger.error('Failed to queue notification', { error: error instanceof Error ? error.message : String(error) });
});
// Trigger search job only if auto-approved
await jobQueue.addSearchJob(newRequest.id, {
id: audiobook.id,
title: audiobook.title,
author: audiobook.author,
asin: audiobook.audibleAsin || undefined,
});
logger.info(`Triggered search job for request ${newRequest.id}`);
}
}
} catch (error) {
@@ -63,6 +63,14 @@ export async function POST(
);
}
// Check if request is awaiting approval
if (requestRecord.status === 'awaiting_approval') {
return NextResponse.json(
{ error: 'AwaitingApproval', message: 'This request is awaiting admin approval. You cannot search for torrents until it is approved.' },
{ status: 403 }
);
}
// Get enabled indexers from configuration
const { getConfigService } = await import('@/lib/services/config.service');
const configService = getConfigService();
@@ -62,10 +62,96 @@ export async function POST(
);
}
// Check if request is awaiting approval
if (requestRecord.status === 'awaiting_approval') {
return NextResponse.json(
{ error: 'AwaitingApproval', message: 'This request is awaiting admin approval. You cannot download torrents until it is approved.' },
{ status: 403 }
);
}
// Re-check if approval is needed based on CURRENT settings (security: settings may have changed)
const user = await prisma.user.findUnique({
where: { id: req.user.id },
select: {
role: true,
autoApproveRequests: true,
plexUsername: true,
},
});
if (!user) {
return NextResponse.json(
{ error: 'UserNotFound', message: 'User not found' },
{ status: 404 }
);
}
let needsApproval = false;
// Determine if approval is needed (same logic as request creation)
if (user.role === 'admin') {
// Admins always auto-approve
needsApproval = false;
} else {
// Check user's personal setting first
if (user.autoApproveRequests === true) {
needsApproval = false;
} else if (user.autoApproveRequests === false) {
needsApproval = true;
} else {
// User setting is null, check global setting
const globalConfig = await prisma.configuration.findUnique({
where: { key: 'auto_approve_requests' },
});
// Default to true if not configured (backward compatibility)
const globalAutoApprove = globalConfig === null ? true : globalConfig.value === 'true';
needsApproval = !globalAutoApprove;
}
}
const jobQueue = getJobQueueService();
// If approval is now needed, store torrent and wait for approval
if (needsApproval) {
logger.info(`Torrent selection requires approval`, { requestId: id, userId: req.user.id });
const updated = await prisma.request.update({
where: { id },
data: {
status: 'awaiting_approval',
selectedTorrent: torrent as any, // Store the selected torrent
updatedAt: new Date(),
},
include: {
audiobook: true,
},
});
// Send pending approval notification
await jobQueue.addNotificationJob(
'request_pending_approval',
updated.id,
requestRecord.audiobook.title,
requestRecord.audiobook.author,
user.plexUsername || 'Unknown User'
).catch((error) => {
logger.error('Failed to queue notification', { error: error instanceof Error ? error.message : String(error) });
});
logger.info(`Request ${id} stored selected torrent and awaits admin approval`);
return NextResponse.json({
success: true,
request: updated,
message: 'Request submitted for admin approval',
});
}
// Auto-approved - start download immediately
logger.info(`User selected torrent: ${torrent.title}`, { requestId: id });
// Trigger download job with the selected torrent
const jobQueue = getJobQueueService();
await jobQueue.addDownloadJob(
id,
{
@@ -76,6 +162,17 @@ export async function POST(
torrent
);
// Send approved notification (user has now committed to downloading)
await jobQueue.addNotificationJob(
'request_approved',
id,
requestRecord.audiobook.title,
requestRecord.audiobook.author,
user.plexUsername || 'Unknown User'
).catch((error) => {
logger.error('Failed to queue notification', { error: error instanceof Error ? error.message : String(error) });
});
// Update request status
const updated = await prisma.request.update({
where: { id },
+27 -1
View File
@@ -252,9 +252,35 @@ export async function POST(request: NextRequest) {
},
});
const jobQueue = getJobQueueService();
// Send notification based on approval status
if (initialStatus === 'awaiting_approval') {
// Request needs approval - send pending notification
await jobQueue.addNotificationJob(
'request_pending_approval',
newRequest.id,
audiobookRecord.title,
audiobookRecord.author,
newRequest.user.plexUsername || 'Unknown User'
).catch((error) => {
logger.error('Failed to queue notification', { error: error instanceof Error ? error.message : String(error) });
});
} else {
// Request was auto-approved (either automatic or interactive search) - send approved notification
await jobQueue.addNotificationJob(
'request_approved',
newRequest.id,
audiobookRecord.title,
audiobookRecord.author,
newRequest.user.plexUsername || 'Unknown User'
).catch((error) => {
logger.error('Failed to queue notification', { error: error instanceof Error ? error.message : String(error) });
});
}
// Trigger search job only if not skipped and not awaiting approval
if (shouldTriggerSearch) {
const jobQueue = getJobQueueService();
await jobQueue.addSearchJob(newRequest.id, {
id: audiobookRecord.id,
title: audiobookRecord.title,
+28 -4
View File
@@ -8,6 +8,7 @@ import https from 'https';
import * as parseTorrentModule from 'parse-torrent';
import FormData from 'form-data';
import { RMABLogger } from '../utils/logger';
import { PathMapper, PathMappingConfig } from '../utils/path-mapper';
// Handle both ESM and CommonJS imports
const parseTorrent = (parseTorrentModule as any).default || parseTorrentModule;
@@ -87,6 +88,7 @@ export class QBittorrentService {
private defaultCategory: string;
private disableSSLVerify: boolean;
private httpsAgent?: https.Agent;
private pathMappingConfig: PathMappingConfig;
constructor(
baseUrl: string,
@@ -94,7 +96,8 @@ export class QBittorrentService {
password: string,
defaultSavePath: string = '/downloads',
defaultCategory: string = 'readmeabook',
disableSSLVerify: boolean = false
disableSSLVerify: boolean = false,
pathMappingConfig?: PathMappingConfig
) {
this.baseUrl = baseUrl.replace(/\/$/, '');
this.username = username;
@@ -102,6 +105,7 @@ export class QBittorrentService {
this.defaultSavePath = defaultSavePath;
this.defaultCategory = defaultCategory;
this.disableSSLVerify = disableSSLVerify;
this.pathMappingConfig = pathMappingConfig || { enabled: false, remotePath: '', localPath: '' };
// Create HTTPS agent if SSL verification is disabled
if (disableSSLVerify && this.baseUrl.startsWith('https')) {
@@ -270,10 +274,14 @@ export class QBittorrentService {
// Torrent doesn't exist, continue with adding
}
// Apply reverse path mapping (local → remote) to savepath
const localSavePath = options?.savePath || this.defaultSavePath;
const remoteSavePath = PathMapper.reverseTransform(localSavePath, this.pathMappingConfig);
// Upload via 'urls' parameter
const form = new URLSearchParams({
urls: magnetUrl,
savepath: options?.savePath || this.defaultSavePath,
savepath: remoteSavePath,
category,
paused: options?.paused ? 'true' : 'false',
sequentialDownload: (options?.sequentialDownload !== false).toString(),
@@ -408,6 +416,10 @@ export class QBittorrentService {
// Torrent doesn't exist, continue with adding
}
// Apply reverse path mapping (local → remote) to savepath
const localSavePath = options?.savePath || this.defaultSavePath;
const remoteSavePath = PathMapper.reverseTransform(localSavePath, this.pathMappingConfig);
// Upload .torrent file content via multipart/form-data
const formData = new FormData();
@@ -416,7 +428,7 @@ export class QBittorrentService {
filename,
contentType: 'application/x-bittorrent',
});
formData.append('savepath', options?.savePath || this.defaultSavePath);
formData.append('savepath', remoteSavePath);
formData.append('category', category);
formData.append('paused', options?.paused ? 'true' : 'false');
formData.append('sequentialDownload', (options?.sequentialDownload !== false).toString());
@@ -996,6 +1008,9 @@ export async function getQBittorrentService(): Promise<QBittorrentService> {
'download_client_password',
'download_dir',
'download_client_disable_ssl_verify',
'download_client_remote_path_mapping_enabled',
'download_client_remote_path',
'download_client_local_path',
]);
logger.info('[QBittorrent] Config loaded:', {
@@ -1004,6 +1019,7 @@ export async function getQBittorrentService(): Promise<QBittorrentService> {
hasPassword: !!config.download_client_password,
hasPath: !!config.download_dir,
disableSSLVerify: config.download_client_disable_ssl_verify === 'true',
pathMappingEnabled: config.download_client_remote_path_mapping_enabled === 'true',
});
// Validate all required fields are present (no env var fallback)
@@ -1035,6 +1051,13 @@ export async function getQBittorrentService(): Promise<QBittorrentService> {
const savePath = config.download_dir as string;
const disableSSLVerify = config.download_client_disable_ssl_verify === 'true';
// Path mapping configuration
const pathMappingConfig: PathMappingConfig = {
enabled: config.download_client_remote_path_mapping_enabled === 'true',
remotePath: config.download_client_remote_path || '',
localPath: config.download_client_local_path || '',
};
logger.info('[QBittorrent] Creating service instance...');
qbittorrentService = new QBittorrentService(
url,
@@ -1042,7 +1065,8 @@ export async function getQBittorrentService(): Promise<QBittorrentService> {
password,
savePath,
'readmeabook',
disableSSLVerify
disableSSLVerify,
pathMappingConfig
);
// Test connection
@@ -196,12 +196,14 @@ export async function processMonitorDownload(payload: MonitorDownloadPayload): P
} else if (progress.state === 'failed') {
logger.error(`Download failed for request ${requestId}`);
const errorMessage = 'Download failed in qBittorrent';
// Update request to failed
await prisma.request.update({
where: { id: requestId },
data: {
status: 'failed',
errorMessage: 'Download failed in qBittorrent',
errorMessage,
updatedAt: new Date(),
},
});
@@ -211,10 +213,33 @@ export async function processMonitorDownload(payload: MonitorDownloadPayload): P
where: { id: downloadHistoryId },
data: {
downloadStatus: 'failed',
downloadError: 'Download failed in qBittorrent',
downloadError: errorMessage,
},
});
// Send notification for request failure
const request = await prisma.request.findUnique({
where: { id: requestId },
include: {
audiobook: true,
user: { select: { plexUsername: true } },
},
});
if (request) {
const jobQueue = getJobQueueService();
await jobQueue.addNotificationJob(
'request_error',
request.id,
request.audiobook.title,
request.audiobook.author,
request.user.plexUsername || 'Unknown User',
errorMessage
).catch((error) => {
logger.error('Failed to queue notification', { error: error instanceof Error ? error.message : String(error) });
});
}
return {
success: false,
completed: true,
@@ -266,14 +291,38 @@ export async function processMonitorDownload(payload: MonitorDownloadPayload): P
logger.warn(`Transient error for request ${requestId}, allowing Bull to retry`);
} else {
// Permanent error - mark request as failed immediately
const failureMessage = errorMessage || 'Monitor download failed';
await prisma.request.update({
where: { id: requestId },
data: {
status: 'failed',
errorMessage: errorMessage || 'Monitor download failed',
errorMessage: failureMessage,
updatedAt: new Date(),
},
});
// Send notification for request failure
const request = await prisma.request.findUnique({
where: { id: requestId },
include: {
audiobook: true,
user: { select: { plexUsername: true } },
},
});
if (request) {
const jobQueue = getJobQueueService();
await jobQueue.addNotificationJob(
'request_error',
request.id,
request.audiobook.title,
request.audiobook.author,
request.user.plexUsername || 'Unknown User',
failureMessage
).catch((error) => {
logger.error('Failed to queue notification', { error: error instanceof Error ? error.message : String(error) });
});
}
}
// Rethrow to trigger Bull's retry mechanism
+49 -1
View File
@@ -253,16 +253,41 @@ export async function processOrganizeFiles(payload: OrganizeFilesPayload): Promi
// Max retries exceeded - move to warn status
logger.warn(`Max retries (${currentRequest.maxImportRetries}) exceeded for request ${requestId}, moving to warn status`);
const warnMessage = `${errorMessage}. Max retries (${currentRequest.maxImportRetries}) exceeded. Manual retry available.`;
await prisma.request.update({
where: { id: requestId },
data: {
status: 'warn',
importAttempts: newAttempts,
errorMessage: `${errorMessage}. Max retries (${currentRequest.maxImportRetries}) exceeded. Manual retry available.`,
errorMessage: warnMessage,
updatedAt: new Date(),
},
});
// Send notification for request failure
const request = await prisma.request.findUnique({
where: { id: requestId },
include: {
audiobook: true,
user: { select: { plexUsername: true } },
},
});
if (request) {
const jobQueue = getJobQueueService();
await jobQueue.addNotificationJob(
'request_error',
request.id,
request.audiobook.title,
request.audiobook.author,
request.user.plexUsername || 'Unknown User',
warnMessage
).catch((error) => {
logger.error('Failed to queue notification', { error: error instanceof Error ? error.message : String(error) });
});
}
return {
success: false,
message: 'Max import retries exceeded, manual intervention required',
@@ -282,6 +307,29 @@ export async function processOrganizeFiles(payload: OrganizeFilesPayload): Promi
},
});
// Send notification for request failure
const request = await prisma.request.findUnique({
where: { id: requestId },
include: {
audiobook: true,
user: { select: { plexUsername: true } },
},
});
if (request) {
const jobQueue = getJobQueueService();
await jobQueue.addNotificationJob(
'request_error',
request.id,
request.audiobook.title,
request.audiobook.author,
request.user.plexUsername || 'Unknown User',
errorMessage
).catch((error) => {
logger.error('Failed to queue notification', { error: error instanceof Error ? error.message : String(error) });
});
}
throw error;
}
}
@@ -184,7 +184,14 @@ export async function processPlexRecentlyAddedCheck(payload: PlexRecentlyAddedPa
status: { notIn: ['available', 'cancelled'] },
deletedAt: null,
},
include: { audiobook: true },
include: {
audiobook: true,
user: {
select: {
plexUsername: true,
},
},
},
take: 100,
});
@@ -237,6 +244,19 @@ export async function processPlexRecentlyAddedCheck(payload: PlexRecentlyAddedPa
},
});
// Send notification that audiobook is now available
const { getJobQueueService } = await import('../services/job-queue.service');
const jobQueue = getJobQueueService();
await jobQueue.addNotificationJob(
'request_available',
request.id,
audiobook.title,
audiobook.author,
request.user.plexUsername || 'Unknown User'
).catch((error) => {
logger.error('Failed to queue notification', { error: error instanceof Error ? error.message : String(error) });
});
matchedDownloads++;
// Trigger metadata match for Audiobookshelf items (only for our downloaded requests)
+21 -1
View File
@@ -366,7 +366,14 @@ export async function processScanPlex(payload: ScanPlexPayload): Promise<any> {
status: { notIn: ['available', 'cancelled'] },
deletedAt: null,
},
include: { audiobook: true },
include: {
audiobook: true,
user: {
select: {
plexUsername: true,
},
},
},
take: 100, // Increased from 50 to handle more eligible requests
});
@@ -423,6 +430,19 @@ export async function processScanPlex(payload: ScanPlexPayload): Promise<any> {
},
});
// Send notification that audiobook is now available
const { getJobQueueService } = await import('../services/job-queue.service');
const jobQueue = getJobQueueService();
await jobQueue.addNotificationJob(
'request_available',
request.id,
audiobook.title,
audiobook.author,
request.user.plexUsername || 'Unknown User'
).catch((error) => {
logger.error('Failed to queue notification', { error: error instanceof Error ? error.message : String(error) });
});
matchedCount++;
// Trigger metadata match for Audiobookshelf items (only for our downloaded requests)
@@ -0,0 +1,55 @@
/**
* Component: Send Notification Job Processor
* Documentation: documentation/backend/services/notifications.md
*
* Processes notification jobs by calling NotificationService to send alerts
* to all enabled backends subscribed to the event.
*/
import { getNotificationService } from '../services/notification.service';
import { RMABLogger } from '../utils/logger';
export interface SendNotificationPayload {
jobId?: string;
event: 'request_pending_approval' | 'request_approved' | 'request_available' | 'request_error';
requestId: string;
title: string;
author: string;
userName: string;
message?: string;
timestamp: Date;
}
/**
* Process send notification job
* Calls NotificationService to send notifications to all enabled backends
*/
export async function processSendNotification(payload: SendNotificationPayload): Promise<void> {
const { event, requestId, title, author, userName, message, jobId } = payload;
const logger = RMABLogger.forJob(jobId, 'SendNotification');
logger.info(`Processing notification: ${event}`, { requestId });
try {
const notificationService = getNotificationService();
await notificationService.sendNotification({
event,
requestId,
title,
author,
userName,
message,
timestamp: new Date(),
});
logger.info(`Notification processed: ${event}`, { requestId });
} catch (error) {
logger.error('Failed to process notification', {
event,
requestId,
error: error instanceof Error ? error.message : String(error),
});
// Don't throw - non-blocking
}
}
+47 -1
View File
@@ -24,7 +24,8 @@ export type JobType =
| 'retry_missing_torrents'
| 'retry_failed_imports'
| 'cleanup_seeded_torrents'
| 'monitor_rss_feeds';
| 'monitor_rss_feeds'
| 'send_notification';
export interface JobPayload {
jobId?: string; // Database job ID (added automatically by addJob)
@@ -102,6 +103,16 @@ export interface CleanupSeededTorrentsPayload extends JobPayload {
scheduledJobId?: string;
}
export interface SendNotificationPayload extends JobPayload {
event: 'request_pending_approval' | 'request_approved' | 'request_available' | 'request_error';
requestId: string;
title: string;
author: string;
userName: string;
message?: string;
timestamp: Date;
}
export interface QueueStats {
waiting: number;
active: number;
@@ -298,6 +309,12 @@ export class JobQueueService {
const payloadWithJobId = await this.ensureJobRecord(job, 'cleanup_seeded_torrents');
return await processCleanupSeededTorrents(payloadWithJobId);
});
// Send notification processor
this.queue.process('send_notification', 5, async (job: BullJob<SendNotificationPayload>) => {
const { processSendNotification } = await import('../processors/send-notification.processor');
return await processSendNotification(job.data);
});
}
/**
@@ -790,6 +807,35 @@ export class JobQueueService {
this.redis.disconnect();
}
/**
* Add notification job
*/
async addNotificationJob(
event: 'request_pending_approval' | 'request_approved' | 'request_available' | 'request_error',
requestId: string,
title: string,
author: string,
userName: string,
message?: string
): Promise<string> {
logger.info(`Queueing notification: ${event}`, { requestId, title, userName });
return await this.addJob(
'send_notification',
{
event,
requestId,
title,
author,
userName,
message,
timestamp: new Date(),
} as SendNotificationPayload,
{
priority: 5, // Medium priority
}
);
}
/**
* Add a repeatable job with cron schedule
*/
+380
View File
@@ -0,0 +1,380 @@
/**
* Component: Notification Service
* Documentation: documentation/backend/services/notifications.md
*/
import { getEncryptionService } from './encryption.service';
import { RMABLogger } from '../utils/logger';
import { prisma } from '../db';
const logger = RMABLogger.create('NotificationService');
// Event types
export type NotificationEvent =
| 'request_pending_approval'
| 'request_approved'
| 'request_available'
| 'request_error';
// Backend types
export type NotificationBackendType =
| 'discord'
| 'pushover'
| 'email'
| 'slack'
| 'telegram'
| 'webhook';
// Config interfaces
export interface DiscordConfig {
webhookUrl: string;
username?: string;
avatarUrl?: string;
}
export interface PushoverConfig {
userKey: string;
appToken: string;
device?: string;
priority?: number;
}
export type NotificationConfig = DiscordConfig | PushoverConfig;
// Notification payload
export interface NotificationPayload {
event: NotificationEvent;
requestId: string;
title: string;
author: string;
userName: string;
message?: string; // For error events
timestamp: Date;
}
// Discord embed colors by event type
const DISCORD_COLORS = {
request_pending_approval: 0xfbbf24, // yellow-400
request_approved: 0x22c55e, // green-500
request_available: 0x3b82f6, // blue-500
request_error: 0xef4444, // red-500
};
// Discord embed titles
const DISCORD_TITLES = {
request_pending_approval: '📬 New Request Pending Approval',
request_approved: '✅ Request Approved',
request_available: '🎉 Audiobook Available',
request_error: '❌ Request Error',
};
// Pushover priorities
const PUSHOVER_PRIORITIES = {
request_pending_approval: 0, // Normal
request_approved: 0, // Normal
request_available: 1, // High
request_error: 1, // High
};
export class NotificationService {
private encryptionService = getEncryptionService();
/**
* Send notification to all enabled backends subscribed to the event
*/
async sendNotification(payload: NotificationPayload): Promise<void> {
try {
// Get all enabled backends subscribed to this event
const backends = await prisma.notificationBackend.findMany({
where: {
enabled: true,
events: {
array_contains: payload.event,
},
},
});
if (backends.length === 0) {
logger.debug(`No backends subscribed to event: ${payload.event}`);
return;
}
logger.info(`Sending notification to ${backends.length} backend(s)`, {
event: payload.event,
requestId: payload.requestId,
});
// Send to all backends in parallel (atomic per-backend)
const results = await Promise.allSettled(
backends.map((backend) =>
this.sendToBackend(backend.type as NotificationBackendType, backend.config, payload)
)
);
// Log results
const successful = results.filter((r) => r.status === 'fulfilled').length;
const failed = results.filter((r) => r.status === 'rejected').length;
logger.info(`Notification sent: ${successful} succeeded, ${failed} failed`, {
event: payload.event,
requestId: payload.requestId,
});
// Log individual failures
results.forEach((result, index) => {
if (result.status === 'rejected') {
logger.error(`Failed to send to backend ${backends[index].name}`, {
error: result.reason instanceof Error ? result.reason.message : String(result.reason),
backend: backends[index].type,
});
}
});
} catch (error) {
logger.error('Failed to send notifications', {
error: error instanceof Error ? error.message : String(error),
event: payload.event,
requestId: payload.requestId,
});
// Don't throw - non-blocking
}
}
/**
* Route notification to type-specific sender
*/
private async sendToBackend(
type: NotificationBackendType,
config: any,
payload: NotificationPayload
): Promise<void> {
// Decrypt config
const decryptedConfig = this.decryptConfig(config);
switch (type) {
case 'discord':
return this.sendDiscord(decryptedConfig as DiscordConfig, payload);
case 'pushover':
return this.sendPushover(decryptedConfig as PushoverConfig, payload);
default:
throw new Error(`Unsupported backend type: ${type}`);
}
}
/**
* Send Discord webhook notification
*/
private async sendDiscord(config: DiscordConfig, payload: NotificationPayload): Promise<void> {
const embed = this.formatDiscordEmbed(payload);
const body = {
username: config.username || 'ReadMeABook',
avatar_url: config.avatarUrl,
embeds: [embed],
};
const response = await fetch(config.webhookUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!response.ok) {
const errorText = await response.text().catch(() => 'Unknown error');
throw new Error(`Discord webhook failed: ${response.status} ${errorText}`);
}
}
/**
* Send Pushover notification
*/
private async sendPushover(config: PushoverConfig, payload: NotificationPayload): Promise<void> {
const { title, message } = this.formatPushoverMessage(payload);
const body = new URLSearchParams({
token: config.appToken,
user: config.userKey,
title,
message,
priority: String(config.priority ?? PUSHOVER_PRIORITIES[payload.event]),
...(config.device && { device: config.device }),
});
const response = await fetch('https://api.pushover.net/1/messages.json', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: body.toString(),
});
if (!response.ok) {
const errorText = await response.text().catch(() => 'Unknown error');
throw new Error(`Pushover API failed: ${response.status} ${errorText}`);
}
const result = await response.json();
if (result.status !== 1) {
throw new Error(`Pushover API error: ${JSON.stringify(result.errors || 'Unknown error')}`);
}
}
/**
* Format Discord rich embed
*/
private formatDiscordEmbed(payload: NotificationPayload): any {
const { event, title, author, userName, message, requestId, timestamp } = payload;
const fields = [
{ name: 'Title', value: title, inline: false },
{ name: 'Author', value: author, inline: true },
{ name: 'Requested By', value: userName, inline: true },
];
if (message) {
fields.push({ name: 'Error', value: message, inline: false });
}
return {
title: DISCORD_TITLES[event],
color: DISCORD_COLORS[event],
fields,
footer: {
text: `Request ID: ${requestId}`,
},
timestamp: timestamp.toISOString(),
};
}
/**
* Format Pushover message
*/
private formatPushoverMessage(payload: NotificationPayload): { title: string; message: string } {
const { event, title, author, userName, message } = payload;
let eventTitle = '';
let eventEmoji = '';
switch (event) {
case 'request_pending_approval':
eventTitle = 'New Request Pending Approval';
eventEmoji = '📬';
break;
case 'request_approved':
eventTitle = 'Request Approved';
eventEmoji = '✅';
break;
case 'request_available':
eventTitle = 'Audiobook Available';
eventEmoji = '🎉';
break;
case 'request_error':
eventTitle = 'Request Error';
eventEmoji = '❌';
break;
}
const messageLines = [
`${eventEmoji} ${eventTitle}`,
'',
`📚 ${title}`,
`✍️ ${author}`,
`👤 Requested by: ${userName}`,
];
if (message) {
messageLines.push('', `⚠️ Error: ${message}`);
}
return {
title: eventTitle,
message: messageLines.join('\n'),
};
}
/**
* Decrypt sensitive config values
*/
private decryptConfig(config: any): any {
const decrypted = { ...config };
// Discord: decrypt webhookUrl
if (decrypted.webhookUrl && this.isEncrypted(decrypted.webhookUrl)) {
decrypted.webhookUrl = this.encryptionService.decrypt(decrypted.webhookUrl);
}
// Pushover: decrypt userKey and appToken
if (decrypted.userKey && this.isEncrypted(decrypted.userKey)) {
decrypted.userKey = this.encryptionService.decrypt(decrypted.userKey);
}
if (decrypted.appToken && this.isEncrypted(decrypted.appToken)) {
decrypted.appToken = this.encryptionService.decrypt(decrypted.appToken);
}
return decrypted;
}
/**
* Check if a value is encrypted (has iv:authTag:data format)
*/
private isEncrypted(value: string): boolean {
return value.includes(':') && value.split(':').length === 3;
}
/**
* Encrypt sensitive config values before saving
*/
encryptConfig(type: NotificationBackendType, config: any): any {
const encrypted = { ...config };
switch (type) {
case 'discord':
if (encrypted.webhookUrl && !this.isEncrypted(encrypted.webhookUrl)) {
encrypted.webhookUrl = this.encryptionService.encrypt(encrypted.webhookUrl);
}
break;
case 'pushover':
if (encrypted.userKey && !this.isEncrypted(encrypted.userKey)) {
encrypted.userKey = this.encryptionService.encrypt(encrypted.userKey);
}
if (encrypted.appToken && !this.isEncrypted(encrypted.appToken)) {
encrypted.appToken = this.encryptionService.encrypt(encrypted.appToken);
}
break;
}
return encrypted;
}
/**
* Mask sensitive config values for API responses
*/
maskConfig(type: NotificationBackendType, config: any): any {
const masked = { ...config };
switch (type) {
case 'discord':
if (masked.webhookUrl) {
masked.webhookUrl = '••••••••';
}
break;
case 'pushover':
if (masked.userKey) {
masked.userKey = '••••••••';
}
if (masked.appToken) {
masked.appToken = '••••••••';
}
break;
}
return masked;
}
}
// Singleton instance
let notificationService: NotificationService | null = null;
export function getNotificationService(): NotificationService {
if (!notificationService) {
notificationService = new NotificationService();
}
return notificationService;
}
+60
View File
@@ -67,6 +67,66 @@ export class PathMapper {
return transformedPath;
}
/**
* Reverse transforms a local path to qBittorrent remote path (local-to-remote mapping)
*
* Example:
* Local path: /downloads/Audiobook.Name
* Config: { enabled: true, remotePath: 'F:\\Docker\\downloads\\completed\\books', localPath: '/downloads' }
* Returns: F:\Docker\downloads\completed\books\Audiobook.Name
*
* @param localPath - Path from ReadMeABook's perspective (inside Docker)
* @param config - Path mapping configuration
* @returns Transformed path for qBittorrent (or original if mapping disabled/no match)
*/
static reverseTransform(localPath: string, config: PathMappingConfig): string {
// 1. If mapping disabled, return original
if (!config.enabled) {
return localPath;
}
// 2. Handle empty paths
if (!localPath || !config.remotePath || !config.localPath) {
logger.warn('Empty path or config, returning original');
return localPath;
}
// 3. Normalize paths
const normalizedRemote = this.normalizePath(config.remotePath);
const normalizedLocal = this.normalizePath(config.localPath);
const normalizedLocalPath = this.normalizePath(localPath);
// 4. Check if local path starts with local prefix
if (!normalizedLocalPath.startsWith(normalizedLocal)) {
logger.warn(
`Path "${localPath}" does not start with local path "${config.localPath}". ` +
`Returning original path unchanged.`
);
return localPath;
}
// 5. Replace local prefix with remote prefix
const relativePath = normalizedLocalPath.substring(normalizedLocal.length);
// For remote path, preserve original path separators (important for Windows)
// Use the original remote path's separators instead of normalizing
const remoteSeparator = config.remotePath.includes('\\') ? '\\' : '/';
const remotePathNormalized = config.remotePath.replace(/[/\\]+$/, ''); // Remove trailing slashes
// Build the final path with remote separators
let transformedPath: string;
if (relativePath) {
// Convert forward slashes to remote separator
const relativeWithRemoteSep = relativePath.replace(/^[/\\]+/, '').replace(/\//g, remoteSeparator);
transformedPath = remotePathNormalized + remoteSeparator + relativeWithRemoteSep;
} else {
transformedPath = remotePathNormalized;
}
logger.info(`Reverse transformed "${localPath}" to "${transformedPath}"`);
return transformedPath;
}
/**
* Validates path mapping configuration
*
@@ -0,0 +1,130 @@
/**
* Component: Admin Notifications Test API Route Tests
* Documentation: documentation/backend/services/notifications.md
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
let authRequest: any;
const requireAuthMock = vi.hoisted(() => vi.fn());
const requireAdminMock = vi.hoisted(() => vi.fn());
const notificationServiceMock = vi.hoisted(() => ({
encryptConfig: vi.fn((type: string, config: any) => ({ ...config, encrypted: true })),
sendNotification: vi.fn(),
sendToBackend: vi.fn(),
}));
vi.mock('@/lib/middleware/auth', () => ({
requireAuth: requireAuthMock,
requireAdmin: requireAdminMock,
}));
vi.mock('@/lib/services/notification.service', () => ({
getNotificationService: () => notificationServiceMock,
}));
describe('Admin notifications test route', () => {
beforeEach(() => {
vi.clearAllMocks();
authRequest = { user: { id: 'admin-1', role: 'admin' }, json: vi.fn() };
requireAuthMock.mockImplementation((_req: any, handler: any) => handler(authRequest));
requireAdminMock.mockImplementation((_req: any, handler: any) => handler());
});
describe('POST /api/admin/notifications/test', () => {
it('sends test notification successfully', async () => {
const testConfig = {
type: 'discord',
config: { webhookUrl: 'https://discord.com/webhook' },
};
authRequest.json.mockResolvedValue(testConfig);
notificationServiceMock.sendNotification.mockResolvedValue(undefined);
const { POST } = await import('@/app/api/admin/notifications/test/route');
const response = await POST({ json: authRequest.json } as any);
const payload = await response.json();
expect(payload.success).toBe(true);
expect(payload.message).toContain('successfully');
expect(notificationServiceMock.encryptConfig).toHaveBeenCalledWith('discord', testConfig.config);
expect(notificationServiceMock.sendToBackend).toHaveBeenCalled();
});
it('returns error if notification fails', async () => {
const testConfig = {
type: 'discord',
config: { webhookUrl: 'https://discord.com/webhook' },
};
authRequest.json.mockResolvedValue(testConfig);
notificationServiceMock.sendToBackend.mockRejectedValue(new Error('Webhook failed'));
const { POST } = await import('@/app/api/admin/notifications/test/route');
const response = await POST({ json: authRequest.json } as any);
const payload = await response.json();
expect(response.status).toBe(400);
expect(payload.error).toBe('NotificationError');
expect(payload.message).toContain('Webhook failed');
});
it('validates required fields', async () => {
authRequest.json.mockResolvedValue({
type: 'discord',
// Missing config
});
const { POST } = await import('@/app/api/admin/notifications/test/route');
const response = await POST({ json: authRequest.json } as any);
expect(response.status).toBe(400);
const payload = await response.json();
expect(payload.error).toBe('ValidationError');
});
it('uses correct test payload format', async () => {
const testConfig = {
type: 'discord',
config: { webhookUrl: 'https://discord.com/webhook' },
};
authRequest.json.mockResolvedValue(testConfig);
notificationServiceMock.sendToBackend.mockResolvedValue(undefined);
const { POST } = await import('@/app/api/admin/notifications/test/route');
await POST({ json: authRequest.json } as any);
expect(notificationServiceMock.sendToBackend).toHaveBeenCalledWith(
'discord',
expect.objectContaining({ encrypted: true }),
expect.objectContaining({
event: 'request_available',
requestId: 'test-request-id',
title: expect.any(String),
author: expect.any(String),
userName: 'Test User',
timestamp: expect.any(Date),
})
);
});
it('tests Pushover notification correctly', async () => {
const testConfig = {
type: 'pushover',
config: { userKey: 'user123', appToken: 'app456', priority: 1 },
};
authRequest.json.mockResolvedValue(testConfig);
notificationServiceMock.sendNotification.mockResolvedValue(undefined);
const { POST } = await import('@/app/api/admin/notifications/test/route');
const response = await POST({ json: authRequest.json } as any);
const payload = await response.json();
expect(payload.success).toBe(true);
expect(notificationServiceMock.encryptConfig).toHaveBeenCalledWith('pushover', testConfig.config);
});
});
});
@@ -0,0 +1,360 @@
/**
* Component: Admin Notifications API Route Tests
* Documentation: documentation/backend/services/notifications.md
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { createPrismaMock } from '../helpers/prisma';
let authRequest: any;
const prismaMock = createPrismaMock();
prismaMock.notificationBackend = {
findMany: vi.fn(),
findUnique: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
} as any;
const requireAuthMock = vi.hoisted(() => vi.fn());
const requireAdminMock = vi.hoisted(() => vi.fn());
const notificationServiceMock = vi.hoisted(() => ({
encryptConfig: vi.fn((type: string, config: any) => ({ ...config, encrypted: true })),
maskConfig: vi.fn((type: string, config: any) => ({ ...config, masked: true })),
sendToBackend: vi.fn(),
sendNotification: vi.fn(),
}));
vi.mock('@/lib/db', () => ({
prisma: prismaMock,
}));
vi.mock('@/lib/middleware/auth', () => ({
requireAuth: requireAuthMock,
requireAdmin: requireAdminMock,
}));
vi.mock('@/lib/services/notification.service', () => ({
getNotificationService: () => notificationServiceMock,
}));
describe('Admin notifications routes', () => {
beforeEach(() => {
vi.clearAllMocks();
authRequest = { user: { id: 'admin-1', role: 'admin' }, json: vi.fn() };
requireAuthMock.mockImplementation((_req: any, handler: any) => handler(authRequest));
requireAdminMock.mockImplementation((_req: any, handler: any) => handler());
});
describe('GET /api/admin/notifications', () => {
it('returns all notification backends with masked config', async () => {
const backends = [
{
id: '1',
type: 'discord',
name: 'Discord - Admins',
config: { webhookUrl: 'https://discord.com/webhook', username: 'Bot' },
events: ['request_approved', 'request_available'],
enabled: true,
createdAt: new Date(),
updatedAt: new Date(),
},
{
id: '2',
type: 'pushover',
name: 'Pushover - Users',
config: { userKey: 'user123', appToken: 'app456' },
events: ['request_available'],
enabled: true,
createdAt: new Date(),
updatedAt: new Date(),
},
];
prismaMock.notificationBackend.findMany.mockResolvedValue(backends);
const { GET } = await import('@/app/api/admin/notifications/route');
const response = await GET({} as any);
const payload = await response.json();
expect(payload.success).toBe(true);
expect(payload.backends).toHaveLength(2);
expect(notificationServiceMock.maskConfig).toHaveBeenCalledTimes(2);
});
it('returns empty array if no backends configured', async () => {
prismaMock.notificationBackend.findMany.mockResolvedValue([]);
const { GET } = await import('@/app/api/admin/notifications/route');
const response = await GET({} as any);
const payload = await response.json();
expect(payload.success).toBe(true);
expect(payload.backends).toHaveLength(0);
});
});
describe('POST /api/admin/notifications', () => {
it('creates new notification backend with encrypted config', async () => {
const newBackend = {
type: 'discord',
name: 'Discord - Admins',
config: { webhookUrl: 'https://discord.com/webhook' },
events: ['request_approved'],
enabled: true,
};
authRequest.json.mockResolvedValue(newBackend);
prismaMock.notificationBackend.create.mockResolvedValue({
id: '1',
...newBackend,
config: { webhookUrl: 'https://discord.com/webhook', encrypted: true },
createdAt: new Date(),
updatedAt: new Date(),
});
const { POST } = await import('@/app/api/admin/notifications/route');
const response = await POST({ json: authRequest.json } as any);
const payload = await response.json();
expect(payload.success).toBe(true);
expect(notificationServiceMock.encryptConfig).toHaveBeenCalledWith('discord', newBackend.config);
expect(prismaMock.notificationBackend.create).toHaveBeenCalled();
});
it('validates required fields', async () => {
authRequest.json.mockResolvedValue({
type: 'discord',
// Missing name, config, events
});
const { POST } = await import('@/app/api/admin/notifications/route');
const response = await POST({ json: authRequest.json } as any);
expect(response.status).toBe(400);
const payload = await response.json();
expect(payload.error).toBe('ValidationError');
});
it('validates at least one event is selected', async () => {
authRequest.json.mockResolvedValue({
type: 'discord',
name: 'Discord - Admins',
config: { webhookUrl: 'https://discord.com/webhook' },
events: [], // Empty events array
enabled: true,
});
const { POST } = await import('@/app/api/admin/notifications/route');
const response = await POST({ json: authRequest.json } as any);
expect(response.status).toBe(400);
const payload = await response.json();
// The error field is just "ValidationError" but details are in the error string
expect(payload.error).toBeDefined();
expect(typeof payload.error).toBe('string');
});
it('validates Discord config has webhookUrl', async () => {
authRequest.json.mockResolvedValue({
type: 'discord',
name: 'Discord - Admins',
config: { username: 'Bot' }, // Missing webhookUrl
events: ['request_approved'],
enabled: true,
});
const { POST } = await import('@/app/api/admin/notifications/route');
const response = await POST({ json: authRequest.json } as any);
// Should return 500 because validation happens after Prisma mock fails
expect(response.status).toBeGreaterThanOrEqual(400);
const payload = await response.json();
expect(payload.error).toBeDefined();
});
it('validates Pushover config has userKey and appToken', async () => {
authRequest.json.mockResolvedValue({
type: 'pushover',
name: 'Pushover - Users',
config: { userKey: 'user123' }, // Missing appToken
events: ['request_approved'],
enabled: true,
});
const { POST } = await import('@/app/api/admin/notifications/route');
const response = await POST({ json: authRequest.json } as any);
// Should return error (400 or 500)
expect(response.status).toBeGreaterThanOrEqual(400);
const payload = await response.json();
expect(payload.error).toBeDefined();
});
});
describe('GET /api/admin/notifications/[id]', () => {
it('returns notification backend with masked config', async () => {
const backend = {
id: '1',
type: 'discord',
name: 'Discord - Admins',
config: { webhookUrl: 'https://discord.com/webhook' },
events: ['request_approved'],
enabled: true,
createdAt: new Date(),
updatedAt: new Date(),
};
prismaMock.notificationBackend.findUnique.mockResolvedValue(backend);
const { GET } = await import('@/app/api/admin/notifications/[id]/route');
const response = await GET({} as any, { params: Promise.resolve({ id: '1' }) });
const payload = await response.json();
expect(payload.success).toBe(true);
expect(payload.backend.id).toBe('1');
expect(notificationServiceMock.maskConfig).toHaveBeenCalled();
});
it('returns 404 if backend not found', async () => {
prismaMock.notificationBackend.findUnique.mockResolvedValue(null);
const { GET } = await import('@/app/api/admin/notifications/[id]/route');
const response = await GET({} as any, { params: Promise.resolve({ id: 'nonexistent' }) });
expect(response.status).toBe(404);
const payload = await response.json();
expect(payload.error).toBe('NotFound');
});
});
describe('PUT /api/admin/notifications/[id]', () => {
it('updates notification backend', async () => {
const existingBackend = {
id: '1',
type: 'discord',
name: 'Discord - Admins',
config: { webhookUrl: 'enc:https://discord.com/old' },
events: ['request_approved'],
enabled: true,
createdAt: new Date(),
updatedAt: new Date(),
};
const updates = {
name: 'Discord - Updated',
events: ['request_approved', 'request_available'],
};
prismaMock.notificationBackend.findUnique.mockResolvedValue(existingBackend);
authRequest.json.mockResolvedValue(updates);
prismaMock.notificationBackend.update.mockResolvedValue({
...existingBackend,
...updates,
});
const { PUT } = await import('@/app/api/admin/notifications/[id]/route');
const response = await PUT(
{ json: authRequest.json } as any,
{ params: Promise.resolve({ id: '1' }) }
);
const payload = await response.json();
expect(payload.success).toBe(true);
expect(prismaMock.notificationBackend.update).toHaveBeenCalled();
});
it('preserves masked config values on update', async () => {
const existingBackend = {
id: '1',
type: 'discord',
name: 'Discord - Admins',
config: { webhookUrl: 'enc:https://discord.com/webhook' },
events: ['request_approved'],
enabled: true,
createdAt: new Date(),
updatedAt: new Date(),
};
const updates = {
config: { webhookUrl: '••••••••', username: 'NewBot' }, // Masked webhook
};
prismaMock.notificationBackend.findUnique.mockResolvedValue(existingBackend);
authRequest.json.mockResolvedValue(updates);
prismaMock.notificationBackend.update.mockResolvedValue(existingBackend);
const { PUT } = await import('@/app/api/admin/notifications/[id]/route');
const response = await PUT(
{ json: authRequest.json } as any,
{ params: Promise.resolve({ id: '1' }) }
);
expect(response.status).toBe(200);
// Should preserve existing encrypted webhook and add new username
expect(prismaMock.notificationBackend.update).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
config: expect.objectContaining({
webhookUrl: 'enc:https://discord.com/webhook', // Original encrypted value
username: 'NewBot',
}),
}),
})
);
});
it('returns 404 if backend not found', async () => {
prismaMock.notificationBackend.findUnique.mockResolvedValue(null);
authRequest.json.mockResolvedValue({ name: 'Updated' });
const { PUT } = await import('@/app/api/admin/notifications/[id]/route');
const response = await PUT(
{ json: authRequest.json } as any,
{ params: Promise.resolve({ id: 'nonexistent' }) }
);
expect(response.status).toBe(404);
});
});
describe('DELETE /api/admin/notifications/[id]', () => {
it('deletes notification backend', async () => {
const backend = {
id: '1',
type: 'discord',
name: 'Discord - Admins',
config: {},
events: [],
enabled: true,
createdAt: new Date(),
updatedAt: new Date(),
};
// Mock findUnique to return the backend (so it passes the existence check)
prismaMock.notificationBackend.findUnique.mockResolvedValue(backend);
// Mock delete to simulate successful deletion
prismaMock.notificationBackend.delete.mockResolvedValue(backend);
const { DELETE } = await import('@/app/api/admin/notifications/[id]/route');
const response = await DELETE({} as any, { params: Promise.resolve({ id: '1' }) });
const payload = await response.json();
expect(payload.success).toBe(true);
expect(prismaMock.notificationBackend.delete).toHaveBeenCalledWith({
where: { id: '1' },
});
});
it('returns 404 if backend not found', async () => {
prismaMock.notificationBackend.delete.mockRejectedValue(new Error('Record not found'));
const { DELETE } = await import('@/app/api/admin/notifications/[id]/route');
const response = await DELETE({} as any, { params: Promise.resolve({ id: 'nonexistent' }) });
expect(response.status).toBe(404);
});
});
});
@@ -12,6 +12,7 @@ const requireAuthMock = vi.hoisted(() => vi.fn());
const prismaMock = createPrismaMock();
const jobQueueMock = vi.hoisted(() => ({
addDownloadJob: vi.fn(),
addNotificationJob: vi.fn(() => Promise.resolve()),
}));
const findPlexMatchMock = vi.hoisted(() => vi.fn());
@@ -31,6 +32,12 @@ vi.mock('@/lib/utils/audiobook-matcher', () => ({
findPlexMatch: findPlexMatchMock,
}));
vi.mock('@/lib/integrations/audible.service', () => ({
getAudibleService: () => ({
getAudiobookDetails: vi.fn().mockResolvedValue(null),
}),
}));
describe('Request with torrent route', () => {
beforeEach(() => {
vi.clearAllMocks();
@@ -51,7 +58,7 @@ describe('Request with torrent route', () => {
status: 'downloaded',
userId: 'user-2',
user: { plexUsername: 'other' },
});
} as any);
const { POST } = await import('@/app/api/audiobooks/request-with-torrent/route');
const response = await POST({} as any);
@@ -69,13 +76,19 @@ describe('Request with torrent route', () => {
prismaMock.request.findFirst.mockResolvedValueOnce(null);
findPlexMatchMock.mockResolvedValueOnce(null);
prismaMock.audiobook.findFirst.mockResolvedValueOnce(null);
prismaMock.audiobook.create.mockResolvedValueOnce({ id: 'ab-1', title: 'Title', author: 'Author', audibleAsin: 'ASIN' });
prismaMock.audiobook.create.mockResolvedValueOnce({ id: 'ab-1', title: 'Title', author: 'Author', audibleAsin: 'ASIN' } as any);
prismaMock.request.findFirst.mockResolvedValueOnce(null);
prismaMock.user.findUnique.mockResolvedValueOnce({
id: 'user-1',
role: 'admin',
autoApproveRequests: null,
plexUsername: 'user',
} as any);
prismaMock.request.create.mockResolvedValueOnce({
id: 'req-2',
audiobook: { id: 'ab-1', title: 'Title', author: 'Author' },
user: { id: 'user-1', plexUsername: 'user' },
});
} as any);
const { POST } = await import('@/app/api/audiobooks/request-with-torrent/route');
const response = await POST({} as any);
+172 -5
View File
@@ -20,6 +20,7 @@ const configServiceMock = vi.hoisted(() => ({
}));
const jobQueueMock = vi.hoisted(() => ({
addSearchJob: vi.fn().mockResolvedValue(undefined),
addNotificationJob: vi.fn(() => Promise.resolve()),
}));
const bookdateHelpersMock = vi.hoisted(() => ({
buildAIPrompt: vi.fn(),
@@ -29,11 +30,18 @@ const bookdateHelpersMock = vi.hoisted(() => ({
isAlreadyRequested: vi.fn(),
isAlreadySwiped: vi.fn(),
}));
const audibleServiceMock = vi.hoisted(() => ({
getAudiobookDetails: vi.fn(),
}));
vi.mock('@/lib/db', () => ({
prisma: prismaMock,
}));
vi.mock('@/lib/integrations/audible.service', () => ({
getAudibleService: () => audibleServiceMock,
}));
vi.mock('@/lib/middleware/auth', () => ({
requireAuth: requireAuthMock,
requireAdmin: requireAdminMock,
@@ -428,7 +436,7 @@ describe('BookDate routes', () => {
expect(payload.recommendations).toHaveLength(1);
});
it('records swipe and creates request on right swipe', async () => {
it('records swipe and creates request on right swipe (admin auto-approves)', async () => {
authRequest.json.mockResolvedValue({ recommendationId: 'rec-1', action: 'right', markedAsKnown: false });
prismaMock.bookDateRecommendation.findUnique.mockResolvedValueOnce({
id: 'rec-1',
@@ -436,21 +444,180 @@ describe('BookDate routes', () => {
title: 'Title',
author: 'Author',
audnexusAsin: 'ASIN',
});
prismaMock.bookDateSwipe.create.mockResolvedValueOnce({});
} as any);
prismaMock.bookDateSwipe.create.mockResolvedValueOnce({} as any);
prismaMock.audiobook.findFirst.mockResolvedValueOnce(null);
prismaMock.audiobook.create.mockResolvedValueOnce({ id: 'ab-1', title: 'Title', author: 'Author', audibleAsin: 'ASIN' });
prismaMock.audiobook.create.mockResolvedValueOnce({ id: 'ab-1', title: 'Title', author: 'Author', audibleAsin: 'ASIN' } as any);
prismaMock.request.findFirst.mockResolvedValueOnce(null);
prismaMock.request.create.mockResolvedValueOnce({ id: 'req-1' });
prismaMock.user.findUnique.mockResolvedValueOnce({
id: 'user-1',
role: 'admin',
autoApproveRequests: null,
plexUsername: 'testuser',
} as any);
prismaMock.request.create.mockResolvedValueOnce({ id: 'req-1', audiobook: { title: 'Title' }, user: { id: 'user-1', plexUsername: 'testuser' } } as any);
const { POST } = await import('@/app/api/bookdate/swipe/route');
const response = await POST({} as any);
const payload = await response.json();
expect(payload.success).toBe(true);
expect(prismaMock.request.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
status: 'pending',
}),
})
);
expect(jobQueueMock.addNotificationJob).toHaveBeenCalledWith(
'request_approved',
'req-1',
'Title',
'Author',
'testuser'
);
expect(jobQueueMock.addSearchJob).toHaveBeenCalled();
});
it('creates request with awaiting_approval status when approval required (user with autoApproveRequests=false)', async () => {
authRequest = { user: { id: 'user-2', role: 'user' }, json: vi.fn() };
requireAuthMock.mockImplementation((_req: any, handler: any) => handler(authRequest));
authRequest.json.mockResolvedValue({ recommendationId: 'rec-2', action: 'right', markedAsKnown: false });
prismaMock.bookDateRecommendation.findUnique.mockResolvedValueOnce({
id: 'rec-2',
userId: 'user-2',
title: 'Title 2',
author: 'Author 2',
audnexusAsin: 'ASIN2',
} as any);
prismaMock.bookDateSwipe.create.mockResolvedValueOnce({} as any);
prismaMock.audiobook.findFirst.mockResolvedValueOnce(null);
prismaMock.audiobook.create.mockResolvedValueOnce({ id: 'ab-2', title: 'Title 2', author: 'Author 2', audibleAsin: 'ASIN2' } as any);
prismaMock.request.findFirst.mockResolvedValueOnce(null);
prismaMock.user.findUnique.mockResolvedValueOnce({
id: 'user-2',
role: 'user',
autoApproveRequests: false,
plexUsername: 'testuser2',
} as any);
prismaMock.request.create.mockResolvedValueOnce({ id: 'req-2', audiobook: { title: 'Title 2' }, user: { id: 'user-2', plexUsername: 'testuser2' } } as any);
const { POST } = await import('@/app/api/bookdate/swipe/route');
const response = await POST({} as any);
const payload = await response.json();
expect(payload.success).toBe(true);
expect(prismaMock.request.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
status: 'awaiting_approval',
}),
})
);
expect(jobQueueMock.addNotificationJob).toHaveBeenCalledWith(
'request_pending_approval',
'req-2',
'Title 2',
'Author 2',
'testuser2'
);
expect(jobQueueMock.addSearchJob).not.toHaveBeenCalled();
});
it('creates request with pending status when user has autoApproveRequests=true', async () => {
authRequest = { user: { id: 'user-3', role: 'user' }, json: vi.fn() };
requireAuthMock.mockImplementation((_req: any, handler: any) => handler(authRequest));
authRequest.json.mockResolvedValue({ recommendationId: 'rec-3', action: 'right', markedAsKnown: false });
prismaMock.bookDateRecommendation.findUnique.mockResolvedValueOnce({
id: 'rec-3',
userId: 'user-3',
title: 'Title 3',
author: 'Author 3',
audnexusAsin: 'ASIN3',
} as any);
prismaMock.bookDateSwipe.create.mockResolvedValueOnce({} as any);
prismaMock.audiobook.findFirst.mockResolvedValueOnce(null);
prismaMock.audiobook.create.mockResolvedValueOnce({ id: 'ab-3', title: 'Title 3', author: 'Author 3', audibleAsin: 'ASIN3' } as any);
prismaMock.request.findFirst.mockResolvedValueOnce(null);
prismaMock.user.findUnique.mockResolvedValueOnce({
id: 'user-3',
role: 'user',
autoApproveRequests: true,
plexUsername: 'testuser3',
} as any);
prismaMock.request.create.mockResolvedValueOnce({ id: 'req-3', audiobook: { title: 'Title 3' }, user: { id: 'user-3', plexUsername: 'testuser3' } } as any);
const { POST } = await import('@/app/api/bookdate/swipe/route');
const response = await POST({} as any);
const payload = await response.json();
expect(payload.success).toBe(true);
expect(prismaMock.request.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
status: 'pending',
}),
})
);
expect(jobQueueMock.addNotificationJob).toHaveBeenCalledWith(
'request_approved',
'req-3',
'Title 3',
'Author 3',
'testuser3'
);
expect(jobQueueMock.addSearchJob).toHaveBeenCalled();
});
it('checks global setting when user autoApproveRequests is null', async () => {
authRequest = { user: { id: 'user-4', role: 'user' }, json: vi.fn() };
requireAuthMock.mockImplementation((_req: any, handler: any) => handler(authRequest));
authRequest.json.mockResolvedValue({ recommendationId: 'rec-4', action: 'right', markedAsKnown: false });
prismaMock.bookDateRecommendation.findUnique.mockResolvedValueOnce({
id: 'rec-4',
userId: 'user-4',
title: 'Title 4',
author: 'Author 4',
audnexusAsin: 'ASIN4',
} as any);
prismaMock.bookDateSwipe.create.mockResolvedValueOnce({} as any);
prismaMock.audiobook.findFirst.mockResolvedValueOnce(null);
prismaMock.audiobook.create.mockResolvedValueOnce({ id: 'ab-4', title: 'Title 4', author: 'Author 4', audibleAsin: 'ASIN4' } as any);
prismaMock.request.findFirst.mockResolvedValueOnce(null);
prismaMock.user.findUnique.mockResolvedValueOnce({
id: 'user-4',
role: 'user',
autoApproveRequests: null,
plexUsername: 'testuser4',
} as any);
prismaMock.configuration.findUnique.mockResolvedValueOnce({
key: 'auto_approve_requests',
value: 'false',
} as any);
prismaMock.request.create.mockResolvedValueOnce({ id: 'req-4', audiobook: { title: 'Title 4' }, user: { id: 'user-4', plexUsername: 'testuser4' } } as any);
const { POST } = await import('@/app/api/bookdate/swipe/route');
const response = await POST({} as any);
const payload = await response.json();
expect(payload.success).toBe(true);
expect(prismaMock.request.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
status: 'awaiting_approval',
}),
})
);
expect(jobQueueMock.addNotificationJob).toHaveBeenCalledWith(
'request_pending_approval',
'req-4',
'Title 4',
'Author 4',
'testuser4'
);
expect(jobQueueMock.addSearchJob).not.toHaveBeenCalled();
});
it('undoes last swipe', async () => {
prismaMock.bookDateSwipe.findFirst.mockResolvedValueOnce({
id: 'swipe-1',
@@ -0,0 +1,373 @@
/**
* Component: Notification Trigger Integration Tests
* Documentation: documentation/backend/services/notifications.md
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { createPrismaMock } from '../helpers/prisma';
let authRequest: any;
const prismaMock = createPrismaMock();
const requireAuthMock = vi.hoisted(() => vi.fn());
const requireAdminMock = vi.hoisted(() => vi.fn());
const jobQueueMock = vi.hoisted(() => ({
addNotificationJob: vi.fn(() => Promise.resolve('job-1')),
addSearchJob: vi.fn(() => Promise.resolve('job-2')),
}));
const findPlexMatchMock = vi.hoisted(() => vi.fn());
const audibleServiceMock = vi.hoisted(() => ({
getAudiobookDetails: vi.fn(),
}));
vi.mock('@/lib/db', () => ({
prisma: prismaMock,
}));
vi.mock('@/lib/middleware/auth', () => ({
requireAuth: requireAuthMock,
requireAdmin: requireAdminMock,
}));
vi.mock('@/lib/services/job-queue.service', () => ({
getJobQueueService: () => jobQueueMock,
}));
vi.mock('@/lib/utils/audiobook-matcher', () => ({
findPlexMatch: findPlexMatchMock,
}));
vi.mock('@/lib/integrations/audible.service', () => ({
getAudibleService: () => audibleServiceMock,
}));
describe('Notification Triggers - Integration Tests', () => {
beforeEach(() => {
vi.clearAllMocks();
authRequest = { user: { id: 'user-1', role: 'user' }, json: vi.fn(), nextUrl: { searchParams: { get: vi.fn() } } };
requireAuthMock.mockImplementation((_req: any, handler: any) => handler(authRequest));
requireAdminMock.mockImplementation((_req: any, handler: any) => handler());
findPlexMatchMock.mockResolvedValue(null);
audibleServiceMock.getAudiobookDetails.mockResolvedValue({
releaseDate: '2024-01-01',
});
});
describe('POST /api/requests - Request Pending Approval', () => {
it('sends pending approval notification when user needs approval', async () => {
const requestBody = {
audiobook: {
asin: 'B001',
title: 'Test Book',
author: 'Test Author',
},
};
authRequest.json.mockResolvedValue(requestBody);
authRequest.nextUrl.searchParams.get.mockReturnValue(null);
prismaMock.request.findFirst.mockResolvedValue(null); // No existing active request
prismaMock.audiobook.findFirst.mockResolvedValue(null); // No existing audiobook
prismaMock.audiobook.create.mockResolvedValue({
id: 'audiobook-1',
audibleAsin: 'B001',
title: 'Test Book',
author: 'Test Author',
status: 'requested',
createdAt: new Date(),
updatedAt: new Date(),
});
// User needs approval (autoApproveRequests = false)
prismaMock.user.findUnique.mockResolvedValue({
id: 'user-1',
role: 'user',
autoApproveRequests: false,
});
prismaMock.request.create.mockResolvedValue({
id: 'req-1',
userId: 'user-1',
audiobookId: 'audiobook-1',
status: 'awaiting_approval',
progress: 0,
createdAt: new Date(),
updatedAt: new Date(),
audiobook: {
id: 'audiobook-1',
title: 'Test Book',
author: 'Test Author',
},
user: {
id: 'user-1',
plexUsername: 'testuser',
},
});
const { POST } = await import('@/app/api/requests/route');
const response = await POST(authRequest as any);
const payload = await response.json();
expect(payload.success).toBe(true);
expect(jobQueueMock.addNotificationJob).toHaveBeenCalledWith(
'request_pending_approval',
'req-1',
'Test Book',
'Test Author',
'testuser'
);
expect(jobQueueMock.addSearchJob).not.toHaveBeenCalled(); // No search when awaiting approval
});
});
describe('POST /api/requests - Request Approved (Auto-Approval)', () => {
it('sends approved notification when user auto-approved with automatic search', async () => {
const requestBody = {
audiobook: {
asin: 'B001',
title: 'Test Book',
author: 'Test Author',
},
};
authRequest.json.mockResolvedValue(requestBody);
authRequest.nextUrl.searchParams.get.mockReturnValue(null); // skipAutoSearch = false
prismaMock.request.findFirst.mockResolvedValue(null);
prismaMock.audiobook.findFirst.mockResolvedValue(null);
prismaMock.audiobook.create.mockResolvedValue({
id: 'audiobook-1',
audibleAsin: 'B001',
title: 'Test Book',
author: 'Test Author',
status: 'requested',
createdAt: new Date(),
updatedAt: new Date(),
});
// User has auto-approve enabled
prismaMock.user.findUnique.mockResolvedValue({
id: 'user-1',
role: 'user',
autoApproveRequests: true,
});
prismaMock.request.create.mockResolvedValue({
id: 'req-1',
userId: 'user-1',
audiobookId: 'audiobook-1',
status: 'pending',
progress: 0,
createdAt: new Date(),
updatedAt: new Date(),
audiobook: {
id: 'audiobook-1',
title: 'Test Book',
author: 'Test Author',
},
user: {
id: 'user-1',
plexUsername: 'testuser',
},
});
const { POST } = await import('@/app/api/requests/route');
await POST(authRequest as any);
expect(jobQueueMock.addNotificationJob).toHaveBeenCalledWith(
'request_approved',
'req-1',
'Test Book',
'Test Author',
'testuser'
);
expect(jobQueueMock.addSearchJob).toHaveBeenCalled(); // Search triggered
});
it('sends approved notification when user auto-approved with interactive search', async () => {
const requestBody = {
audiobook: {
asin: 'B001',
title: 'Test Book',
author: 'Test Author',
},
};
authRequest.json.mockResolvedValue(requestBody);
authRequest.nextUrl.searchParams.get.mockReturnValue('true'); // skipAutoSearch = true
prismaMock.request.findFirst.mockResolvedValue(null);
prismaMock.audiobook.findFirst.mockResolvedValue(null);
prismaMock.audiobook.create.mockResolvedValue({
id: 'audiobook-1',
audibleAsin: 'B001',
title: 'Test Book',
author: 'Test Author',
status: 'requested',
createdAt: new Date(),
updatedAt: new Date(),
});
// User has auto-approve enabled
prismaMock.user.findUnique.mockResolvedValue({
id: 'user-1',
role: 'user',
autoApproveRequests: true,
});
prismaMock.request.create.mockResolvedValue({
id: 'req-1',
userId: 'user-1',
audiobookId: 'audiobook-1',
status: 'awaiting_search',
progress: 0,
createdAt: new Date(),
updatedAt: new Date(),
audiobook: {
id: 'audiobook-1',
title: 'Test Book',
author: 'Test Author',
},
user: {
id: 'user-1',
plexUsername: 'testuser',
},
});
const { POST } = await import('@/app/api/requests/route');
await POST(authRequest as any);
// Should still send approved notification even with interactive search
expect(jobQueueMock.addNotificationJob).toHaveBeenCalledWith(
'request_approved',
'req-1',
'Test Book',
'Test Author',
'testuser'
);
expect(jobQueueMock.addSearchJob).not.toHaveBeenCalled(); // No automatic search
});
});
describe('POST /api/admin/requests/[id]/approve - Manual Approval', () => {
it('sends approved notification when admin manually approves request', async () => {
const adminRequest = {
user: { id: 'admin-1', role: 'admin' },
json: vi.fn().mockResolvedValue({}),
};
requireAuthMock.mockImplementation((_req: any, handler: any) => handler(adminRequest));
prismaMock.request.findUnique.mockResolvedValue({
id: 'req-1',
userId: 'user-1',
audiobookId: 'audiobook-1',
status: 'awaiting_approval',
progress: 0,
audiobook: {
id: 'audiobook-1',
audibleAsin: 'B001',
title: 'Test Book',
author: 'Test Author',
},
user: {
id: 'user-1',
plexUsername: 'testuser',
},
createdAt: new Date(),
updatedAt: new Date(),
});
prismaMock.request.update.mockResolvedValue({
id: 'req-1',
userId: 'user-1',
audiobookId: 'audiobook-1',
status: 'pending',
progress: 0,
audiobook: {
id: 'audiobook-1',
title: 'Test Book',
author: 'Test Author',
},
user: {
id: 'user-1',
plexUsername: 'testuser',
},
createdAt: new Date(),
updatedAt: new Date(),
});
const { POST } = await import('@/app/api/admin/requests/[id]/approve/route');
const approveRequest = {
json: vi.fn().mockResolvedValue({ action: 'approve' }),
};
await POST(approveRequest as any, { params: Promise.resolve({ id: 'req-1' }) });
expect(jobQueueMock.addNotificationJob).toHaveBeenCalledWith(
'request_approved',
'req-1',
'Test Book',
'Test Author',
'testuser'
);
});
});
describe('Interactive Search - Approval Bypass Prevention', () => {
it('blocks interactive search when request awaiting approval', async () => {
authRequest.json.mockResolvedValue({});
prismaMock.request.findUnique.mockResolvedValue({
id: 'req-1',
userId: 'user-1',
audiobookId: 'audiobook-1',
status: 'awaiting_approval', // Awaiting approval
progress: 0,
audiobook: {
id: 'audiobook-1',
title: 'Test Book',
author: 'Test Author',
},
createdAt: new Date(),
updatedAt: new Date(),
});
const { POST } = await import('@/app/api/requests/[id]/interactive-search/route');
const response = await POST(authRequest as any, { params: Promise.resolve({ id: 'req-1' }) });
const payload = await response.json();
expect(response.status).toBe(403);
expect(payload.error).toBe('AwaitingApproval');
});
it('blocks torrent selection when request awaiting approval', async () => {
authRequest.json.mockResolvedValue({
torrent: {
title: 'Test Torrent',
downloadUrl: 'magnet:?xt=...',
},
});
prismaMock.request.findUnique.mockResolvedValue({
id: 'req-1',
userId: 'user-1',
audiobookId: 'audiobook-1',
status: 'awaiting_approval', // Awaiting approval
progress: 0,
audiobook: {
id: 'audiobook-1',
title: 'Test Book',
author: 'Test Author',
},
createdAt: new Date(),
updatedAt: new Date(),
});
const { POST } = await import('@/app/api/requests/[id]/select-torrent/route');
const response = await POST(authRequest as any, { params: Promise.resolve({ id: 'req-1' }) });
const payload = await response.json();
expect(response.status).toBe(403);
expect(payload.error).toBe('AwaitingApproval');
});
});
});
+10 -2
View File
@@ -19,6 +19,7 @@ const configState = vi.hoisted(() => ({
const jobQueueMock = vi.hoisted(() => ({
addSearchJob: vi.fn(),
addDownloadJob: vi.fn(),
addNotificationJob: vi.fn(() => Promise.resolve()),
}));
const downloadEbookMock = vi.hoisted(() => vi.fn());
const fsMock = vi.hoisted(() => ({
@@ -114,9 +115,16 @@ describe('Request action routes', () => {
prismaMock.request.findUnique.mockResolvedValueOnce({
id: 'req-3',
userId: 'user-1',
status: 'awaiting_search',
audiobook: { id: 'ab-2', title: 'Title', author: 'Author' },
});
prismaMock.request.update.mockResolvedValueOnce({ id: 'req-3', status: 'downloading' });
} as any);
prismaMock.user.findUnique.mockResolvedValueOnce({
id: 'user-1',
role: 'admin',
autoApproveRequests: null,
plexUsername: 'testuser',
} as any);
prismaMock.request.update.mockResolvedValueOnce({ id: 'req-3', status: 'downloading', audiobook: { title: 'Title' } } as any);
const { POST } = await import('@/app/api/requests/[id]/select-torrent/route');
const response = await POST({} as any, { params: Promise.resolve({ id: 'req-3' }) });
+16 -1
View File
@@ -13,6 +13,8 @@ const requireAdminMock = vi.hoisted(() => vi.fn());
const prismaMock = createPrismaMock();
const jobQueueMock = vi.hoisted(() => ({
addSearchJob: vi.fn(),
addNotificationJob: vi.fn(() => Promise.resolve()),
addDownloadJob: vi.fn(),
}));
const findPlexMatchMock = vi.hoisted(() => vi.fn());
@@ -269,17 +271,28 @@ describe('Request Approval Workflow', () => {
audiobook: { asin: 'ASIN-7', title: 'Test Book', author: 'Test Author' },
});
// Mock first request.findFirst call (check for existing requests by ASIN)
prismaMock.request.findFirst.mockResolvedValueOnce(null);
// Mock findPlexMatch
findPlexMatchMock.mockResolvedValueOnce(null);
// Mock audiobook.findFirst
prismaMock.audiobook.findFirst.mockResolvedValueOnce(null);
prismaMock.audiobook.create.mockResolvedValueOnce({
id: 'ab-7',
title: 'Test Book',
author: 'Test Author',
audibleAsin: 'ASIN-7',
});
} as any);
// Mock second request.findFirst call (check for user's existing request)
prismaMock.request.findFirst.mockResolvedValueOnce(null);
prismaMock.user.findUnique.mockResolvedValue({
id: 'user-1',
role: 'user',
autoApproveRequests: true,
plexUsername: 'testuser',
} as any);
prismaMock.request.create.mockResolvedValue({
@@ -520,6 +533,7 @@ describe('Request Approval Workflow', () => {
prismaMock.request.findUnique.mockResolvedValue({
id: 'req-1',
status: 'awaiting_approval',
selectedTorrent: null,
userId: 'user-1',
audiobook: { id: 'ab-1', title: 'Test Book', author: 'Test Author', audibleAsin: 'ASIN-1' },
user: { id: 'user-1', plexUsername: 'testuser' },
@@ -569,6 +583,7 @@ describe('Request Approval Workflow', () => {
prismaMock.request.findUnique.mockResolvedValue({
id: 'req-2',
status: 'awaiting_approval',
selectedTorrent: null,
userId: 'user-1',
audiobook: { id: 'ab-2', title: 'Test Book 2', author: 'Test Author 2', audibleAsin: 'ASIN-2' },
user: { id: 'user-1', plexUsername: 'testuser' },
+8 -1
View File
@@ -12,6 +12,7 @@ const requireAuthMock = vi.hoisted(() => vi.fn());
const prismaMock = createPrismaMock();
const jobQueueMock = vi.hoisted(() => ({
addSearchJob: vi.fn(),
addNotificationJob: vi.fn(() => Promise.resolve()),
}));
const findPlexMatchMock = vi.hoisted(() => vi.fn());
@@ -31,6 +32,12 @@ vi.mock('@/lib/utils/audiobook-matcher', () => ({
findPlexMatch: findPlexMatchMock,
}));
vi.mock('@/lib/integrations/audible.service', () => ({
getAudibleService: () => ({
getAudiobookDetails: vi.fn().mockResolvedValue(null),
}),
}));
describe('Requests API routes', () => {
beforeEach(() => {
vi.clearAllMocks();
@@ -51,7 +58,7 @@ describe('Requests API routes', () => {
status: 'downloaded',
userId: 'user-2',
user: { plexUsername: 'someone' },
});
} as any);
const { POST } = await import('@/app/api/requests/route');
const response = await POST({} as any);
@@ -19,11 +19,18 @@ const configMock = vi.hoisted(() => ({
const thumbnailCacheServiceMock = vi.hoisted(() => ({
cacheLibraryThumbnail: vi.fn(),
}));
const jobQueueMock = vi.hoisted(() => ({
addNotificationJob: vi.fn(() => Promise.resolve()),
}));
vi.mock('@/lib/db', () => ({
prisma: prismaMock,
}));
vi.mock('@/lib/services/job-queue.service', () => ({
getJobQueueService: () => jobQueueMock,
}));
vi.mock('@/lib/services/library', () => ({
getLibraryService: async () => libraryServiceMock,
}));
@@ -160,8 +167,11 @@ describe('processPlexRecentlyAddedCheck', () => {
narrator: 'Narrator A',
audibleAsin: 'ASIN-ABS',
},
user: {
plexUsername: 'testuser',
},
},
]);
] as any);
(matcher.findPlexMatch as ReturnType<typeof vi.fn>).mockResolvedValue({
plexGuid: 'abs-item-1',
+11 -1
View File
@@ -19,11 +19,18 @@ const configMock = vi.hoisted(() => ({
const thumbnailCacheServiceMock = vi.hoisted(() => ({
cacheLibraryThumbnail: vi.fn(),
}));
const jobQueueMock = vi.hoisted(() => ({
addNotificationJob: vi.fn(() => Promise.resolve()),
}));
vi.mock('@/lib/utils/audiobook-matcher', () => ({
findPlexMatch: vi.fn(),
}));
vi.mock('@/lib/services/job-queue.service', () => ({
getJobQueueService: () => jobQueueMock,
}));
vi.mock('@/lib/services/audiobookshelf/api', () => ({
triggerABSItemMatch: vi.fn(),
}));
@@ -260,8 +267,11 @@ describe('processScanPlex', () => {
narrator: 'Narrator',
audibleAsin: 'ASIN123',
},
user: {
plexUsername: 'testuser',
},
},
]);
] as any);
prismaMock.audiobook.update.mockResolvedValue({});
prismaMock.request.update.mockResolvedValue({});
@@ -0,0 +1,118 @@
/**
* Component: Send Notification Processor Tests
* Documentation: documentation/backend/services/notifications.md
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
const notificationServiceMock = vi.hoisted(() => ({
sendNotification: vi.fn(),
}));
vi.mock('@/lib/services/notification.service', () => ({
getNotificationService: () => notificationServiceMock,
}));
describe('processSendNotification', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('calls notification service with correct payload', async () => {
const { processSendNotification } = await import('@/lib/processors/send-notification.processor');
const payload = {
event: 'request_approved' as const,
requestId: 'req-1',
title: 'Test Book',
author: 'Test Author',
userName: 'Test User',
timestamp: new Date('2024-01-01T00:00:00Z'),
jobId: 'job-1',
};
await processSendNotification(payload);
expect(notificationServiceMock.sendNotification).toHaveBeenCalledWith({
event: 'request_approved',
requestId: 'req-1',
title: 'Test Book',
author: 'Test Author',
userName: 'Test User',
timestamp: expect.any(Date),
});
});
it('includes error message if provided', async () => {
const { processSendNotification } = await import('@/lib/processors/send-notification.processor');
const payload = {
event: 'request_error' as const,
requestId: 'req-1',
title: 'Test Book',
author: 'Test Author',
userName: 'Test User',
message: 'Download failed',
timestamp: new Date('2024-01-01T00:00:00Z'),
jobId: 'job-1',
};
await processSendNotification(payload);
expect(notificationServiceMock.sendNotification).toHaveBeenCalledWith({
event: 'request_error',
requestId: 'req-1',
title: 'Test Book',
author: 'Test Author',
userName: 'Test User',
message: 'Download failed',
timestamp: expect.any(Date),
});
});
it('does not throw if notification service fails', async () => {
notificationServiceMock.sendNotification.mockRejectedValue(new Error('Service error'));
const { processSendNotification } = await import('@/lib/processors/send-notification.processor');
const payload = {
event: 'request_approved' as const,
requestId: 'req-1',
title: 'Test Book',
author: 'Test Author',
userName: 'Test User',
timestamp: new Date('2024-01-01T00:00:00Z'),
jobId: 'job-1',
};
// Should not throw
await expect(processSendNotification(payload)).resolves.toBeUndefined();
});
it('processes all event types correctly', async () => {
const { processSendNotification } = await import('@/lib/processors/send-notification.processor');
const events: Array<'request_pending_approval' | 'request_approved' | 'request_available' | 'request_error'> = [
'request_pending_approval',
'request_approved',
'request_available',
'request_error',
];
for (const event of events) {
const payload = {
event,
requestId: 'req-1',
title: 'Test Book',
author: 'Test Author',
userName: 'Test User',
timestamp: new Date('2024-01-01T00:00:00Z'),
jobId: 'job-1',
};
await processSendNotification(payload);
}
expect(notificationServiceMock.sendNotification).toHaveBeenCalledTimes(4);
});
});
@@ -0,0 +1,91 @@
/**
* Component: Job Queue Notification Integration Tests
* Documentation: documentation/backend/services/notifications.md
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
describe('JobQueueService - Notification Integration', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('addNotificationJob payload structure', () => {
it('creates correct payload for request_pending_approval', () => {
const event = 'request_pending_approval' as const;
const requestId = 'req-1';
const title = 'Test Book';
const author = 'Test Author';
const userName = 'Test User';
const payload = {
event,
requestId,
title,
author,
userName,
timestamp: new Date(),
};
expect(payload.event).toBe('request_pending_approval');
expect(payload.requestId).toBe(requestId);
expect(payload.title).toBe(title);
expect(payload.author).toBe(author);
expect(payload.userName).toBe(userName);
expect(payload.timestamp).toBeInstanceOf(Date);
});
it('includes error message for request_error events', () => {
const payload = {
event: 'request_error' as const,
requestId: 'req-1',
title: 'Test Book',
author: 'Test Author',
userName: 'Test User',
message: 'Download failed',
timestamp: new Date(),
};
expect(payload.message).toBe('Download failed');
});
it('handles all event types', () => {
const events: Array<'request_pending_approval' | 'request_approved' | 'request_available' | 'request_error'> = [
'request_pending_approval',
'request_approved',
'request_available',
'request_error',
];
events.forEach((event) => {
const payload = {
event,
requestId: 'req-1',
title: 'Test Book',
author: 'Test Author',
userName: 'Test User',
timestamp: new Date(),
};
expect(payload.event).toBe(event);
});
});
});
describe('notification job configuration', () => {
it('should use priority 5 for notification jobs', () => {
const priority = 5;
expect(priority).toBe(5);
});
it('should have concurrency 5 for send_notification processor', () => {
const concurrency = 5;
expect(concurrency).toBe(5);
});
it('should use job type send_notification', () => {
const jobType = 'send_notification';
expect(jobType).toBe('send_notification');
});
});
});
+414
View File
@@ -0,0 +1,414 @@
/**
* Component: Notification Service Tests
* Documentation: documentation/backend/services/notifications.md
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { createPrismaMock } from '../helpers/prisma';
const prismaMock = createPrismaMock();
prismaMock.notificationBackend = {
findMany: vi.fn(),
findUnique: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
} as any;
const encryptionMock = vi.hoisted(() => ({
encrypt: vi.fn((value: string) => `enc:${value}`),
decrypt: vi.fn((value: string) => value.replace('enc:', '')),
}));
const fetchMock = vi.hoisted(() => vi.fn());
vi.mock('@/lib/db', () => ({
prisma: prismaMock,
}));
vi.mock('@/lib/services/encryption.service', () => ({
getEncryptionService: () => encryptionMock,
}));
global.fetch = fetchMock as any;
describe('NotificationService', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('sendNotification', () => {
it('sends notifications to all enabled backends subscribed to the event', async () => {
prismaMock.notificationBackend.findMany.mockResolvedValue([
{
id: '1',
type: 'discord',
name: 'Discord - Admins',
config: { webhookUrl: 'https://discord.com/webhook1' },
events: ['request_approved', 'request_available'],
enabled: true,
createdAt: new Date(),
updatedAt: new Date(),
},
{
id: '2',
type: 'pushover',
name: 'Pushover - Users',
config: { userKey: 'user123', appToken: 'app456' },
events: ['request_approved'],
enabled: true,
createdAt: new Date(),
updatedAt: new Date(),
},
]);
fetchMock.mockResolvedValue({
ok: true,
json: async () => ({ success: true }),
});
const { NotificationService } = await import('@/lib/services/notification.service');
const service = new NotificationService();
await service.sendNotification({
event: 'request_approved',
requestId: 'req-1',
title: 'Test Book',
author: 'Test Author',
userName: 'Test User',
timestamp: new Date(),
});
expect(prismaMock.notificationBackend.findMany).toHaveBeenCalledWith({
where: {
enabled: true,
events: { array_contains: 'request_approved' },
},
});
// Should send to both backends
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it('does not send if no backends are subscribed to the event', async () => {
prismaMock.notificationBackend.findMany.mockResolvedValue([]);
const { NotificationService } = await import('@/lib/services/notification.service');
const service = new NotificationService();
await service.sendNotification({
event: 'request_approved',
requestId: 'req-1',
title: 'Test Book',
author: 'Test Author',
userName: 'Test User',
timestamp: new Date(),
});
expect(fetchMock).not.toHaveBeenCalled();
});
it('continues sending to other backends if one fails', async () => {
prismaMock.notificationBackend.findMany.mockResolvedValue([
{
id: '1',
type: 'discord',
name: 'Discord - Admins',
config: { webhookUrl: 'https://discord.com/webhook1' },
events: ['request_approved'],
enabled: true,
createdAt: new Date(),
updatedAt: new Date(),
},
{
id: '2',
type: 'pushover',
name: 'Pushover - Users',
config: { userKey: 'user123', appToken: 'app456' },
events: ['request_approved'],
enabled: true,
createdAt: new Date(),
updatedAt: new Date(),
},
]);
// First backend fails, second succeeds
fetchMock
.mockRejectedValueOnce(new Error('Network error'))
.mockResolvedValueOnce({
ok: true,
json: async () => ({ success: true }),
});
const { NotificationService } = await import('@/lib/services/notification.service');
const service = new NotificationService();
await service.sendNotification({
event: 'request_approved',
requestId: 'req-1',
title: 'Test Book',
author: 'Test Author',
userName: 'Test User',
timestamp: new Date(),
});
// Should still attempt both
expect(fetchMock).toHaveBeenCalledTimes(2);
});
});
describe('sendDiscord', () => {
it('sends Discord webhook with rich embed', async () => {
fetchMock.mockResolvedValue({
ok: true,
json: async () => ({ success: true }),
});
const { NotificationService } = await import('@/lib/services/notification.service');
const service = new NotificationService();
await service.sendDiscord(
{
webhookUrl: 'enc:https://discord.com/webhook',
username: 'ReadMeABook',
},
{
event: 'request_approved',
requestId: 'req-1',
title: 'Test Book',
author: 'Test Author',
userName: 'Test User',
timestamp: new Date('2024-01-01T00:00:00Z'),
}
);
// Should call the webhook (URL decryption happens internally)
expect(fetchMock).toHaveBeenCalled();
const fetchCall = fetchMock.mock.calls[0];
const body = JSON.parse(fetchCall[1].body);
expect(fetchCall[1].method).toBe('POST');
expect(fetchCall[1].headers['Content-Type']).toBe('application/json');
expect(body.username).toBe('ReadMeABook');
expect(body.embeds).toHaveLength(1);
expect(body.embeds[0].title).toBe('✅ Request Approved');
expect(body.embeds[0].color).toBe(2278750); // Green for approved (0x22C55E)
});
it('uses default username if not provided', async () => {
fetchMock.mockResolvedValue({
ok: true,
json: async () => ({ success: true }),
});
const { NotificationService } = await import('@/lib/services/notification.service');
const service = new NotificationService();
await service.sendDiscord(
{
webhookUrl: 'enc:https://discord.com/webhook',
},
{
event: 'request_approved',
requestId: 'req-1',
title: 'Test Book',
author: 'Test Author',
userName: 'Test User',
timestamp: new Date(),
}
);
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
expect(body.username).toBe('ReadMeABook');
});
it('throws error if Discord API returns non-OK response', async () => {
fetchMock.mockResolvedValue({
ok: false,
status: 400,
statusText: 'Bad Request',
text: async () => 'Bad Request',
});
const { NotificationService } = await import('@/lib/services/notification.service');
const service = new NotificationService();
await expect(
service.sendDiscord(
{ webhookUrl: 'enc:https://discord.com/webhook' },
{
event: 'request_approved',
requestId: 'req-1',
title: 'Test Book',
author: 'Test Author',
userName: 'Test User',
timestamp: new Date(),
}
)
).rejects.toThrow('Discord webhook failed: 400 Bad Request');
});
});
describe('sendPushover', () => {
it('sends Pushover notification with correct payload', async () => {
fetchMock.mockResolvedValue({
ok: true,
json: async () => ({ status: 1 }),
});
const { NotificationService } = await import('@/lib/services/notification.service');
const service = new NotificationService();
await service.sendPushover(
{
userKey: 'enc:user123',
appToken: 'enc:app456',
priority: 1,
},
{
event: 'request_approved',
requestId: 'req-1',
title: 'Test Book',
author: 'Test Author',
userName: 'Test User',
timestamp: new Date(),
}
);
// Should call the Pushover API (credential decryption happens internally)
expect(fetchMock).toHaveBeenCalled();
const fetchCall = fetchMock.mock.calls[0];
expect(fetchCall[0]).toBe('https://api.pushover.net/1/messages.json');
expect(fetchCall[1].method).toBe('POST');
expect(fetchCall[1].headers['Content-Type']).toBe('application/x-www-form-urlencoded');
const body = fetchCall[1].body;
// Body should be URL-encoded string
expect(typeof body).toBe('string');
expect(body).toContain('priority=1');
});
it('uses default priority if not provided', async () => {
fetchMock.mockResolvedValue({
ok: true,
json: async () => ({ status: 1 }),
});
const { NotificationService } = await import('@/lib/services/notification.service');
const service = new NotificationService();
await service.sendPushover(
{
userKey: 'enc:user123',
appToken: 'enc:app456',
},
{
event: 'request_approved',
requestId: 'req-1',
title: 'Test Book',
author: 'Test Author',
userName: 'Test User',
timestamp: new Date(),
}
);
const body = fetchMock.mock.calls[0][1].body;
expect(body.toString()).toContain('priority=0');
});
it('throws error if Pushover API returns non-OK response', async () => {
fetchMock.mockResolvedValue({
ok: false,
status: 400,
text: async () => 'invalid user key',
});
const { NotificationService } = await import('@/lib/services/notification.service');
const service = new NotificationService();
await expect(
service.sendPushover(
{ userKey: 'enc:user123', appToken: 'enc:app456' },
{
event: 'request_approved',
requestId: 'req-1',
title: 'Test Book',
author: 'Test Author',
userName: 'Test User',
timestamp: new Date(),
}
)
).rejects.toThrow();
});
});
// Note: formatDiscordEmbed is a private method, tested indirectly through sendDiscord
describe('encryptConfig', () => {
it('encrypts sensitive Discord config values', async () => {
const { NotificationService } = await import('@/lib/services/notification.service');
const service = new NotificationService();
const encrypted = service.encryptConfig('discord', {
webhookUrl: 'https://discord.com/webhook',
username: 'ReadMeABook',
});
expect(encryptionMock.encrypt).toHaveBeenCalledWith('https://discord.com/webhook');
expect(encrypted.webhookUrl).toBe('enc:https://discord.com/webhook');
expect(encrypted.username).toBe('ReadMeABook'); // Not encrypted
});
it('encrypts sensitive Pushover config values', async () => {
const { NotificationService } = await import('@/lib/services/notification.service');
const service = new NotificationService();
const encrypted = service.encryptConfig('pushover', {
userKey: 'user123',
appToken: 'app456',
priority: 1,
});
expect(encryptionMock.encrypt).toHaveBeenCalledWith('user123');
expect(encryptionMock.encrypt).toHaveBeenCalledWith('app456');
expect(encrypted.userKey).toBe('enc:user123');
expect(encrypted.appToken).toBe('enc:app456');
expect(encrypted.priority).toBe(1); // Not encrypted
});
});
// Note: decryptConfig is a private method, tested indirectly through sendDiscord/sendPushover
describe('maskConfig', () => {
it('masks sensitive Discord config values', async () => {
const { NotificationService } = await import('@/lib/services/notification.service');
const service = new NotificationService();
const masked = service.maskConfig('discord', {
webhookUrl: 'https://discord.com/webhook/very/long/url',
username: 'ReadMeABook',
});
expect(masked.webhookUrl).toBe('••••••••');
expect(masked.username).toBe('ReadMeABook'); // Not masked
});
it('masks sensitive Pushover config values', async () => {
const { NotificationService } = await import('@/lib/services/notification.service');
const service = new NotificationService();
const masked = service.maskConfig('pushover', {
userKey: 'user123',
appToken: 'app456',
priority: 1,
});
expect(masked.userKey).toBe('••••••••');
expect(masked.appToken).toBe('••••••••');
expect(masked.priority).toBe(1); // Not masked
});
});
});
+72
View File
@@ -45,6 +45,78 @@ describe('PathMapper', () => {
PathMapper.validate({ enabled: true, remotePath: '/remote', localPath: '' })
).toThrow('Local path cannot be empty');
});
describe('reverseTransform', () => {
it('returns original path when mapping is disabled', () => {
const result = PathMapper.reverseTransform('/downloads/Book', {
enabled: false,
remotePath: 'F:\\Docker\\downloads\\completed\\books',
localPath: '/downloads',
});
expect(result).toBe('/downloads/Book');
});
it('transforms local path to remote path with Unix-style separators', () => {
const result = PathMapper.reverseTransform('/downloads/Audiobook.Name', {
enabled: true,
remotePath: '/remote/mnt/d/done',
localPath: '/downloads',
});
expect(result).toBe('/remote/mnt/d/done/Audiobook.Name');
});
it('transforms local path to remote path with Windows-style separators', () => {
const result = PathMapper.reverseTransform('/downloads/Audiobook.Name', {
enabled: true,
remotePath: 'F:\\Docker\\downloads\\completed\\books',
localPath: '/downloads',
});
expect(result).toBe('F:\\Docker\\downloads\\completed\\books\\Audiobook.Name');
});
it('returns original path when local prefix does not match', () => {
const result = PathMapper.reverseTransform('/other/path/book', {
enabled: true,
remotePath: 'F:\\Docker\\downloads\\completed\\books',
localPath: '/downloads',
});
expect(result).toBe('/other/path/book');
});
it('handles exact path match (no subdirectory)', () => {
const result = PathMapper.reverseTransform('/downloads', {
enabled: true,
remotePath: 'F:\\Docker\\downloads\\completed\\books',
localPath: '/downloads',
});
expect(result).toBe('F:\\Docker\\downloads\\completed\\books');
});
it('handles nested subdirectories', () => {
const result = PathMapper.reverseTransform('/downloads/Author/Book Name/file.m4b', {
enabled: true,
remotePath: 'F:\\seedbox\\audiobooks',
localPath: '/downloads',
});
expect(result).toBe('F:\\seedbox\\audiobooks\\Author\\Book Name\\file.m4b');
});
it('handles trailing slashes in config', () => {
const result = PathMapper.reverseTransform('/downloads/Book', {
enabled: true,
remotePath: '/remote/path/',
localPath: '/downloads/',
});
expect(result).toBe('/remote/path/Book');
});
});
});