diff --git a/LICENSE b/LICENSE index 14de9f3..be3f7b2 100644 --- a/LICENSE +++ b/LICENSE @@ -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. + 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. + + + Copyright (C) + + 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 . + +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 +. diff --git a/README.md b/README.md index b1c92f8..8748b93 100644 --- a/README.md +++ b/README.md @@ -1,43 +1,55 @@ -# ReadMeABook +
-**[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 +
-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. +
-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) + +
+ +--- + +## 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 +image +image +image -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. +
+ +**AGPL v3 License** + +
diff --git a/documentation/TABLEOFCONTENTS.md b/documentation/TABLEOFCONTENTS.md index 0892793..05d01fd 100644 --- a/documentation/TABLEOFCONTENTS.md +++ b/documentation/TABLEOFCONTENTS.md @@ -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) diff --git a/documentation/admin-features/request-approval.md b/documentation/admin-features/request-approval.md index 5ca5c90..8104090 100644 --- a/documentation/admin-features/request-approval.md +++ b/documentation/admin-features/request-approval.md @@ -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) diff --git a/documentation/backend/services/notifications.md b/documentation/backend/services/notifications.md new file mode 100644 index 0000000..c41e535 --- /dev/null +++ b/documentation/backend/services/notifications.md @@ -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) diff --git a/documentation/features/bookdate.md b/documentation/features/bookdate.md index ee344e4..88a46c0 100644 --- a/documentation/features/bookdate.md +++ b/documentation/features/bookdate.md @@ -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) diff --git a/documentation/phase3/qbittorrent.md b/documentation/phase3/qbittorrent.md index e9e1403..1c1ec62 100644 --- a/documentation/phase3/qbittorrent.md +++ b/documentation/phase3/qbittorrent.md @@ -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 diff --git a/documentation/settings-pages.md b/documentation/settings-pages.md index d8e5735..bce650e 100644 --- a/documentation/settings-pages.md +++ b/documentation/settings-pages.md @@ -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 diff --git a/prisma/migrations/20260121000000_add_selected_torrent_to_requests/migration.sql b/prisma/migrations/20260121000000_add_selected_torrent_to_requests/migration.sql new file mode 100644 index 0000000..5998db9 --- /dev/null +++ b/prisma/migrations/20260121000000_add_selected_torrent_to_requests/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "requests" ADD COLUMN "selected_torrent" JSONB; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 7a28c7a..388f563 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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") +} diff --git a/screenshots/ADMIN.png b/screenshots/ADMIN.png new file mode 100644 index 0000000..89e3cfa Binary files /dev/null and b/screenshots/ADMIN.png differ diff --git a/screenshots/BOOKDATE.png b/screenshots/BOOKDATE.png new file mode 100644 index 0000000..8dc9a33 Binary files /dev/null and b/screenshots/BOOKDATE.png differ diff --git a/screenshots/HOMEPAGE.png b/screenshots/HOMEPAGE.png new file mode 100644 index 0000000..1365b38 Binary files /dev/null and b/screenshots/HOMEPAGE.png differ diff --git a/screenshots/RMAB_hero.png b/screenshots/RMAB_hero.png new file mode 100644 index 0000000..af6c9ef Binary files /dev/null and b/screenshots/RMAB_hero.png differ diff --git a/src/app/admin/settings/lib/helpers.ts b/src/app/admin/settings/lib/helpers.ts index 1eecfdd..b5ee1af 100644 --- a/src/app/admin/settings/lib/helpers.ts +++ b/src/app/admin/settings/lib/helpers.ts @@ -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: '🔔' }, ]; diff --git a/src/app/admin/settings/lib/types.ts b/src/app/admin/settings/lib/types.ts index 0f6ffc3..df5a933 100644 --- a/src/app/admin/settings/lib/types.ts +++ b/src/app/admin/settings/lib/types.ts @@ -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'; diff --git a/src/app/admin/settings/page.tsx b/src/app/admin/settings/page.tsx index e09ba4f..ff2d3ca 100644 --- a/src/app/admin/settings/page.tsx +++ b/src/app/admin/settings/page.tsx @@ -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' && } + {/* Save Button (only for tabs that save through main page) */} - {activeTab !== 'ebook' && activeTab !== 'bookdate' && ( + {activeTab !== 'ebook' && activeTab !== 'bookdate' && activeTab !== 'notifications' && (
+ + +
+ + + {/* Configured Backends */} +
+

