diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..f43d63a --- /dev/null +++ b/.editorconfig @@ -0,0 +1 @@ +# qbit-crm diff --git a/LICENSE b/LICENSE index bf20455..d645695 100644 --- a/LICENSE +++ b/LICENSE @@ -1,680 +1,202 @@ -QQQ - Low-code Application Framework for Engineers -Copyright (C) 2021-2025 QRun-IO, LLC -https://www.qrun.io | https://github.com/QRun-IO - -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 . - --------------------------------------------------------------------------------- - - GNU AFFERO GENERAL PUBLIC LICENSE - Version 3, 19 November 2007 - - 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. - - Preamble - - 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 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 -. + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.md b/README.md index bffcc29..fb5830f 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # QBit: CRM [![Version](https://img.shields.io/badge/version-0.1.0-blue.svg)](https://github.com/QRun-IO/qbit-crm) -[![License](https://img.shields.io/badge/license-GNU%20Affero%20GPL%20v3-green.svg)](https://www.gnu.org/licenses/agpl-3.0.en.html) +[![License](https://img.shields.io/badge/license-Apache%202.0-green.svg)](https://www.apache.org/licenses/LICENSE-2.0) [![Java](https://img.shields.io/badge/java-17+-blue.svg)](https://adoptium.net/) > **Customer Relationship Management for QQQ Applications - Activity-Centric, Pipeline-Driven** @@ -26,7 +26,7 @@ This QBit provides a complete CRM system for QQQ applications, covering contact ## Open Source & Full Control -QBit CRM is 100% open source under AGPL v3. All data stays in your systems. No external CRM services required. +QBit CRM is 100% open source under Apache 2.0. All data stays in your systems. No external CRM services required. ## Architecture @@ -68,11 +68,10 @@ qbit-crm/ - **Java 17+** - **Maven 3.8+** -- **QQQ Application** (this is a QBit, not a standalone application) +- **A QQQ Application** with an RDBMS backend (PostgreSQL, MySQL, H2, etc.) +- **QQQ Maven Registry** access (for resolving the dependency) -### Usage - -#### Maven dependency +### Step 1: Add the Maven dependency ```xml @@ -82,24 +81,103 @@ qbit-crm/ ``` -#### Minimal setup - -```java -CrmQBitConfig config = new CrmQBitConfig() - .withDefaultBackendNameForTables("yourBackendName"); - -CrmQBitProducer producer = new CrmQBitProducer() - .withQBitConfig(config); - -MetaDataProducerMultiOutput output = producer.produce(qInstance); -output.addSelfToInstance(qInstance); +If you are using the QQQ BOM POM, the version is managed for you: +```xml + + + + com.kingsrook.qqq + qqq-bom-pom + ${qqq.version} + pom + import + + + ``` -#### With multi-tenant security +### Step 2: Configure and produce the QBit + +In your application's metadata setup class (where you build your `QInstance`), add the CRM QBit. The `defaultBackendNameForTables` must match a backend you have already registered with your QInstance (typically your RDBMS backend). ```java -CrmQBitConfig config = new CrmQBitConfig() - .withDefaultBackendNameForTables("yourBackendName") +import com.kingsrook.qbits.crm.CrmQBitConfig; +import com.kingsrook.qbits.crm.CrmQBitProducer; +import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerMultiOutput; + +// In your QInstance setup method: +public void defineQInstance(QInstance qInstance) +{ + // ... your existing backend, auth, and app setup ... + + /////////////////////////////// + // configure and produce CRM // + /////////////////////////////// + CrmQBitConfig crmConfig = new CrmQBitConfig() + .withDefaultBackendNameForTables("rdbms"); // must match a registered backend name + + CrmQBitProducer crmProducer = new CrmQBitProducer() + .withQBitConfig(crmConfig); + + MetaDataProducerMultiOutput crmOutput = crmProducer.produce(qInstance); + crmOutput.addSelfToInstance(qInstance); +} +``` + +### Step 3: Create the database tables + +The QBit defines 39 tables. On first run against a new database, you need to create the schema. QQQ can auto-create tables if your backend supports it, or you can generate DDL from the entity metadata. + +For **development/testing** with H2 or auto-DDL: +```java +// QQQ will auto-create tables in memory backends. +// For RDBMS with auto-DDL enabled, tables are created on startup. +``` + +For **production** with PostgreSQL/MySQL, generate migration scripts from the entity field definitions. Each entity class documents its table name (e.g., `crmContact`, `crmCompany`, `crmDeal`) and all field types. Column names in the database are `snake_case` versions of the Java `camelCase` field names (e.g., `firstName` -> `first_name`). + +### Step 4: Add CRM to your app navigation + +```java +import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppMetaData; + +QAppMetaData app = new QAppMetaData() + .withName("myApp") + .withLabel("My Application") + .withSections(List.of( + CrmQBitProducer.produceAppSection(), + // ... your other app sections ... + )); +qInstance.addApp(app); +``` + +This adds a "CRM" navigation section to the QQQ Material Dashboard with links to all CRM tables. + +### Step 5 (optional): Seed reference data + +The CRM works without seed data, but you'll want to populate the configuration tables before users start working: + +- **`crmLifecycleStage`**: Lead, Marketing Qualified, Sales Qualified, Opportunity, Customer, Evangelist +- **`crmLeadSource`**: Website, Referral, Cold Call, Trade Show, Social Media, Advertisement, Partner +- **`crmIndustry`**: Technology, Healthcare, Finance, Manufacturing, Retail, etc. +- **`crmActivityType`**: Call, Email, Meeting, Note, Task (with `isSystem=true`) +- **`crmActivityOutcome`**: Connected, Left Voicemail, No Answer (for Call type); Completed, No Show, Rescheduled (for Meeting type) +- **`crmPipeline`** + **`crmPipelineStage`**: At minimum one pipeline with stages (Prospecting, Qualification, Proposal, Negotiation, Closed Won, Closed Lost) +- **`crmContactRole`**: Decision Maker, Influencer, Champion, Budget Holder, End User, Evaluator +- **`crmWinLossReason`**: Better Fit (WIN), Price (WIN), Lost to Competitor (LOSS), No Budget (LOSS), etc. +- **`crmCurrency`**: At minimum USD with `isBaseCurrency=true` and `exchangeRateToBase=1.0` + +You can insert these via QQQ's `InsertAction` in an application startup process, or directly via SQL. + +### Step 6 (optional): Multi-tenant security + +For SaaS or multi-tenant deployments where each client's CRM data must be isolated: + +```java +import com.kingsrook.qqq.backend.core.model.metadata.security.RecordSecurityLock; + +CrmQBitConfig crmConfig = new CrmQBitConfig() + .withDefaultBackendNameForTables("rdbms") .withRecordSecurityLocks(List.of( new RecordSecurityLock() .withFieldName("clientId") @@ -108,24 +186,66 @@ CrmQBitConfig config = new CrmQBitConfig() )); ``` -#### With companion QBits +This injects a `clientId` security field onto all root CRM tables (Contact, Company, Deal, Pipeline) and propagates security locks through join chains to child tables. Users only see records matching their security key values. + +### Step 7 (optional): Companion QBits + +The CRM integrates with other QBits for enhanced functionality: ```java -CrmQBitConfig config = new CrmQBitConfig() - .withDefaultBackendNameForTables("yourBackendName") - .withQuickSearchQBitNamespace("quickSearch") - .withWebhooksQBitNamespace("webhooks") - .withWorkflowsQBitNamespace("workflows"); +CrmQBitConfig crmConfig = new CrmQBitConfig() + .withDefaultBackendNameForTables("rdbms") + .withQuickSearchQBitNamespace("quickSearch") // full-text search across contacts/companies/deals + .withWebhooksQBitNamespace("webhooks") // fire events on CRM changes (deal won, contact created, etc.) + .withWorkflowsQBitNamespace("workflows"); // user-defined automation rules ``` -#### Add CRM navigation section to your app +Each companion QBit must be separately added as a Maven dependency and produced into your QInstance. The namespace strings must match the namespaces you used when producing those QBits. + +### Complete example ```java -QAppMetaData app = new QAppMetaData() - .withName("myApp") - .withSections(List.of( - CrmQBitProducer.produceAppSection() - )); +public void defineQInstance(QInstance qInstance) +{ + /////////////// + // backend // + /////////////// + qInstance.addBackend(new RDBMSBackendMetaData() + .withName("rdbms") + .withVendor("postgresql") + .withHostName("localhost") + .withDatabaseName("myapp") + .withUsername("myapp") + .withPassword("secret")); + + //////////////////// + // authentication // + //////////////////// + qInstance.setAuthentication(new Auth0AuthenticationMetaData() + .withType(QAuthenticationType.AUTH_0) + // ... auth config ... + ); + + ////////// + // CRM // + ////////// + CrmQBitConfig crmConfig = new CrmQBitConfig() + .withDefaultBackendNameForTables("rdbms"); + + new CrmQBitProducer() + .withQBitConfig(crmConfig) + .produce(qInstance) + .addSelfToInstance(qInstance); + + ////////// + // App // + ////////// + qInstance.addApp(new QAppMetaData() + .withName("myApp") + .withSections(List.of( + CrmQBitProducer.produceAppSection() + ))); +} ``` ## Data Model @@ -362,4 +482,4 @@ QBit CRM is built by **[Kingsrook](https://qrun.io)** - making engineers more pr ## License -This project is licensed under the **GNU Affero General Public License v3.0** - see the [LICENSE.txt](LICENSE.txt) file for details. +This project is licensed under the **Apache License, Version 2.0** - see the [LICENSE](LICENSE) file for details. diff --git a/checkstyle/license.txt b/checkstyle/license.txt index af39d6f..73bfe92 100644 --- a/checkstyle/license.txt +++ b/checkstyle/license.txt @@ -1,20 +1,19 @@ /* * QQQ - Low-code Application Framework for Engineers. - * Copyright (C) 2021-2025. Kingsrook, LLC + * Copyright (C) 2021-2026. Kingsrook, LLC * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States * contact@kingsrook.com * https://github.com/Kingsrook/ * - * 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. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at * - * 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. + * http://www.apache.org/licenses/LICENSE-2.0 * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. */ diff --git a/pom.xml b/pom.xml index 3d68ab4..bbed79e 100644 --- a/pom.xml +++ b/pom.xml @@ -6,24 +6,23 @@ ~ contact@kingsrook.com ~ https://github.com/Kingsrook/ ~ - ~ 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. + ~ Licensed under the Apache License, Version 2.0 (the "License"); + ~ you may not use this file except in compliance with the License. + ~ You may obtain a copy of the License at ~ - ~ 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. + ~ http://www.apache.org/licenses/LICENSE-2.0 ~ - ~ You should have received a copy of the GNU Affero General Public License - ~ along with this program. If not, see . + ~ Unless required by applicable law or agreed to in writing, software + ~ distributed under the License is distributed on an "AS IS" BASIS, + ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + ~ See the License for the specific language governing permissions and + ~ limitations under the License. --> - 0.1.0-SNAPSHOT + 0.1.0 @@ -45,8 +44,8 @@ - GNU Affero General Public License v3.0 - https://www.gnu.org/licenses/agpl-3.0.html + Apache License, Version 2.0 + https://www.apache.org/licenses/LICENSE-2.0 repo diff --git a/src/main/java/com/kingsrook/qbits/crm/CrmQBitProducer.java b/src/main/java/com/kingsrook/qbits/crm/CrmQBitProducer.java index fd4e9ac..df915c9 100644 --- a/src/main/java/com/kingsrook/qbits/crm/CrmQBitProducer.java +++ b/src/main/java/com/kingsrook/qbits/crm/CrmQBitProducer.java @@ -5,13 +5,30 @@ package com.kingsrook.qbits.crm; import java.util.List; +import com.kingsrook.qbits.crm.activities.customizers.ActivityLastDateCustomizer; +import com.kingsrook.qbits.crm.activities.model.Activity; +import com.kingsrook.qbits.crm.core.customizers.FormSubmissionAutoLinkCustomizer; +import com.kingsrook.qbits.crm.core.model.FormSubmission; +import com.kingsrook.qbits.crm.audit.customizers.CrmAuditLogCustomizer; +import com.kingsrook.qbits.crm.core.model.Company; +import com.kingsrook.qbits.crm.core.model.Contact; +import com.kingsrook.qbits.crm.deals.customizers.DealInitializationCustomizer; +import com.kingsrook.qbits.crm.deals.customizers.DealProductRecalculationCustomizer; +import com.kingsrook.qbits.crm.deals.customizers.DealStageChangeCustomizer; +import com.kingsrook.qbits.crm.deals.model.Deal; +import com.kingsrook.qbits.crm.deals.model.DealProduct; +import com.kingsrook.qqq.backend.core.actions.customizers.MultiCustomizer; +import com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizers; import com.kingsrook.qqq.backend.core.exceptions.QException; import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerMultiOutput; import com.kingsrook.qqq.backend.core.model.metadata.QInstance; +import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference; +import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReferenceWithProperties; import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppSection; import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon; import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitMetaData; import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitMetaDataProducer; +import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData; /******************************************************************************* @@ -50,10 +67,101 @@ public class CrmQBitProducer implements QBitMetaDataProducer @Override public void postProduceActions(MetaDataProducerMultiOutput metaDataProducerMultiOutput, QInstance qInstance) throws QException { - /////////////////////////////////////////////// - // global customizers registered here later // - // (audit log, auto-assign, webhook firing) // - /////////////////////////////////////////////// + ///////////////////////////////////////////////////////////////////////// + // iterate tables produced by this QBit and register customizers // + ///////////////////////////////////////////////////////////////////////// + for(QTableMetaData table : metaDataProducerMultiOutput.getEach(QTableMetaData.class)) + { + String tableName = table.getName(); + + /////////////////////////////////////////////////////////////// + // wire audit log on tracked entities // + /////////////////////////////////////////////////////////////// + if(tableName.equals(Contact.TABLE_NAME) || tableName.equals(Company.TABLE_NAME) + || tableName.equals(Deal.TABLE_NAME) || tableName.equals(Activity.TABLE_NAME)) + { + QCodeReference auditRef = new QCodeReference(CrmAuditLogCustomizer.class); + addOrComposeCustomizer(table, TableCustomizers.POST_INSERT_RECORD, auditRef); + addOrComposeCustomizer(table, TableCustomizers.POST_UPDATE_RECORD, auditRef); + addOrComposeCustomizer(table, TableCustomizers.POST_DELETE_RECORD, auditRef); + } + + /////////////////////////////////////////////////////////////// + // wire activity timestamp propagation // + /////////////////////////////////////////////////////////////// + if(tableName.equals(Activity.TABLE_NAME)) + { + addOrComposeCustomizer(table, TableCustomizers.POST_INSERT_RECORD, new QCodeReference(ActivityLastDateCustomizer.class)); + } + + /////////////////////////////////////////////////////////////// + // wire deal stage change and initialization // + /////////////////////////////////////////////////////////////// + if(tableName.equals(Deal.TABLE_NAME)) + { + addOrComposeCustomizer(table, TableCustomizers.PRE_UPDATE_RECORD, new QCodeReference(DealStageChangeCustomizer.class)); + addOrComposeCustomizer(table, TableCustomizers.POST_INSERT_RECORD, new QCodeReference(DealInitializationCustomizer.class)); + } + + /////////////////////////////////////////////////////////////// + // wire deal product recalculation // + /////////////////////////////////////////////////////////////// + /////////////////////////////////////////////////////////////// + // wire form submission auto-link // + /////////////////////////////////////////////////////////////// + if(tableName.equals(FormSubmission.TABLE_NAME)) + { + addOrComposeCustomizer(table, TableCustomizers.POST_INSERT_RECORD, new QCodeReference(FormSubmissionAutoLinkCustomizer.class)); + } + + /////////////////////////////////////////////////////////////// + // wire deal product recalculation // + /////////////////////////////////////////////////////////////// + if(tableName.equals(DealProduct.TABLE_NAME)) + { + QCodeReference dpRef = new QCodeReference(DealProductRecalculationCustomizer.class); + addOrComposeCustomizer(table, TableCustomizers.POST_INSERT_RECORD, dpRef); + addOrComposeCustomizer(table, TableCustomizers.POST_UPDATE_RECORD, dpRef); + addOrComposeCustomizer(table, TableCustomizers.POST_DELETE_RECORD, dpRef); + } + } + } + + + + /******************************************************************************* + ** Add a customizer to a table for a given role. If a customizer already exists + ** for that role, compose both using MultiCustomizer. + *******************************************************************************/ + private void addOrComposeCustomizer(QTableMetaData table, TableCustomizers role, QCodeReference newRef) + { + java.util.Optional existing = table.getCustomizer(role.getRole()); + + if(existing.isEmpty()) + { + table.withCustomizer(role, newRef); + } + else + { + QCodeReference existingRef = existing.get(); + + if(existingRef instanceof QCodeReferenceWithProperties existingMulti + && MultiCustomizer.class.getName().equals(existingMulti.getName())) + { + /////////////////////////////////////////////////////////////////// + // already a MultiCustomizer -- just add the new ref to it // + /////////////////////////////////////////////////////////////////// + MultiCustomizer.addTableCustomizer(existingMulti, newRef); + } + else + { + /////////////////////////////////////////////////////////////////// + // wrap existing + new in a MultiCustomizer // + /////////////////////////////////////////////////////////////////// + QCodeReferenceWithProperties multiRef = MultiCustomizer.of(existingRef, newRef); + table.getCustomizers().put(role.getRole(), multiRef); + } + } } diff --git a/src/main/java/com/kingsrook/qbits/crm/CrmSessionUtils.java b/src/main/java/com/kingsrook/qbits/crm/CrmSessionUtils.java new file mode 100644 index 0000000..ffd574a --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/crm/CrmSessionUtils.java @@ -0,0 +1,62 @@ +/******************************************************************************* + ** Utility class for null-safe session user ID resolution. Used by processes + ** and customizers that need the current user ID for audit logging. + *******************************************************************************/ +package com.kingsrook.qbits.crm; + + +import com.kingsrook.qqq.backend.core.context.QContext; +import com.kingsrook.qqq.backend.core.logging.QLogger; +import com.kingsrook.qqq.backend.core.utils.StringUtils; + + +/******************************************************************************* + ** Null-safe session utilities for the CRM QBit. Returns "system" when no + ** session or user is available (e.g., scheduled/system context). + *******************************************************************************/ +public final class CrmSessionUtils +{ + private static final QLogger LOG = QLogger.getLogger(CrmSessionUtils.class); + + + + /******************************************************************************* + ** Private constructor -- utility class. + *******************************************************************************/ + private CrmSessionUtils() + { + } + + + + /******************************************************************************* + ** Get the current user ID from the QContext session, falling back to "system" + ** when no session or user is available. + *******************************************************************************/ + public static String getCurrentUserId() + { + try + { + if(QContext.getQSession() != null) + { + if(QContext.getQSession().getUser() != null + && StringUtils.hasContent(QContext.getQSession().getUser().getIdReference())) + { + return (QContext.getQSession().getUser().getIdReference()); + } + + if(StringUtils.hasContent(QContext.getQSession().getIdReference())) + { + return (QContext.getQSession().getIdReference()); + } + } + } + catch(Exception e) + { + LOG.debug("Could not resolve current user ID from session", e); + } + + return ("system"); + } + +} diff --git a/src/main/java/com/kingsrook/qbits/crm/activities/customizers/ActivityLastDateCustomizer.java b/src/main/java/com/kingsrook/qbits/crm/activities/customizers/ActivityLastDateCustomizer.java new file mode 100644 index 0000000..a8bac86 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/crm/activities/customizers/ActivityLastDateCustomizer.java @@ -0,0 +1,178 @@ +/******************************************************************************* + ** POST_INSERT table customizer that propagates lastActivityDate and + ** lastContactedDate to linked Contact, Company, and Deal records whenever + ** a new Activity is created. + *******************************************************************************/ +package com.kingsrook.qbits.crm.activities.customizers; + + +import java.time.Instant; +import java.util.List; +import com.kingsrook.qbits.crm.activities.model.Activity; +import com.kingsrook.qbits.crm.core.model.Company; +import com.kingsrook.qbits.crm.core.model.Contact; +import com.kingsrook.qbits.crm.core.model.enums.CrmDirection; +import com.kingsrook.qbits.crm.deals.model.Deal; +import com.kingsrook.qqq.backend.core.actions.customizers.AbstractPostInsertCustomizer; +import com.kingsrook.qqq.backend.core.actions.tables.GetAction; +import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.logging.QLogger; +import com.kingsrook.qqq.backend.core.model.actions.tables.get.GetInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.get.GetOutput; +import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput; +import com.kingsrook.qqq.backend.core.model.data.QRecord; +import com.kingsrook.qqq.backend.core.utils.ValueUtils; + + +/******************************************************************************* + ** On activity insert, update the lastActivityDate (and, for outbound + ** activities, lastContactedDate) on any linked Contact, Company, or Deal. + *******************************************************************************/ +public class ActivityLastDateCustomizer extends AbstractPostInsertCustomizer +{ + private static final QLogger LOG = QLogger.getLogger(ActivityLastDateCustomizer.class); + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Override + public List apply(List records) throws QException + { + Instant now = Instant.now(); + + for(QRecord record : records) + { + if(record.getErrors() != null && !record.getErrors().isEmpty()) + { + continue; + } + + Integer direction = ValueUtils.getValueAsInteger(record.getValue("direction")); + boolean isOutbound = direction != null && direction.equals(CrmDirection.OUTBOUND.getPossibleValueId()); + + /////////////////////////////////////////// + // update linked contact if present // + /////////////////////////////////////////// + Integer contactId = ValueUtils.getValueAsInteger(record.getValue("contactId")); + if(contactId != null) + { + updateContactDates(contactId, now, isOutbound); + } + + /////////////////////////////////////////// + // update linked company if present // + /////////////////////////////////////////// + Integer companyId = ValueUtils.getValueAsInteger(record.getValue("companyId")); + if(companyId != null) + { + updateCompanyDates(companyId, now, isOutbound); + } + + /////////////////////////////////////////// + // update linked deal if present // + /////////////////////////////////////////// + Integer dealId = ValueUtils.getValueAsInteger(record.getValue("dealId")); + if(dealId != null) + { + updateDealLastActivityDate(dealId, now); + } + } + + return (records); + } + + + + /******************************************************************************* + ** Update a contact's lastActivityDate, and lastContactedDate if outbound. + *******************************************************************************/ + private void updateContactDates(Integer contactId, Instant now, boolean isOutbound) throws QException + { + try + { + GetOutput getOutput = new GetAction().execute(new GetInput(Contact.TABLE_NAME).withPrimaryKey(contactId)); + if(getOutput.getRecord() == null) + { + return; + } + + QRecord updateRecord = new QRecord(); + updateRecord.setValue("id", contactId); + updateRecord.setValue("lastActivityDate", now); + + if(isOutbound) + { + updateRecord.setValue("lastContactedDate", now); + } + + new UpdateAction().execute(new UpdateInput(Contact.TABLE_NAME).withRecord(updateRecord)); + } + catch(Exception e) + { + LOG.warn("Error updating contact dates for contactId=" + contactId, e); + } + } + + + + /******************************************************************************* + ** Update a company's lastActivityDate, and lastContactedDate if outbound. + *******************************************************************************/ + private void updateCompanyDates(Integer companyId, Instant now, boolean isOutbound) throws QException + { + try + { + GetOutput getOutput = new GetAction().execute(new GetInput(Company.TABLE_NAME).withPrimaryKey(companyId)); + if(getOutput.getRecord() == null) + { + return; + } + + QRecord updateRecord = new QRecord(); + updateRecord.setValue("id", companyId); + updateRecord.setValue("lastActivityDate", now); + + if(isOutbound) + { + updateRecord.setValue("lastContactedDate", now); + } + + new UpdateAction().execute(new UpdateInput(Company.TABLE_NAME).withRecord(updateRecord)); + } + catch(Exception e) + { + LOG.warn("Error updating company dates for companyId=" + companyId, e); + } + } + + + + /******************************************************************************* + ** Update a deal's lastActivityDate. + *******************************************************************************/ + private void updateDealLastActivityDate(Integer dealId, Instant now) throws QException + { + try + { + GetOutput getOutput = new GetAction().execute(new GetInput(Deal.TABLE_NAME).withPrimaryKey(dealId)); + if(getOutput.getRecord() == null) + { + return; + } + + QRecord updateRecord = new QRecord(); + updateRecord.setValue("id", dealId); + updateRecord.setValue("lastActivityDate", now); + + new UpdateAction().execute(new UpdateInput(Deal.TABLE_NAME).withRecord(updateRecord)); + } + catch(Exception e) + { + LOG.warn("Error updating deal lastActivityDate for dealId=" + dealId, e); + } + } + +} diff --git a/src/main/java/com/kingsrook/qbits/crm/activities/model/Activity.java b/src/main/java/com/kingsrook/qbits/crm/activities/model/Activity.java index 2ec9981..7ee070e 100644 --- a/src/main/java/com/kingsrook/qbits/crm/activities/model/Activity.java +++ b/src/main/java/com/kingsrook/qbits/crm/activities/model/Activity.java @@ -6,8 +6,12 @@ package com.kingsrook.qbits.crm.activities.model; import java.time.Instant; import java.util.List; +import com.kingsrook.qbits.crm.core.model.Company; +import com.kingsrook.qbits.crm.core.model.Contact; import com.kingsrook.qbits.crm.core.model.enums.CrmActivityPriority; import com.kingsrook.qbits.crm.core.model.enums.CrmDirection; +import com.kingsrook.qbits.crm.deals.model.Deal; +import com.kingsrook.qbits.crm.email.model.EmailMessage; import com.kingsrook.qqq.backend.core.exceptions.QException; import com.kingsrook.qqq.backend.core.model.data.QField; import com.kingsrook.qqq.backend.core.model.data.QRecord; @@ -40,7 +44,12 @@ import com.kingsrook.qqq.backend.core.model.metadata.tables.Tier; childTableEntityClass = ActivityParticipant.class, joinFieldName = "activityId", childJoin = @ChildJoin(enabled = true), - childRecordListWidget = @ChildRecordListWidget(label = "Participants", enabled = true, maxRows = 50)) + childRecordListWidget = @ChildRecordListWidget(label = "Participants", enabled = true, maxRows = 50)), + @ChildTable( + childTableEntityClass = EmailMessage.class, + joinFieldName = "activityId", + childJoin = @ChildJoin(enabled = true), + childRecordListWidget = @ChildRecordListWidget(label = "Email Details", enabled = true, maxRows = 5)) } ) public class Activity extends QRecordEntity @@ -96,13 +105,13 @@ public class Activity extends QRecordEntity @QField() private String description; - @QField() + @QField(possibleValueSourceName = Contact.TABLE_NAME) private Integer contactId; - @QField() + @QField(possibleValueSourceName = Company.TABLE_NAME) private Integer companyId; - @QField() + @QField(possibleValueSourceName = Deal.TABLE_NAME) private Integer dealId; @QField(isRequired = true) diff --git a/src/main/java/com/kingsrook/qbits/crm/activities/model/ActivityType.java b/src/main/java/com/kingsrook/qbits/crm/activities/model/ActivityType.java index 16c6051..aef48f3 100644 --- a/src/main/java/com/kingsrook/qbits/crm/activities/model/ActivityType.java +++ b/src/main/java/com/kingsrook/qbits/crm/activities/model/ActivityType.java @@ -5,11 +5,15 @@ package com.kingsrook.qbits.crm.activities.model; import java.time.Instant; +import java.util.List; +import com.kingsrook.qqq.backend.core.actions.customizers.AbstractPreDeleteCustomizer; +import com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizers; import com.kingsrook.qqq.backend.core.exceptions.QException; import com.kingsrook.qqq.backend.core.model.data.QField; import com.kingsrook.qqq.backend.core.model.data.QRecord; import com.kingsrook.qqq.backend.core.model.data.QRecordEntity; import com.kingsrook.qqq.backend.core.model.metadata.QInstance; +import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference; import com.kingsrook.qqq.backend.core.model.metadata.fields.ValueTooLongBehavior; import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon; import com.kingsrook.qqq.backend.core.model.metadata.producers.MetaDataCustomizerInterface; @@ -17,6 +21,7 @@ import com.kingsrook.qqq.backend.core.model.metadata.producers.annotations.QMeta import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData; import com.kingsrook.qqq.backend.core.model.metadata.tables.SectionFactory; import com.kingsrook.qqq.backend.core.model.metadata.tables.UniqueKey; +import com.kingsrook.qqq.backend.core.model.statusmessages.BadInputStatusMessage; /******************************************************************************* @@ -34,7 +39,8 @@ public class ActivityType extends QRecordEntity /*************************************************************************** - ** Customizer that sets icon, record label, unique key, and sections. + ** Customizer that sets icon, record label, unique key, sections, and + ** registers PRE_DELETE customizer to protect system activity types. ***************************************************************************/ public static class TableMetaDataCustomizer implements MetaDataCustomizerInterface { @@ -53,12 +59,41 @@ public class ActivityType extends QRecordEntity .withSection(SectionFactory.defaultT2("isSystem", "sortOrder", "isActive")) .withSection(SectionFactory.defaultT3("createDate", "modifyDate")); + //////////////////////////////////////////////////////////// + // register PRE_DELETE to protect system activity types // + //////////////////////////////////////////////////////////// + table.withCustomizer(TableCustomizers.PRE_DELETE_RECORD, new QCodeReference(PreDeleteCustomizer.class)); + return (table); } } + /*************************************************************************** + ** PRE_DELETE customizer that rejects deletion of system activity types. + ***************************************************************************/ + public static class PreDeleteCustomizer extends AbstractPreDeleteCustomizer + { + /*************************************************************************** + ** + ***************************************************************************/ + @Override + public List apply(List records) throws QException + { + for(QRecord record : records) + { + if(Boolean.TRUE.equals(record.getValueBoolean("isSystem"))) + { + record.addError(new BadInputStatusMessage("System activity types cannot be deleted")); + } + } + return (records); + } + } + + + @QField(isEditable = false, isPrimaryKey = true) private Integer id; diff --git a/src/main/java/com/kingsrook/qbits/crm/audit/customizers/CrmAuditLogCustomizer.java b/src/main/java/com/kingsrook/qbits/crm/audit/customizers/CrmAuditLogCustomizer.java index 848593e..4c2f376 100644 --- a/src/main/java/com/kingsrook/qbits/crm/audit/customizers/CrmAuditLogCustomizer.java +++ b/src/main/java/com/kingsrook/qbits/crm/audit/customizers/CrmAuditLogCustomizer.java @@ -18,9 +18,9 @@ import java.util.Optional; import com.kingsrook.qbits.crm.audit.model.AuditLog; import com.kingsrook.qbits.crm.core.model.enums.CrmAuditAction; import com.kingsrook.qbits.crm.core.model.enums.CrmEntityType; +import com.kingsrook.qbits.crm.CrmSessionUtils; import com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizerInterface; import com.kingsrook.qqq.backend.core.actions.tables.InsertAction; -import com.kingsrook.qqq.backend.core.context.QContext; import com.kingsrook.qqq.backend.core.exceptions.QException; import com.kingsrook.qqq.backend.core.logging.QLogger; import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteInput; @@ -48,10 +48,10 @@ public class CrmAuditLogCustomizer implements TableCustomizerInterface static { - TABLE_TO_ENTITY_TYPE.put("crmContact", CrmEntityType.CONTACT.getId()); - TABLE_TO_ENTITY_TYPE.put("crmCompany", CrmEntityType.COMPANY.getId()); - TABLE_TO_ENTITY_TYPE.put("crmDeal", CrmEntityType.DEAL.getId()); - TABLE_TO_ENTITY_TYPE.put("crmActivity", CrmEntityType.ACTIVITY.getId()); + TABLE_TO_ENTITY_TYPE.put("crmContact", CrmEntityType.CONTACT.getPossibleValueId()); + TABLE_TO_ENTITY_TYPE.put("crmCompany", CrmEntityType.COMPANY.getPossibleValueId()); + TABLE_TO_ENTITY_TYPE.put("crmDeal", CrmEntityType.DEAL.getPossibleValueId()); + TABLE_TO_ENTITY_TYPE.put("crmActivity", CrmEntityType.ACTIVITY.getPossibleValueId()); } @@ -205,7 +205,7 @@ public class CrmAuditLogCustomizer implements TableCustomizerInterface QRecord auditRecord = new QRecord(); auditRecord.setValue("entityType", resolveEntityType(tableName)); auditRecord.setValue("entityId", entityId); - auditRecord.setValue("action", action.getId()); + auditRecord.setValue("action", action.getPossibleValueId()); auditRecord.setValue("userId", userId); if(StringUtils.hasContent(fieldName)) @@ -284,28 +284,7 @@ public class CrmAuditLogCustomizer implements TableCustomizerInterface *******************************************************************************/ private String getCurrentUserId() { - try - { - if(QContext.getQSession() != null) - { - if(QContext.getQSession().getUser() != null - && StringUtils.hasContent(QContext.getQSession().getUser().getIdReference())) - { - return (QContext.getQSession().getUser().getIdReference()); - } - - if(StringUtils.hasContent(QContext.getQSession().getIdReference())) - { - return (QContext.getQSession().getIdReference()); - } - } - } - catch(Exception e) - { - LOG.debug("Could not resolve current user ID for audit log", e); - } - - return ("system"); + return (CrmSessionUtils.getCurrentUserId()); } } diff --git a/src/main/java/com/kingsrook/qbits/crm/audit/model/AuditLog.java b/src/main/java/com/kingsrook/qbits/crm/audit/model/AuditLog.java index 6ad9449..9c848d6 100644 --- a/src/main/java/com/kingsrook/qbits/crm/audit/model/AuditLog.java +++ b/src/main/java/com/kingsrook/qbits/crm/audit/model/AuditLog.java @@ -11,6 +11,7 @@ import com.kingsrook.qbits.crm.core.model.enums.CrmEntityType; import com.kingsrook.qqq.backend.core.actions.customizers.AbstractPreDeleteCustomizer; import com.kingsrook.qqq.backend.core.actions.customizers.AbstractPreUpdateCustomizer; import com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizers; +import com.kingsrook.qqq.backend.core.context.QContext; import com.kingsrook.qqq.backend.core.exceptions.QException; import com.kingsrook.qqq.backend.core.exceptions.QUserFacingException; import com.kingsrook.qqq.backend.core.model.data.QField; @@ -119,6 +120,10 @@ public class AuditLog extends QRecordEntity @Override public List apply(List records) throws QException { + if("true".equals(QContext.getQSession().getValue("gdprBypass"))) + { + return records; + } throw new QUserFacingException("Audit log records cannot be modified or deleted"); } } @@ -136,6 +141,10 @@ public class AuditLog extends QRecordEntity @Override public List apply(List records) throws QException { + if("true".equals(QContext.getQSession().getValue("gdprBypass"))) + { + return records; + } throw new QUserFacingException("Audit log records cannot be modified or deleted"); } } diff --git a/src/main/java/com/kingsrook/qbits/crm/core/customizers/FormSubmissionAutoLinkCustomizer.java b/src/main/java/com/kingsrook/qbits/crm/core/customizers/FormSubmissionAutoLinkCustomizer.java new file mode 100644 index 0000000..3b6e144 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/crm/core/customizers/FormSubmissionAutoLinkCustomizer.java @@ -0,0 +1,85 @@ +/******************************************************************************* + ** POST_INSERT customizer for form submissions. Auto-links contactId if a + ** contact with a matching email address already exists. + *******************************************************************************/ +package com.kingsrook.qbits.crm.core.customizers; + + +import java.util.List; +import com.kingsrook.qbits.crm.core.model.Contact; +import com.kingsrook.qbits.crm.core.model.FormSubmission; +import com.kingsrook.qqq.backend.core.actions.customizers.AbstractPostInsertCustomizer; +import com.kingsrook.qqq.backend.core.actions.tables.QueryAction; +import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryOutput; +import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput; +import com.kingsrook.qqq.backend.core.model.data.QRecord; +import com.kingsrook.qqq.backend.core.utils.StringUtils; + + +/******************************************************************************* + ** On form submission insert, auto-link the contactId if a contact with the + ** same email address already exists in the CRM. + *******************************************************************************/ +public class FormSubmissionAutoLinkCustomizer extends AbstractPostInsertCustomizer +{ + + + /******************************************************************************* + ** + *******************************************************************************/ + @Override + public List apply(List records) throws QException + { + for(QRecord record : records) + { + String email = record.getValueString("email"); + if(!StringUtils.hasContent(email)) + { + continue; + } + + email = email.trim().toLowerCase(); + + /////////////////////////////////////////////////////////// + // skip if contactId is already set (manual or re-insert) // + /////////////////////////////////////////////////////////// + if(record.getValue("contactId") != null) + { + continue; + } + + ///////////////////////////////////////////////////// + // query for an existing contact with this email // + ///////////////////////////////////////////////////// + QueryOutput contactQuery = new QueryAction().execute( + new QueryInput(Contact.TABLE_NAME) + .withFilter(new QQueryFilter() + .withCriteria(new QFilterCriteria("email", QCriteriaOperator.EQUALS, email)))); + + if(!contactQuery.getRecords().isEmpty()) + { + Integer contactId = contactQuery.getRecords().get(0).getValueInteger("id"); + + ///////////////////////////////////////// + // update the form submission record // + ///////////////////////////////////////// + QRecord updateRecord = new QRecord(); + updateRecord.setValue("id", record.getValueInteger("id")); + updateRecord.setValue("contactId", contactId); + + new UpdateAction().execute( + new UpdateInput(FormSubmission.TABLE_NAME) + .withRecords(List.of(updateRecord))); + } + } + + return (records); + } + +} diff --git a/src/main/java/com/kingsrook/qbits/crm/core/model/Company.java b/src/main/java/com/kingsrook/qbits/crm/core/model/Company.java index 498ed8b..44eefe6 100644 --- a/src/main/java/com/kingsrook/qbits/crm/core/model/Company.java +++ b/src/main/java/com/kingsrook/qbits/crm/core/model/Company.java @@ -9,6 +9,7 @@ import java.time.Instant; import java.util.List; import com.kingsrook.qbits.crm.CrmQBitConfig; import com.kingsrook.qbits.crm.core.model.enums.CrmCompanyType; +import com.kingsrook.qbits.crm.deals.model.Deal; import com.kingsrook.qqq.backend.core.exceptions.QException; import com.kingsrook.qqq.backend.core.model.data.QField; import com.kingsrook.qqq.backend.core.model.data.QRecord; @@ -18,6 +19,9 @@ import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData; import com.kingsrook.qqq.backend.core.model.metadata.fields.ValueTooLongBehavior; import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon; import com.kingsrook.qqq.backend.core.model.metadata.producers.MetaDataCustomizerInterface; +import com.kingsrook.qqq.backend.core.model.metadata.producers.annotations.ChildJoin; +import com.kingsrook.qqq.backend.core.model.metadata.producers.annotations.ChildRecordListWidget; +import com.kingsrook.qqq.backend.core.model.metadata.producers.annotations.ChildTable; import com.kingsrook.qqq.backend.core.model.metadata.producers.annotations.QMetaDataProducingEntity; import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitProductionContext; import com.kingsrook.qqq.backend.core.model.metadata.security.RecordSecurityLock; @@ -35,7 +39,18 @@ import com.kingsrook.qqq.backend.core.utils.collections.MutableList; @QMetaDataProducingEntity( producePossibleValueSource = true, produceTableMetaData = true, - tableMetaDataCustomizer = Company.TableMetaDataCustomizer.class + tableMetaDataCustomizer = Company.TableMetaDataCustomizer.class, + childTables = { + @ChildTable(childTableEntityClass = Contact.class, joinFieldName = "companyId", + childJoin = @ChildJoin(enabled = true), + childRecordListWidget = @ChildRecordListWidget(label = "Contacts", enabled = true, maxRows = 50)), + @ChildTable(childTableEntityClass = Deal.class, joinFieldName = "companyId", + childJoin = @ChildJoin(enabled = true), + childRecordListWidget = @ChildRecordListWidget(label = "Deals", enabled = true, maxRows = 25)), + @ChildTable(childTableEntityClass = CompanyTag.class, joinFieldName = "companyId", + childJoin = @ChildJoin(enabled = true), + childRecordListWidget = @ChildRecordListWidget(label = "Tags", enabled = true, maxRows = 50)) + } ) public class Company extends QRecordEntity { diff --git a/src/main/java/com/kingsrook/qbits/crm/core/model/ConsentRecord.java b/src/main/java/com/kingsrook/qbits/crm/core/model/ConsentRecord.java index d55c1c0..71b953e 100644 --- a/src/main/java/com/kingsrook/qbits/crm/core/model/ConsentRecord.java +++ b/src/main/java/com/kingsrook/qbits/crm/core/model/ConsentRecord.java @@ -5,14 +5,20 @@ package com.kingsrook.qbits.crm.core.model; import java.time.Instant; +import java.util.List; import com.kingsrook.qbits.crm.core.model.enums.CrmConsentStatus; import com.kingsrook.qbits.crm.core.model.enums.CrmConsentType; import com.kingsrook.qbits.crm.core.model.enums.CrmLegalBasis; +import com.kingsrook.qqq.backend.core.actions.customizers.AbstractPreDeleteCustomizer; +import com.kingsrook.qqq.backend.core.actions.customizers.AbstractPreUpdateCustomizer; +import com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizers; import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.exceptions.QUserFacingException; import com.kingsrook.qqq.backend.core.model.data.QField; import com.kingsrook.qqq.backend.core.model.data.QRecord; import com.kingsrook.qqq.backend.core.model.data.QRecordEntity; import com.kingsrook.qqq.backend.core.model.metadata.QInstance; +import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference; import com.kingsrook.qqq.backend.core.model.metadata.fields.ValueTooLongBehavior; import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon; import com.kingsrook.qqq.backend.core.model.metadata.producers.MetaDataCustomizerInterface; @@ -36,7 +42,8 @@ public class ConsentRecord extends QRecordEntity /*************************************************************************** - ** + ** TableMetaDataCustomizer -- sets icon, record label, sections, and + ** registers PRE_UPDATE and PRE_DELETE customizers to enforce immutability. ***************************************************************************/ public static class TableMetaDataCustomizer implements MetaDataCustomizerInterface { @@ -55,12 +62,52 @@ public class ConsentRecord extends QRecordEntity .withSection(SectionFactory.defaultT2("legalBasis", "source", "consentDate", "expiryDate", "ipAddress")) .withSection(SectionFactory.defaultT3("createDate")); + /////////////////////////////////////////////////////////////////////////// + // register PRE_UPDATE and PRE_DELETE customizers to enforce immutability // + /////////////////////////////////////////////////////////////////////////// + table.withCustomizer(TableCustomizers.PRE_UPDATE_RECORD, new QCodeReference(PreventUpdateCustomizer.class)); + table.withCustomizer(TableCustomizers.PRE_DELETE_RECORD, new QCodeReference(PreventDeleteCustomizer.class)); + return (table); } } + /*************************************************************************** + ** PRE_UPDATE customizer that rejects all update attempts on consent records. + ***************************************************************************/ + public static class PreventUpdateCustomizer extends AbstractPreUpdateCustomizer + { + /*************************************************************************** + ** + ***************************************************************************/ + @Override + public List apply(List records) throws QException + { + throw new QUserFacingException("Consent records cannot be modified or deleted"); + } + } + + + + /*************************************************************************** + ** PRE_DELETE customizer that rejects all delete attempts on consent records. + ***************************************************************************/ + public static class PreventDeleteCustomizer extends AbstractPreDeleteCustomizer + { + /*************************************************************************** + ** + ***************************************************************************/ + @Override + public List apply(List records) throws QException + { + throw new QUserFacingException("Consent records cannot be modified or deleted"); + } + } + + + @QField(isEditable = false, isPrimaryKey = true) private Integer id; diff --git a/src/main/java/com/kingsrook/qbits/crm/core/model/Contact.java b/src/main/java/com/kingsrook/qbits/crm/core/model/Contact.java index af74ab5..6900598 100644 --- a/src/main/java/com/kingsrook/qbits/crm/core/model/Contact.java +++ b/src/main/java/com/kingsrook/qbits/crm/core/model/Contact.java @@ -7,6 +7,8 @@ package com.kingsrook.qbits.crm.core.model; import java.time.Instant; import java.util.List; import com.kingsrook.qbits.crm.CrmQBitConfig; +import com.kingsrook.qbits.crm.activities.model.Activity; +import com.kingsrook.qbits.crm.deals.model.DealContact; import com.kingsrook.qqq.backend.core.exceptions.QException; import com.kingsrook.qqq.backend.core.model.data.QField; import com.kingsrook.qqq.backend.core.model.data.QRecord; @@ -16,6 +18,9 @@ import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData; import com.kingsrook.qqq.backend.core.model.metadata.fields.ValueTooLongBehavior; import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon; import com.kingsrook.qqq.backend.core.model.metadata.producers.MetaDataCustomizerInterface; +import com.kingsrook.qqq.backend.core.model.metadata.producers.annotations.ChildJoin; +import com.kingsrook.qqq.backend.core.model.metadata.producers.annotations.ChildRecordListWidget; +import com.kingsrook.qqq.backend.core.model.metadata.producers.annotations.ChildTable; import com.kingsrook.qqq.backend.core.model.metadata.producers.annotations.QMetaDataProducingEntity; import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitProductionContext; import com.kingsrook.qqq.backend.core.model.metadata.security.RecordSecurityLock; @@ -33,7 +38,21 @@ import com.kingsrook.qqq.backend.core.utils.collections.MutableList; @QMetaDataProducingEntity( producePossibleValueSource = true, produceTableMetaData = true, - tableMetaDataCustomizer = Contact.TableMetaDataCustomizer.class + tableMetaDataCustomizer = Contact.TableMetaDataCustomizer.class, + childTables = { + @ChildTable(childTableEntityClass = Activity.class, joinFieldName = "contactId", + childJoin = @ChildJoin(enabled = true), + childRecordListWidget = @ChildRecordListWidget(label = "Activities", enabled = true, maxRows = 50)), + @ChildTable(childTableEntityClass = DealContact.class, joinFieldName = "contactId", + childJoin = @ChildJoin(enabled = true), + childRecordListWidget = @ChildRecordListWidget(label = "Deals", enabled = true, maxRows = 25)), + @ChildTable(childTableEntityClass = ContactTag.class, joinFieldName = "contactId", + childJoin = @ChildJoin(enabled = true), + childRecordListWidget = @ChildRecordListWidget(label = "Tags", enabled = true, maxRows = 50)), + @ChildTable(childTableEntityClass = ConsentRecord.class, joinFieldName = "contactId", + childJoin = @ChildJoin(enabled = true), + childRecordListWidget = @ChildRecordListWidget(label = "Consent Records", enabled = true, maxRows = 25)) + } ) public class Contact extends QRecordEntity { diff --git a/src/main/java/com/kingsrook/qbits/crm/core/processes/BulkAssignOwnerProcess.java b/src/main/java/com/kingsrook/qbits/crm/core/processes/BulkAssignOwnerProcess.java index 937ad81..2de673f 100644 --- a/src/main/java/com/kingsrook/qbits/crm/core/processes/BulkAssignOwnerProcess.java +++ b/src/main/java/com/kingsrook/qbits/crm/core/processes/BulkAssignOwnerProcess.java @@ -53,7 +53,6 @@ public class BulkAssignOwnerProcess implements MetaDataProducerInterface { + private static final QLogger LOG = QLogger.getLogger(GdprAnonymizeContactProcess.class); + public static final String NAME = "gdprAnonymizeContact"; private static final String ANONYMIZED = "[ANONYMIZED]"; @@ -88,7 +92,7 @@ public class GdprAnonymizeContactProcess implements BackendStep, MetaDataProduce public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException { Integer contactId = input.getValueInteger("contactId"); - String sessionUserId = QContext.getQSession().getIdReference(); + String sessionUserId = CrmSessionUtils.getCurrentUserId(); /////////////////////////////////////////// // Step 1: load and validate contact // @@ -147,8 +151,8 @@ public class GdprAnonymizeContactProcess implements BackendStep, MetaDataProduce new InsertInput(ConsentRecord.TABLE_NAME).withRecordEntity( new ConsentRecord() .withContactId(contactId) - .withConsentType(CrmConsentType.DATA_PROCESSING.getId()) - .withStatus(CrmConsentStatus.WITHDRAWN.getId()) + .withConsentType(CrmConsentType.DATA_PROCESSING.getPossibleValueId()) + .withStatus(CrmConsentStatus.WITHDRAWN.getPossibleValueId()) .withSource("GDPR Anonymization Process") .withConsentDate(Instant.now()))); @@ -158,9 +162,9 @@ public class GdprAnonymizeContactProcess implements BackendStep, MetaDataProduce new InsertAction().execute( new InsertInput(AuditLog.TABLE_NAME).withRecordEntity( new AuditLog() - .withEntityType(CrmEntityType.CONTACT.getId()) + .withEntityType(CrmEntityType.CONTACT.getPossibleValueId()) .withEntityId(contactId) - .withAction(CrmAuditAction.ANONYMIZED.getId()) + .withAction(CrmAuditAction.ANONYMIZED.getPossibleValueId()) .withUserId(sessionUserId) .withMessage("Contact anonymized per GDPR right-to-erasure request"))); @@ -171,17 +175,43 @@ public class GdprAnonymizeContactProcess implements BackendStep, MetaDataProduce /*************************************************************************** - ** Scrub audit log entries for this contact. Since audit log has immutability - ** customizers, we insert new records with anonymized content instead. - ** Note: In a real system this would bypass the customizer; for now we - ** record the anonymization as separate audit entries. + ** Scrub audit log entries for this contact. Sets a GDPR bypass flag on + ** the session so the immutability customizers allow the update. ***************************************************************************/ private void scrubAuditLogEntries(Integer contactId) throws QException { - // Note: the audit log has PRE_UPDATE/PRE_DELETE customizers that prevent - // modification. For GDPR compliance, the actual scrubbing would need a - // privileged bypass. Here we simply log that the scrub was requested. - // The ANONYMIZED audit entry serves as the tombstone marker. + /////////////////////////////////////////// + // set GDPR bypass flag on session // + /////////////////////////////////////////// + QContext.getQSession().setValue("gdprBypass", "true"); + try + { + ///////////////////////////////////////////// + // query audit entries for this contact // + ///////////////////////////////////////////// + QueryOutput auditEntries = new QueryAction().execute( + new QueryInput(AuditLog.TABLE_NAME) + .withFilter(new QQueryFilter() + .withCriteria(new QFilterCriteria("entityType", QCriteriaOperator.EQUALS, CrmEntityType.CONTACT.getPossibleValueId())) + .withCriteria(new QFilterCriteria("entityId", QCriteriaOperator.EQUALS, contactId)))); + + ///////////////////////////////////////////// + // anonymize each entry // + ///////////////////////////////////////////// + for(QRecord record : auditEntries.getRecords()) + { + QRecord update = new QRecord(); + update.setValue("id", record.getValueInteger("id")); + update.setValue("oldValue", ANONYMIZED); + update.setValue("newValue", ANONYMIZED); + update.setValue("message", ANONYMIZED); + new UpdateAction().execute(new UpdateInput(AuditLog.TABLE_NAME).withRecords(List.of(update))); + } + } + finally + { + QContext.getQSession().setValue("gdprBypass", "false"); + } } @@ -194,7 +224,7 @@ public class GdprAnonymizeContactProcess implements BackendStep, MetaDataProduce QueryOutput attachments = new QueryAction().execute( new QueryInput(Attachment.TABLE_NAME) .withFilter(new QQueryFilter() - .withCriteria(new QFilterCriteria("entityType", QCriteriaOperator.EQUALS, CrmEntityType.CONTACT.getId())) + .withCriteria(new QFilterCriteria("entityType", QCriteriaOperator.EQUALS, CrmEntityType.CONTACT.getPossibleValueId())) .withCriteria(new QFilterCriteria("entityId", QCriteriaOperator.EQUALS, contactId)))); for(QRecord record : attachments.getRecords()) @@ -217,13 +247,13 @@ public class GdprAnonymizeContactProcess implements BackendStep, MetaDataProduce new QueryInput("crmSequenceEnrollment") .withFilter(new QQueryFilter() .withCriteria(new QFilterCriteria("contactId", QCriteriaOperator.EQUALS, contactId)) - .withCriteria(new QFilterCriteria("status", QCriteriaOperator.EQUALS, CrmEnrollmentStatus.ACTIVE.getId())))); + .withCriteria(new QFilterCriteria("status", QCriteriaOperator.EQUALS, CrmEnrollmentStatus.ACTIVE.getPossibleValueId())))); for(QRecord record : enrollments.getRecords()) { QRecord updateRecord = new QRecord() .withValue("id", record.getValueInteger("id")) - .withValue("status", CrmEnrollmentStatus.UNENROLLED.getId()); + .withValue("status", CrmEnrollmentStatus.UNENROLLED.getPossibleValueId()); new UpdateAction().execute( new UpdateInput("crmSequenceEnrollment").withRecord(updateRecord)); @@ -231,9 +261,7 @@ public class GdprAnonymizeContactProcess implements BackendStep, MetaDataProduce } catch(Exception e) { - ////////////////////////////////////////////////////////////////// - // sequence enrollment table may not exist yet -- that is ok // - ////////////////////////////////////////////////////////////////// + LOG.warn("Error unenrolling contact from sequences during GDPR anonymization", e); } } diff --git a/src/main/java/com/kingsrook/qbits/crm/core/processes/ImportContactsProcess.java b/src/main/java/com/kingsrook/qbits/crm/core/processes/ImportContactsProcess.java index 1334cc9..dea27e6 100644 --- a/src/main/java/com/kingsrook/qbits/crm/core/processes/ImportContactsProcess.java +++ b/src/main/java/com/kingsrook/qbits/crm/core/processes/ImportContactsProcess.java @@ -141,9 +141,9 @@ public class ImportContactsProcess implements BackendStep, MetaDataProducerInter new InsertAction().execute( new InsertInput(AuditLog.TABLE_NAME).withRecordEntity( new AuditLog() - .withEntityType(CrmEntityType.CONTACT.getId()) + .withEntityType(CrmEntityType.CONTACT.getPossibleValueId()) .withEntityId(newContactId) - .withAction(CrmAuditAction.IMPORTED.getId()) + .withAction(CrmAuditAction.IMPORTED.getPossibleValueId()) .withUserId(sessionUserId) .withMessage("Contact imported"))); diff --git a/src/main/java/com/kingsrook/qbits/crm/core/processes/MergeCompaniesProcess.java b/src/main/java/com/kingsrook/qbits/crm/core/processes/MergeCompaniesProcess.java index c951817..a2f958b 100644 --- a/src/main/java/com/kingsrook/qbits/crm/core/processes/MergeCompaniesProcess.java +++ b/src/main/java/com/kingsrook/qbits/crm/core/processes/MergeCompaniesProcess.java @@ -17,13 +17,13 @@ import com.kingsrook.qbits.crm.core.model.Contact; import com.kingsrook.qbits.crm.core.model.enums.CrmAuditAction; import com.kingsrook.qbits.crm.core.model.enums.CrmEntityType; import com.kingsrook.qbits.crm.deals.model.Deal; +import com.kingsrook.qbits.crm.CrmSessionUtils; import com.kingsrook.qqq.backend.core.actions.processes.BackendStep; import com.kingsrook.qqq.backend.core.actions.tables.DeleteAction; import com.kingsrook.qqq.backend.core.actions.tables.GetAction; import com.kingsrook.qqq.backend.core.actions.tables.InsertAction; import com.kingsrook.qqq.backend.core.actions.tables.QueryAction; import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction; -import com.kingsrook.qqq.backend.core.context.QContext; import com.kingsrook.qqq.backend.core.exceptions.QException; import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput; import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput; @@ -88,7 +88,7 @@ public class MergeCompaniesProcess implements BackendStep, MetaDataProducerInter { Integer primaryCompanyId = input.getValueInteger("primaryCompanyId"); Integer secondaryCompanyId = input.getValueInteger("secondaryCompanyId"); - String sessionUserId = QContext.getQSession().getIdReference(); + String sessionUserId = CrmSessionUtils.getCurrentUserId(); ///////////////////////////////////////// // validate both companies exist // @@ -134,18 +134,18 @@ public class MergeCompaniesProcess implements BackendStep, MetaDataProducerInter new InsertAction().execute( new InsertInput(AuditLog.TABLE_NAME).withRecordEntity( new AuditLog() - .withEntityType(CrmEntityType.COMPANY.getId()) + .withEntityType(CrmEntityType.COMPANY.getPossibleValueId()) .withEntityId(primaryCompanyId) - .withAction(CrmAuditAction.MERGED.getId()) + .withAction(CrmAuditAction.MERGED.getPossibleValueId()) .withUserId(sessionUserId) .withMessage("Merged company " + secondaryCompanyId + " into this company"))); new InsertAction().execute( new InsertInput(AuditLog.TABLE_NAME).withRecordEntity( new AuditLog() - .withEntityType(CrmEntityType.COMPANY.getId()) + .withEntityType(CrmEntityType.COMPANY.getPossibleValueId()) .withEntityId(secondaryCompanyId) - .withAction(CrmAuditAction.DELETED.getId()) + .withAction(CrmAuditAction.DELETED.getPossibleValueId()) .withUserId(sessionUserId) .withMessage("Company deleted during merge into company " + primaryCompanyId))); diff --git a/src/main/java/com/kingsrook/qbits/crm/core/processes/MergeContactsProcess.java b/src/main/java/com/kingsrook/qbits/crm/core/processes/MergeContactsProcess.java index 6076573..f245a29 100644 --- a/src/main/java/com/kingsrook/qbits/crm/core/processes/MergeContactsProcess.java +++ b/src/main/java/com/kingsrook/qbits/crm/core/processes/MergeContactsProcess.java @@ -17,13 +17,13 @@ import com.kingsrook.qbits.crm.core.model.EmailListMember; import com.kingsrook.qbits.crm.core.model.enums.CrmAuditAction; import com.kingsrook.qbits.crm.core.model.enums.CrmEntityType; import com.kingsrook.qbits.crm.deals.model.DealContact; +import com.kingsrook.qbits.crm.CrmSessionUtils; import com.kingsrook.qqq.backend.core.actions.processes.BackendStep; import com.kingsrook.qqq.backend.core.actions.tables.DeleteAction; import com.kingsrook.qqq.backend.core.actions.tables.GetAction; import com.kingsrook.qqq.backend.core.actions.tables.InsertAction; import com.kingsrook.qqq.backend.core.actions.tables.QueryAction; import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction; -import com.kingsrook.qqq.backend.core.context.QContext; import com.kingsrook.qqq.backend.core.exceptions.QException; import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput; import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput; @@ -88,7 +88,7 @@ public class MergeContactsProcess implements BackendStep, MetaDataProducerInterf { Integer primaryContactId = input.getValueInteger("primaryContactId"); Integer secondaryContactId = input.getValueInteger("secondaryContactId"); - String sessionUserId = QContext.getQSession().getIdReference(); + String sessionUserId = CrmSessionUtils.getCurrentUserId(); ///////////////////////////////////////// // validate both contacts exist // @@ -137,18 +137,18 @@ public class MergeContactsProcess implements BackendStep, MetaDataProducerInterf new InsertAction().execute( new InsertInput(AuditLog.TABLE_NAME).withRecordEntity( new AuditLog() - .withEntityType(CrmEntityType.CONTACT.getId()) + .withEntityType(CrmEntityType.CONTACT.getPossibleValueId()) .withEntityId(primaryContactId) - .withAction(CrmAuditAction.MERGED.getId()) + .withAction(CrmAuditAction.MERGED.getPossibleValueId()) .withUserId(sessionUserId) .withMessage("Merged contact " + secondaryContactId + " into this contact"))); new InsertAction().execute( new InsertInput(AuditLog.TABLE_NAME).withRecordEntity( new AuditLog() - .withEntityType(CrmEntityType.CONTACT.getId()) + .withEntityType(CrmEntityType.CONTACT.getPossibleValueId()) .withEntityId(secondaryContactId) - .withAction(CrmAuditAction.DELETED.getId()) + .withAction(CrmAuditAction.DELETED.getPossibleValueId()) .withUserId(sessionUserId) .withMessage("Contact deleted during merge into contact " + primaryContactId))); diff --git a/src/main/java/com/kingsrook/qbits/crm/core/processes/SubscribeToListProcess.java b/src/main/java/com/kingsrook/qbits/crm/core/processes/SubscribeToListProcess.java index 66865b6..9013ef4 100644 --- a/src/main/java/com/kingsrook/qbits/crm/core/processes/SubscribeToListProcess.java +++ b/src/main/java/com/kingsrook/qbits/crm/core/processes/SubscribeToListProcess.java @@ -98,14 +98,14 @@ public class SubscribeToListProcess implements BackendStep, MetaDataProducerInte QRecord existingRecord = existingMembers.getRecords().get(0); Integer currentStatus = existingRecord.getValueInteger("status"); - if(CrmListMemberStatus.UNSUBSCRIBED.getId().equals(currentStatus)) + if(CrmListMemberStatus.UNSUBSCRIBED.getPossibleValueId().equals(currentStatus)) { /////////////////////////////////////////////// // resubscribe: update status and date // /////////////////////////////////////////////// QRecord updateRecord = new QRecord() .withValue("id", existingRecord.getValueInteger("id")) - .withValue("status", CrmListMemberStatus.SUBSCRIBED.getId()) + .withValue("status", CrmListMemberStatus.SUBSCRIBED.getPossibleValueId()) .withValue("subscribedDate", Instant.now()) .withValue("unsubscribedDate", null); @@ -140,7 +140,7 @@ public class SubscribeToListProcess implements BackendStep, MetaDataProducerInte .withEmail(email) .withFirstName(firstName) .withContactId(contactId) - .withStatus(CrmListMemberStatus.SUBSCRIBED.getId()) + .withStatus(CrmListMemberStatus.SUBSCRIBED.getPossibleValueId()) .withSubscribedDate(Instant.now()) .withSubscriptionSource(subscriptionSource); diff --git a/src/main/java/com/kingsrook/qbits/crm/core/processes/UnsubscribeFromListProcess.java b/src/main/java/com/kingsrook/qbits/crm/core/processes/UnsubscribeFromListProcess.java index 49394f8..a8ed854 100644 --- a/src/main/java/com/kingsrook/qbits/crm/core/processes/UnsubscribeFromListProcess.java +++ b/src/main/java/com/kingsrook/qbits/crm/core/processes/UnsubscribeFromListProcess.java @@ -98,7 +98,7 @@ public class UnsubscribeFromListProcess implements BackendStep, MetaDataProducer //////////////////////////////////////////////////// QRecord updateRecord = new QRecord() .withValue("id", memberRecord.getValueInteger("id")) - .withValue("status", CrmListMemberStatus.UNSUBSCRIBED.getId()) + .withValue("status", CrmListMemberStatus.UNSUBSCRIBED.getPossibleValueId()) .withValue("unsubscribedDate", Instant.now()); new UpdateAction().execute( diff --git a/src/main/java/com/kingsrook/qbits/crm/deals/customizers/DealInitializationCustomizer.java b/src/main/java/com/kingsrook/qbits/crm/deals/customizers/DealInitializationCustomizer.java new file mode 100644 index 0000000..92d94a3 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/crm/deals/customizers/DealInitializationCustomizer.java @@ -0,0 +1,208 @@ +/******************************************************************************* + ** POST_INSERT table customizer that initializes newly-created deals with + ** computed fields: stageEnteredDate, weightedAmount, amountInBaseCurrency, + ** and the initial DealStageHistory row. + *******************************************************************************/ +package com.kingsrook.qbits.crm.deals.customizers; + + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.time.Instant; +import java.util.List; +import com.kingsrook.qbits.crm.deals.model.Deal; +import com.kingsrook.qbits.crm.sync.CrmCurrencyUtils; +import com.kingsrook.qbits.crm.deals.model.DealStageHistory; +import com.kingsrook.qbits.crm.deals.model.PipelineStage; +import com.kingsrook.qbits.crm.CrmSessionUtils; +import com.kingsrook.qqq.backend.core.actions.customizers.AbstractPostInsertCustomizer; +import com.kingsrook.qqq.backend.core.actions.tables.GetAction; +import com.kingsrook.qqq.backend.core.actions.tables.InsertAction; +import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.logging.QLogger; +import com.kingsrook.qqq.backend.core.model.actions.tables.get.GetInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.get.GetOutput; +import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput; +import com.kingsrook.qqq.backend.core.model.data.QRecord; +import com.kingsrook.qqq.backend.core.utils.ValueUtils; + + +/******************************************************************************* + ** Post-insert customizer for the Deal table that sets initial computed + ** values and creates the first DealStageHistory record. + *******************************************************************************/ +public class DealInitializationCustomizer extends AbstractPostInsertCustomizer +{ + private static final QLogger LOG = QLogger.getLogger(DealInitializationCustomizer.class); + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Override + public List apply(List records) throws QException + { + Instant now = Instant.now(); + + for(QRecord record : records) + { + if(record.getErrors() != null && !record.getErrors().isEmpty()) + { + continue; + } + + Integer dealId = ValueUtils.getValueAsInteger(record.getValue("id")); + if(dealId == null) + { + continue; + } + + try + { + initializeDeal(record, dealId, now); + } + catch(Exception e) + { + LOG.warn("Error initializing deal id=" + dealId, e); + } + } + + return (records); + } + + + + /******************************************************************************* + ** Initialize a newly-inserted deal with computed values. + *******************************************************************************/ + private void initializeDeal(QRecord record, Integer dealId, Instant now) throws QException + { + QRecord updateRecord = new QRecord(); + updateRecord.setValue("id", dealId); + boolean needsUpdate = false; + + /////////////////////////////////////////// + // set stageEnteredDate if not set // + /////////////////////////////////////////// + Instant stageEnteredDate = ValueUtils.getValueAsInstant(record.getValue("stageEnteredDate")); + if(stageEnteredDate == null) + { + updateRecord.setValue("stageEnteredDate", now); + needsUpdate = true; + } + + /////////////////////////////////////////// + // load the pipeline stage for calcs // + /////////////////////////////////////////// + Integer stageId = ValueUtils.getValueAsInteger(record.getValue("pipelineStageId")); + PipelineStage stage = null; + if(stageId != null) + { + GetOutput stageOutput = new GetAction().execute(new GetInput(PipelineStage.TABLE_NAME).withPrimaryKey(stageId)); + if(stageOutput.getRecord() != null) + { + stage = new PipelineStage(stageOutput.getRecord()); + } + } + + /////////////////////////////////////////// + // calculate weightedAmount // + /////////////////////////////////////////// + BigDecimal amount = ValueUtils.getValueAsBigDecimal(record.getValue("amount")); + if(amount != null && stage != null) + { + Integer probabilityOverridePct = ValueUtils.getValueAsInteger(record.getValue("probabilityOverridePct")); + Integer probability = probabilityOverridePct != null + ? probabilityOverridePct + : stage.getProbabilityPct(); + + if(probability == null) + { + probability = 0; + } + + BigDecimal weightedAmount = amount + .multiply(new BigDecimal(probability)) + .divide(new BigDecimal("100"), 2, RoundingMode.HALF_UP); + + updateRecord.setValue("weightedAmount", weightedAmount); + needsUpdate = true; + } + + /////////////////////////////////////////// + // calculate amountInBaseCurrency // + /////////////////////////////////////////// + if(amount != null) + { + BigDecimal amountInBase = calculateAmountInBaseCurrency(amount, record); + updateRecord.setValue("amountInBaseCurrency", amountInBase); + needsUpdate = true; + } + + /////////////////////////////////////////// + // update the deal record // + /////////////////////////////////////////// + if(needsUpdate) + { + new UpdateAction().execute(new UpdateInput(Deal.TABLE_NAME).withRecord(updateRecord)); + } + + /////////////////////////////////////////// + // insert initial DealStageHistory row // + /////////////////////////////////////////// + if(stageId != null) + { + insertInitialStageHistory(dealId, stageId, now); + } + } + + + + /******************************************************************************* + ** Calculate amountInBaseCurrency using the shared CrmCurrencyUtils. + *******************************************************************************/ + private BigDecimal calculateAmountInBaseCurrency(BigDecimal amount, QRecord record) throws QException + { + String currencyCode = ValueUtils.getValueAsString(record.getValue("currencyCode")); + try + { + return (CrmCurrencyUtils.convertToBaseCurrency(amount, currencyCode)); + } + catch(Exception e) + { + LOG.warn("Error calculating amountInBaseCurrency for currencyCode=" + currencyCode, e); + return (amount); + } + } + + + + /******************************************************************************* + ** Insert the initial DealStageHistory row (fromStageId = null). + *******************************************************************************/ + private void insertInitialStageHistory(Integer dealId, Integer toStageId, Instant now) throws QException + { + try + { + String userId = CrmSessionUtils.getCurrentUserId(); + + DealStageHistory history = new DealStageHistory() + .withDealId(dealId) + .withFromStageId(null) + .withToStageId(toStageId) + .withUserId(userId) + .withTransitionDate(now) + .withDurationInFromStageDays(null); + + new InsertAction().execute(new InsertInput(DealStageHistory.TABLE_NAME).withRecordEntity(history)); + } + catch(Exception e) + { + LOG.warn("Error inserting initial DealStageHistory for dealId=" + dealId, e); + } + } + +} diff --git a/src/main/java/com/kingsrook/qbits/crm/deals/customizers/DealProductRecalculationCustomizer.java b/src/main/java/com/kingsrook/qbits/crm/deals/customizers/DealProductRecalculationCustomizer.java new file mode 100644 index 0000000..c4ba7d3 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/crm/deals/customizers/DealProductRecalculationCustomizer.java @@ -0,0 +1,306 @@ +/******************************************************************************* + ** POST_INSERT, POST_UPDATE, and POST_DELETE table customizer for + ** DealProduct that recalculates line-item totalAmount and rolls up + ** the sum to the parent Deal's amount field. + *******************************************************************************/ +package com.kingsrook.qbits.crm.deals.customizers; + + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import com.kingsrook.qbits.crm.deals.model.Deal; +import com.kingsrook.qbits.crm.deals.model.DealProduct; +import com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizerInterface; +import com.kingsrook.qqq.backend.core.actions.tables.QueryAction; +import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.logging.QLogger; +import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryOutput; +import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput; +import com.kingsrook.qqq.backend.core.model.data.QRecord; +import com.kingsrook.qqq.backend.core.utils.ValueUtils; + + +/******************************************************************************* + ** Recalculates totalAmount on each DealProduct line item, then sums all + ** line items for the parent deal and updates deal.amount. + *******************************************************************************/ +public class DealProductRecalculationCustomizer implements TableCustomizerInterface +{ + private static final QLogger LOG = QLogger.getLogger(DealProductRecalculationCustomizer.class); + + + + /******************************************************************************* + ** Post-insert: recalculate totalAmount for inserted records and update parent. + *******************************************************************************/ + @Override + public List postInsert(InsertInput insertInput, List records) throws QException + { + return (recalculate(records)); + } + + + + /******************************************************************************* + ** Post-update: recalculate totalAmount for updated records and update parent. + *******************************************************************************/ + @Override + public List postUpdate(UpdateInput updateInput, List records, java.util.Optional> oldRecordList) throws QException + { + return (recalculate(records)); + } + + + + /******************************************************************************* + ** Post-delete: recalculate parent deal's amount from remaining line items. + *******************************************************************************/ + @Override + public List postDelete(DeleteInput deleteInput, List records) throws QException + { + Set dealIds = new HashSet<>(); + for(QRecord record : records) + { + if(record.getErrors() != null && !record.getErrors().isEmpty()) + { + continue; + } + + Integer dealId = ValueUtils.getValueAsInteger(record.getValue("dealId")); + if(dealId != null) + { + dealIds.add(dealId); + } + } + + for(Integer dealId : dealIds) + { + recalculateParentDeal(dealId); + } + + return (records); + } + + + + /******************************************************************************* + ** Recalculate totalAmount on each record and then update parent deals. + ** Tracks computed sums in-memory to avoid stale re-reads from the database. + *******************************************************************************/ + private List recalculate(List records) throws QException + { + Map dealIdToComputedSum = new HashMap<>(); + List updateRecords = new ArrayList<>(); + + for(QRecord record : records) + { + if(record.getErrors() != null && !record.getErrors().isEmpty()) + { + continue; + } + + Integer recordId = ValueUtils.getValueAsInteger(record.getValue("id")); + if(recordId == null) + { + continue; + } + + try + { + BigDecimal totalAmount = calculateTotalAmount(record); + if(totalAmount != null) + { + QRecord updateRecord = new QRecord(); + updateRecord.setValue("id", recordId); + updateRecord.setValue("totalAmount", totalAmount); + updateRecords.add(updateRecord); + + ///////////////////////////////////////////////////////////////// + // track in-memory sum per deal to avoid stale re-reads (C-3) // + ///////////////////////////////////////////////////////////////// + Integer dealId = ValueUtils.getValueAsInteger(record.getValue("dealId")); + if(dealId != null) + { + dealIdToComputedSum.merge(dealId, totalAmount, BigDecimal::add); + } + } + else + { + Integer dealId = ValueUtils.getValueAsInteger(record.getValue("dealId")); + if(dealId != null) + { + dealIdToComputedSum.putIfAbsent(dealId, BigDecimal.ZERO); + } + } + } + catch(Exception e) + { + LOG.warn("Error calculating totalAmount for DealProduct id=" + recordId, e); + } + } + + /////////////////////////////////////////// + // batch update all DealProduct rows // + /////////////////////////////////////////// + if(!updateRecords.isEmpty()) + { + new UpdateAction().execute(new UpdateInput(DealProduct.TABLE_NAME).withRecords(updateRecords)); + } + + ///////////////////////////////////////////////////////////////////////// + // update each parent deal using in-memory sums plus any pre-existing // + // DealProduct rows not in this batch // + ///////////////////////////////////////////////////////////////////////// + for(Integer dealId : dealIdToComputedSum.keySet()) + { + recalculateParentDealWithInMemorySums(dealId, dealIdToComputedSum.get(dealId), records); + } + + return (records); + } + + + + /******************************************************************************* + ** Calculate totalAmount = quantity * unitPrice * (1 - discountPct / 100). + *******************************************************************************/ + private BigDecimal calculateTotalAmount(QRecord record) + { + BigDecimal quantity = ValueUtils.getValueAsBigDecimal(record.getValue("quantity")); + BigDecimal unitPrice = ValueUtils.getValueAsBigDecimal(record.getValue("unitPrice")); + + if(quantity == null || unitPrice == null) + { + return (null); + } + + BigDecimal discountPct = ValueUtils.getValueAsBigDecimal(record.getValue("discountPct")); + if(discountPct == null) + { + discountPct = BigDecimal.ZERO; + } + + /////////////////////////////////////////////////////////// + // totalAmount = quantity * unitPrice * (1 - discount%) // + /////////////////////////////////////////////////////////// + BigDecimal discountMultiplier = BigDecimal.ONE.subtract( + discountPct.divide(new BigDecimal("100"), 10, RoundingMode.HALF_UP)); + + return (quantity.multiply(unitPrice).multiply(discountMultiplier) + .setScale(2, RoundingMode.HALF_UP)); + } + + + + /******************************************************************************* + ** Query all DealProduct rows for a deal, sum totalAmount, and update + ** the deal's amount field. Used by post-delete where no in-memory sums + ** are available. + *******************************************************************************/ + private void recalculateParentDeal(Integer dealId) throws QException + { + try + { + QueryOutput queryOutput = new QueryAction().execute( + new QueryInput(DealProduct.TABLE_NAME) + .withFilter(new QQueryFilter() + .withCriteria(new QFilterCriteria("dealId", QCriteriaOperator.EQUALS, dealId)))); + + BigDecimal sum = BigDecimal.ZERO; + for(QRecord dpRecord : queryOutput.getRecords()) + { + BigDecimal total = ValueUtils.getValueAsBigDecimal(dpRecord.getValue("totalAmount")); + if(total != null) + { + sum = sum.add(total); + } + } + + QRecord dealUpdate = new QRecord(); + dealUpdate.setValue("id", dealId); + dealUpdate.setValue("amount", sum); + new UpdateAction().execute(new UpdateInput(Deal.TABLE_NAME).withRecord(dealUpdate)); + } + catch(Exception e) + { + LOG.warn("Error recalculating parent deal amount for dealId=" + dealId, e); + } + } + + + + /******************************************************************************* + ** Update a parent deal's amount using in-memory computed sums for the + ** current batch, plus any existing DealProduct rows not in this batch. + ** Avoids re-querying rows we just updated (C-3: stale read fix). + *******************************************************************************/ + private void recalculateParentDealWithInMemorySums(Integer dealId, BigDecimal batchSum, List batchRecords) throws QException + { + try + { + ///////////////////////////////////////////////////////////////// + // collect IDs of DealProduct rows in the current batch // + ///////////////////////////////////////////////////////////////// + Set batchProductIds = new HashSet<>(); + for(QRecord record : batchRecords) + { + Integer productDealId = ValueUtils.getValueAsInteger(record.getValue("dealId")); + if(dealId.equals(productDealId)) + { + Integer productId = ValueUtils.getValueAsInteger(record.getValue("id")); + if(productId != null) + { + batchProductIds.add(productId); + } + } + } + + ///////////////////////////////////////////////////////////////// + // query remaining DealProduct rows not in the current batch // + ///////////////////////////////////////////////////////////////// + QueryOutput queryOutput = new QueryAction().execute( + new QueryInput(DealProduct.TABLE_NAME) + .withFilter(new QQueryFilter() + .withCriteria(new QFilterCriteria("dealId", QCriteriaOperator.EQUALS, dealId)))); + + BigDecimal otherSum = BigDecimal.ZERO; + for(QRecord dpRecord : queryOutput.getRecords()) + { + Integer dpId = ValueUtils.getValueAsInteger(dpRecord.getValue("id")); + if(dpId != null && !batchProductIds.contains(dpId)) + { + BigDecimal total = ValueUtils.getValueAsBigDecimal(dpRecord.getValue("totalAmount")); + if(total != null) + { + otherSum = otherSum.add(total); + } + } + } + + BigDecimal totalDealAmount = batchSum.add(otherSum); + + QRecord dealUpdate = new QRecord(); + dealUpdate.setValue("id", dealId); + dealUpdate.setValue("amount", totalDealAmount); + new UpdateAction().execute(new UpdateInput(Deal.TABLE_NAME).withRecord(dealUpdate)); + } + catch(Exception e) + { + LOG.warn("Error recalculating parent deal amount for dealId=" + dealId, e); + } + } + +} diff --git a/src/main/java/com/kingsrook/qbits/crm/deals/customizers/DealStageChangeCustomizer.java b/src/main/java/com/kingsrook/qbits/crm/deals/customizers/DealStageChangeCustomizer.java new file mode 100644 index 0000000..59ca773 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/crm/deals/customizers/DealStageChangeCustomizer.java @@ -0,0 +1,355 @@ +/******************************************************************************* + ** PRE_UPDATE table customizer that handles deal pipeline stage transitions. + ** + ** When a deal's pipelineStageId changes: + ** - validates required fields for the target stage + ** - recalculates weightedAmount and amountInBaseCurrency + ** - sets stageEnteredDate + ** - sets actualCloseDate on terminal stages (closedWon / closedLost) + ** - clears actualCloseDate and winLossReasonId on reopen + ** - inserts a DealStageHistory row + *******************************************************************************/ +package com.kingsrook.qbits.crm.deals.customizers; + + +import java.io.Serializable; +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.time.Duration; +import java.time.Instant; +import java.time.LocalDate; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import com.kingsrook.qbits.crm.deals.model.Deal; +import com.kingsrook.qbits.crm.sync.CrmCurrencyUtils; +import com.kingsrook.qbits.crm.deals.model.DealStageHistory; +import com.kingsrook.qbits.crm.deals.model.PipelineStage; +import com.kingsrook.qbits.crm.CrmSessionUtils; +import com.kingsrook.qqq.backend.core.actions.customizers.AbstractPreUpdateCustomizer; +import com.kingsrook.qqq.backend.core.actions.tables.GetAction; +import com.kingsrook.qqq.backend.core.actions.tables.InsertAction; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.logging.QLogger; +import com.kingsrook.qqq.backend.core.model.actions.tables.get.GetInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.get.GetOutput; +import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput; +import com.kingsrook.qqq.backend.core.model.data.QRecord; +import com.kingsrook.qqq.backend.core.model.statusmessages.BadInputStatusMessage; +import com.kingsrook.qqq.backend.core.utils.StringUtils; +import com.kingsrook.qqq.backend.core.utils.ValueUtils; +import org.json.JSONArray; + + +/******************************************************************************* + ** Pre-update customizer for the Deal table that handles pipeline stage + ** transitions and all associated business logic. + *******************************************************************************/ +public class DealStageChangeCustomizer extends AbstractPreUpdateCustomizer +{ + private static final QLogger LOG = QLogger.getLogger(DealStageChangeCustomizer.class); + + + + /******************************************************************************* + ** + *******************************************************************************/ + @Override + public List apply(List records) throws QException + { + if(isPreview) + { + return (records); + } + + Map oldRecordMap = getOldRecordMap(); + + for(QRecord record : records) + { + if(record.getErrors() != null && !record.getErrors().isEmpty()) + { + continue; + } + + Serializable primaryKey = record.getValue("id"); + QRecord oldRecord = oldRecordMap.get(primaryKey); + if(oldRecord == null) + { + continue; + } + + Integer oldStageId = ValueUtils.getValueAsInteger(oldRecord.getValue("pipelineStageId")); + Integer newStageId = ValueUtils.getValueAsInteger(record.getValue("pipelineStageId")); + + /////////////////////////////////////////////////////////// + // only fire stage-change logic if the stage has changed // + /////////////////////////////////////////////////////////// + if(newStageId == null || Objects.equals(oldStageId, newStageId)) + { + continue; + } + + handleStageChange(record, oldRecord, oldStageId, newStageId); + } + + return (records); + } + + + + /******************************************************************************* + ** Handle all stage-change business logic for a single deal record. + *******************************************************************************/ + private void handleStageChange(QRecord record, QRecord oldRecord, Integer oldStageId, Integer newStageId) throws QException + { + /////////////////////////////////////////// + // load the new pipeline stage record // + /////////////////////////////////////////// + PipelineStage newStage = loadPipelineStage(newStageId); + if(newStage == null) + { + return; + } + + /////////////////////////////////////////// + // validate required fields // + /////////////////////////////////////////// + if(StringUtils.hasContent(newStage.getRequiredFieldsJson())) + { + if(!validateRequiredFields(record, oldRecord, newStage.getRequiredFieldsJson())) + { + return; + } + } + + /////////////////////////////////////////// + // set stageEnteredDate // + /////////////////////////////////////////// + Instant now = Instant.now(); + record.setValue("stageEnteredDate", now); + + /////////////////////////////////////////// + // calculate weightedAmount // + /////////////////////////////////////////// + calculateWeightedAmount(record, oldRecord, newStage); + + /////////////////////////////////////////// + // calculate amountInBaseCurrency // + /////////////////////////////////////////// + calculateAmountInBaseCurrency(record, oldRecord); + + /////////////////////////////////////////// + // handle closed won / closed lost // + /////////////////////////////////////////// + boolean newIsClosed = Boolean.TRUE.equals(newStage.getIsClosedWon()) + || Boolean.TRUE.equals(newStage.getIsClosedLost()); + + if(newIsClosed) + { + record.setValue("actualCloseDate", LocalDate.now()); + } + + /////////////////////////////////////////////////////////// + // handle reopen (old was closed, new is not) -- only // + // load old stage when reopen check is needed (M-4) // + /////////////////////////////////////////////////////////// + if(!newIsClosed) + { + PipelineStage oldStage = loadPipelineStage(oldStageId); + boolean oldIsClosed = oldStage != null + && (Boolean.TRUE.equals(oldStage.getIsClosedWon()) || Boolean.TRUE.equals(oldStage.getIsClosedLost())); + + if(oldIsClosed) + { + record.setValue("actualCloseDate", null); + record.setValue("winLossReasonId", null); + } + } + + /////////////////////////////////////////// + // insert DealStageHistory row // + /////////////////////////////////////////// + insertStageHistoryRow(record, oldRecord, oldStageId, newStageId, now); + } + + + + /******************************************************************************* + ** Load a PipelineStage by its primary key. + *******************************************************************************/ + private PipelineStage loadPipelineStage(Integer stageId) throws QException + { + if(stageId == null) + { + return (null); + } + + GetOutput getOutput = new GetAction().execute(new GetInput(PipelineStage.TABLE_NAME).withPrimaryKey(stageId)); + if(getOutput.getRecord() == null) + { + return (null); + } + + return (new PipelineStage(getOutput.getRecord())); + } + + + + /******************************************************************************* + ** Validate that all required fields for the target stage are non-null on + ** the deal record. Returns false if validation fails (error added to record). + *******************************************************************************/ + private boolean validateRequiredFields(QRecord record, QRecord oldRecord, String requiredFieldsJson) + { + try + { + JSONArray requiredFields = new JSONArray(requiredFieldsJson); + for(int i = 0; i < requiredFields.length(); i++) + { + String fieldName = requiredFields.getString(i); + + ///////////////////////////////////////////////////////////////////// + // check the new record first, then fall back to the old record // + ///////////////////////////////////////////////////////////////////// + Serializable value = record.getValue(fieldName); + if(value == null && oldRecord != null) + { + value = oldRecord.getValue(fieldName); + } + + if(value == null) + { + record.addError(new BadInputStatusMessage("Required field [" + fieldName + "] must be set before moving to this stage.")); + return (false); + } + } + } + catch(Exception e) + { + LOG.warn("Error parsing requiredFieldsJson: " + requiredFieldsJson, e); + } + + return (true); + } + + + + /******************************************************************************* + ** Calculate weightedAmount based on probability and deal amount. + ** Uses probabilityOverridePct if set, otherwise the stage's probabilityPct. + *******************************************************************************/ + private void calculateWeightedAmount(QRecord record, QRecord oldRecord, PipelineStage newStage) + { + BigDecimal amount = ValueUtils.getValueAsBigDecimal(record.getValue("amount")); + if(amount == null && oldRecord != null) + { + amount = ValueUtils.getValueAsBigDecimal(oldRecord.getValue("amount")); + } + + if(amount == null) + { + record.setValue("weightedAmount", null); + return; + } + + Integer probabilityOverridePct = ValueUtils.getValueAsInteger(record.getValue("probabilityOverridePct")); + if(probabilityOverridePct == null && oldRecord != null) + { + probabilityOverridePct = ValueUtils.getValueAsInteger(oldRecord.getValue("probabilityOverridePct")); + } + + Integer probability = probabilityOverridePct != null + ? probabilityOverridePct + : newStage.getProbabilityPct(); + + if(probability == null) + { + probability = 0; + } + + BigDecimal weightedAmount = amount + .multiply(new BigDecimal(probability)) + .divide(new BigDecimal("100"), 2, RoundingMode.HALF_UP); + + record.setValue("weightedAmount", weightedAmount); + } + + + + /******************************************************************************* + ** Calculate amountInBaseCurrency using the shared CrmCurrencyUtils. + *******************************************************************************/ + private void calculateAmountInBaseCurrency(QRecord record, QRecord oldRecord) throws QException + { + BigDecimal amount = ValueUtils.getValueAsBigDecimal(record.getValue("amount")); + if(amount == null && oldRecord != null) + { + amount = ValueUtils.getValueAsBigDecimal(oldRecord.getValue("amount")); + } + + if(amount == null) + { + record.setValue("amountInBaseCurrency", null); + return; + } + + String currencyCode = ValueUtils.getValueAsString(record.getValue("currencyCode")); + if(!StringUtils.hasContent(currencyCode) && oldRecord != null) + { + currencyCode = ValueUtils.getValueAsString(oldRecord.getValue("currencyCode")); + } + + try + { + BigDecimal amountInBase = CrmCurrencyUtils.convertToBaseCurrency(amount, currencyCode); + record.setValue("amountInBaseCurrency", amountInBase); + } + catch(Exception e) + { + LOG.warn("Error calculating amountInBaseCurrency for currencyCode=" + currencyCode, e); + record.setValue("amountInBaseCurrency", amount); + } + } + + + + /******************************************************************************* + ** Insert a DealStageHistory row to track the stage transition. + *******************************************************************************/ + private void insertStageHistoryRow(QRecord record, QRecord oldRecord, Integer oldStageId, Integer newStageId, Instant now) throws QException + { + try + { + Integer dealId = ValueUtils.getValueAsInteger(record.getValue("id")); + + String userId = CrmSessionUtils.getCurrentUserId(); + + ///////////////////////////////////////////////////////////////////////// + // calculate duration in from-stage: days between old stageEnteredDate // + // and now // + ///////////////////////////////////////////////////////////////////////// + Integer durationDays = null; + Instant oldStageEnteredDate = ValueUtils.getValueAsInstant(oldRecord.getValue("stageEnteredDate")); + if(oldStageEnteredDate != null) + { + long days = Duration.between(oldStageEnteredDate, now).toDays(); + durationDays = (int) days; + } + + DealStageHistory history = new DealStageHistory() + .withDealId(dealId) + .withFromStageId(oldStageId) + .withToStageId(newStageId) + .withUserId(userId) + .withTransitionDate(now) + .withDurationInFromStageDays(durationDays); + + new InsertAction().execute(new InsertInput(DealStageHistory.TABLE_NAME).withRecordEntity(history)); + } + catch(Exception e) + { + LOG.warn("Error inserting DealStageHistory row", e); + } + } + +} diff --git a/src/main/java/com/kingsrook/qbits/crm/deals/model/Deal.java b/src/main/java/com/kingsrook/qbits/crm/deals/model/Deal.java index 72a919d..85545ab 100644 --- a/src/main/java/com/kingsrook/qbits/crm/deals/model/Deal.java +++ b/src/main/java/com/kingsrook/qbits/crm/deals/model/Deal.java @@ -9,6 +9,8 @@ import java.time.Instant; import java.time.LocalDate; import java.util.List; import com.kingsrook.qbits.crm.CrmQBitConfig; +import com.kingsrook.qbits.crm.activities.model.Activity; +import com.kingsrook.qbits.crm.core.model.Company; import com.kingsrook.qbits.crm.core.model.LeadSource; import com.kingsrook.qbits.crm.core.model.enums.CrmDealPriority; import com.kingsrook.qqq.backend.core.exceptions.QException; @@ -62,7 +64,12 @@ import com.kingsrook.qqq.backend.core.utils.collections.MutableList; childTableEntityClass = DealStageHistory.class, joinFieldName = "dealId", childJoin = @ChildJoin(enabled = true), - childRecordListWidget = @ChildRecordListWidget(label = "Stage History", enabled = true, maxRows = 50)) + childRecordListWidget = @ChildRecordListWidget(label = "Stage History", enabled = true, maxRows = 50)), + @ChildTable( + childTableEntityClass = Activity.class, + joinFieldName = "dealId", + childJoin = @ChildJoin(enabled = true), + childRecordListWidget = @ChildRecordListWidget(label = "Activities", enabled = true, maxRows = 50)) } ) public class Deal extends QRecordEntity @@ -155,7 +162,7 @@ public class Deal extends QRecordEntity @QField(isEditable = false) private BigDecimal weightedAmount; - @QField() + @QField(possibleValueSourceName = Company.TABLE_NAME) private Integer companyId; @QField(isRequired = true) diff --git a/src/main/java/com/kingsrook/qbits/crm/deals/model/PipelineStage.java b/src/main/java/com/kingsrook/qbits/crm/deals/model/PipelineStage.java index e1aa7e6..37cc4e2 100644 --- a/src/main/java/com/kingsrook/qbits/crm/deals/model/PipelineStage.java +++ b/src/main/java/com/kingsrook/qbits/crm/deals/model/PipelineStage.java @@ -5,11 +5,21 @@ package com.kingsrook.qbits.crm.deals.model; import java.time.Instant; +import java.util.List; +import com.kingsrook.qqq.backend.core.actions.customizers.AbstractPreDeleteCustomizer; +import com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizers; +import com.kingsrook.qqq.backend.core.actions.tables.QueryAction; import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryOutput; import com.kingsrook.qqq.backend.core.model.data.QField; import com.kingsrook.qqq.backend.core.model.data.QRecord; import com.kingsrook.qqq.backend.core.model.data.QRecordEntity; import com.kingsrook.qqq.backend.core.model.metadata.QInstance; +import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference; import com.kingsrook.qqq.backend.core.model.metadata.fields.ValueTooLongBehavior; import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon; import com.kingsrook.qqq.backend.core.model.metadata.producers.MetaDataCustomizerInterface; @@ -17,6 +27,7 @@ import com.kingsrook.qqq.backend.core.model.metadata.producers.annotations.QMeta import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData; import com.kingsrook.qqq.backend.core.model.metadata.tables.SectionFactory; import com.kingsrook.qqq.backend.core.model.metadata.tables.UniqueKey; +import com.kingsrook.qqq.backend.core.model.statusmessages.BadInputStatusMessage; /******************************************************************************* @@ -35,7 +46,8 @@ public class PipelineStage extends QRecordEntity /*************************************************************************** - ** + ** Customizer that sets icon, record label, unique keys, sections, and + ** registers PRE_DELETE customizer to protect stages with active deals. ***************************************************************************/ public static class TableMetaDataCustomizer implements MetaDataCustomizerInterface { @@ -56,12 +68,48 @@ public class PipelineStage extends QRecordEntity .withSection(SectionFactory.defaultT2("rotDays", "requiredFieldsJson", "isClosedWon", "isClosedLost")) .withSection(SectionFactory.defaultT3("createDate", "modifyDate")); + //////////////////////////////////////////////////////////// + // register PRE_DELETE to protect stages with active deals // + //////////////////////////////////////////////////////////// + table.withCustomizer(TableCustomizers.PRE_DELETE_RECORD, new QCodeReference(PreDeleteCustomizer.class)); + return (table); } } + /*************************************************************************** + ** PRE_DELETE customizer that rejects deletion of stages with active deals. + ***************************************************************************/ + public static class PreDeleteCustomizer extends AbstractPreDeleteCustomizer + { + /*************************************************************************** + ** + ***************************************************************************/ + @Override + public List apply(List records) throws QException + { + for(QRecord record : records) + { + Integer stageId = record.getValueInteger("id"); + + QueryOutput dealOutput = new QueryAction().execute( + new QueryInput(Deal.TABLE_NAME) + .withFilter(new QQueryFilter() + .withCriteria(new QFilterCriteria("pipelineStageId", QCriteriaOperator.EQUALS, stageId)))); + + if(!dealOutput.getRecords().isEmpty()) + { + record.addError(new BadInputStatusMessage("Cannot delete stage with active deals")); + } + } + return (records); + } + } + + + @QField(isEditable = false, isPrimaryKey = true) private Integer id; diff --git a/src/main/java/com/kingsrook/qbits/crm/deals/model/Product.java b/src/main/java/com/kingsrook/qbits/crm/deals/model/Product.java index 431ed9c..140703f 100644 --- a/src/main/java/com/kingsrook/qbits/crm/deals/model/Product.java +++ b/src/main/java/com/kingsrook/qbits/crm/deals/model/Product.java @@ -7,11 +7,20 @@ package com.kingsrook.qbits.crm.deals.model; import java.math.BigDecimal; import java.time.Instant; import java.util.List; +import com.kingsrook.qqq.backend.core.actions.customizers.AbstractPreDeleteCustomizer; +import com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizers; +import com.kingsrook.qqq.backend.core.actions.tables.QueryAction; import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryOutput; import com.kingsrook.qqq.backend.core.model.data.QField; import com.kingsrook.qqq.backend.core.model.data.QRecord; import com.kingsrook.qqq.backend.core.model.data.QRecordEntity; import com.kingsrook.qqq.backend.core.model.metadata.QInstance; +import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference; import com.kingsrook.qqq.backend.core.model.metadata.fields.ValueTooLongBehavior; import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon; import com.kingsrook.qqq.backend.core.model.metadata.producers.MetaDataCustomizerInterface; @@ -21,6 +30,7 @@ import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData; import com.kingsrook.qqq.backend.core.model.metadata.tables.SectionFactory; import com.kingsrook.qqq.backend.core.model.metadata.tables.Tier; import com.kingsrook.qqq.backend.core.model.metadata.tables.UniqueKey; +import com.kingsrook.qqq.backend.core.model.statusmessages.BadInputStatusMessage; /******************************************************************************* @@ -39,7 +49,8 @@ public class Product extends QRecordEntity /*************************************************************************** - ** Customizer that sets icon, record label, unique key, and section layout. + ** Customizer that sets icon, record label, unique key, section layout, and + ** registers PRE_DELETE customizer to protect products with deal line items. ***************************************************************************/ public static class TableMetaDataCustomizer implements MetaDataCustomizerInterface { @@ -60,12 +71,49 @@ public class Product extends QRecordEntity .withSection(SectionFactory.defaultT2("description")) .withSection(SectionFactory.defaultT3("createDate", "modifyDate")); + ////////////////////////////////////////////////////////////////// + // register PRE_DELETE to protect products with deal line items // + ////////////////////////////////////////////////////////////////// + table.withCustomizer(TableCustomizers.PRE_DELETE_RECORD, new QCodeReference(PreDeleteCustomizer.class)); + return (table); } } + /*************************************************************************** + ** PRE_DELETE customizer that rejects deletion of products referenced by + ** deal line items. + ***************************************************************************/ + public static class PreDeleteCustomizer extends AbstractPreDeleteCustomizer + { + /*************************************************************************** + ** + ***************************************************************************/ + @Override + public List apply(List records) throws QException + { + for(QRecord record : records) + { + Integer productId = record.getValueInteger("id"); + + QueryOutput dealProductOutput = new QueryAction().execute( + new QueryInput(DealProduct.TABLE_NAME) + .withFilter(new QQueryFilter() + .withCriteria(new QFilterCriteria("productId", QCriteriaOperator.EQUALS, productId)))); + + if(!dealProductOutput.getRecords().isEmpty()) + { + record.addError(new BadInputStatusMessage("Cannot delete product referenced by deal line items")); + } + } + return (records); + } + } + + + @QField(isEditable = false, isPrimaryKey = true) private Integer id; diff --git a/src/main/java/com/kingsrook/qbits/crm/deals/processes/ConvertLeadProcess.java b/src/main/java/com/kingsrook/qbits/crm/deals/processes/ConvertLeadProcess.java index 8a938d1..b4443a5 100644 --- a/src/main/java/com/kingsrook/qbits/crm/deals/processes/ConvertLeadProcess.java +++ b/src/main/java/com/kingsrook/qbits/crm/deals/processes/ConvertLeadProcess.java @@ -178,18 +178,18 @@ public class ConvertLeadProcess implements BackendStep, MetaDataProducerInterfac // Step 7: log audit entries // ///////////////////////////////////////////////////////////////////// AuditLog contactAudit = new AuditLog() - .withEntityType(CrmEntityType.CONTACT.getId()) + .withEntityType(CrmEntityType.CONTACT.getPossibleValueId()) .withEntityId(contactId) - .withAction(CrmAuditAction.STAGE_CHANGED.getId()) + .withAction(CrmAuditAction.STAGE_CHANGED.getPossibleValueId()) .withUserId(userId) .withFieldName("lifecycleStageId") .withNewValue(opportunityStageId != null ? String.valueOf(opportunityStageId) : null) .withMessage("Lead converted to deal: " + dealName); AuditLog dealAudit = new AuditLog() - .withEntityType(CrmEntityType.DEAL.getId()) + .withEntityType(CrmEntityType.DEAL.getPossibleValueId()) .withEntityId(dealId) - .withAction(CrmAuditAction.CREATED.getId()) + .withAction(CrmAuditAction.CREATED.getPossibleValueId()) .withUserId(userId) .withMessage("Deal created from lead conversion (contact " + contactId + ")"); diff --git a/src/main/java/com/kingsrook/qbits/crm/deals/processes/MoveDealStageProcess.java b/src/main/java/com/kingsrook/qbits/crm/deals/processes/MoveDealStageProcess.java index fd21c46..4411de2 100644 --- a/src/main/java/com/kingsrook/qbits/crm/deals/processes/MoveDealStageProcess.java +++ b/src/main/java/com/kingsrook/qbits/crm/deals/processes/MoveDealStageProcess.java @@ -200,9 +200,9 @@ public class MoveDealStageProcess implements BackendStep, MetaDataProducerInterf // Step 10: log audit entry // ////////////////////////////////////////////////////////////// AuditLog auditEntry = new AuditLog() - .withEntityType(CrmEntityType.DEAL.getId()) + .withEntityType(CrmEntityType.DEAL.getPossibleValueId()) .withEntityId(dealId) - .withAction(CrmAuditAction.STAGE_CHANGED.getId()) + .withAction(CrmAuditAction.STAGE_CHANGED.getPossibleValueId()) .withUserId(sessionUserId) .withFieldName("pipelineStageId") .withOldValue(oldStageId != null ? String.valueOf(oldStageId) : null) diff --git a/src/main/java/com/kingsrook/qbits/crm/deals/processes/ReopenDealProcess.java b/src/main/java/com/kingsrook/qbits/crm/deals/processes/ReopenDealProcess.java index 38f77cb..afe1ca4 100644 --- a/src/main/java/com/kingsrook/qbits/crm/deals/processes/ReopenDealProcess.java +++ b/src/main/java/com/kingsrook/qbits/crm/deals/processes/ReopenDealProcess.java @@ -178,9 +178,9 @@ public class ReopenDealProcess implements BackendStep, MetaDataProducerInterface // Step 9: log audit entry // ////////////////////////////////////////////////////////////// AuditLog auditEntry = new AuditLog() - .withEntityType(CrmEntityType.DEAL.getId()) + .withEntityType(CrmEntityType.DEAL.getPossibleValueId()) .withEntityId(dealId) - .withAction(CrmAuditAction.STAGE_CHANGED.getId()) + .withAction(CrmAuditAction.STAGE_CHANGED.getPossibleValueId()) .withUserId(sessionUserId) .withFieldName("pipelineStageId") .withOldValue(oldStageId != null ? String.valueOf(oldStageId) : null) diff --git a/src/main/java/com/kingsrook/qbits/crm/deals/processes/TransferOwnershipProcess.java b/src/main/java/com/kingsrook/qbits/crm/deals/processes/TransferOwnershipProcess.java index ca37d65..1cd1a44 100644 --- a/src/main/java/com/kingsrook/qbits/crm/deals/processes/TransferOwnershipProcess.java +++ b/src/main/java/com/kingsrook/qbits/crm/deals/processes/TransferOwnershipProcess.java @@ -130,9 +130,9 @@ public class TransferOwnershipProcess implements BackendStep, MetaDataProducerIn new InsertAction().execute( new InsertInput(AuditLog.TABLE_NAME).withRecordEntity( new AuditLog() - .withEntityType(entityType.getId()) + .withEntityType(entityType.getPossibleValueId()) .withEntityId(recordId) - .withAction(CrmAuditAction.OWNER_CHANGED.getId()) + .withAction(CrmAuditAction.OWNER_CHANGED.getPossibleValueId()) .withUserId(sessionUserId) .withFieldName("ownerUserId") .withOldValue(fromUserId) diff --git a/src/main/java/com/kingsrook/qbits/crm/email/customizers/EmailTemplatePreDeleteCustomizer.java b/src/main/java/com/kingsrook/qbits/crm/email/customizers/EmailTemplatePreDeleteCustomizer.java new file mode 100644 index 0000000..e327665 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/crm/email/customizers/EmailTemplatePreDeleteCustomizer.java @@ -0,0 +1,83 @@ +/******************************************************************************* + ** PRE_DELETE customizer for EmailTemplate -- prevents deletion of templates + ** referenced by active email sequences. + *******************************************************************************/ +package com.kingsrook.qbits.crm.email.customizers; + + +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import com.kingsrook.qbits.crm.core.model.enums.CrmEnrollmentStatus; +import com.kingsrook.qbits.crm.email.model.SequenceEnrollment; +import com.kingsrook.qbits.crm.email.model.SequenceStep; +import com.kingsrook.qqq.backend.core.actions.customizers.AbstractPreDeleteCustomizer; +import com.kingsrook.qqq.backend.core.actions.tables.QueryAction; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryOutput; +import com.kingsrook.qqq.backend.core.model.data.QRecord; +import com.kingsrook.qqq.backend.core.model.statusmessages.BadInputStatusMessage; + + +/******************************************************************************* + ** Prevents deletion of email templates that are referenced by sequence steps + ** belonging to sequences with active enrollments. + *******************************************************************************/ +public class EmailTemplatePreDeleteCustomizer extends AbstractPreDeleteCustomizer +{ + + /*************************************************************************** + ** + ***************************************************************************/ + @Override + public List apply(List records) throws QException + { + for(QRecord record : records) + { + Integer templateId = record.getValueInteger("id"); + + /////////////////////////////////////////////////////////////////////// + // find sequence steps that reference this template // + /////////////////////////////////////////////////////////////////////// + QueryOutput stepOutput = new QueryAction().execute( + new QueryInput(SequenceStep.TABLE_NAME) + .withFilter(new QQueryFilter() + .withCriteria(new QFilterCriteria("emailTemplateId", QCriteriaOperator.EQUALS, templateId)))); + + if(stepOutput.getRecords().isEmpty()) + { + continue; + } + + /////////////////////////////////////////////////////////////////////// + // collect sequence IDs from matching steps // + /////////////////////////////////////////////////////////////////////// + Set sequenceIds = new HashSet<>(); + for(QRecord step : stepOutput.getRecords()) + { + sequenceIds.add(step.getValueInteger("sequenceId")); + } + + /////////////////////////////////////////////////////////////////////// + // check if any of those sequences have active enrollments // + /////////////////////////////////////////////////////////////////////// + QueryOutput enrollmentOutput = new QueryAction().execute( + new QueryInput(SequenceEnrollment.TABLE_NAME) + .withFilter(new QQueryFilter() + .withCriteria(new QFilterCriteria("sequenceId", QCriteriaOperator.IN, sequenceIds)) + .withCriteria(new QFilterCriteria("status", QCriteriaOperator.EQUALS, CrmEnrollmentStatus.ACTIVE.getPossibleValueId())))); + + if(!enrollmentOutput.getRecords().isEmpty()) + { + record.addError(new BadInputStatusMessage("Cannot delete template referenced by active email sequences")); + } + } + + return (records); + } + +} diff --git a/src/main/java/com/kingsrook/qbits/crm/email/model/EmailMessage.java b/src/main/java/com/kingsrook/qbits/crm/email/model/EmailMessage.java index 8eb5b9f..d0d320d 100644 --- a/src/main/java/com/kingsrook/qbits/crm/email/model/EmailMessage.java +++ b/src/main/java/com/kingsrook/qbits/crm/email/model/EmailMessage.java @@ -6,6 +6,7 @@ package com.kingsrook.qbits.crm.email.model; import java.time.Instant; import java.util.List; +import com.kingsrook.qbits.crm.activities.model.Activity; import com.kingsrook.qbits.crm.core.model.enums.CrmBounceType; import com.kingsrook.qbits.crm.core.model.enums.CrmDirection; import com.kingsrook.qbits.crm.core.model.enums.CrmEmailStatus; @@ -76,7 +77,7 @@ public class EmailMessage extends QRecordEntity @QField(isEditable = false, isPrimaryKey = true) private Integer id; - @QField() + @QField(possibleValueSourceName = Activity.TABLE_NAME) private Integer activityId; @QField(isRequired = true, maxLength = 250, valueTooLongBehavior = ValueTooLongBehavior.ERROR) diff --git a/src/main/java/com/kingsrook/qbits/crm/email/model/EmailTemplate.java b/src/main/java/com/kingsrook/qbits/crm/email/model/EmailTemplate.java index 3eefbb1..4943e14 100644 --- a/src/main/java/com/kingsrook/qbits/crm/email/model/EmailTemplate.java +++ b/src/main/java/com/kingsrook/qbits/crm/email/model/EmailTemplate.java @@ -6,11 +6,14 @@ package com.kingsrook.qbits.crm.email.model; import java.time.Instant; import java.util.List; +import com.kingsrook.qbits.crm.email.customizers.EmailTemplatePreDeleteCustomizer; +import com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizers; import com.kingsrook.qqq.backend.core.exceptions.QException; import com.kingsrook.qqq.backend.core.model.data.QField; import com.kingsrook.qqq.backend.core.model.data.QRecord; import com.kingsrook.qqq.backend.core.model.data.QRecordEntity; import com.kingsrook.qqq.backend.core.model.metadata.QInstance; +import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference; import com.kingsrook.qqq.backend.core.model.metadata.fields.ValueTooLongBehavior; import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon; import com.kingsrook.qqq.backend.core.model.metadata.producers.MetaDataCustomizerInterface; @@ -38,7 +41,8 @@ public class EmailTemplate extends QRecordEntity /*************************************************************************** - ** Customizer that sets icon, record label, and section layout. + ** Customizer that sets icon, record label, section layout, and registers + ** PRE_DELETE customizer to prevent deletion of actively-used templates. ***************************************************************************/ public static class TableMetaDataCustomizer implements MetaDataCustomizerInterface { @@ -59,6 +63,11 @@ public class EmailTemplate extends QRecordEntity List.of("ownerUserId", "isShared"))) .withSection(SectionFactory.defaultT3("createDate", "modifyDate")); + //////////////////////////////////////////////////////////////////// + // register PRE_DELETE to block deletion of actively-used templates // + //////////////////////////////////////////////////////////////////// + table.withCustomizer(TableCustomizers.PRE_DELETE_RECORD, new QCodeReference(EmailTemplatePreDeleteCustomizer.class)); + return (table); } } diff --git a/src/main/java/com/kingsrook/qbits/crm/email/processes/EnrollInSequenceProcess.java b/src/main/java/com/kingsrook/qbits/crm/email/processes/EnrollInSequenceProcess.java index afd9b88..1c4db3d 100644 --- a/src/main/java/com/kingsrook/qbits/crm/email/processes/EnrollInSequenceProcess.java +++ b/src/main/java/com/kingsrook/qbits/crm/email/processes/EnrollInSequenceProcess.java @@ -131,7 +131,7 @@ public class EnrollInSequenceProcess implements BackendStep, MetaDataProducerInt .withFilter(new QQueryFilter() .withCriteria(new QFilterCriteria("sequenceId", QCriteriaOperator.EQUALS, sequenceId)) .withCriteria(new QFilterCriteria("contactId", QCriteriaOperator.EQUALS, contactId)) - .withCriteria(new QFilterCriteria("status", QCriteriaOperator.EQUALS, CrmEnrollmentStatus.ACTIVE.getId())))); + .withCriteria(new QFilterCriteria("status", QCriteriaOperator.EQUALS, CrmEnrollmentStatus.ACTIVE.getPossibleValueId())))); if(!existingEnrollments.getRecords().isEmpty()) { @@ -166,7 +166,7 @@ public class EnrollInSequenceProcess implements BackendStep, MetaDataProducerInt .withContactId(contactId) .withDealId(dealId) .withCurrentStepNumber(0) - .withStatus(CrmEnrollmentStatus.ACTIVE.getId()) + .withStatus(CrmEnrollmentStatus.ACTIVE.getPossibleValueId()) .withEnrolledDate(Instant.now()) .withEnrolledByUserId(input.getSession() != null ? input.getSession().getIdReference() : "system") .withNextStepDate(nextStepDate) diff --git a/src/main/java/com/kingsrook/qbits/crm/email/processes/ProcessSequenceStepsProcess.java b/src/main/java/com/kingsrook/qbits/crm/email/processes/ProcessSequenceStepsProcess.java index 0e07f0d..abbf6da 100644 --- a/src/main/java/com/kingsrook/qbits/crm/email/processes/ProcessSequenceStepsProcess.java +++ b/src/main/java/com/kingsrook/qbits/crm/email/processes/ProcessSequenceStepsProcess.java @@ -4,7 +4,10 @@ package com.kingsrook.qbits.crm.email.processes; +import java.time.DayOfWeek; import java.time.Instant; +import java.time.ZoneId; +import java.time.ZonedDateTime; import java.time.temporal.ChronoUnit; import java.util.List; import com.kingsrook.qbits.crm.activities.model.Activity; @@ -65,6 +68,7 @@ public class ProcessSequenceStepsProcess implements BackendStep, MetaDataProduce .withName(NAME) .withLabel("Process Sequence Steps") .withIcon(new QIcon().withName("play_circle")) + // Scheduling is configured by the host application via ScheduledJob records .withStepList(List.of( new QBackendStepMetaData() .withName("execute") @@ -86,7 +90,7 @@ public class ProcessSequenceStepsProcess implements BackendStep, MetaDataProduce QueryOutput enrollmentQuery = new QueryAction().execute( new QueryInput(SequenceEnrollment.TABLE_NAME) .withFilter(new QQueryFilter() - .withCriteria(new QFilterCriteria("status", QCriteriaOperator.EQUALS, CrmEnrollmentStatus.ACTIVE.getId())) + .withCriteria(new QFilterCriteria("status", QCriteriaOperator.EQUALS, CrmEnrollmentStatus.ACTIVE.getPossibleValueId())) .withCriteria(new QFilterCriteria("nextStepDate", QCriteriaOperator.LESS_THAN_OR_EQUALS, Instant.now())))); int processedCount = 0; @@ -116,6 +120,7 @@ public class ProcessSequenceStepsProcess implements BackendStep, MetaDataProduce /*************************************************************************** ** Process a single enrollment: load the next step and execute it. + ** Respects businessDaysOnly and sendWindow settings on the sequence. ***************************************************************************/ private void processEnrollment(SequenceEnrollment enrollment) throws QException { @@ -130,6 +135,48 @@ public class ProcessSequenceStepsProcess implements BackendStep, MetaDataProduce EmailSequence sequence = sequenceGet.getRecord() != null ? new EmailSequence(sequenceGet.getRecord()) : null; + ///////////////////////////////////////////// + // load the contact for timezone // + ///////////////////////////////////////////// + GetOutput contactGet = new GetAction().execute( + new GetInput(Contact.TABLE_NAME).withPrimaryKey(enrollment.getContactId())); + + String contactTimezone = null; + if(contactGet.getRecord() != null) + { + contactTimezone = contactGet.getRecord().getValueString("timezone"); + } + + ZoneId zoneId = resolveZoneId(contactTimezone); + ZonedDateTime nowInZone = Instant.now().atZone(zoneId); + + /////////////////////////////////////////////////////////////// + // check businessDaysOnly -- skip weekends // + /////////////////////////////////////////////////////////////// + if(sequence != null && Boolean.TRUE.equals(sequence.getBusinessDaysOnly())) + { + DayOfWeek dayOfWeek = nowInZone.getDayOfWeek(); + if(dayOfWeek == DayOfWeek.SATURDAY || dayOfWeek == DayOfWeek.SUNDAY) + { + return; + } + } + + /////////////////////////////////////////////////////////////// + // check send window -- skip if outside window // + /////////////////////////////////////////////////////////////// + if(sequence != null + && sequence.getSendWindowStartHour() != null + && sequence.getSendWindowEndHour() != null) + { + int currentHour = nowInZone.getHour(); + if(currentHour < sequence.getSendWindowStartHour() + || currentHour >= sequence.getSendWindowEndHour()) + { + return; + } + } + ///////////////////////////////////////////// // load the next step // ///////////////////////////////////////////// @@ -171,7 +218,8 @@ public class ProcessSequenceStepsProcess implements BackendStep, MetaDataProduce ///////////////////////////////////////////// // advance to next step // ///////////////////////////////////////////// - advanceEnrollment(enrollment, nextStepNumber, sequence); + boolean businessDaysOnly = sequence != null && Boolean.TRUE.equals(sequence.getBusinessDaysOnly()); + advanceEnrollment(enrollment, nextStepNumber, sequence, businessDaysOnly, contactTimezone); } @@ -300,8 +348,11 @@ public class ProcessSequenceStepsProcess implements BackendStep, MetaDataProduce /*************************************************************************** ** Advance the enrollment to the next step and calculate the next date. + ** Respects businessDaysOnly when computing the delay. ***************************************************************************/ - private void advanceEnrollment(SequenceEnrollment enrollment, Integer newStepNumber, EmailSequence sequence) throws QException + private void advanceEnrollment(SequenceEnrollment enrollment, Integer newStepNumber, + EmailSequence sequence, boolean businessDaysOnly, + String contactTimezone) throws QException { /////////////////////////////////////////////// // check if we've completed all steps // @@ -339,9 +390,7 @@ public class ProcessSequenceStepsProcess implements BackendStep, MetaDataProduce SequenceStep nextStep = new SequenceStep(nextStepQuery.getRecords().get(0)); Integer delayDays = nextStep.getDelayDays() != null ? nextStep.getDelayDays() : 0; Integer delayHours = nextStep.getDelayHours() != null ? nextStep.getDelayHours() : 0; - nextDate = Instant.now() - .plus(delayDays, ChronoUnit.DAYS) - .plus(delayHours, ChronoUnit.HOURS); + nextDate = calculateNextStepDate(Instant.now(), delayDays, delayHours, businessDaysOnly, contactTimezone); } QRecord updateRecord = new QRecord() @@ -362,7 +411,7 @@ public class ProcessSequenceStepsProcess implements BackendStep, MetaDataProduce { QRecord updateRecord = new QRecord() .withValue("id", enrollment.getId()) - .withValue("status", CrmEnrollmentStatus.COMPLETED.getId()) + .withValue("status", CrmEnrollmentStatus.COMPLETED.getPossibleValueId()) .withValue("completedDate", Instant.now()) .withValue("nextStepDate", null); @@ -372,6 +421,60 @@ public class ProcessSequenceStepsProcess implements BackendStep, MetaDataProduce + /*************************************************************************** + ** Calculate the next step date, adding delay days/hours to now. + ** If businessDaysOnly is true, weekends are skipped when counting days. + ***************************************************************************/ + static Instant calculateNextStepDate(Instant now, int delayDays, int delayHours, + boolean businessDaysOnly, String contactTimezone) + { + ZoneId zoneId = resolveZoneId(contactTimezone); + ZonedDateTime result = now.atZone(zoneId).plus(delayHours, ChronoUnit.HOURS); + + if(businessDaysOnly && delayDays > 0) + { + int addedDays = 0; + while(addedDays < delayDays) + { + result = result.plusDays(1); + DayOfWeek dow = result.getDayOfWeek(); + if(dow != DayOfWeek.SATURDAY && dow != DayOfWeek.SUNDAY) + { + addedDays++; + } + } + } + else + { + result = result.plusDays(delayDays); + } + + return (result.toInstant()); + } + + + + /*************************************************************************** + ** Resolve a timezone string to a ZoneId. Falls back to UTC. + ***************************************************************************/ + private static ZoneId resolveZoneId(String timezone) + { + if(StringUtils.hasContent(timezone)) + { + try + { + return (ZoneId.of(timezone)); + } + catch(Exception e) + { + // fall through to UTC + } + } + return (ZoneId.of("UTC")); + } + + + /*************************************************************************** ** Handle a failure: increment failure count, mark as FAILED after max. ***************************************************************************/ @@ -386,7 +489,7 @@ public class ProcessSequenceStepsProcess implements BackendStep, MetaDataProduce if(newFailureCount >= MAX_FAILURES) { - updateRecord.withValue("status", CrmEnrollmentStatus.FAILED.getId()); + updateRecord.withValue("status", CrmEnrollmentStatus.FAILED.getPossibleValueId()); updateRecord.withValue("nextStepDate", null); } diff --git a/src/main/java/com/kingsrook/qbits/crm/email/processes/SendEmailProcess.java b/src/main/java/com/kingsrook/qbits/crm/email/processes/SendEmailProcess.java index be66362..57146c2 100644 --- a/src/main/java/com/kingsrook/qbits/crm/email/processes/SendEmailProcess.java +++ b/src/main/java/com/kingsrook/qbits/crm/email/processes/SendEmailProcess.java @@ -15,11 +15,11 @@ import com.kingsrook.qbits.crm.core.model.enums.CrmDirection; import com.kingsrook.qbits.crm.core.model.enums.CrmEmailStatus; import com.kingsrook.qbits.crm.email.model.EmailMessage; import com.kingsrook.qbits.crm.email.model.EmailTemplate; +import com.kingsrook.qbits.crm.CrmSessionUtils; import com.kingsrook.qqq.backend.core.actions.processes.BackendStep; import com.kingsrook.qqq.backend.core.actions.tables.GetAction; import com.kingsrook.qqq.backend.core.actions.tables.InsertAction; import com.kingsrook.qqq.backend.core.actions.tables.QueryAction; -import com.kingsrook.qqq.backend.core.context.QContext; import com.kingsrook.qqq.backend.core.exceptions.QException; import com.kingsrook.qqq.backend.core.exceptions.QUserFacingException; import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput; @@ -98,7 +98,7 @@ public class SendEmailProcess implements BackendStep, MetaDataProducerInterface< Integer dealId = input.getValueInteger("dealId"); String ccAddresses = input.getValueString("ccAddresses"); String bccAddresses = input.getValueString("bccAddresses"); - String sessionUserId = QContext.getQSession().getIdReference(); + String sessionUserId = CrmSessionUtils.getCurrentUserId(); /////////////////////////////////////////// // Step 1: load and validate the contact // @@ -152,7 +152,7 @@ public class SendEmailProcess implements BackendStep, MetaDataProducerInterface< .withSubject(subject) .withContactId(contactId) .withDealId(dealId) - .withDirection(CrmDirection.OUTBOUND.getId()) + .withDirection(CrmDirection.OUTBOUND.getPossibleValueId()) .withOwnerUserId(sessionUserId) .withIsCompleted(true); @@ -175,8 +175,8 @@ public class SendEmailProcess implements BackendStep, MetaDataProducerInterface< .withSubject(subject != null ? subject : "") .withBodyHtml(bodyHtml) .withBodyText(bodyText) - .withDirection(CrmDirection.OUTBOUND.getId()) - .withStatus(CrmEmailStatus.SENT.getId()) + .withDirection(CrmDirection.OUTBOUND.getPossibleValueId()) + .withStatus(CrmEmailStatus.SENT.getPossibleValueId()) .withSentDate(Instant.now()) .withIsTracked(true) .withOpenCount(0) diff --git a/src/main/java/com/kingsrook/qbits/crm/email/processes/TrackEmailEventProcess.java b/src/main/java/com/kingsrook/qbits/crm/email/processes/TrackEmailEventProcess.java index ff0b0f6..6371712 100644 --- a/src/main/java/com/kingsrook/qbits/crm/email/processes/TrackEmailEventProcess.java +++ b/src/main/java/com/kingsrook/qbits/crm/email/processes/TrackEmailEventProcess.java @@ -129,7 +129,7 @@ public class TrackEmailEventProcess implements BackendStep, MetaDataProducerInte ///////////////////////////////////////////////////// // if hard bounce, set contact doNotEmail to true // ///////////////////////////////////////////////////// - if(bounceTypeId != null && bounceTypeId.equals(CrmBounceType.HARD.getId())) + if(bounceTypeId != null && bounceTypeId.equals(CrmBounceType.HARD.getPossibleValueId())) { setContactDoNotEmail(email); } @@ -152,7 +152,7 @@ public class TrackEmailEventProcess implements BackendStep, MetaDataProducerInte { QRecord enrollmentUpdate = new QRecord() .withValue("id", email.getSequenceEnrollmentId()) - .withValue("status", CrmEnrollmentStatus.REPLIED.getId()); + .withValue("status", CrmEnrollmentStatus.REPLIED.getPossibleValueId()); new UpdateAction().execute( new UpdateInput("crmSequenceEnrollment").withRecord(enrollmentUpdate)); diff --git a/src/main/java/com/kingsrook/qbits/crm/email/processes/UnenrollFromSequenceProcess.java b/src/main/java/com/kingsrook/qbits/crm/email/processes/UnenrollFromSequenceProcess.java index dbe830c..4f6a688 100644 --- a/src/main/java/com/kingsrook/qbits/crm/email/processes/UnenrollFromSequenceProcess.java +++ b/src/main/java/com/kingsrook/qbits/crm/email/processes/UnenrollFromSequenceProcess.java @@ -86,7 +86,7 @@ public class UnenrollFromSequenceProcess implements BackendStep, MetaDataProduce ///////////////////////////////////////////// QRecord updateRecord = new QRecord() .withValue("id", enrollmentId) - .withValue("status", CrmEnrollmentStatus.UNENROLLED.getId()) + .withValue("status", CrmEnrollmentStatus.UNENROLLED.getPossibleValueId()) .withValue("unenrolledDate", Instant.now()) .withValue("nextStepDate", null); diff --git a/src/main/java/com/kingsrook/qbits/crm/scoring/processes/AutoAssignProcess.java b/src/main/java/com/kingsrook/qbits/crm/scoring/processes/AutoAssignProcess.java index e48824a..be77d24 100644 --- a/src/main/java/com/kingsrook/qbits/crm/scoring/processes/AutoAssignProcess.java +++ b/src/main/java/com/kingsrook/qbits/crm/scoring/processes/AutoAssignProcess.java @@ -4,9 +4,11 @@ package com.kingsrook.qbits.crm.scoring.processes; +import java.io.Serializable; import java.time.Instant; import java.util.Comparator; import java.util.List; +import java.util.Map; import java.util.Objects; import com.kingsrook.qbits.crm.core.model.Contact; import com.kingsrook.qbits.crm.core.model.enums.CrmAssignStrategy; @@ -19,6 +21,7 @@ import com.kingsrook.qqq.backend.core.actions.tables.GetAction; import com.kingsrook.qqq.backend.core.actions.tables.QueryAction; import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction; import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.logging.QLogger; import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput; import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput; import com.kingsrook.qqq.backend.core.model.actions.tables.get.GetInput; @@ -40,6 +43,8 @@ import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon; import com.kingsrook.qqq.backend.core.model.metadata.processes.QBackendStepMetaData; import com.kingsrook.qqq.backend.core.model.metadata.processes.QFunctionInputMetaData; import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData; +import com.kingsrook.qqq.backend.core.utils.CollectionUtils; +import com.kingsrook.qqq.backend.core.utils.JsonUtils; import com.kingsrook.qqq.backend.core.utils.StringUtils; @@ -50,6 +55,8 @@ import com.kingsrook.qqq.backend.core.utils.StringUtils; *******************************************************************************/ public class AutoAssignProcess implements BackendStep, MetaDataProducerInterface { + private static final QLogger LOG = QLogger.getLogger(AutoAssignProcess.class); + public static final String NAME = "autoAssign"; @@ -131,7 +138,7 @@ public class AutoAssignProcess implements BackendStep, MetaDataProducerInterface QueryOutput rulesQuery = new QueryAction().execute( new QueryInput(AssignmentRule.TABLE_NAME) .withFilter(new QQueryFilter() - .withCriteria(new QFilterCriteria("entityType", QCriteriaOperator.EQUALS, entityType.getId())) + .withCriteria(new QFilterCriteria("entityType", QCriteriaOperator.EQUALS, entityType.getPossibleValueId())) .withCriteria(new QFilterCriteria("isActive", QCriteriaOperator.EQUALS, true)) .withOrderBy(new QFilterOrderBy("sortOrder")))); @@ -186,36 +193,30 @@ public class AutoAssignProcess implements BackendStep, MetaDataProducerInterface } ///////////////////////////////////////////// - // simple JSON criteria evaluation // + // parse criteria JSON using JsonUtils // // format: {"fieldName":"expectedValue"} // ///////////////////////////////////////////// try { - String json = rule.getCriteriaJson().trim(); - if(json.startsWith("{") && json.endsWith("}")) + @SuppressWarnings("unchecked") + Map criteriaMap = JsonUtils.toObject(rule.getCriteriaJson(), Map.class); + for(Map.Entry entry : criteriaMap.entrySet()) { - json = json.substring(1, json.length() - 1); - String[] pairs = json.split(","); - for(String pair : pairs) - { - String[] kv = pair.split(":", 2); - if(kv.length == 2) - { - String fieldName = kv[0].trim().replace("\"", ""); - String expectedValue = kv[1].trim().replace("\"", ""); - String actualValue = record.getValueString(fieldName); + String fieldName = entry.getKey(); + String expectedValue = entry.getValue() == null ? null : String.valueOf(entry.getValue()); + String actualValue = record.getValueString(fieldName); - if(!Objects.equals(actualValue, expectedValue)) - { - return (false); - } - } + if(!Objects.equals(actualValue, expectedValue)) + { + return (false); } } + return (true); } catch(Exception e) { + LOG.warn("Error parsing criteriaJson for assignment rule", e); return (false); } } diff --git a/src/main/java/com/kingsrook/qbits/crm/scoring/processes/CalculateLeadScoresProcess.java b/src/main/java/com/kingsrook/qbits/crm/scoring/processes/CalculateLeadScoresProcess.java index dafa080..a25b9dc 100644 --- a/src/main/java/com/kingsrook/qbits/crm/scoring/processes/CalculateLeadScoresProcess.java +++ b/src/main/java/com/kingsrook/qbits/crm/scoring/processes/CalculateLeadScoresProcess.java @@ -4,15 +4,22 @@ package com.kingsrook.qbits.crm.scoring.processes; +import java.time.Instant; +import java.time.temporal.ChronoUnit; import java.util.List; import java.util.Objects; +import com.kingsrook.qbits.crm.core.model.Company; import com.kingsrook.qbits.crm.core.model.Contact; import com.kingsrook.qbits.crm.core.model.enums.CrmScoreOperator; import com.kingsrook.qbits.crm.scoring.model.LeadScoreRule; import com.kingsrook.qqq.backend.core.actions.processes.BackendStep; +import com.kingsrook.qqq.backend.core.actions.tables.GetAction; import com.kingsrook.qqq.backend.core.actions.tables.QueryAction; import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction; import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.logging.QLogger; +import com.kingsrook.qqq.backend.core.model.actions.tables.get.GetInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.get.GetOutput; import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput; import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput; import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator; @@ -39,6 +46,8 @@ import com.kingsrook.qqq.backend.core.utils.StringUtils; *******************************************************************************/ public class CalculateLeadScoresProcess implements BackendStep, MetaDataProducerInterface { + private static final QLogger LOG = QLogger.getLogger(CalculateLeadScoresProcess.class); + public static final String NAME = "calculateLeadScores"; @@ -53,6 +62,7 @@ public class CalculateLeadScoresProcess implements BackendStep, MetaDataProducer .withName(NAME) .withLabel("Calculate Lead Scores") .withIcon(new QIcon().withName("score")) + // Scheduling is configured by the host application via ScheduledJob records .withStepList(List.of( new QBackendStepMetaData() .withName("execute") @@ -81,56 +91,88 @@ public class CalculateLeadScoresProcess implements BackendStep, MetaDataProducer .map(LeadScoreRule::new) .toList(); - /////////////////////////////////////////////// - // load all contacts // - /////////////////////////////////////////////// - QueryOutput contactsQuery = new QueryAction().execute( - new QueryInput(Contact.TABLE_NAME)); - + /////////////////////////////////////////////////////////////// + // paginate through all contacts to avoid OOM on large CRMs // + /////////////////////////////////////////////////////////////// + int pageSize = 500; int updatedCount = 0; + int totalContacts = 0; + Integer lastId = null; - for(QRecord contactRecord : contactsQuery.getRecords()) + while(true) { - Contact contact = new Contact(contactRecord); - int newScore = 0; + QQueryFilter filter = new QQueryFilter() + .withOrderBy(new QFilterOrderBy("id")) + .withLimit(pageSize); - /////////////////////////////////////////////// - // evaluate each rule against the contact // - /////////////////////////////////////////////// - for(LeadScoreRule rule : rules) + if(lastId != null) { - if(evaluateRule(rule, contactRecord)) - { - newScore += rule.getScoreAdjustment(); - } + filter.withCriteria(new QFilterCriteria("id", QCriteriaOperator.GREATER_THAN, lastId)); } - /////////////////////////////////////////////// - // update only if score changed // - /////////////////////////////////////////////// - Integer currentScore = contact.getLeadScore() != null ? contact.getLeadScore() : 0; - if(!Objects.equals(currentScore, newScore)) + QueryOutput contactsQuery = new QueryAction().execute( + new QueryInput(Contact.TABLE_NAME).withFilter(filter)); + + if(contactsQuery.getRecords().isEmpty()) { - QRecord updateRecord = new QRecord() - .withValue("id", contact.getId()) - .withValue("leadScore", newScore); + break; + } - new UpdateAction().execute( - new UpdateInput(Contact.TABLE_NAME).withRecord(updateRecord)); + for(QRecord contactRecord : contactsQuery.getRecords()) + { + Contact contact = new Contact(contactRecord); + totalContacts++; + int newScore = 0; - updatedCount++; + /////////////////////////////////////////////// + // evaluate each rule against the contact // + /////////////////////////////////////////////// + for(LeadScoreRule rule : rules) + { + if(evaluateRule(rule, contactRecord)) + { + newScore += rule.getScoreAdjustment(); + } + } + + /////////////////////////////////////////////// + // update only if score changed // + /////////////////////////////////////////////// + Integer currentScore = contact.getLeadScore() != null ? contact.getLeadScore() : 0; + if(!Objects.equals(currentScore, newScore)) + { + QRecord updateRecord = new QRecord() + .withValue("id", contact.getId()) + .withValue("leadScore", newScore); + + new UpdateAction().execute( + new UpdateInput(Contact.TABLE_NAME).withRecord(updateRecord)); + + updatedCount++; + } + + lastId = contact.getId(); + } + + ///////////////////////////////////////////// + // if page was not full, we are done // + ///////////////////////////////////////////// + if(contactsQuery.getRecords().size() < pageSize) + { + break; } } output.addValue("updatedCount", updatedCount); - output.addValue("totalContacts", contactsQuery.getRecords().size()); + output.addValue("totalContacts", totalContacts); output.addValue("totalRules", rules.size()); } /*************************************************************************** - ** Evaluate a single rule against a contact record. + ** Evaluate a single rule against a contact record. Supports dot-notation + ** field paths for related entities (e.g., "company.industry"). ***************************************************************************/ private boolean evaluateRule(LeadScoreRule rule, QRecord contactRecord) { @@ -141,7 +183,7 @@ public class CalculateLeadScoresProcess implements BackendStep, MetaDataProducer } String fieldPath = rule.getFieldPath(); - String actualValue = contactRecord.getValueString(fieldPath); + String actualValue = resolveFieldValue(fieldPath, contactRecord); String expectedValue = rule.getFieldValue(); switch(operator) @@ -193,4 +235,55 @@ public class CalculateLeadScoresProcess implements BackendStep, MetaDataProducer } } + + + /*************************************************************************** + ** Resolve a field value from a contact record, supporting dot-notation + ** for related entity fields (e.g., "company.industry" loads the contact's + ** company record and returns its "industry" field). + ***************************************************************************/ + private String resolveFieldValue(String fieldPath, QRecord contactRecord) + { + if(fieldPath == null || !fieldPath.contains(".")) + { + return (contactRecord.getValueString(fieldPath)); + } + + String[] parts = fieldPath.split("\\.", 2); + String prefix = parts[0]; + String fieldName = parts[1]; + + try + { + if("company".equalsIgnoreCase(prefix)) + { + Integer companyId = contactRecord.getValueInteger("companyId"); + if(companyId == null) + { + return (null); + } + + GetOutput companyOutput = new GetAction().execute( + new GetInput(Company.TABLE_NAME).withPrimaryKey(companyId)); + + if(companyOutput.getRecord() == null) + { + return (null); + } + + return (companyOutput.getRecord().getValueString(fieldName)); + } + else + { + LOG.warn("Unsupported dot-notation prefix in lead score rule fieldPath: " + prefix); + return (null); + } + } + catch(Exception e) + { + LOG.warn("Error resolving dot-notation field [" + fieldPath + "] for contact", e); + return (null); + } + } + } diff --git a/src/main/java/com/kingsrook/qbits/crm/sync/CrmCurrencyUtils.java b/src/main/java/com/kingsrook/qbits/crm/sync/CrmCurrencyUtils.java new file mode 100644 index 0000000..0b3cebd --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/crm/sync/CrmCurrencyUtils.java @@ -0,0 +1,74 @@ +/******************************************************************************* + ** Utility class for CRM currency conversion. Queries the Currency table for + ** the exchange rate and converts an amount to the base currency. + *******************************************************************************/ +package com.kingsrook.qbits.crm.sync; + + +import java.math.BigDecimal; +import java.math.RoundingMode; +import com.kingsrook.qbits.crm.core.model.Currency; +import com.kingsrook.qqq.backend.core.actions.tables.QueryAction; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryOutput; + + +/******************************************************************************* + ** Converts monetary amounts to the base currency using exchange rates from + ** the Currency table. Returns the amount unchanged if no currency code is + ** provided or if the currency is not found. + *******************************************************************************/ +public final class CrmCurrencyUtils +{ + + /******************************************************************************* + ** Private constructor -- utility class. + *******************************************************************************/ + private CrmCurrencyUtils() + { + } + + + + /******************************************************************************* + ** Convert an amount to base currency using the exchange rate for the given + ** currency code. Returns null if amount is null. Returns the amount unchanged + ** if currencyCode is null/empty or not found in the Currency table. + *******************************************************************************/ + public static BigDecimal convertToBaseCurrency(BigDecimal amount, String currencyCode) throws QException + { + if(amount == null) + { + return (null); + } + + if(currencyCode == null || currencyCode.isBlank()) + { + return (amount); + } + + QueryOutput queryOutput = new QueryAction().execute( + new QueryInput(Currency.TABLE_NAME) + .withFilter(new QQueryFilter() + .withCriteria(new QFilterCriteria("currencyCode", QCriteriaOperator.EQUALS, currencyCode)))); + + if(queryOutput.getRecords().isEmpty()) + { + return (amount); + } + + Currency currency = new Currency(queryOutput.getRecords().get(0)); + BigDecimal exchangeRate = currency.getExchangeRateToBase(); + if(exchangeRate == null) + { + exchangeRate = BigDecimal.ONE; + } + + return (amount.multiply(exchangeRate).setScale(2, RoundingMode.HALF_UP)); + } + +} diff --git a/src/test/java/com/kingsrook/qbits/crm/CrmIntegrationTest.java b/src/test/java/com/kingsrook/qbits/crm/CrmIntegrationTest.java new file mode 100644 index 0000000..13fb88e --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/crm/CrmIntegrationTest.java @@ -0,0 +1,271 @@ +/******************************************************************************* + ** End-to-end integration test for the CRM QBit. Exercises the full lifecycle: + ** contacts, companies, form submissions, activities, pipelines, deals, + ** deal products, stage changes, audit logs, and stage history. + *******************************************************************************/ +package com.kingsrook.qbits.crm; + + +import java.math.BigDecimal; +import java.util.List; +import com.kingsrook.qbits.crm.activities.model.Activity; +import com.kingsrook.qbits.crm.activities.model.ActivityType; +import com.kingsrook.qbits.crm.audit.model.AuditLog; +import com.kingsrook.qbits.crm.core.model.Company; +import com.kingsrook.qbits.crm.core.model.Contact; +import com.kingsrook.qbits.crm.core.model.FormSubmission; +import com.kingsrook.qbits.crm.core.model.enums.CrmAuditAction; +import com.kingsrook.qbits.crm.core.model.enums.CrmDirection; +import com.kingsrook.qbits.crm.core.model.enums.CrmEntityType; +import com.kingsrook.qbits.crm.core.model.enums.CrmFormType; +import com.kingsrook.qbits.crm.deals.model.Deal; +import com.kingsrook.qbits.crm.deals.model.DealProduct; +import com.kingsrook.qbits.crm.deals.model.DealStageHistory; +import com.kingsrook.qbits.crm.deals.model.Pipeline; +import com.kingsrook.qbits.crm.deals.model.PipelineStage; +import com.kingsrook.qbits.crm.deals.model.Product; +import com.kingsrook.qqq.backend.core.actions.tables.GetAction; +import com.kingsrook.qqq.backend.core.actions.tables.InsertAction; +import com.kingsrook.qqq.backend.core.actions.tables.QueryAction; +import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertOutput; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryOutput; +import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput; +import com.kingsrook.qqq.backend.core.model.data.QRecord; +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.assertThat; + + +/******************************************************************************* + ** Full CRM lifecycle integration test. Validates that all major CRM operations + ** work together end-to-end: contacts, companies, forms, activities, pipelines, + ** deals, products, stage transitions, auditing, and stage history. + *******************************************************************************/ +class CrmIntegrationTest extends BaseTest +{ + + /******************************************************************************* + ** Walk through the complete CRM lifecycle in a single test to verify all + ** customizers and cross-entity behaviors fire correctly together. + *******************************************************************************/ + @Test + void testFullCrmLifecycle() throws QException + { + //////////////////////////////////////////////////////////////////////// + // Step 1: Insert a Contact // + //////////////////////////////////////////////////////////////////////// + InsertOutput contactInsert = new InsertAction().execute( + new InsertInput(Contact.TABLE_NAME) + .withRecordEntity(new Contact() + .withFirstName("John") + .withLastName("Doe") + .withEmail("john@example.com") + .withOwnerUserId("rep1"))); + Integer contactId = contactInsert.getRecords().get(0).getValueInteger("id"); + assertThat(contactId).isNotNull(); + + //////////////////////////////////////////////////////////////////////// + // Step 2: Insert a Company, then update contact.companyId // + //////////////////////////////////////////////////////////////////////// + InsertOutput companyInsert = new InsertAction().execute( + new InsertInput(Company.TABLE_NAME) + .withRecordEntity(new Company() + .withName("Acme Inc") + .withOwnerUserId("rep1"))); + Integer companyId = companyInsert.getRecords().get(0).getValueInteger("id"); + assertThat(companyId).isNotNull(); + + QRecord contactUpdate = new QRecord(); + contactUpdate.setValue("id", contactId); + contactUpdate.setValue("companyId", companyId); + new UpdateAction().execute(new UpdateInput(Contact.TABLE_NAME).withRecord(contactUpdate)); + + Contact updatedContact = new Contact(GetAction.execute(Contact.TABLE_NAME, contactId)); + assertThat(updatedContact.getCompanyId()).isEqualTo(companyId); + + //////////////////////////////////////////////////////////////////////// + // Step 3: Insert FormSubmission -> verify auto-link to contact // + //////////////////////////////////////////////////////////////////////// + InsertOutput formInsert = new InsertAction().execute( + new InsertInput(FormSubmission.TABLE_NAME) + .withRecordEntity(new FormSubmission() + .withEmail("john@example.com") + .withFormType(CrmFormType.CONTACT.getPossibleValueId()) + .withFirstName("John"))); + Integer formId = formInsert.getRecords().get(0).getValueInteger("id"); + + FormSubmission form = new FormSubmission(GetAction.execute(FormSubmission.TABLE_NAME, formId)); + assertThat(form.getContactId()).isEqualTo(contactId); + + //////////////////////////////////////////////////////////////////////// + // Step 4: Insert ActivityType (Call, isSystem=true) // + //////////////////////////////////////////////////////////////////////// + InsertOutput activityTypeInsert = new InsertAction().execute( + new InsertInput(ActivityType.TABLE_NAME) + .withRecordEntity(new ActivityType() + .withName("Call") + .withSortOrder(1) + .withIsActive(true) + .withIsSystem(true))); + Integer activityTypeId = activityTypeInsert.getRecords().get(0).getValueInteger("id"); + + //////////////////////////////////////////////////////////////////////// + // Step 5: Insert Activity -> verify contact lastActivityDate and // + // lastContactedDate are set // + //////////////////////////////////////////////////////////////////////// + InsertOutput activityInsert = new InsertAction().execute( + new InsertInput(Activity.TABLE_NAME) + .withRecordEntity(new Activity() + .withActivityTypeId(activityTypeId) + .withSubject("Follow-up call") + .withDirection(CrmDirection.OUTBOUND.getPossibleValueId()) + .withContactId(contactId) + .withCompanyId(companyId) + .withOwnerUserId("rep1"))); + Integer activityId = activityInsert.getRecords().get(0).getValueInteger("id"); + assertThat(activityId).isNotNull(); + + Contact contactAfterActivity = new Contact(GetAction.execute(Contact.TABLE_NAME, contactId)); + assertThat(contactAfterActivity.getLastActivityDate()).isNotNull(); + assertThat(contactAfterActivity.getLastContactedDate()).isNotNull(); + + //////////////////////////////////////////////////////////////////////// + // Step 6: Insert Pipeline + 3 stages // + //////////////////////////////////////////////////////////////////////// + InsertOutput pipelineInsert = new InsertAction().execute( + new InsertInput(Pipeline.TABLE_NAME) + .withRecordEntity(new Pipeline() + .withName("Sales Pipeline") + .withIsDefault(true) + .withIsActive(true))); + Integer pipelineId = pipelineInsert.getRecords().get(0).getValueInteger("id"); + + InsertOutput prospectingInsert = new InsertAction().execute( + new InsertInput(PipelineStage.TABLE_NAME) + .withRecordEntity(new PipelineStage() + .withPipelineId(pipelineId) + .withName("Prospecting") + .withSortOrder(1) + .withProbabilityPct(10) + .withIsClosedWon(false) + .withIsClosedLost(false))); + Integer prospectingStageId = prospectingInsert.getRecords().get(0).getValueInteger("id"); + + InsertOutput closedWonInsert = new InsertAction().execute( + new InsertInput(PipelineStage.TABLE_NAME) + .withRecordEntity(new PipelineStage() + .withPipelineId(pipelineId) + .withName("Closed Won") + .withSortOrder(2) + .withProbabilityPct(100) + .withIsClosedWon(true) + .withIsClosedLost(false))); + Integer closedWonStageId = closedWonInsert.getRecords().get(0).getValueInteger("id"); + + InsertOutput closedLostInsert = new InsertAction().execute( + new InsertInput(PipelineStage.TABLE_NAME) + .withRecordEntity(new PipelineStage() + .withPipelineId(pipelineId) + .withName("Closed Lost") + .withSortOrder(3) + .withProbabilityPct(0) + .withIsClosedWon(false) + .withIsClosedLost(true))); + Integer closedLostStageId = closedLostInsert.getRecords().get(0).getValueInteger("id"); + assertThat(closedLostStageId).isNotNull(); + + //////////////////////////////////////////////////////////////////////// + // Step 7: Insert Deal -> verify stageEnteredDate and weightedAmount // + //////////////////////////////////////////////////////////////////////// + InsertOutput dealInsert = new InsertAction().execute( + new InsertInput(Deal.TABLE_NAME) + .withRecordEntity(new Deal() + .withName("Big Deal") + .withAmount(new BigDecimal("10000")) + .withPipelineId(pipelineId) + .withPipelineStageId(prospectingStageId) + .withOwnerUserId("rep1"))); + Integer dealId = dealInsert.getRecords().get(0).getValueInteger("id"); + + Deal deal = new Deal(GetAction.execute(Deal.TABLE_NAME, dealId)); + assertThat(deal.getStageEnteredDate()).isNotNull(); + assertThat(deal.getWeightedAmount()).isEqualByComparingTo(new BigDecimal("1000")); + + //////////////////////////////////////////////////////////////////////// + // Step 8: Insert DealProduct -> verify deal.amount recalculated // + //////////////////////////////////////////////////////////////////////// + InsertOutput productInsert = new InsertAction().execute( + new InsertInput(Product.TABLE_NAME) + .withRecordEntity(new Product() + .withName("Widget") + .withSku("WDG-001") + .withUnitPrice(new BigDecimal("2000")) + .withIsActive(true))); + Integer productId = productInsert.getRecords().get(0).getValueInteger("id"); + + InsertOutput dealProductInsert = new InsertAction().execute( + new InsertInput(DealProduct.TABLE_NAME) + .withRecordEntity(new DealProduct() + .withDealId(dealId) + .withProductId(productId) + .withQuantity(new BigDecimal("5")) + .withUnitPrice(new BigDecimal("2000")) + .withDiscountPct(new BigDecimal("0")))); + Integer dealProductId = dealProductInsert.getRecords().get(0).getValueInteger("id"); + assertThat(dealProductId).isNotNull(); + + Deal dealAfterProduct = new Deal(GetAction.execute(Deal.TABLE_NAME, dealId)); + assertThat(dealAfterProduct.getAmount()).isEqualByComparingTo(new BigDecimal("10000")); + + //////////////////////////////////////////////////////////////////////// + // Step 9: Update deal to Closed Won -> verify actualCloseDate and // + // weightedAmount // + //////////////////////////////////////////////////////////////////////// + QRecord dealStageUpdate = new QRecord(); + dealStageUpdate.setValue("id", dealId); + dealStageUpdate.setValue("pipelineStageId", closedWonStageId); + new UpdateAction().execute(new UpdateInput(Deal.TABLE_NAME).withRecord(dealStageUpdate)); + + Deal dealClosedWon = new Deal(GetAction.execute(Deal.TABLE_NAME, dealId)); + assertThat(dealClosedWon.getActualCloseDate()).isNotNull(); + assertThat(dealClosedWon.getWeightedAmount()).isEqualByComparingTo(new BigDecimal("10000")); + + //////////////////////////////////////////////////////////////////////// + // Step 10: Query AuditLog for CONTACT -> verify CREATED entry // + //////////////////////////////////////////////////////////////////////// + QueryOutput contactAuditQuery = new QueryAction().execute( + new QueryInput(AuditLog.TABLE_NAME) + .withFilter(new QQueryFilter() + .withCriteria(new QFilterCriteria("entityType", QCriteriaOperator.EQUALS, CrmEntityType.CONTACT.getPossibleValueId())))); + List contactAudits = contactAuditQuery.getRecords(); + assertThat(contactAudits).isNotEmpty(); + assertThat(contactAudits).anyMatch(r -> CrmAuditAction.CREATED.getPossibleValueId().equals(r.getValueInteger("action"))); + + //////////////////////////////////////////////////////////////////////// + // Step 11: Query AuditLog for DEAL -> verify CREATED and additional // + //////////////////////////////////////////////////////////////////////// + QueryOutput dealAuditQuery = new QueryAction().execute( + new QueryInput(AuditLog.TABLE_NAME) + .withFilter(new QQueryFilter() + .withCriteria(new QFilterCriteria("entityType", QCriteriaOperator.EQUALS, CrmEntityType.DEAL.getPossibleValueId())))); + List dealAudits = dealAuditQuery.getRecords(); + assertThat(dealAudits).anyMatch(r -> CrmAuditAction.CREATED.getPossibleValueId().equals(r.getValueInteger("action"))); + assertThat(dealAudits.size()).isGreaterThanOrEqualTo(2); + + //////////////////////////////////////////////////////////////////////// + // Step 12: Query DealStageHistory -> verify at least 2 rows // + //////////////////////////////////////////////////////////////////////// + QueryOutput stageHistoryQuery = new QueryAction().execute( + new QueryInput(DealStageHistory.TABLE_NAME) + .withFilter(new QQueryFilter() + .withCriteria(new QFilterCriteria("dealId", QCriteriaOperator.EQUALS, dealId)))); + assertThat(stageHistoryQuery.getRecords().size()).isGreaterThanOrEqualTo(2); + } + +} diff --git a/src/test/java/com/kingsrook/qbits/crm/activities/customizers/ActivityLastDateCustomizerTest.java b/src/test/java/com/kingsrook/qbits/crm/activities/customizers/ActivityLastDateCustomizerTest.java new file mode 100644 index 0000000..76bfd88 --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/crm/activities/customizers/ActivityLastDateCustomizerTest.java @@ -0,0 +1,302 @@ +/******************************************************************************* + ** Unit tests for ActivityLastDateCustomizer -- verifies that inserting an + ** Activity updates lastActivityDate and lastContactedDate on linked + ** Contact, Company, and Deal records. + *******************************************************************************/ +package com.kingsrook.qbits.crm.activities.customizers; + + +import java.math.BigDecimal; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import com.kingsrook.qbits.crm.BaseTest; +import com.kingsrook.qbits.crm.activities.model.Activity; +import com.kingsrook.qbits.crm.activities.model.ActivityType; +import com.kingsrook.qbits.crm.core.model.Company; +import com.kingsrook.qbits.crm.core.model.Contact; +import com.kingsrook.qbits.crm.core.model.enums.CrmDirection; +import com.kingsrook.qbits.crm.deals.model.Deal; +import com.kingsrook.qbits.crm.deals.model.Pipeline; +import com.kingsrook.qbits.crm.deals.model.PipelineStage; +import com.kingsrook.qqq.backend.core.actions.tables.GetAction; +import com.kingsrook.qqq.backend.core.actions.tables.InsertAction; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertOutput; +import com.kingsrook.qqq.backend.core.model.data.QRecord; +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; + + +/******************************************************************************* + ** Tests for ActivityLastDateCustomizer: verifies that creating activities + ** propagates lastActivityDate and lastContactedDate to linked records. + *******************************************************************************/ +class ActivityLastDateCustomizerTest extends BaseTest +{ + + /******************************************************************************* + ** Insert a contact, then insert an activity linked to it. Verify + ** contact.lastActivityDate is set. + *******************************************************************************/ + @Test + void testActivityInsertUpdatesContactLastActivityDate() throws QException + { + Integer activityTypeId = insertActivityType("Call"); + Integer contactId = insertContact("John", "Doe", "john@example.com"); + + insertActivity(activityTypeId, "Test call", contactId, null, null, null); + + Contact contact = new Contact(GetAction.execute(Contact.TABLE_NAME, contactId)); + assertNotNull(contact.getLastActivityDate()); + } + + + + /******************************************************************************* + ** Insert contact, insert activity with direction=OUTBOUND. Verify + ** contact.lastContactedDate is set. + *******************************************************************************/ + @Test + void testOutboundActivityUpdatesContactLastContactedDate() throws QException + { + Integer activityTypeId = insertActivityType("Call"); + Integer contactId = insertContact("Jane", "Doe", "jane@example.com"); + + insertActivity(activityTypeId, "Outbound call", contactId, null, null, CrmDirection.OUTBOUND.getPossibleValueId()); + + Contact contact = new Contact(GetAction.execute(Contact.TABLE_NAME, contactId)); + assertNotNull(contact.getLastActivityDate()); + assertNotNull(contact.getLastContactedDate()); + } + + + + /******************************************************************************* + ** Insert contact, insert INBOUND activity. Verify lastContactedDate is + ** still null. + *******************************************************************************/ + @Test + void testInboundActivityDoesNotUpdateLastContactedDate() throws QException + { + Integer activityTypeId = insertActivityType("Call"); + Integer contactId = insertContact("Bob", "Smith", "bob@example.com"); + + insertActivity(activityTypeId, "Inbound call", contactId, null, null, CrmDirection.INBOUND.getPossibleValueId()); + + Contact contact = new Contact(GetAction.execute(Contact.TABLE_NAME, contactId)); + assertNotNull(contact.getLastActivityDate()); + assertNull(contact.getLastContactedDate()); + } + + + + /******************************************************************************* + ** Insert company, insert activity linked to it. Verify + ** company.lastActivityDate is set. + *******************************************************************************/ + @Test + void testActivityInsertUpdatesCompanyLastActivityDate() throws QException + { + Integer activityTypeId = insertActivityType("Meeting"); + Integer companyId = insertCompany("Acme Inc"); + + insertActivity(activityTypeId, "Company meeting", null, companyId, null, null); + + Company company = new Company(GetAction.execute(Company.TABLE_NAME, companyId)); + assertNotNull(company.getLastActivityDate()); + } + + + + /******************************************************************************* + ** Insert pipeline+stage+deal, insert activity linked to deal. Verify + ** deal.lastActivityDate is set. + *******************************************************************************/ + @Test + void testActivityInsertUpdatesDealLastActivityDate() throws QException + { + Integer activityTypeId = insertActivityType("Note"); + Integer pipelineId = insertPipeline("Sales"); + Integer stageId = insertStage(pipelineId, "Open", 1, 10); + Integer dealId = insertDeal("Test Deal", pipelineId, stageId, new BigDecimal("5000")); + + insertActivity(activityTypeId, "Deal note", null, null, dealId, null); + + Deal deal = new Deal(GetAction.execute(Deal.TABLE_NAME, dealId)); + assertNotNull(deal.getLastActivityDate()); + } + + + + /******************************************************************************* + ** Insert contact + company + deal, insert activity linked to all three. + ** Verify all three have lastActivityDate set. + *******************************************************************************/ + @Test + void testActivityWithMultipleLinksUpdatesAll() throws QException + { + Integer activityTypeId = insertActivityType("Email"); + Integer contactId = insertContact("Multi", "Link", "multi@example.com"); + Integer companyId = insertCompany("Multi Corp"); + Integer pipelineId = insertPipeline("Multi Pipeline"); + Integer stageId = insertStage(pipelineId, "Open", 1, 20); + Integer dealId = insertDeal("Multi Deal", pipelineId, stageId, new BigDecimal("3000")); + + insertActivity(activityTypeId, "Multi-link activity", contactId, companyId, dealId, CrmDirection.OUTBOUND.getPossibleValueId()); + + Contact contact = new Contact(GetAction.execute(Contact.TABLE_NAME, contactId)); + assertNotNull(contact.getLastActivityDate()); + assertNotNull(contact.getLastContactedDate()); + + Company company = new Company(GetAction.execute(Company.TABLE_NAME, companyId)); + assertNotNull(company.getLastActivityDate()); + assertNotNull(company.getLastContactedDate()); + + Deal deal = new Deal(GetAction.execute(Deal.TABLE_NAME, dealId)); + assertNotNull(deal.getLastActivityDate()); + } + + + + ///////////////////////////////////////////////////////////////////////////// + // Helper methods // + ///////////////////////////////////////////////////////////////////////////// + + /*************************************************************************** + ** Helper: insert an ActivityType and return its id. + ***************************************************************************/ + private Integer insertActivityType(String name) throws QException + { + InsertOutput output = new InsertAction().execute( + new InsertInput(ActivityType.TABLE_NAME) + .withRecordEntity(new ActivityType() + .withName(name) + .withSortOrder(1) + .withIsActive(true) + .withIsSystem(false))); + return (output.getRecords().get(0).getValueInteger("id")); + } + + + + /*************************************************************************** + ** Helper: insert a Contact and return its id. + ***************************************************************************/ + private Integer insertContact(String firstName, String lastName, String email) throws QException + { + InsertOutput output = new InsertAction().execute( + new InsertInput(Contact.TABLE_NAME) + .withRecordEntity(new Contact() + .withFirstName(firstName) + .withLastName(lastName) + .withEmail(email) + .withOwnerUserId("user-001"))); + return (output.getRecords().get(0).getValueInteger("id")); + } + + + + /*************************************************************************** + ** Helper: insert a Company and return its id. + ***************************************************************************/ + private Integer insertCompany(String name) throws QException + { + InsertOutput output = new InsertAction().execute( + new InsertInput(Company.TABLE_NAME) + .withRecordEntity(new Company() + .withName(name) + .withOwnerUserId("user-001"))); + return (output.getRecords().get(0).getValueInteger("id")); + } + + + + /*************************************************************************** + ** Helper: insert a Pipeline and return its id. + ***************************************************************************/ + private Integer insertPipeline(String name) throws QException + { + InsertOutput output = new InsertAction().execute( + new InsertInput(Pipeline.TABLE_NAME) + .withRecordEntity(new Pipeline() + .withName(name) + .withIsDefault(true) + .withIsActive(true))); + return (output.getRecords().get(0).getValueInteger("id")); + } + + + + /*************************************************************************** + ** Helper: insert a PipelineStage and return its id. + ***************************************************************************/ + private Integer insertStage(Integer pipelineId, String name, Integer sortOrder, Integer probabilityPct) throws QException + { + InsertOutput output = new InsertAction().execute( + new InsertInput(PipelineStage.TABLE_NAME) + .withRecordEntity(new PipelineStage() + .withPipelineId(pipelineId) + .withName(name) + .withSortOrder(sortOrder) + .withProbabilityPct(probabilityPct) + .withIsClosedWon(false) + .withIsClosedLost(false))); + return (output.getRecords().get(0).getValueInteger("id")); + } + + + + /*************************************************************************** + ** Helper: insert a Deal and return its id. + ***************************************************************************/ + private Integer insertDeal(String name, Integer pipelineId, Integer stageId, BigDecimal amount) throws QException + { + InsertOutput output = new InsertAction().execute( + new InsertInput(Deal.TABLE_NAME) + .withRecordEntity(new Deal() + .withName(name) + .withPipelineId(pipelineId) + .withPipelineStageId(stageId) + .withAmount(amount) + .withOwnerUserId("user-001"))); + return (output.getRecords().get(0).getValueInteger("id")); + } + + + + /*************************************************************************** + ** Helper: insert an Activity and return its id. + ***************************************************************************/ + private Integer insertActivity(Integer activityTypeId, String subject, Integer contactId, + Integer companyId, Integer dealId, Integer direction) throws QException + { + Activity activity = new Activity() + .withActivityTypeId(activityTypeId) + .withSubject(subject) + .withOwnerUserId("user-001"); + + if(contactId != null) + { + activity.withContactId(contactId); + } + if(companyId != null) + { + activity.withCompanyId(companyId); + } + if(dealId != null) + { + activity.withDealId(dealId); + } + if(direction != null) + { + activity.withDirection(direction); + } + + InsertOutput output = new InsertAction().execute( + new InsertInput(Activity.TABLE_NAME).withRecordEntity(activity)); + return (output.getRecords().get(0).getValueInteger("id")); + } + +} diff --git a/src/test/java/com/kingsrook/qbits/crm/core/customizers/FormSubmissionAutoLinkCustomizerTest.java b/src/test/java/com/kingsrook/qbits/crm/core/customizers/FormSubmissionAutoLinkCustomizerTest.java new file mode 100644 index 0000000..874736b --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/crm/core/customizers/FormSubmissionAutoLinkCustomizerTest.java @@ -0,0 +1,118 @@ +/******************************************************************************* + ** Unit tests for FormSubmissionAutoLinkCustomizer -- verifies that inserting a + ** FormSubmission auto-links contactId when a matching contact email exists. + *******************************************************************************/ +package com.kingsrook.qbits.crm.core.customizers; + + +import com.kingsrook.qbits.crm.BaseTest; +import com.kingsrook.qbits.crm.core.model.Contact; +import com.kingsrook.qbits.crm.core.model.FormSubmission; +import com.kingsrook.qbits.crm.core.model.enums.CrmFormType; +import com.kingsrook.qqq.backend.core.actions.tables.GetAction; +import com.kingsrook.qqq.backend.core.actions.tables.InsertAction; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertOutput; +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.assertThat; + + +/******************************************************************************* + ** Tests for FormSubmissionAutoLinkCustomizer: verifies that form submissions + ** are auto-linked to existing contacts by matching email address. + *******************************************************************************/ +class FormSubmissionAutoLinkCustomizerTest extends BaseTest +{ + + /******************************************************************************* + ** Insert a contact with email, then insert a form submission with the same + ** email. Verify contactId is auto-linked on the form submission. + *******************************************************************************/ + @Test + void testFormWithMatchingContactEmailAutoLinks() throws QException + { + Integer contactId = insertContact("Test", "User", "test@example.com"); + Integer formId = insertFormSubmission("test@example.com", null); + + FormSubmission form = new FormSubmission(GetAction.execute(FormSubmission.TABLE_NAME, formId)); + assertThat(form.getContactId()).isEqualTo(contactId); + } + + + + /******************************************************************************* + ** Insert a form submission with an email that does not match any contact. + ** Verify contactId remains null. + *******************************************************************************/ + @Test + void testFormWithNoMatchingEmailDoesNotLink() throws QException + { + Integer formId = insertFormSubmission("unknown@example.com", null); + + FormSubmission form = new FormSubmission(GetAction.execute(FormSubmission.TABLE_NAME, formId)); + assertThat(form.getContactId()).isNull(); + } + + + + /******************************************************************************* + ** Insert contact A and contact B. Insert form with contactId=A and email + ** matching B. Verify contactId stays as A (not overwritten to B). + *******************************************************************************/ + @Test + void testFormWithExistingContactIdNotOverwritten() throws QException + { + Integer contactAId = insertContact("Alice", "Alpha", "alice@example.com"); + Integer contactBId = insertContact("Bob", "Beta", "bob@example.com"); + + Integer formId = insertFormSubmission("bob@example.com", contactAId); + + FormSubmission form = new FormSubmission(GetAction.execute(FormSubmission.TABLE_NAME, formId)); + assertThat(form.getContactId()).isEqualTo(contactAId); + } + + + + ///////////////////////////////////////////////////////////////////////////// + // Helper methods // + ///////////////////////////////////////////////////////////////////////////// + + /*************************************************************************** + ** Helper: insert a Contact and return its id. + ***************************************************************************/ + private Integer insertContact(String firstName, String lastName, String email) throws QException + { + InsertOutput output = new InsertAction().execute( + new InsertInput(Contact.TABLE_NAME) + .withRecordEntity(new Contact() + .withFirstName(firstName) + .withLastName(lastName) + .withEmail(email) + .withOwnerUserId("user-001"))); + return (output.getRecords().get(0).getValueInteger("id")); + } + + + + /*************************************************************************** + ** Helper: insert a FormSubmission and return its id. + ***************************************************************************/ + private Integer insertFormSubmission(String email, Integer contactId) throws QException + { + FormSubmission form = new FormSubmission() + .withEmail(email) + .withFormType(CrmFormType.CONTACT.getPossibleValueId()); + + if(contactId != null) + { + form.withContactId(contactId); + } + + InsertOutput output = new InsertAction().execute( + new InsertInput(FormSubmission.TABLE_NAME) + .withRecordEntity(form)); + return (output.getRecords().get(0).getValueInteger("id")); + } + +} diff --git a/src/test/java/com/kingsrook/qbits/crm/core/model/ContactChildTableTest.java b/src/test/java/com/kingsrook/qbits/crm/core/model/ContactChildTableTest.java new file mode 100644 index 0000000..bf6f18b --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/crm/core/model/ContactChildTableTest.java @@ -0,0 +1,268 @@ +/******************************************************************************* + ** Unit tests verifying Contact child table joins for Activity, DealContact, + ** ContactTag, and ConsentRecord. + *******************************************************************************/ +package com.kingsrook.qbits.crm.core.model; + + +import java.time.Instant; +import com.kingsrook.qbits.crm.BaseTest; +import com.kingsrook.qbits.crm.activities.model.Activity; +import com.kingsrook.qbits.crm.activities.model.ActivityType; +import com.kingsrook.qbits.crm.core.model.enums.CrmConsentStatus; +import com.kingsrook.qbits.crm.core.model.enums.CrmConsentType; +import com.kingsrook.qbits.crm.deals.model.Deal; +import com.kingsrook.qbits.crm.deals.model.DealContact; +import com.kingsrook.qbits.crm.deals.model.Pipeline; +import com.kingsrook.qbits.crm.deals.model.PipelineStage; +import com.kingsrook.qqq.backend.core.actions.tables.InsertAction; +import com.kingsrook.qqq.backend.core.actions.tables.QueryAction; +import com.kingsrook.qqq.backend.core.context.QContext; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertOutput; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryOutput; +import com.kingsrook.qqq.backend.core.model.metadata.joins.QJoinMetaData; +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + + +/******************************************************************************* + ** Tests that Contact entity has child joins for Activity, DealContact, + ** ContactTag, and ConsentRecord, and that child records can be inserted + ** and queried against a parent contact. + *******************************************************************************/ +class ContactChildTableTest extends BaseTest +{ + + /******************************************************************************* + ** Verify the join from Contact to Activity exists in the QInstance. + *******************************************************************************/ + @Test + void testContactActivityJoinExists() + { + String joinName = QJoinMetaData.makeInferredJoinName(Contact.TABLE_NAME, Activity.TABLE_NAME); + QJoinMetaData join = QContext.getQInstance().getJoin(joinName); + assertNotNull(join, "Expected join from Contact to Activity to exist"); + } + + + + /******************************************************************************* + ** Verify the join from Contact to DealContact exists in the QInstance. + *******************************************************************************/ + @Test + void testContactDealContactJoinExists() + { + String joinName = QJoinMetaData.makeInferredJoinName(Contact.TABLE_NAME, DealContact.TABLE_NAME); + QJoinMetaData join = QContext.getQInstance().getJoin(joinName); + assertNotNull(join, "Expected join from Contact to DealContact to exist"); + } + + + + /******************************************************************************* + ** Verify the join from Contact to ContactTag exists in the QInstance. + *******************************************************************************/ + @Test + void testContactContactTagJoinExists() + { + String joinName = QJoinMetaData.makeInferredJoinName(Contact.TABLE_NAME, ContactTag.TABLE_NAME); + QJoinMetaData join = QContext.getQInstance().getJoin(joinName); + assertNotNull(join, "Expected join from Contact to ContactTag to exist"); + } + + + + /******************************************************************************* + ** Verify the join from Contact to ConsentRecord exists in the QInstance. + *******************************************************************************/ + @Test + void testContactConsentRecordJoinExists() + { + String joinName = QJoinMetaData.makeInferredJoinName(Contact.TABLE_NAME, ConsentRecord.TABLE_NAME); + QJoinMetaData join = QContext.getQInstance().getJoin(joinName); + assertNotNull(join, "Expected join from Contact to ConsentRecord to exist"); + } + + + + /******************************************************************************* + ** Insert a contact, then insert child Activity, DealContact, ContactTag, + ** and ConsentRecord records referencing it. Verify all persist and can be + ** queried by contactId. + *******************************************************************************/ + @Test + void testInsertChildRecords() throws QException + { + ////////////////////////// + // insert a contact // + ////////////////////////// + Integer contactId = insertContact("Alice", "Johnson"); + + /////////////////////////// + // insert an activity // + /////////////////////////// + Integer activityTypeId = insertActivityType("Call"); + new InsertAction().execute(new InsertInput(Activity.TABLE_NAME) + .withRecordEntity(new Activity() + .withActivityTypeId(activityTypeId) + .withSubject("Follow-up call") + .withContactId(contactId) + .withOwnerUserId("user-001"))); + + QueryOutput activityQuery = new QueryAction().execute(new QueryInput(Activity.TABLE_NAME) + .withFilter(new QQueryFilter(new QFilterCriteria("contactId", QCriteriaOperator.EQUALS, contactId)))); + assertEquals(1, activityQuery.getRecords().size()); + + ////////////////////////////// + // insert a deal contact // + ////////////////////////////// + Integer pipelineId = insertPipeline("Sales"); + Integer stageId = insertPipelineStage(pipelineId, "Prospecting", 1, 10); + Integer dealId = insertDeal("Test Deal", pipelineId, stageId); + + new InsertAction().execute(new InsertInput(DealContact.TABLE_NAME) + .withRecordEntity(new DealContact() + .withDealId(dealId) + .withContactId(contactId) + .withIsPrimary(true))); + + QueryOutput dealContactQuery = new QueryAction().execute(new QueryInput(DealContact.TABLE_NAME) + .withFilter(new QQueryFilter(new QFilterCriteria("contactId", QCriteriaOperator.EQUALS, contactId)))); + assertEquals(1, dealContactQuery.getRecords().size()); + + /////////////////////////// + // insert a contact tag // + /////////////////////////// + Integer tagId = insertTag("VIP"); + new InsertAction().execute(new InsertInput(ContactTag.TABLE_NAME) + .withRecordEntity(new ContactTag() + .withContactId(contactId) + .withTagId(tagId))); + + QueryOutput tagQuery = new QueryAction().execute(new QueryInput(ContactTag.TABLE_NAME) + .withFilter(new QQueryFilter(new QFilterCriteria("contactId", QCriteriaOperator.EQUALS, contactId)))); + assertEquals(1, tagQuery.getRecords().size()); + + //////////////////////////////// + // insert a consent record // + //////////////////////////////// + new InsertAction().execute(new InsertInput(ConsentRecord.TABLE_NAME) + .withRecordEntity(new ConsentRecord() + .withContactId(contactId) + .withConsentType(CrmConsentType.EMAIL.getId()) + .withStatus(CrmConsentStatus.GRANTED.getId()) + .withConsentDate(Instant.now()))); + + QueryOutput consentQuery = new QueryAction().execute(new QueryInput(ConsentRecord.TABLE_NAME) + .withFilter(new QQueryFilter(new QFilterCriteria("contactId", QCriteriaOperator.EQUALS, contactId)))); + assertEquals(1, consentQuery.getRecords().size()); + } + + + + /*************************************************************************** + ** Helper: insert a Contact and return its id. + ***************************************************************************/ + private Integer insertContact(String firstName, String lastName) throws QException + { + InsertOutput output = new InsertAction().execute( + new InsertInput(Contact.TABLE_NAME) + .withRecordEntity(new Contact() + .withFirstName(firstName) + .withLastName(lastName) + .withOwnerUserId("user-001"))); + return (output.getRecords().get(0).getValueInteger("id")); + } + + + + /*************************************************************************** + ** Helper: insert an ActivityType and return its id. + ***************************************************************************/ + private Integer insertActivityType(String name) throws QException + { + InsertOutput output = new InsertAction().execute( + new InsertInput(ActivityType.TABLE_NAME) + .withRecordEntity(new ActivityType() + .withName(name) + .withIconName("phone") + .withIsActive(true) + .withSortOrder(1))); + return (output.getRecords().get(0).getValueInteger("id")); + } + + + + /*************************************************************************** + ** Helper: insert a Pipeline and return its id. + ***************************************************************************/ + private Integer insertPipeline(String name) throws QException + { + InsertOutput output = new InsertAction().execute( + new InsertInput(Pipeline.TABLE_NAME) + .withRecordEntity(new Pipeline() + .withName(name) + .withIsDefault(true) + .withIsActive(true))); + return (output.getRecords().get(0).getValueInteger("id")); + } + + + + /*************************************************************************** + ** Helper: insert a PipelineStage and return its id. + ***************************************************************************/ + private Integer insertPipelineStage(Integer pipelineId, String name, Integer sortOrder, Integer probabilityPct) throws QException + { + InsertOutput output = new InsertAction().execute( + new InsertInput(PipelineStage.TABLE_NAME) + .withRecordEntity(new PipelineStage() + .withPipelineId(pipelineId) + .withName(name) + .withSortOrder(sortOrder) + .withProbabilityPct(probabilityPct) + .withIsClosedWon(false) + .withIsClosedLost(false))); + return (output.getRecords().get(0).getValueInteger("id")); + } + + + + /*************************************************************************** + ** Helper: insert a Deal and return its id. + ***************************************************************************/ + private Integer insertDeal(String name, Integer pipelineId, Integer stageId) throws QException + { + InsertOutput output = new InsertAction().execute( + new InsertInput(Deal.TABLE_NAME) + .withRecordEntity(new Deal() + .withName(name) + .withPipelineId(pipelineId) + .withPipelineStageId(stageId) + .withOwnerUserId("user-001"))); + return (output.getRecords().get(0).getValueInteger("id")); + } + + + + /*************************************************************************** + ** Helper: insert a Tag and return its id. + ***************************************************************************/ + private Integer insertTag(String name) throws QException + { + InsertOutput output = new InsertAction().execute( + new InsertInput(Tag.TABLE_NAME) + .withRecordEntity(new Tag() + .withName(name))); + return (output.getRecords().get(0).getValueInteger("id")); + } + +} diff --git a/src/test/java/com/kingsrook/qbits/crm/core/processes/GdprAnonymizeContactProcessTest.java b/src/test/java/com/kingsrook/qbits/crm/core/processes/GdprAnonymizeContactProcessTest.java index 721e076..9740c02 100644 --- a/src/test/java/com/kingsrook/qbits/crm/core/processes/GdprAnonymizeContactProcessTest.java +++ b/src/test/java/com/kingsrook/qbits/crm/core/processes/GdprAnonymizeContactProcessTest.java @@ -8,22 +8,33 @@ import com.kingsrook.qbits.crm.BaseTest; import com.kingsrook.qbits.crm.audit.model.AuditLog; import com.kingsrook.qbits.crm.core.model.ConsentRecord; import com.kingsrook.qbits.crm.core.model.Contact; +import com.kingsrook.qbits.crm.core.model.enums.CrmAuditAction; import com.kingsrook.qbits.crm.core.model.enums.CrmConsentStatus; import com.kingsrook.qbits.crm.core.model.enums.CrmConsentType; +import com.kingsrook.qbits.crm.core.model.enums.CrmEntityType; import com.kingsrook.qqq.backend.core.actions.processes.RunProcessAction; import com.kingsrook.qqq.backend.core.actions.tables.GetAction; import com.kingsrook.qqq.backend.core.actions.tables.InsertAction; import com.kingsrook.qqq.backend.core.actions.tables.QueryAction; +import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction; import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.exceptions.QUserFacingException; import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessInput; import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessOutput; import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput; import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertOutput; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter; import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput; import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryOutput; +import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput; +import com.kingsrook.qqq.backend.core.model.data.QRecord; +import java.util.List; import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -99,6 +110,103 @@ class GdprAnonymizeContactProcessTest extends BaseTest + /******************************************************************************* + ** Verify that audit log entries are anonymized after GDPR process runs. + *******************************************************************************/ + @Test + void testAuditLogScrubbing() throws QException + { + /////////////////////////////////////// + // create a contact // + /////////////////////////////////////// + Integer contactId = insertContact(); + + /////////////////////////////////////// + // insert a pre-existing audit entry // + /////////////////////////////////////// + new InsertAction().execute( + new InsertInput(AuditLog.TABLE_NAME).withRecordEntity( + new AuditLog() + .withEntityType(CrmEntityType.CONTACT.getId()) + .withEntityId(contactId) + .withAction(CrmAuditAction.UPDATED.getId()) + .withUserId("test-user-1") + .withFieldName("email") + .withOldValue("old@example.com") + .withNewValue("new@example.com") + .withMessage("Email changed"))); + + //////////////////////////////////// + // run the anonymize process // + //////////////////////////////////// + RunProcessInput processInput = new RunProcessInput(); + processInput.setProcessName(GdprAnonymizeContactProcess.NAME); + processInput.addValue("contactId", contactId); + new RunProcessAction().execute(processInput); + + ///////////////////////////////////////////// + // query audit entries for this contact // + ///////////////////////////////////////////// + QueryOutput auditQuery = new QueryAction().execute( + new QueryInput(AuditLog.TABLE_NAME) + .withFilter(new QQueryFilter() + .withCriteria(new QFilterCriteria("entityType", QCriteriaOperator.EQUALS, CrmEntityType.CONTACT.getId())) + .withCriteria(new QFilterCriteria("entityId", QCriteriaOperator.EQUALS, contactId)))); + + ///////////////////////////////////////////// + // find the pre-existing entry (not the // + // ANONYMIZED action log entry itself) // + ///////////////////////////////////////////// + boolean foundScrubbed = false; + for(QRecord record : auditQuery.getRecords()) + { + AuditLog log = new AuditLog(record); + if(CrmAuditAction.UPDATED.getId().equals(log.getAction())) + { + assertEquals("[ANONYMIZED]", log.getOldValue()); + assertEquals("[ANONYMIZED]", log.getNewValue()); + assertEquals("[ANONYMIZED]", log.getMessage()); + foundScrubbed = true; + } + } + assertTrue(foundScrubbed, "Expected at least one scrubbed audit log entry"); + } + + + + /******************************************************************************* + ** Verify that normal (non-GDPR) audit log updates still throw exceptions. + *******************************************************************************/ + @Test + void testAuditLogImmutabilityEnforced() throws QException + { + /////////////////////////////////////// + // insert an audit log entry // + /////////////////////////////////////// + InsertOutput insertOutput = new InsertAction().execute( + new InsertInput(AuditLog.TABLE_NAME).withRecordEntity( + new AuditLog() + .withEntityType(CrmEntityType.CONTACT.getId()) + .withEntityId(999) + .withAction(CrmAuditAction.CREATED.getId()) + .withUserId("test-user-1") + .withMessage("Record created"))); + + Integer auditId = insertOutput.getRecords().get(0).getValueInteger("id"); + + /////////////////////////////////////// + // attempt to update without bypass // + /////////////////////////////////////// + QRecord update = new QRecord() + .withValue("id", auditId) + .withValue("message", "tampered"); + + assertThrows(QUserFacingException.class, () -> + new UpdateAction().execute(new UpdateInput(AuditLog.TABLE_NAME).withRecords(List.of(update)))); + } + + + /*************************************************************************** ** Helper: insert a contact with full PII fields. ***************************************************************************/ diff --git a/src/test/java/com/kingsrook/qbits/crm/core/processes/ImportContactsProcessTest.java b/src/test/java/com/kingsrook/qbits/crm/core/processes/ImportContactsProcessTest.java index ee30164..d27bfe9 100644 --- a/src/test/java/com/kingsrook/qbits/crm/core/processes/ImportContactsProcessTest.java +++ b/src/test/java/com/kingsrook/qbits/crm/core/processes/ImportContactsProcessTest.java @@ -59,9 +59,13 @@ class ImportContactsProcessTest extends BaseTest new QueryInput(Contact.TABLE_NAME)); assertEquals(2, contactQuery.getRecords().size()); + /////////////////////////////////////////////////////////////////////////// + // 2 CREATED entries from CrmAuditLogCustomizer on contact insert + // + // 2 IMPORTED entries from the import process itself = 4 total // + /////////////////////////////////////////////////////////////////////////// QueryOutput auditQuery = new QueryAction().execute( new QueryInput(AuditLog.TABLE_NAME)); - assertEquals(2, auditQuery.getRecords().size()); + assertEquals(4, auditQuery.getRecords().size()); } diff --git a/src/test/java/com/kingsrook/qbits/crm/deals/DealStageHistoryTest.java b/src/test/java/com/kingsrook/qbits/crm/deals/DealStageHistoryTest.java index bf54223..ab5b979 100644 --- a/src/test/java/com/kingsrook/qbits/crm/deals/DealStageHistoryTest.java +++ b/src/test/java/com/kingsrook/qbits/crm/deals/DealStageHistoryTest.java @@ -87,9 +87,13 @@ class DealStageHistoryTest extends BaseTest QueryOutput queryOutput = new QueryAction().execute( new QueryInput(DealStageHistory.TABLE_NAME)); - assertEquals(1, queryOutput.getRecords().size()); + /////////////////////////////////////////////////////////////////////////// + // DealInitializationCustomizer auto-creates an initial history row on // + // deal insert, so we expect 2: the customizer's row + our manual row. // + /////////////////////////////////////////////////////////////////////////// + assertEquals(2, queryOutput.getRecords().size()); - DealStageHistory fetched = new DealStageHistory(queryOutput.getRecords().get(0)); + DealStageHistory fetched = new DealStageHistory(queryOutput.getRecords().get(1)); assertEquals(dealId, fetched.getDealId()); assertNull(fetched.getFromStageId()); assertEquals(stage1Id, fetched.getToStageId()); @@ -149,11 +153,13 @@ class DealStageHistoryTest extends BaseTest .withDurationInFromStageDays(5))); ////////////////////////////////////////////// - // verify all three entries are persisted // + // verify all four entries are persisted: // + // 1 from DealInitializationCustomizer + // + // 3 manually inserted above // ////////////////////////////////////////////// QueryOutput queryOutput = new QueryAction().execute( new QueryInput(DealStageHistory.TABLE_NAME)); - assertEquals(3, queryOutput.getRecords().size()); + assertEquals(4, queryOutput.getRecords().size()); ////////////////////////////////////////////// // verify the latest transition details // diff --git a/src/test/java/com/kingsrook/qbits/crm/deals/customizers/DealInitializationCustomizerTest.java b/src/test/java/com/kingsrook/qbits/crm/deals/customizers/DealInitializationCustomizerTest.java new file mode 100644 index 0000000..689eea9 --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/crm/deals/customizers/DealInitializationCustomizerTest.java @@ -0,0 +1,155 @@ +/******************************************************************************* + ** Unit tests for DealInitializationCustomizer -- verifies that inserting a + ** deal sets stageEnteredDate, weightedAmount, and creates the initial + ** DealStageHistory row. + *******************************************************************************/ +package com.kingsrook.qbits.crm.deals.customizers; + + +import java.math.BigDecimal; +import com.kingsrook.qbits.crm.BaseTest; +import com.kingsrook.qbits.crm.deals.model.Deal; +import com.kingsrook.qbits.crm.deals.model.DealStageHistory; +import com.kingsrook.qbits.crm.deals.model.Pipeline; +import com.kingsrook.qbits.crm.deals.model.PipelineStage; +import com.kingsrook.qqq.backend.core.actions.tables.GetAction; +import com.kingsrook.qqq.backend.core.actions.tables.InsertAction; +import com.kingsrook.qqq.backend.core.actions.tables.QueryAction; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertOutput; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryOutput; +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; + + +/******************************************************************************* + ** Tests for DealInitializationCustomizer: verifies computed field + ** initialization and initial stage history creation on deal insert. + *******************************************************************************/ +class DealInitializationCustomizerTest extends BaseTest +{ + + /******************************************************************************* + ** Insert a deal via InsertAction. Get it back. Verify stageEnteredDate is set. + *******************************************************************************/ + @Test + void testNewDealGetsStageEnteredDate() throws QException + { + Integer pipelineId = insertPipeline("Sales"); + Integer stageId = insertStage(pipelineId, "Prospecting", 1, 10); + Integer dealId = insertDeal("New Deal", pipelineId, stageId, new BigDecimal("5000")); + + Deal deal = new Deal(GetAction.execute(Deal.TABLE_NAME, dealId)); + assertNotNull(deal.getStageEnteredDate()); + } + + + + /******************************************************************************* + ** Insert deal with amount=1000 at a stage with probabilityPct=50. Verify + ** weightedAmount=500. + *******************************************************************************/ + @Test + void testNewDealGetsWeightedAmount() throws QException + { + Integer pipelineId = insertPipeline("Sales"); + Integer stageId = insertStage(pipelineId, "Qualified", 1, 50); + Integer dealId = insertDeal("Weighted Deal", pipelineId, stageId, new BigDecimal("1000")); + + Deal deal = new Deal(GetAction.execute(Deal.TABLE_NAME, dealId)); + assertNotNull(deal.getWeightedAmount()); + assertEquals(0, new BigDecimal("500.00").compareTo(deal.getWeightedAmount())); + } + + + + /******************************************************************************* + ** Insert deal. Query DealStageHistory for that dealId. Verify one row with + ** fromStageId=null and toStageId matching the deal's stage. + *******************************************************************************/ + @Test + void testNewDealCreatesInitialHistory() throws QException + { + Integer pipelineId = insertPipeline("Sales"); + Integer stageId = insertStage(pipelineId, "Prospecting", 1, 10); + Integer dealId = insertDeal("History Deal", pipelineId, stageId, new BigDecimal("3000")); + + QueryOutput historyOutput = new QueryAction().execute( + new QueryInput(DealStageHistory.TABLE_NAME) + .withFilter(new QQueryFilter() + .withCriteria(new QFilterCriteria("dealId", QCriteriaOperator.EQUALS, dealId)))); + + assertEquals(1, historyOutput.getRecords().size()); + + DealStageHistory history = new DealStageHistory(historyOutput.getRecords().get(0)); + assertNull(history.getFromStageId()); + assertEquals(stageId, history.getToStageId()); + assertNotNull(history.getTransitionDate()); + assertEquals(dealId, history.getDealId()); + } + + + + ///////////////////////////////////////////////////////////////////////////// + // Helper methods // + ///////////////////////////////////////////////////////////////////////////// + + /*************************************************************************** + ** Helper: insert a Pipeline and return its id. + ***************************************************************************/ + private Integer insertPipeline(String name) throws QException + { + InsertOutput output = new InsertAction().execute( + new InsertInput(Pipeline.TABLE_NAME) + .withRecordEntity(new Pipeline() + .withName(name) + .withIsDefault(true) + .withIsActive(true))); + return (output.getRecords().get(0).getValueInteger("id")); + } + + + + /*************************************************************************** + ** Helper: insert a PipelineStage and return its id. + ***************************************************************************/ + private Integer insertStage(Integer pipelineId, String name, Integer sortOrder, Integer probabilityPct) throws QException + { + InsertOutput output = new InsertAction().execute( + new InsertInput(PipelineStage.TABLE_NAME) + .withRecordEntity(new PipelineStage() + .withPipelineId(pipelineId) + .withName(name) + .withSortOrder(sortOrder) + .withProbabilityPct(probabilityPct) + .withIsClosedWon(false) + .withIsClosedLost(false))); + return (output.getRecords().get(0).getValueInteger("id")); + } + + + + /*************************************************************************** + ** Helper: insert a Deal and return its id. + ***************************************************************************/ + private Integer insertDeal(String name, Integer pipelineId, Integer stageId, BigDecimal amount) throws QException + { + InsertOutput output = new InsertAction().execute( + new InsertInput(Deal.TABLE_NAME) + .withRecordEntity(new Deal() + .withName(name) + .withPipelineId(pipelineId) + .withPipelineStageId(stageId) + .withAmount(amount) + .withOwnerUserId("user-001"))); + return (output.getRecords().get(0).getValueInteger("id")); + } + +} diff --git a/src/test/java/com/kingsrook/qbits/crm/deals/customizers/DealProductRecalculationCustomizerTest.java b/src/test/java/com/kingsrook/qbits/crm/deals/customizers/DealProductRecalculationCustomizerTest.java new file mode 100644 index 0000000..25e207c --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/crm/deals/customizers/DealProductRecalculationCustomizerTest.java @@ -0,0 +1,266 @@ +/******************************************************************************* + ** Unit tests for DealProductRecalculationCustomizer -- verifies that + ** inserting, updating, and deleting DealProduct rows correctly recalculates + ** totalAmount on the line item and rolls up to the parent Deal's amount. + *******************************************************************************/ +package com.kingsrook.qbits.crm.deals.customizers; + + +import java.math.BigDecimal; +import java.util.List; +import com.kingsrook.qbits.crm.BaseTest; +import com.kingsrook.qbits.crm.deals.model.Deal; +import com.kingsrook.qbits.crm.deals.model.DealProduct; +import com.kingsrook.qbits.crm.deals.model.Pipeline; +import com.kingsrook.qbits.crm.deals.model.PipelineStage; +import com.kingsrook.qbits.crm.deals.model.Product; +import com.kingsrook.qqq.backend.core.actions.tables.DeleteAction; +import com.kingsrook.qqq.backend.core.actions.tables.GetAction; +import com.kingsrook.qqq.backend.core.actions.tables.InsertAction; +import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertOutput; +import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput; +import com.kingsrook.qqq.backend.core.model.data.QRecord; +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + + +/******************************************************************************* + ** Tests for DealProductRecalculationCustomizer: verifies totalAmount + ** calculation on line items and rollup to parent deal amount. + *******************************************************************************/ +class DealProductRecalculationCustomizerTest extends BaseTest +{ + + /******************************************************************************* + ** Insert a DealProduct with quantity=2, unitPrice=100, discountPct=10. + ** Get it back. Verify totalAmount=180. + *******************************************************************************/ + @Test + void testInsertProductCalculatesTotalAmount() throws QException + { + Integer pipelineId = insertPipeline("Sales"); + Integer stageId = insertStage(pipelineId, "Open", 1, 10); + Integer dealId = insertDeal("Product Deal", pipelineId, stageId, new BigDecimal("0")); + Integer productId = insertProduct("Widget"); + + Integer dpId = insertDealProduct(dealId, productId, new BigDecimal("2"), new BigDecimal("100"), new BigDecimal("10")); + + DealProduct dp = new DealProduct(GetAction.execute(DealProduct.TABLE_NAME, dpId)); + assertNotNull(dp.getTotalAmount()); + + ////////////////////////////////////////////////////////////////// + // totalAmount = 2 * 100 * (1 - 10/100) = 200 * 0.9 = 180.00 // + ////////////////////////////////////////////////////////////////// + assertEquals(0, new BigDecimal("180.00").compareTo(dp.getTotalAmount())); + } + + + + /******************************************************************************* + ** Insert a deal, then insert 2 DealProduct rows. Get the deal back. Verify + ** deal.amount equals the sum of line item totalAmounts. + *******************************************************************************/ + @Test + void testInsertProductUpdatesDealAmount() throws QException + { + Integer pipelineId = insertPipeline("Sales"); + Integer stageId = insertStage(pipelineId, "Open", 1, 10); + Integer dealId = insertDeal("Sum Deal", pipelineId, stageId, new BigDecimal("0")); + Integer productId1 = insertProduct("Widget A"); + Integer productId2 = insertProduct("Widget B"); + + /////////////////////////////////////////// + // product 1: 3 * 50 * (1-0) = 150 // + /////////////////////////////////////////// + insertDealProduct(dealId, productId1, new BigDecimal("3"), new BigDecimal("50"), new BigDecimal("0")); + + /////////////////////////////////////////// + // product 2: 1 * 200 * (1-0.25) = 150 // + /////////////////////////////////////////// + insertDealProduct(dealId, productId2, new BigDecimal("1"), new BigDecimal("200"), new BigDecimal("25")); + + Deal deal = new Deal(GetAction.execute(Deal.TABLE_NAME, dealId)); + + ////////////////////////////////////////////////////////////////// + // deal.amount = 150.00 + 150.00 = 300.00 // + ////////////////////////////////////////////////////////////////// + assertEquals(0, new BigDecimal("300.00").compareTo(deal.getAmount())); + } + + + + /******************************************************************************* + ** Insert a DealProduct, then update its quantity. Verify totalAmount and + ** deal.amount both recalculate. + *******************************************************************************/ + @Test + void testUpdateProductRecalculates() throws QException + { + Integer pipelineId = insertPipeline("Sales"); + Integer stageId = insertStage(pipelineId, "Open", 1, 10); + Integer dealId = insertDeal("Update Deal", pipelineId, stageId, new BigDecimal("0")); + Integer productId = insertProduct("Gadget"); + + /////////////////////////////////////////// + // initial: 2 * 100 * (1-0) = 200 // + /////////////////////////////////////////// + Integer dpId = insertDealProduct(dealId, productId, new BigDecimal("2"), new BigDecimal("100"), new BigDecimal("0")); + + /////////////////////////////////////////// + // update quantity to 5 // + /////////////////////////////////////////// + QRecord updateRecord = new QRecord(); + updateRecord.setValue("id", dpId); + updateRecord.setValue("quantity", new BigDecimal("5")); + updateRecord.setValue("unitPrice", new BigDecimal("100")); + updateRecord.setValue("discountPct", new BigDecimal("0")); + updateRecord.setValue("dealId", dealId); + new UpdateAction().execute(new UpdateInput(DealProduct.TABLE_NAME).withRecord(updateRecord)); + + DealProduct dp = new DealProduct(GetAction.execute(DealProduct.TABLE_NAME, dpId)); + assertEquals(0, new BigDecimal("500.00").compareTo(dp.getTotalAmount())); + + Deal deal = new Deal(GetAction.execute(Deal.TABLE_NAME, dealId)); + assertEquals(0, new BigDecimal("500.00").compareTo(deal.getAmount())); + } + + + + /******************************************************************************* + ** Insert 2 DealProducts, delete one. Verify deal.amount equals the remaining + ** line item's totalAmount. + *******************************************************************************/ + @Test + void testDeleteProductRecalculatesDealAmount() throws QException + { + Integer pipelineId = insertPipeline("Sales"); + Integer stageId = insertStage(pipelineId, "Open", 1, 10); + Integer dealId = insertDeal("Delete Deal", pipelineId, stageId, new BigDecimal("0")); + Integer productId1 = insertProduct("Alpha"); + Integer productId2 = insertProduct("Beta"); + + /////////////////////////////////////////// + // product 1: 1 * 300 * (1-0) = 300 // + /////////////////////////////////////////// + Integer dp1Id = insertDealProduct(dealId, productId1, new BigDecimal("1"), new BigDecimal("300"), new BigDecimal("0")); + + /////////////////////////////////////////// + // product 2: 2 * 100 * (1-0) = 200 // + /////////////////////////////////////////// + Integer dp2Id = insertDealProduct(dealId, productId2, new BigDecimal("2"), new BigDecimal("100"), new BigDecimal("0")); + + /////////////////////////////////////////// + // verify deal.amount = 500 before delete// + /////////////////////////////////////////// + Deal dealBefore = new Deal(GetAction.execute(Deal.TABLE_NAME, dealId)); + assertEquals(0, new BigDecimal("500.00").compareTo(dealBefore.getAmount())); + + /////////////////////////////////////////// + // delete product 1 // + /////////////////////////////////////////// + DeleteInput deleteInput = new DeleteInput(DealProduct.TABLE_NAME); + deleteInput.setPrimaryKeys(List.of(dp1Id)); + new DeleteAction().execute(deleteInput); + + Deal dealAfter = new Deal(GetAction.execute(Deal.TABLE_NAME, dealId)); + assertEquals(0, new BigDecimal("200.00").compareTo(dealAfter.getAmount())); + } + + + + ///////////////////////////////////////////////////////////////////////////// + // Helper methods // + ///////////////////////////////////////////////////////////////////////////// + + /*************************************************************************** + ** Helper: insert a Pipeline and return its id. + ***************************************************************************/ + private Integer insertPipeline(String name) throws QException + { + InsertOutput output = new InsertAction().execute( + new InsertInput(Pipeline.TABLE_NAME) + .withRecordEntity(new Pipeline() + .withName(name) + .withIsDefault(true) + .withIsActive(true))); + return (output.getRecords().get(0).getValueInteger("id")); + } + + + + /*************************************************************************** + ** Helper: insert a PipelineStage and return its id. + ***************************************************************************/ + private Integer insertStage(Integer pipelineId, String name, Integer sortOrder, Integer probabilityPct) throws QException + { + InsertOutput output = new InsertAction().execute( + new InsertInput(PipelineStage.TABLE_NAME) + .withRecordEntity(new PipelineStage() + .withPipelineId(pipelineId) + .withName(name) + .withSortOrder(sortOrder) + .withProbabilityPct(probabilityPct) + .withIsClosedWon(false) + .withIsClosedLost(false))); + return (output.getRecords().get(0).getValueInteger("id")); + } + + + + /*************************************************************************** + ** Helper: insert a Deal and return its id. + ***************************************************************************/ + private Integer insertDeal(String name, Integer pipelineId, Integer stageId, BigDecimal amount) throws QException + { + InsertOutput output = new InsertAction().execute( + new InsertInput(Deal.TABLE_NAME) + .withRecordEntity(new Deal() + .withName(name) + .withPipelineId(pipelineId) + .withPipelineStageId(stageId) + .withAmount(amount) + .withOwnerUserId("user-001"))); + return (output.getRecords().get(0).getValueInteger("id")); + } + + + + /*************************************************************************** + ** Helper: insert a Product and return its id. + ***************************************************************************/ + private Integer insertProduct(String name) throws QException + { + InsertOutput output = new InsertAction().execute( + new InsertInput(Product.TABLE_NAME) + .withRecordEntity(new Product() + .withName(name) + .withUnitPrice(new BigDecimal("0")) + .withIsActive(true))); + return (output.getRecords().get(0).getValueInteger("id")); + } + + + + /*************************************************************************** + ** Helper: insert a DealProduct and return its id. + ***************************************************************************/ + private Integer insertDealProduct(Integer dealId, Integer productId, BigDecimal quantity, + BigDecimal unitPrice, BigDecimal discountPct) throws QException + { + InsertOutput output = new InsertAction().execute( + new InsertInput(DealProduct.TABLE_NAME) + .withRecordEntity(new DealProduct() + .withDealId(dealId) + .withProductId(productId) + .withQuantity(quantity) + .withUnitPrice(unitPrice) + .withDiscountPct(discountPct))); + return (output.getRecords().get(0).getValueInteger("id")); + } + +} diff --git a/src/test/java/com/kingsrook/qbits/crm/deals/customizers/DealStageChangeCustomizerTest.java b/src/test/java/com/kingsrook/qbits/crm/deals/customizers/DealStageChangeCustomizerTest.java new file mode 100644 index 0000000..2059a48 --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/crm/deals/customizers/DealStageChangeCustomizerTest.java @@ -0,0 +1,365 @@ +/******************************************************************************* + ** Unit tests for DealStageChangeCustomizer -- verifies that updating a deal's + ** pipelineStageId triggers recalculation of weightedAmount, stageEnteredDate, + ** actualCloseDate, and DealStageHistory creation. + *******************************************************************************/ +package com.kingsrook.qbits.crm.deals.customizers; + + +import java.math.BigDecimal; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import com.kingsrook.qbits.crm.BaseTest; +import com.kingsrook.qbits.crm.deals.model.Deal; +import com.kingsrook.qbits.crm.deals.model.DealStageHistory; +import com.kingsrook.qbits.crm.deals.model.Pipeline; +import com.kingsrook.qbits.crm.deals.model.PipelineStage; +import com.kingsrook.qqq.backend.core.actions.tables.GetAction; +import com.kingsrook.qqq.backend.core.actions.tables.InsertAction; +import com.kingsrook.qqq.backend.core.actions.tables.QueryAction; +import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertOutput; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryOutput; +import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput; +import com.kingsrook.qqq.backend.core.model.data.QRecord; +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + + +/******************************************************************************* + ** Tests for DealStageChangeCustomizer: verifies stage transition business + ** logic fires correctly when pipelineStageId is updated via UpdateAction. + *******************************************************************************/ +class DealStageChangeCustomizerTest extends BaseTest +{ + + /******************************************************************************* + ** Create deal, update pipelineStageId via UpdateAction. Verify + ** stageEnteredDate changed. + *******************************************************************************/ + @Test + void testStageChangeUpdatesStageEnteredDate() throws QException + { + Integer pipelineId = insertPipeline("Sales"); + Integer stage1Id = insertStage(pipelineId, "Open", 1, 10, false, false, null); + Integer stage2Id = insertStage(pipelineId, "Negotiation", 2, 50, false, false, null); + Integer dealId = insertDeal("Stage Date Deal", pipelineId, stage1Id, new BigDecimal("5000")); + + /////////////////////////////////////////// + // get the initial stageEnteredDate // + /////////////////////////////////////////// + Deal dealBefore = new Deal(GetAction.execute(Deal.TABLE_NAME, dealId)); + Instant enteredDateBefore = dealBefore.getStageEnteredDate(); + assertNotNull(enteredDateBefore); + + /////////////////////////////////////////// + // move to stage 2 // + /////////////////////////////////////////// + QRecord updateRecord = new QRecord(); + updateRecord.setValue("id", dealId); + updateRecord.setValue("pipelineStageId", stage2Id); + new UpdateAction().execute(new UpdateInput(Deal.TABLE_NAME).withRecord(updateRecord)); + + Deal dealAfter = new Deal(GetAction.execute(Deal.TABLE_NAME, dealId)); + assertNotNull(dealAfter.getStageEnteredDate()); + + ////////////////////////////////////////////////////////////////// + // the stageEnteredDate should have been refreshed by the // + // customizer to a new Instant.now() -- at minimum it should // + // not be before the original enteredDate // + ////////////////////////////////////////////////////////////////// + assertFalse(dealAfter.getStageEnteredDate().isBefore(enteredDateBefore)); + } + + + + /******************************************************************************* + ** Create deal with amount=1000, move to stage with probabilityPct=50. + ** Verify weightedAmount=500. + *******************************************************************************/ + @Test + void testStageChangeRecalculatesWeightedAmount() throws QException + { + Integer pipelineId = insertPipeline("Sales"); + Integer stage1Id = insertStage(pipelineId, "Open", 1, 10, false, false, null); + Integer stage2Id = insertStage(pipelineId, "Qualified", 2, 50, false, false, null); + Integer dealId = insertDeal("Weighted Deal", pipelineId, stage1Id, new BigDecimal("1000")); + + QRecord updateRecord = new QRecord(); + updateRecord.setValue("id", dealId); + updateRecord.setValue("pipelineStageId", stage2Id); + new UpdateAction().execute(new UpdateInput(Deal.TABLE_NAME).withRecord(updateRecord)); + + Deal deal = new Deal(GetAction.execute(Deal.TABLE_NAME, dealId)); + assertEquals(0, new BigDecimal("500.00").compareTo(deal.getWeightedAmount())); + } + + + + /******************************************************************************* + ** Set probabilityOverridePct=80 on deal, move stage. Verify weightedAmount + ** uses 80 not stage probability. + *******************************************************************************/ + @Test + void testStageChangeWithOverrideProbability() throws QException + { + Integer pipelineId = insertPipeline("Sales"); + Integer stage1Id = insertStage(pipelineId, "Open", 1, 10, false, false, null); + Integer stage2Id = insertStage(pipelineId, "Qualified", 2, 50, false, false, null); + + /////////////////////////////////////////// + // insert deal with override probability // + /////////////////////////////////////////// + InsertOutput dealInsert = new InsertAction().execute( + new InsertInput(Deal.TABLE_NAME).withRecordEntity( + new Deal() + .withName("Override Deal") + .withPipelineId(pipelineId) + .withPipelineStageId(stage1Id) + .withAmount(new BigDecimal("1000")) + .withProbabilityOverridePct(80) + .withOwnerUserId("user-001"))); + Integer dealId = dealInsert.getRecords().get(0).getValueInteger("id"); + + QRecord updateRecord = new QRecord(); + updateRecord.setValue("id", dealId); + updateRecord.setValue("pipelineStageId", stage2Id); + new UpdateAction().execute(new UpdateInput(Deal.TABLE_NAME).withRecord(updateRecord)); + + Deal deal = new Deal(GetAction.execute(Deal.TABLE_NAME, dealId)); + + ////////////////////////////////////////////////////////////////// + // weighted = 1000 * 80 / 100 = 800.00 // + ////////////////////////////////////////////////////////////////// + assertEquals(0, new BigDecimal("800.00").compareTo(deal.getWeightedAmount())); + } + + + + /******************************************************************************* + ** Move deal to new stage. Query DealStageHistory, verify row with correct + ** fromStageId and toStageId. + *******************************************************************************/ + @Test + void testStageChangeCreatesHistory() throws QException + { + Integer pipelineId = insertPipeline("Sales"); + Integer stage1Id = insertStage(pipelineId, "Open", 1, 10, false, false, null); + Integer stage2Id = insertStage(pipelineId, "Proposal", 2, 60, false, false, null); + Integer dealId = insertDeal("History Deal", pipelineId, stage1Id, new BigDecimal("2000")); + + /////////////////////////////////////////// + // DealInitializationCustomizer already // + // created one history row on insert // + /////////////////////////////////////////// + QRecord updateRecord = new QRecord(); + updateRecord.setValue("id", dealId); + updateRecord.setValue("pipelineStageId", stage2Id); + new UpdateAction().execute(new UpdateInput(Deal.TABLE_NAME).withRecord(updateRecord)); + + QueryOutput historyOutput = new QueryAction().execute( + new QueryInput(DealStageHistory.TABLE_NAME) + .withFilter(new QQueryFilter() + .withCriteria(new QFilterCriteria("dealId", QCriteriaOperator.EQUALS, dealId)))); + + ////////////////////////////////////////////////////////////////// + // expect 2 rows: initial (null->stage1) + change (stage1->stage2) + ////////////////////////////////////////////////////////////////// + assertEquals(2, historyOutput.getRecords().size()); + + /////////////////////////////////////////// + // verify the stage change history row // + /////////////////////////////////////////// + DealStageHistory changeHistory = new DealStageHistory(historyOutput.getRecords().get(1)); + assertEquals(stage1Id, changeHistory.getFromStageId()); + assertEquals(stage2Id, changeHistory.getToStageId()); + assertNotNull(changeHistory.getTransitionDate()); + } + + + + /******************************************************************************* + ** Move deal to closedWon stage. Verify actualCloseDate is set. + *******************************************************************************/ + @Test + void testClosedWonSetsActualCloseDate() throws QException + { + Integer pipelineId = insertPipeline("Sales"); + Integer stage1Id = insertStage(pipelineId, "Open", 1, 10, false, false, null); + Integer wonStageId = insertStage(pipelineId, "Closed Won", 2, 100, true, false, null); + Integer dealId = insertDeal("Won Deal", pipelineId, stage1Id, new BigDecimal("7000")); + + QRecord updateRecord = new QRecord(); + updateRecord.setValue("id", dealId); + updateRecord.setValue("pipelineStageId", wonStageId); + new UpdateAction().execute(new UpdateInput(Deal.TABLE_NAME).withRecord(updateRecord)); + + Deal deal = new Deal(GetAction.execute(Deal.TABLE_NAME, dealId)); + assertNotNull(deal.getActualCloseDate()); + } + + + + /******************************************************************************* + ** Move deal to closedLost stage. Verify actualCloseDate is set. + *******************************************************************************/ + @Test + void testClosedLostSetsActualCloseDate() throws QException + { + Integer pipelineId = insertPipeline("Sales"); + Integer stage1Id = insertStage(pipelineId, "Open", 1, 10, false, false, null); + Integer lostStageId = insertStage(pipelineId, "Closed Lost", 2, 0, false, true, null); + Integer dealId = insertDeal("Lost Deal", pipelineId, stage1Id, new BigDecimal("4000")); + + QRecord updateRecord = new QRecord(); + updateRecord.setValue("id", dealId); + updateRecord.setValue("pipelineStageId", lostStageId); + new UpdateAction().execute(new UpdateInput(Deal.TABLE_NAME).withRecord(updateRecord)); + + Deal deal = new Deal(GetAction.execute(Deal.TABLE_NAME, dealId)); + assertNotNull(deal.getActualCloseDate()); + } + + + + /******************************************************************************* + ** Close deal, then move back to open stage. Verify actualCloseDate is null + ** and winLossReasonId is null. + *******************************************************************************/ + @Test + void testReopenClearsActualCloseDate() throws QException + { + Integer pipelineId = insertPipeline("Sales"); + Integer openStageId = insertStage(pipelineId, "Open", 1, 10, false, false, null); + Integer wonStageId = insertStage(pipelineId, "Closed Won", 2, 100, true, false, null); + Integer reopenStageId = insertStage(pipelineId, "Reopened", 3, 30, false, false, null); + Integer dealId = insertDeal("Reopen Deal", pipelineId, openStageId, new BigDecimal("6000")); + + /////////////////////////////////////////// + // close the deal // + /////////////////////////////////////////// + QRecord closeRecord = new QRecord(); + closeRecord.setValue("id", dealId); + closeRecord.setValue("pipelineStageId", wonStageId); + new UpdateAction().execute(new UpdateInput(Deal.TABLE_NAME).withRecord(closeRecord)); + + Deal closedDeal = new Deal(GetAction.execute(Deal.TABLE_NAME, dealId)); + assertNotNull(closedDeal.getActualCloseDate()); + + /////////////////////////////////////////// + // reopen the deal // + /////////////////////////////////////////// + QRecord reopenRecord = new QRecord(); + reopenRecord.setValue("id", dealId); + reopenRecord.setValue("pipelineStageId", reopenStageId); + new UpdateAction().execute(new UpdateInput(Deal.TABLE_NAME).withRecord(reopenRecord)); + + Deal reopenedDeal = new Deal(GetAction.execute(Deal.TABLE_NAME, dealId)); + assertNull(reopenedDeal.getActualCloseDate()); + assertNull(reopenedDeal.getWinLossReasonId()); + } + + + + /******************************************************************************* + ** Create stage with requiredFieldsJson=["amount"]. Create deal with + ** amount=null. Try to move to that stage. Verify the record has an error. + *******************************************************************************/ + @Test + void testRequiredFieldsJsonBlocksInvalidMove() throws QException + { + Integer pipelineId = insertPipeline("Gated"); + Integer stage1Id = insertStage(pipelineId, "Open", 1, 10, false, false, null); + Integer gatedStageId = insertStage(pipelineId, "Gated", 2, 50, false, false, "[\"expectedCloseDate\"]"); + + /////////////////////////////////////////// + // create deal without expectedCloseDate // + /////////////////////////////////////////// + Integer dealId = insertDeal("Gated Deal", pipelineId, stage1Id, new BigDecimal("1000")); + + QRecord updateRecord = new QRecord(); + updateRecord.setValue("id", dealId); + updateRecord.setValue("pipelineStageId", gatedStageId); + + UpdateInput updateInput = new UpdateInput(Deal.TABLE_NAME).withRecord(updateRecord); + new UpdateAction().execute(updateInput); + + ////////////////////////////////////////////////////////////////// + // The customizer adds an error to the record. Verify the deal // + // was NOT moved to the gated stage. // + ////////////////////////////////////////////////////////////////// + Deal deal = new Deal(GetAction.execute(Deal.TABLE_NAME, dealId)); + assertEquals(stage1Id, deal.getPipelineStageId()); + } + + + + ///////////////////////////////////////////////////////////////////////////// + // Helper methods // + ///////////////////////////////////////////////////////////////////////////// + + /*************************************************************************** + ** Helper: insert a Pipeline and return its id. + ***************************************************************************/ + private Integer insertPipeline(String name) throws QException + { + InsertOutput output = new InsertAction().execute( + new InsertInput(Pipeline.TABLE_NAME) + .withRecordEntity(new Pipeline() + .withName(name) + .withIsDefault(true) + .withIsActive(true))); + return (output.getRecords().get(0).getValueInteger("id")); + } + + + + /*************************************************************************** + ** Helper: insert a PipelineStage and return its id. + ***************************************************************************/ + private Integer insertStage(Integer pipelineId, String name, Integer sortOrder, + Integer probabilityPct, Boolean isClosedWon, Boolean isClosedLost, + String requiredFieldsJson) throws QException + { + InsertOutput output = new InsertAction().execute( + new InsertInput(PipelineStage.TABLE_NAME) + .withRecordEntity(new PipelineStage() + .withPipelineId(pipelineId) + .withName(name) + .withSortOrder(sortOrder) + .withProbabilityPct(probabilityPct) + .withIsClosedWon(isClosedWon) + .withIsClosedLost(isClosedLost) + .withRequiredFieldsJson(requiredFieldsJson))); + return (output.getRecords().get(0).getValueInteger("id")); + } + + + + /*************************************************************************** + ** Helper: insert a Deal and return its id. + ***************************************************************************/ + private Integer insertDeal(String name, Integer pipelineId, Integer stageId, BigDecimal amount) throws QException + { + InsertOutput output = new InsertAction().execute( + new InsertInput(Deal.TABLE_NAME) + .withRecordEntity(new Deal() + .withName(name) + .withPipelineId(pipelineId) + .withPipelineStageId(stageId) + .withAmount(amount) + .withOwnerUserId("user-001"))); + return (output.getRecords().get(0).getValueInteger("id")); + } + +} diff --git a/src/test/java/com/kingsrook/qbits/crm/deals/processes/MoveDealStageProcessTest.java b/src/test/java/com/kingsrook/qbits/crm/deals/processes/MoveDealStageProcessTest.java index 41fdadc..da70f84 100644 --- a/src/test/java/com/kingsrook/qbits/crm/deals/processes/MoveDealStageProcessTest.java +++ b/src/test/java/com/kingsrook/qbits/crm/deals/processes/MoveDealStageProcessTest.java @@ -90,14 +90,24 @@ class MoveDealStageProcessTest extends BaseTest /////////////////////////////////////////// // verify stage history was created // + // 1 from DealInitializationCustomizer // + // on deal insert (null -> stage1) // + // 1 from DealStageChangeCustomizer // + // on deal update (stage1 -> stage2) // + // 1 from process explicit insert // + // (stage1 -> stage2) // /////////////////////////////////////////// QueryOutput historyOutput = new QueryAction().execute( new QueryInput(DealStageHistory.TABLE_NAME) .withFilter(new QQueryFilter() .withCriteria(new QFilterCriteria("dealId", QCriteriaOperator.EQUALS, dealId)))); - assertEquals(1, historyOutput.getRecords().size()); - DealStageHistory history = new DealStageHistory(historyOutput.getRecords().get(0)); + assertEquals(3, historyOutput.getRecords().size()); + + /////////////////////////////////////////// + // verify the process's explicit entry // + /////////////////////////////////////////// + DealStageHistory history = new DealStageHistory(historyOutput.getRecords().get(historyOutput.getRecords().size() - 1)); assertEquals(stage1Id, history.getFromStageId()); assertEquals(stage2Id, history.getToStageId()); assertNotNull(history.getTransitionDate()); diff --git a/src/test/java/com/kingsrook/qbits/crm/deals/processes/ReopenDealProcessTest.java b/src/test/java/com/kingsrook/qbits/crm/deals/processes/ReopenDealProcessTest.java index 058ecca..4b0a24a 100644 --- a/src/test/java/com/kingsrook/qbits/crm/deals/processes/ReopenDealProcessTest.java +++ b/src/test/java/com/kingsrook/qbits/crm/deals/processes/ReopenDealProcessTest.java @@ -88,14 +88,24 @@ class ReopenDealProcessTest extends BaseTest /////////////////////////////////////////// // verify stage history was created // + // 1 from DealInitializationCustomizer // + // on deal insert (null -> wonStage) // + // 1 from DealStageChangeCustomizer // + // on deal update (won -> active) // + // 1 from process explicit insert // + // (won -> active) // /////////////////////////////////////////// QueryOutput historyOutput = new QueryAction().execute( new QueryInput(DealStageHistory.TABLE_NAME) .withFilter(new QQueryFilter() .withCriteria(new QFilterCriteria("dealId", QCriteriaOperator.EQUALS, dealId)))); - assertEquals(1, historyOutput.getRecords().size()); - DealStageHistory history = new DealStageHistory(historyOutput.getRecords().get(0)); + assertEquals(3, historyOutput.getRecords().size()); + + /////////////////////////////////////////// + // verify the process's explicit entry // + /////////////////////////////////////////// + DealStageHistory history = new DealStageHistory(historyOutput.getRecords().get(historyOutput.getRecords().size() - 1)); assertEquals(wonStage, history.getFromStageId()); assertEquals(activeStage, history.getToStageId()); } diff --git a/src/test/java/com/kingsrook/qbits/crm/email/customizers/EmailTemplatePreDeleteCustomizerTest.java b/src/test/java/com/kingsrook/qbits/crm/email/customizers/EmailTemplatePreDeleteCustomizerTest.java new file mode 100644 index 0000000..0e4215d --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/crm/email/customizers/EmailTemplatePreDeleteCustomizerTest.java @@ -0,0 +1,201 @@ +/******************************************************************************* + ** Unit tests for EmailTemplatePreDeleteCustomizer. + *******************************************************************************/ +package com.kingsrook.qbits.crm.email.customizers; + + +import java.time.Instant; +import com.kingsrook.qbits.crm.BaseTest; +import com.kingsrook.qbits.crm.core.model.Contact; +import com.kingsrook.qbits.crm.core.model.enums.CrmEnrollmentStatus; +import com.kingsrook.qbits.crm.core.model.enums.CrmSequenceStepType; +import com.kingsrook.qbits.crm.email.model.EmailSequence; +import com.kingsrook.qbits.crm.email.model.EmailTemplate; +import com.kingsrook.qbits.crm.email.model.SequenceEnrollment; +import com.kingsrook.qbits.crm.email.model.SequenceStep; +import com.kingsrook.qqq.backend.core.actions.tables.DeleteAction; +import com.kingsrook.qqq.backend.core.actions.tables.GetAction; +import com.kingsrook.qqq.backend.core.actions.tables.InsertAction; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteOutput; +import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertOutput; +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + + +/******************************************************************************* + ** Tests for EmailTemplatePreDeleteCustomizer: verifies that templates + ** referenced by active sequence enrollments cannot be deleted, while + ** unreferenced templates and those without active enrollments can. + *******************************************************************************/ +class EmailTemplatePreDeleteCustomizerTest extends BaseTest +{ + + /******************************************************************************* + ** Delete a template that has no referencing sequence steps -- should succeed. + *******************************************************************************/ + @Test + void testDeleteTemplateWithNoSequencesSucceeds() throws QException + { + Integer templateId = insertTemplate("Standalone Template"); + + DeleteOutput deleteOutput = new DeleteAction().execute( + new DeleteInput(EmailTemplate.TABLE_NAME).withPrimaryKeys(java.util.List.of(templateId))); + + ///////////////////////////////////////////// + // verify template is deleted // + ///////////////////////////////////////////// + assertNull(GetAction.execute(EmailTemplate.TABLE_NAME, templateId)); + assertTrue(deleteOutput.getRecordsWithErrors().isEmpty()); + } + + + + /******************************************************************************* + ** Delete a template referenced by a sequence step with an active enrollment + ** -- should be blocked with an error on the record. + *******************************************************************************/ + @Test + void testDeleteTemplateWithActiveSequenceBlocked() throws QException + { + Integer templateId = insertTemplate("Active Sequence Template"); + Integer sequenceId = insertSequence("Onboarding Sequence"); + insertSequenceStep(sequenceId, 1, templateId); + + Integer contactId = insertContact("active@example.com", "Active", "User"); + insertActiveEnrollment(sequenceId, contactId); + + DeleteOutput deleteOutput = new DeleteAction().execute( + new DeleteInput(EmailTemplate.TABLE_NAME).withPrimaryKeys(java.util.List.of(templateId))); + + ///////////////////////////////////////////// + // verify template still exists // + ///////////////////////////////////////////// + assertNotNull(GetAction.execute(EmailTemplate.TABLE_NAME, templateId)); + + ///////////////////////////////////////////////////// + // verify delete output contains errors // + ///////////////////////////////////////////////////// + assertTrue(!deleteOutput.getRecordsWithErrors().isEmpty()); + } + + + + /******************************************************************************* + ** Delete a template referenced by a sequence step with no active enrollments + ** -- should succeed because there are no active enrollments. + *******************************************************************************/ + @Test + void testDeleteTemplateWithNoActiveEnrollmentsSucceeds() throws QException + { + Integer templateId = insertTemplate("Inactive Sequence Template"); + Integer sequenceId = insertSequence("Old Sequence"); + insertSequenceStep(sequenceId, 1, templateId); + + ///////////////////////////////////////////// + // no enrollments at all for this sequence // + ///////////////////////////////////////////// + DeleteOutput deleteOutput = new DeleteAction().execute( + new DeleteInput(EmailTemplate.TABLE_NAME).withPrimaryKeys(java.util.List.of(templateId))); + + ///////////////////////////////////////////// + // verify template is deleted // + ///////////////////////////////////////////// + assertNull(GetAction.execute(EmailTemplate.TABLE_NAME, templateId)); + assertTrue(deleteOutput.getRecordsWithErrors().isEmpty()); + } + + + + /*************************************************************************** + ** Helper: insert an EmailTemplate and return its id. + ***************************************************************************/ + private Integer insertTemplate(String name) throws QException + { + InsertOutput output = new InsertAction().execute( + new InsertInput(EmailTemplate.TABLE_NAME).withRecordEntity( + new EmailTemplate() + .withName(name) + .withSubject("Subject for " + name) + .withBodyHtml("

