A CRM component in which agents (users) can set up events that trigger actions based on a set of conditions.
CRM users would like the ability to set up automated events that execute under a set of conditions. For example, "if a deal get's cancelled, cancel all related events". Unlike the typical callback approach, which must be programmatically maintained, they need a solution that let's non-technical users generate the system's behavior.
A system of related data models alongside a database transaction interceptor. The data models provide a structure for storing trigger/condition/action events. The database transaction interceptor captures each CRUD transaction and executes the logic around any related triggers.
trigger
- stores the class type of the objects to observe as well as the type of action to observe. For example, "if deal get's cancelled" would create a trigger that watches theDeal
class for anupdate
action.condition
- represents a condition by relating a set offield
andvalue
together with anoperator
. For example, "if deal get's cancelled" would have a condition with a field"deal_stage"
, value"cancelled"
, and operator"=="
.action
- action ties atrigger
to a CRUD action. It is related to afield_value_pair
that is used to find the object that is to be acted upon in the case where the CRUD action is an update or destroy. It is also related to a set offield_value_pairs
that are used to update or create an object. For example, "cancel all related events" will have anaction
that finds theevents
that got cancelled via alookup_field_value_pair
and changes theevents
with achange_field_value_pair
havingfield
"canceled"
andvalue
true
.
condition_trigger_join
- join table forcondition
andtrigger
as well as storage foroperator
metadata. Multipleconditions
may be tied to onetrigger
. The operator can be set to"&&"
or"||"
which will be accounted for when concatenating the conditions. Note, the conditions are only executed horizontally (at the same level). Meaningcondition1 && condition2 || condition3
is possible but not(condition1 && condition2) || condition3
crud_action
- houses the name for the CRUD actions being considered. Currently used intrigger
andaction
and excludes CRUD's Read as it is not required.field_value_pairs
- used in relation toconditions
andactions
to store pairs used in an object's parameters hash.
The interceptor is found in global_observer.rb
. It contains an after_commit
callback that is run on every ActiveRecord transaction. The callback instantiates a TriggerHandler
and executes it's main method, perform
.
Instance method perform
executes in the following manner.
- Find any triggers related to the given object type and transaction type.
- For each trigger, see if the conditions are met.
- If all conditions are met (AND or OR depending on the trigger/condition join), execute the CRUD actions.
Helper functions provide the iteration and execution of these high level steps.
The functionality is in place and easily extensible. There is currently no user interface though the following section explains how one may be implemented. To see the true power of the application, direct your attention to the tests in trigger_handler_spec.rb
which provide a variety of trigger/condition/action combinations. Every CUD (CRUD - Read) action is used and some unique cases are displayed such as doing a lookup via a related table or accessing an attribute via a related object. Some complex cases of multiple actions and/or conditions are shown as well.
The pattern of behavior of this feature immediately makes me think of dynamically generated nested forms. The type of front-end functionality I have in mind is supported by gems such as Cocoon.
The user would start with a basic trigger form. They select which object to observe from a list of dynamically generated objects that exist in the application (probably with whitelisting so they don't see all). Then they select what crud action. Using JavaScript, forms for conditions and actions are displayed. The same type of dropdowns could be generated for the fields of the objects that they are interacting with so that they avoid errors in typing in a name of a field. To add additional conditions or actions, "+" sign icons would be nested within each section of the sub-forms to indicate which level of functionality is being extended. For advanced lookup of related objects, a modal could be displayed that allows users to dive deep into creating a custom query. As with the main form functionality, this modal form would throttle the level of access that users have so that they do not disrupt the behavior of sensitive models.
The form and sub-forms would submit to a thin controller that feeds the data to a handler responsible for building the objects, running validations, and saving them as trigger/condition/action combinations.
This is a unique problem. Typically each trigger/action would be made into a feature request for some custom functionality and handled when the development team has the time and is at the priority level. A solution such as this takes a lot off the plates of both the client and development team by putting the behavior of the system into the hands of the client.
My first thought was that every trigger is fired by a CRUD transaction with the database. I wanted to intercept these transactions in a non-obtrusive way. In other words, I could have put a callback in every model that I wanted to watch to hit a generic handler, but I wanted to make the model observing programmatic as well. I wanted to ensure that new models are automatically watched without the developer needing to put in a callback. After some research, I found that I could build an Observer. This was a core piece of Rails until Rails 4+ at which point it was extracted to the rails-observers gem. It was a great start but I needed a bit more research to figure out how to have the observer watch every transaction without having to register each model in it. That led to great piece of code that is easily understandable observe ActiveRecord::Base
. Note, to test the after_commit
functionality, I used the test_after_commit gem.
With my interceptor in place, I began to focus on how to model the data storage for the trigger/action events. The breakthroughs in this process came with analyzing the use cases. Let's take an example,
If Deal is updated and new deal stage is a "dead" stage, update tasks and events to mark as "cancelled"
Here we see Deal, the object, getting updated, the CRUD action. Initially, I thought that the value of the update would be coupled with the trigger, but after several use cases, I realized that "dead" and stage are the conditions. Finally, the "if-then" syntax of every use case made it obvious that the latter half of the statement is the action(s). For simplicity, I focused on just one: object, task and field change, "cancelled". Breaking this part of the statement down, we see that update is another CRUD action, just like the one used in the trigger, and "mark as cancelled" is a field and value that need to be changed.
To recap, trigger = "If Deal is updated", condition = "deal stage is 'dead' stage", and action = "mark as [tasks and events] cancelled".
The relationships were somewhat obvious after figuring out the models. Each trigger could have multiple conditions and actions. While coding examples, I later discovered that a join between condition and trigger should exist to signify AND or OR relations between conditions. Another discovery was noticing the reuse of object param hashes, lead to a refactor in which field <> value pairs were extracted into their own model. The relationship between the change and lookup value pairs and action were refactored so that there was one lookup pair and many change pairs per action. This could be changed in the future to have a one-to-many relationship between action and change pair if the functionality later called for narrower querying of the objects being acted upon.
With the interceptor and data modeling in place, it came down to functionality. The trigger handler was created to house the processing of a given CRUD transaction. It first does a single and inexpensive query to see if any triggers exist. The method will most likely terminate here in most cases. The reasoning being 1) that the transaction must match the model type and crud action in the transaction 2) the number of triggers for each model type and transaction will most likely be small. If any triggers are found, we need to iterate through them, iterate through their conditions, and iterate through their actions.
All development was done via tests. It is the best way to model the behavior of the application and no interface was needed to solve the problem. I explored the path of the form interface briefly but it was not required and would most likely have been cumbersome to maintain while flushing out the functionality. Tests were done using the following gems:
- guard - file watcher to run tests on file save.
- guard-rspec - rspec plugin for guard.
- database_cleaner - flushes the test database between tests.
- factory_girl_rails - a personal favorite used to mock models. Check the
/spec/factories
directory to see my extensive use of this gem.
If I were building this for an established system, I would research the codebase, conceive a solution, and discuss my design with my co-workers and team leads. This is a rather complex bit of functionality and addition to the database schema and not to be implemented without review. This project was built as a tool to understand and test my solution. Finally, a great deal of unit and functional testing would still be needed for each model as well as end-to-end and integration testing once the interface is designed.