Configured Backends

+ {loading ? ( +

Loading...

+ ) : backends.length === 0 ? ( +

No notification backends configured.

+ ) : ( +
+ {backends.map((backend) => ( +
+
+
+
+ {backend.type.charAt(0).toUpperCase()} +
+
+
{backend.name}
+
{backend.type}
+
+
+
+
+
+ {backend.enabled ? 'Enabled' : 'Disabled'} +
+
+ {backend.events.length} {backend.events.length === 1 ? 'event' : 'events'} subscribed +
+
+
+ + +
+
+ ))} +
+ )} +
+ + {/* Modal */} + {modalState.isOpen && modalState.selectedType && ( +
+
+
+
+

+ {modalState.mode === 'add' ? 'Add' : 'Edit'} {modalState.selectedType.charAt(0).toUpperCase() + modalState.selectedType.slice(1)} Notification +

+ +
+ +
+ {/* Name */} +
+ + 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" + /> +
+ + {/* Config Fields */} + {modalState.selectedType === 'discord' && ( + <> +
+ + 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/..." + /> +
+
+ + 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" + /> +
+ + )} + + {modalState.selectedType === 'pushover' && ( + <> +
+ + 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" + /> +
+
+ + 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" + /> +
+
+ + +
+ + )} + + {/* Events */} +
+ +
+ {Object.entries(eventLabels).map(([event, label]) => ( + + ))} +
+
+ + {/* Enabled Toggle */} +
+ +
+ + {/* Test Result */} + {testResult && ( +
+ {testResult.message} +
+ )} + + {/* Actions */} +
+ +
+ + +
+
+
+
+
+
+ )} + + ); +} diff --git a/src/app/admin/settings/tabs/NotificationsTab/index.ts b/src/app/admin/settings/tabs/NotificationsTab/index.ts new file mode 100644 index 0000000..b26d349 --- /dev/null +++ b/src/app/admin/settings/tabs/NotificationsTab/index.ts @@ -0,0 +1 @@ +export { NotificationsTab } from './NotificationsTab'; diff --git a/src/app/api/admin/notifications/[id]/route.ts b/src/app/api/admin/notifications/[id]/route.ts new file mode 100644 index 0000000..4a47bc1 --- /dev/null +++ b/src/app/api/admin/notifications/[id]/route.ts @@ -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 } + ); + } + }); + }); +} diff --git a/src/app/api/admin/notifications/route.ts b/src/app/api/admin/notifications/route.ts new file mode 100644 index 0000000..d53a150 --- /dev/null +++ b/src/app/api/admin/notifications/route.ts @@ -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 } + ); + } + }); + }); +} diff --git a/src/app/api/admin/notifications/test/route.ts b/src/app/api/admin/notifications/test/route.ts new file mode 100644 index 0000000..1ca2c7b --- /dev/null +++ b/src/app/api/admin/notifications/test/route.ts @@ -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 } + ); + } + }); + }); +} diff --git a/src/app/api/admin/requests/[id]/approve/route.ts b/src/app/api/admin/requests/[id]/approve/route.ts index 4b8473e..f7eea6a 100644 --- a/src/app/api/admin/requests/[id]/approve/route.ts +++ b/src/app/api/admin/requests/[id]/approve/route.ts @@ -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({ diff --git a/src/app/api/audiobooks/request-with-torrent/route.ts b/src/app/api/audiobooks/request-with-torrent/route.ts index 3706176..3aa6426 100644 --- a/src/app/api/audiobooks/request-with-torrent/route.ts +++ b/src/app/api/audiobooks/request-with-torrent/route.ts @@ -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) }); diff --git a/src/app/api/bookdate/swipe/route.ts b/src/app/api/bookdate/swipe/route.ts index 3477f68..2f0bfbb 100644 --- a/src/app/api/bookdate/swipe/route.ts +++ b/src/app/api/bookdate/swipe/route.ts @@ -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) { diff --git a/src/app/api/requests/[id]/interactive-search/route.ts b/src/app/api/requests/[id]/interactive-search/route.ts index b3bf8b3..011f1b1 100644 --- a/src/app/api/requests/[id]/interactive-search/route.ts +++ b/src/app/api/requests/[id]/interactive-search/route.ts @@ -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(); diff --git a/src/app/api/requests/[id]/select-torrent/route.ts b/src/app/api/requests/[id]/select-torrent/route.ts index e759493..e205716 100644 --- a/src/app/api/requests/[id]/select-torrent/route.ts +++ b/src/app/api/requests/[id]/select-torrent/route.ts @@ -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 }, diff --git a/src/app/api/requests/route.ts b/src/app/api/requests/route.ts index 61b5811..2d876f0 100644 --- a/src/app/api/requests/route.ts +++ b/src/app/api/requests/route.ts @@ -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, diff --git a/src/lib/integrations/qbittorrent.service.ts b/src/lib/integrations/qbittorrent.service.ts index 3424031..eaee5d0 100644 --- a/src/lib/integrations/qbittorrent.service.ts +++ b/src/lib/integrations/qbittorrent.service.ts @@ -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 { '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 { 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 { 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 { password, savePath, 'readmeabook', - disableSSLVerify + disableSSLVerify, + pathMappingConfig ); // Test connection diff --git a/src/lib/processors/monitor-download.processor.ts b/src/lib/processors/monitor-download.processor.ts index b697a5b..b687d83 100644 --- a/src/lib/processors/monitor-download.processor.ts +++ b/src/lib/processors/monitor-download.processor.ts @@ -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 diff --git a/src/lib/processors/organize-files.processor.ts b/src/lib/processors/organize-files.processor.ts index f553050..1cfd640 100644 --- a/src/lib/processors/organize-files.processor.ts +++ b/src/lib/processors/organize-files.processor.ts @@ -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; } } diff --git a/src/lib/processors/plex-recently-added.processor.ts b/src/lib/processors/plex-recently-added.processor.ts index 75a18e1..ae91dc8 100644 --- a/src/lib/processors/plex-recently-added.processor.ts +++ b/src/lib/processors/plex-recently-added.processor.ts @@ -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) diff --git a/src/lib/processors/scan-plex.processor.ts b/src/lib/processors/scan-plex.processor.ts index 90ab126..87e3682 100644 --- a/src/lib/processors/scan-plex.processor.ts +++ b/src/lib/processors/scan-plex.processor.ts @@ -366,7 +366,14 @@ export async function processScanPlex(payload: ScanPlexPayload): Promise { 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 { }, }); + // 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) diff --git a/src/lib/processors/send-notification.processor.ts b/src/lib/processors/send-notification.processor.ts new file mode 100644 index 0000000..1912114 --- /dev/null +++ b/src/lib/processors/send-notification.processor.ts @@ -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 { + 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 + } +} diff --git a/src/lib/services/job-queue.service.ts b/src/lib/services/job-queue.service.ts index 6c6012d..38356a0 100644 --- a/src/lib/services/job-queue.service.ts +++ b/src/lib/services/job-queue.service.ts @@ -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) => { + 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 { + 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 */ diff --git a/src/lib/services/notification.service.ts b/src/lib/services/notification.service.ts new file mode 100644 index 0000000..da6a848 --- /dev/null +++ b/src/lib/services/notification.service.ts @@ -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 { + 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 { + // 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 { + 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 { + 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; +} diff --git a/src/lib/utils/path-mapper.ts b/src/lib/utils/path-mapper.ts index 6b9a552..14d3056 100644 --- a/src/lib/utils/path-mapper.ts +++ b/src/lib/utils/path-mapper.ts @@ -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 * diff --git a/tests/api/admin-notifications-test.routes.test.ts b/tests/api/admin-notifications-test.routes.test.ts new file mode 100644 index 0000000..a58fdf7 --- /dev/null +++ b/tests/api/admin-notifications-test.routes.test.ts @@ -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); + }); + }); +}); diff --git a/tests/api/admin-notifications.routes.test.ts b/tests/api/admin-notifications.routes.test.ts new file mode 100644 index 0000000..9ce5a03 --- /dev/null +++ b/tests/api/admin-notifications.routes.test.ts @@ -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); + }); + }); +}); diff --git a/tests/api/audiobooks-request-torrent.routes.test.ts b/tests/api/audiobooks-request-torrent.routes.test.ts index cbf0dfd..7dc089a 100644 --- a/tests/api/audiobooks-request-torrent.routes.test.ts +++ b/tests/api/audiobooks-request-torrent.routes.test.ts @@ -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); diff --git a/tests/api/bookdate.routes.test.ts b/tests/api/bookdate.routes.test.ts index 3305fec..2da4f48 100644 --- a/tests/api/bookdate.routes.test.ts +++ b/tests/api/bookdate.routes.test.ts @@ -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', diff --git a/tests/api/notification-triggers.integration.test.ts b/tests/api/notification-triggers.integration.test.ts new file mode 100644 index 0000000..e1943cc --- /dev/null +++ b/tests/api/notification-triggers.integration.test.ts @@ -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'); + }); + }); +}); diff --git a/tests/api/requests-actions.routes.test.ts b/tests/api/requests-actions.routes.test.ts index 2efcb34..49560e0 100644 --- a/tests/api/requests-actions.routes.test.ts +++ b/tests/api/requests-actions.routes.test.ts @@ -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' }) }); diff --git a/tests/api/requests-approval.routes.test.ts b/tests/api/requests-approval.routes.test.ts index 8dafe23..ae3f4fc 100644 --- a/tests/api/requests-approval.routes.test.ts +++ b/tests/api/requests-approval.routes.test.ts @@ -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' }, diff --git a/tests/api/requests.route.test.ts b/tests/api/requests.route.test.ts index 7774cb8..87590a4 100644 --- a/tests/api/requests.route.test.ts +++ b/tests/api/requests.route.test.ts @@ -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); diff --git a/tests/processors/plex-recently-added.processor.test.ts b/tests/processors/plex-recently-added.processor.test.ts index 99aacc1..430b5ef 100644 --- a/tests/processors/plex-recently-added.processor.test.ts +++ b/tests/processors/plex-recently-added.processor.test.ts @@ -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).mockResolvedValue({ plexGuid: 'abs-item-1', diff --git a/tests/processors/scan-plex.processor.test.ts b/tests/processors/scan-plex.processor.test.ts index 0b9ab29..d9e46a7 100644 --- a/tests/processors/scan-plex.processor.test.ts +++ b/tests/processors/scan-plex.processor.test.ts @@ -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({}); diff --git a/tests/processors/send-notification.processor.test.ts b/tests/processors/send-notification.processor.test.ts new file mode 100644 index 0000000..44b414a --- /dev/null +++ b/tests/processors/send-notification.processor.test.ts @@ -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); + }); +}); diff --git a/tests/services/job-queue-notifications.service.test.ts b/tests/services/job-queue-notifications.service.test.ts new file mode 100644 index 0000000..ce1d62e --- /dev/null +++ b/tests/services/job-queue-notifications.service.test.ts @@ -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'); + }); + }); +}); diff --git a/tests/services/notification.service.test.ts b/tests/services/notification.service.test.ts new file mode 100644 index 0000000..6496b9b --- /dev/null +++ b/tests/services/notification.service.test.ts @@ -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 + }); + }); +}); diff --git a/tests/utils/path-mapper.test.ts b/tests/utils/path-mapper.test.ts index d467181..3f0d5eb 100644 --- a/tests/utils/path-mapper.test.ts +++ b/tests/utils/path-mapper.test.ts @@ -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'); + }); + }); });