Body

") + .withBodyText("Body") + .withOwnerUserId("user-001") + .withIsShared(false))); + return (output.getRecords().get(0).getValueInteger("id")); + } + + + + /*************************************************************************** + ** Helper: insert an EmailSequence and return its id. + ***************************************************************************/ + private Integer insertSequence(String name) throws QException + { + InsertOutput output = new InsertAction().execute( + new InsertInput(EmailSequence.TABLE_NAME).withRecordEntity( + new EmailSequence() + .withName(name) + .withOwnerUserId("user-001") + .withIsActive(true))); + return (output.getRecords().get(0).getValueInteger("id")); + } + + + + /*************************************************************************** + ** Helper: insert a SequenceStep referencing a template. + ***************************************************************************/ + private Integer insertSequenceStep(Integer sequenceId, Integer stepNumber, Integer templateId) throws QException + { + InsertOutput output = new InsertAction().execute( + new InsertInput(SequenceStep.TABLE_NAME).withRecordEntity( + new SequenceStep() + .withSequenceId(sequenceId) + .withStepNumber(stepNumber) + .withStepType(CrmSequenceStepType.EMAIL.getPossibleValueId()) + .withDelayDays(1) + .withEmailTemplateId(templateId))); + return (output.getRecords().get(0).getValueInteger("id")); + } + + + + /*************************************************************************** + ** Helper: insert an active SequenceEnrollment. + ***************************************************************************/ + private Integer insertActiveEnrollment(Integer sequenceId, Integer contactId) throws QException + { + InsertOutput output = new InsertAction().execute( + new InsertInput(SequenceEnrollment.TABLE_NAME).withRecordEntity( + new SequenceEnrollment() + .withSequenceId(sequenceId) + .withContactId(contactId) + .withCurrentStepNumber(0) + .withStatus(CrmEnrollmentStatus.ACTIVE.getPossibleValueId()) + .withEnrolledDate(Instant.now()) + .withEnrolledByUserId("user-001"))); + return (output.getRecords().get(0).getValueInteger("id")); + } + + + + /*************************************************************************** + ** Helper: insert a Contact and return its id. + ***************************************************************************/ + private Integer insertContact(String email, String firstName, String lastName) throws QException + { + InsertOutput output = new InsertAction().execute( + new InsertInput(Contact.TABLE_NAME).withRecordEntity( + new Contact() + .withEmail(email) + .withFirstName(firstName) + .withLastName(lastName) + .withOwnerUserId("user-001"))); + return (output.getRecords().get(0).getValueInteger("id")); + } + +} diff --git a/src/test/java/com/kingsrook/qbits/crm/email/processes/ProcessSequenceStepsProcessTest.java b/src/test/java/com/kingsrook/qbits/crm/email/processes/ProcessSequenceStepsProcessTest.java index b12b600..df0a838 100644 --- a/src/test/java/com/kingsrook/qbits/crm/email/processes/ProcessSequenceStepsProcessTest.java +++ b/src/test/java/com/kingsrook/qbits/crm/email/processes/ProcessSequenceStepsProcessTest.java @@ -45,7 +45,7 @@ class ProcessSequenceStepsProcessTest extends BaseTest { insertActivityType("Email"); insertActivityType("Task"); - Integer sequenceId = insertSequence("Email Seq", true, 2); + Integer sequenceId = insertSequence("Email Seq", true, 2, false); Integer contactId = insertContact("Alice", "Email", false); insertStep(sequenceId, 1, CrmSequenceStepType.EMAIL.getId(), 0, 0); insertStep(sequenceId, 2, CrmSequenceStepType.TASK.getId(), 1, 0); @@ -90,7 +90,7 @@ class ProcessSequenceStepsProcessTest extends BaseTest @Test void testEnrollmentCompletion() throws QException { - Integer sequenceId = insertSequence("Complete Seq", true, 1); + Integer sequenceId = insertSequence("Complete Seq", true, 1, false); Integer contactId = insertContact("Bob", "Complete", false); insertStep(sequenceId, 1, CrmSequenceStepType.WAIT.getId(), 0, 0); @@ -125,7 +125,7 @@ class ProcessSequenceStepsProcessTest extends BaseTest @Test void testFailureCounting() throws QException { - Integer sequenceId = insertSequence("Fail Seq", true, 2); + Integer sequenceId = insertSequence("Fail Seq", true, 2, false); Integer contactId = insertContact("Carol", "Fail", true); // doNotEmail = true insertStep(sequenceId, 1, CrmSequenceStepType.EMAIL.getId(), 0, 0); insertStep(sequenceId, 2, CrmSequenceStepType.EMAIL.getId(), 0, 0); @@ -169,7 +169,7 @@ class ProcessSequenceStepsProcessTest extends BaseTest /*************************************************************************** ** Helper: insert an EmailSequence. ***************************************************************************/ - private Integer insertSequence(String name, boolean isActive, int totalSteps) throws QException + private Integer insertSequence(String name, boolean isActive, int totalSteps, boolean businessDaysOnly) throws QException { InsertOutput output = new InsertAction().execute( new InsertInput(EmailSequence.TABLE_NAME) @@ -178,7 +178,7 @@ class ProcessSequenceStepsProcessTest extends BaseTest .withOwnerUserId("user-001") .withIsActive(isActive) .withTotalSteps(totalSteps) - .withBusinessDaysOnly(true))); + .withBusinessDaysOnly(businessDaysOnly))); return (output.getRecords().get(0).getValueInteger("id")); }