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
[](https://github.com/QRun-IO/qbit-crm)
-[](https://www.gnu.org/licenses/agpl-3.0.en.html)
+[](https://www.apache.org/licenses/LICENSE-2.0)
[](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"));
}