AgileApps Support Wiki Pre Release

HowTo:Send Notification Messages to Followers

From AgileApps Support Wiki

This code sample sends an email to everyone who registered themselves as a "follower" of a Case (or any other object where the application designer allows it).

To follow the record, a "related record" with the user's email address is added to the Followers object. When the Case (or other record) is updated, an email notification is sent to everyone who is following it, using an API and an email template created for the purpose.

Learn more:

Setup and Testing

  1. Create the Followers object:
  2. Create the linking field:
    Select the objects that can be followed, or choose All Objects.
    (The data in those fields has the format object_id:record_id. That's why you can follow any record in the system.)
    • Click [Save].
  3. Modify the Case form to create a new tab that displays Followers.
    • Go to GearIcon.png > Objects > Cases > Forms > Agent Form
    • Click [New Related Information]:
      • Object: Followers
      • Fields to Link: Followers Field: Related To, links to Cases Field: ID
      • Fields to Display: User Name, Email
        (App ID will stay hidden, as it is basically unreadable.)
      • Sort by: Email, Order: Ascending
      • Click [Save].
  4. Use the sample code below to create the Notifications class.
  5. Create a record-updated rule that invokes the notifyFollowers() method defined in the code.
    • Name: Send Update Notifications
    • Description: Tell anyone who is following the record that a change has occurred.
    • Run this Rule: Unconditionally
    • Actions to Perform: Invoke Method, Class: Notifications, Method: notifyFollowers
    • Click [Save].
  6. Open a case record, click the Followers tab, and manually add yourself as a follower.
    • Fill in the user and email fields.
    • If you're in the ServiceDesk app, the app ID is easy. It's 1 (one).
    • Otherwise, leave the field empty and delete the record later. Or go to the Applications page to retrieve the ID of the current app.
  7. Update the case record and check your inbox for the notification message.

Next Steps

Here are some things you can do after you get the basic behavior working:

  1. Define a Follow macro for any object in which you want to add Followers:
    In that macro, invoke the addFollower() method defined in the sample code.
    The macro will appear in the list of actions available for the record.
    When clicked, the macro will add a Follower record, with all fields filled in.
    Here's what you need for the basic macro:
    Name: Follow
    Description: Add the user who clicks the button to the list of "followers"--people who will receive an email when the record is updated.
    Show: Always
    Action: Invoke Method, Class: Notifications, Method: addFollower
  2. Send notifications for a subset of updates
    This is an extra credit assignment.
    To do it, you hard-code a list of fields you care about in the class. Or you could use the Functions.getFieldMetadata API to see send notifications only for Audited Fields.
  3. Allow for "unfollowing" (maybe)
    Users can unfollow a record manually by clicking their Follower record and choosing the Delete action. Alternatively, you could cannibalize the code below to add a method that searches for a Follower with a matching email_address and related_to field. (But then you have to remove the macro and add a JavaScript button to the form, instead. That JavaScript will then use the REST APIs to query the Followers object and display either a [Follow] or an [Unfollow] button--which makes a nice user interface, except that it adds lag time before the form appears.)

Email Template

Template Name: Update Notification
Type: Case Templates (rather than an SLA template)
From Name: Support System
From Email Address: $custom.support_team_email_address
Subject: Case Record Updated
Template Variables:
Case Record Variables:
$cases.case_number, $cases.modified_id.full_name, $cases.description
Current Note Variable (case history): $__current_note__.description
Custom Variable: $custom.support_team_email_address

Here is a sample Update Notification template that uses those variables:

Case Record# $cases.case_number
was updated by $cases.modified_id.full_name
$__current_note__.description
Case Description:
$cases.description

The template HTML looks like this:

<p><b>Case Record#</b> <a href="https://{domain}/networking/servicedesk/index.jsp#_cases/$cases.case_number">$cases.case_number</a>&nbsp;<br />
was updated by $cases.modified_id.full_name</p>
<blockquote>
    $__current_note__.description
</blockquote>

<p><b>Case Description:</b></p>
<blockquote>
    $cases.description
</blockquote>

Code

Thumbsup.gif

Tip:
Whether or not a field is included in the change-list depends on several aspects of the field--it's name, it's type, and (for extra credit) whether or not it is an Audited Field. That data is defined by the field's "metadata". There are several ways to get that information:

package com.platform.acme.demo;

// Basic imports
import com.platform.api.*;
import com.platform.beans.*;
import com.platform.beans.EnumerationDetailsBean.*;

import java.util.*;

// Reference static functions without having to specify the Functions class.
//import static com.platform.api.Functions.*;
//import static com.platform.api.CONSTANTS.*;

/**
 * Prerequisites: 
 *   1. There is a Main object with records (e.g. cases) that people want to
 *      "follow", so they receive an email notification when there is an update.
 *   2. There is an object that has related records, one for each person who is 
 *      following records in the Main object. This code assumes that the related 
 *      object is called "Followers".
 *   3. A related record is added to that object when someone becomes a follower.
 *   4. The related object has a Lookup field that points to the thing being followed,
 *      an email address to send the notification to, and the ID of the application
 *      the user was in when the follower was added. (The ID is used to create
 *      a link to the record in the notification message.)
 *   5. The object being followed has a "description" field to include in the 
 *      message. (But you can easily modify the code to leave that out.)
 *
 * Record Management:
 *   a. For initial testing, some "related records" can be created by hand.
 *   b. In a production setting, a "Follow" action will add a related record.
 *   c. To "unfollow", delete the related record in the UI.
 *
 * Usage:
 *   1. An update Rule on the main object should invoke the notifyFollowers()
 *      method defined in this class. That method finds all related records and
 *      sends the notification messages. 
 *   2. The addFollower() method, meanwhile can be invoked by a macro defined 
 *      on any object which has records that users want to follow.
 *
 * Caveat:
 *   This mechanism does catch the situation when a private note is added to
 *   a record. (A rule could be added to the History object, but such rules
 *   do not fire.)
 */
public class Notifications
{
   public void show(String msg) throws Exception { Functions.showMessage(msg); }
   public void log(String msg) throws Exception { Logger.info(msg, "Notifications"); }        
   public void debug(String msg) throws Exception { show(msg); log(msg); }

   /*
    * Get singular object name.<br>
    * (Does not work for User and Team objects.)
    *
    * @param objectID The object's identifier, as passed in the incoming Parameters
    *                 passed to a method called from the platform.
    */
   public String objectName(String objectID) throws Exception
   {
      try {
         CustomObjectMetaDataBean mdb = Functions.getObjectMetaData(objectID);
         return mdb.getSingluarDisplayTitle();
      }
      catch (Exception e) {
         String msg = e.getMessage() + "\n objectName(): "+e.getClass().getName();
         Functions.throwError(msg);
         return "";  // Required to keep the compiler happy
      }
   }

   /** 
    * Let people know when a record they are following has been updated. 
    * <br>Usage:<br>
    * Call this message from an update rule.
    */
   public void notifyFollowers(Parameters p) throws Exception
   {
      //log( "Record params from rule:\n"+ m.toString().replace(",","\n") );

      // Get information from the current record
      String objectID = p.get("object_id");
      String recordID = p.get("id");  
      String userName = p.get("user");  // The user's full name
      String serverName = p.get("serverName"); 

      String recordName = p.get("name"); // The record's human-readable identifier
      String recordDescr = p.get("description"); // We assume this field exists
      Parameters priorValues = p.getPriorParams();
      // Records that point to this one have this in their "related_to" field.
      String target_record = objectID +":"+ recordID;    

      String objName = "";
      try {
         objName = objectName(objectID);
      } catch (Exception e) {
         String msg = e.getMessage() + "\n notifyFollowers(): "+e.getClass().getName();
         Logger.info( msg, "Notifications" );  // Additional log statement
         Functions.throwError(msg);
      }

      // Get the activity details. (Report new values only.)
      String modifiedFields = "<ul>\n";
      try {
          Map m = Functions.getFieldMetaDataMap(objectID);
          Iterator fieldIterator = m.entrySet().iterator();
          while ( fieldIterator.hasNext() ) {
            // Compare fields to priorValues. Report those that differ.
            // Skip the modified_date field.
            Map.Entry entry = (Map.Entry) fieldIterator.next();
            String field = (String) entry.getKey(); // Field name
            if (field.equals("modified_date")) continue;

            String newValue = p.get(field) == null ? "" : p.get(field);
            String oldValue = priorValues.get(field);
            oldValue = oldValue == null ? "" : oldValue;
            if (newValue.equals(oldValue)) continue;

            // Replace the record ID in a Lookup field w/a human-readable label.
            FieldMetaDataBean fmdb = (FieldMetaDataBean) entry.getValue();
            String type = fmdb.getType();
            // **********************************************************
            // EXTRA CREDIT: Ignore the field if it is not audited, so the
            // email includes values displayed in the record history only.
            // (FieldMetaDataBean.getIsTracked() returns true)
            // ***********************************************************

            if ( "LOOKUP".equals(type) && newValue != "" )
            {
               IDBean objectBean = fmdb.getLookUpObjectId();
               String targetObject = objectBean.getValue();

               // Get the list of fields that define the label for a record
               RecordLocatorBean rlb = Functions.getRecordLocator(targetObject);
               List<String> fieldList = rlb.getKeyColumns();
               String fields = "";
               for (String item : fieldList) {
                  if ( ! "".equals(fields) ) fields += ",";
                  fields += item;
               }
               Result r = Functions.getRecord(targetObject, fields, newValue);
               Parameters recParams = r.getParameters();
               // log( recParams.toString().replace(", ", "\n") );
               String record_name = "";
               for (String item : fieldList) {
                   if ( ! "".equals(record_name) ) record_name += " - ";
                   record_name += recParams.get(item);
                }
               newValue =  record_name; // as defined by the Record Locator
               if ( "".equals(newValue) ) newValue = "(unnamed record)";
            }
            else if ("MULTI_OBJECT_LOOKUP".equals(type))
            {
               // A Multi Object Lookup field has the form {objectID}:{recordID}
               String[] parts = newValue.split(":");
               String objectName = parts[0]; 
               String targetRecord = parts[1];
               // **********************************************************
               // EXTRA CREDIT: Factor out the logic above that gets
               // a record's identifying label, and call it here
               // ***********************************************************
            }
            else if ("PICK_LIST".equals(type) 
                 ||  "GLOBAL_PICK_LIST".equals(type) 
                 ||  "DEPENDENT_PICK_LIST".equals(type) 
                 ||  "RADIO_BUTTON".equals(type)) 
            {
               // Replace enumeration value with display label
               EnumerationDetailsBean edb = fmdb.getEnumerationDetails();
               List<EnumerationItemBean> entries = edb.getEnumerationItems();
               for (EnumerationItemBean item:entries) {
                  if (newValue.equals(item.getPicklistValue()) ) {
                     newValue = item.getPicklistLabel();
                     break;
                  }
               }
            }
            else if ("MULTI_CHECK_BOX".equals(type)) 
            {
                // Process list of values. Replace with labels.
                // **********************************************************
                // EXTRA CREDIT: The field value is a comma-separated list
                // of enumeration values. Split on comma to get the items,
                // use the logic above to get their labels, and create a 
                // comma separated list of labels.
                // ***********************************************************
            }
            else if ("CHECK_BOX".equals(type)) 
            {
                // Replace boolean 1 or 0 with text label
                newValue = "No";
                if ("1".equals(newValue)) newValue = "Yes";
            }
           else if ("FILE_FIELD".equals(type) 
                 || "IMAGE_FIELD".equals(type)) 
            {
               // Replace record identifier with file name
               // Format: "{filename}:{documentID}"
               String[] parts = newValue.split(":");
               newValue = parts[0];
               // **********************************************************
               // EXTRA CREDIT: Make the file name a link.
               // ***********************************************************
            }
            if (newValue == "") newValue = "(removed)";
            modifiedFields += "<li><b>"+ field +"</b>: "+ newValue +"</li>\n";
         }
         modifiedFields += "</ul>\n";
      } catch (Exception e) {
         String msg = e.getMessage() + "\n notifyFollowers(): "
                    + e.getClass().getName();
         Logger.info( msg, "Notifications" );  // Additional log statement
         Functions.throwError(msg);
      }

      try {
         // Construct the message. This message assumes that the record
         // that was modified has a description field. 
         String subject = objName + " record updated";
         String ccList = "";
         String recordURL = "https://"+ serverName 
        		          + "/networking/servicedesk/index.jsp";

         // Build a link to the record for use in the email
         // The link format is based on the object and the current application.
         Map<String,Object> m = Functions.getLoggedInUserInfo();
         //log( "Logged-in User Info:\n"+ m.toString().replace(",","\n") );
         String appID = (String)m.get("startingAppId");
         String recordLink = "";
         if (objName.equals("Case")) {
            // A Case record:
            // https://{domain}/networking/servicedesk/index.jsp#_cases/{caseNum}
            recordURL += "#_cases/"+ p.get("case_number");
         }
         else if (objName.equals("Task") && appID == "1") { 
            // A Task in the ServiceDesk app:
            // https://{domain}/networking/servicedesk/index.jsp#_tasks/{recID}  
            recordURL += "#_tasks/"+ recordID;
         }
         else { 
            // A record in another object, including tasks in other applications:
            // https://{domain}/networking/servicedesk/index.jsp?applicationId={appID}#_{objectID}/{recID} 
            recordURL += "?applicationId="+ appID +"#_"+ objectID +"/"+ recordID;
         }
         // Formulate an HTML link to the record
         if (recordURL != "" && recordName != "") {
          recordLink = "<a href=\""+ recordURL +"\">"+ recordName +"</a>";
         }

         // Set up the email message
         String body = "<p><b>"+ objName + " record " + recordLink +": </b><br>\n"
                     + "was updated by "+ userName +"</p>\n"
                     +  modifiedFields
                     + "<p><b>"+ objName +" description:</b></p>\n"
                     + "<blockquote>\n"
                     + recordDescr
                     + "\n</blockquote>";

         // Find all related Follower records that target the current record
         // Format of the criteria string is: related_to = '{object}:{record}'
         String relatedObject = "Followers";
         Result result = Functions.searchRecords(relatedObject, 
            "email", "related_to = '" + target_record +"'");
         int resultCode = result.getCode(); 
         if (resultCode > 0)
         {
            // Process the records that were found.
            // (If getCode() has a positive number, it was the number found.)
            int count = 0;
            ParametersIterator iterator = result.getIterator();
            while(iterator.hasNext())
            {
               Parameters params = iterator.next();
               String toAddress = params.get("email");

               // Send message (nulls are parameters for attachments)
               Result r = Functions.sendEmail(
                  objectID, recordID, toAddress, ccList, subject, body, 
                  null, null);
               if (r.getCode() < 0) {  
                  String msg = "Error sending email:\n" + r.getMessage();
                  Functions.throwError(msg);
               }
               count++;
            }
            debug(count + " update notifications sent");
         }
     } catch (Exception e) {
         // Catch surprises, display a popup, and put them in the log.
         String msg = e.getMessage() + "\n notifyFollowers(): "+e.getClass().getName();
         Logger.info(msg, "Notifications");  // Additional log statement
         Functions.throwError(msg);
     }
   }

   /*
    * Usage: This method should be called by Macro (i.e. a record Action)
    * on the record that the person wants to follow. (The form that displays
    * the record should also have a Related Information section, so the user
    * can remove their record.
    */
   public void addFollower(Parameters p) throws Exception
   {
      try {
         //log( "Macro params:\n"+ p.toString().replace(",","\n") );
         String objectID = p.get("object_id");
         String recordID = p.get("id");  
         String lookup_target_record = objectID +":"+ recordID; // ex: cases:99999

         Map<String,Object> m = Functions.getLoggedInUserInfo();
         //log( "Logged-in User Info:\n"+ m.toString().replace(",","\n") );
         String userName = (String)m.get("full_name");    
         String email = (String)m.get("email");      

         //*************************************************
         // EXTRA CREDIT: Exit here if a record already exists with those values
         //*************************************************

         Parameters params = Functions.getParametersInstance();
         params.add("object_id", "Followers");  // required param
         params.add("user_name", userName);
         params.add("email", email);
         params.add("related_to", lookup_target_record);
         params.add(PLATFORM.PARAMS.RECORD.DO_NOT_EXEC_RULES,"1"); // No rules
         Functions.addRecord("Followers", params);
      } 
      catch (Exception e) {
         String msg = e.getMessage() + "\n addFollower(2): "+e.getClass().getName();
         Logger.info( msg, "Notifications" );  // Additional log statement
         Functions.throwError(msg);
      }
   }   
} // end class

Notepad.png

Note:
The code above builds the HTML link to the record based on the object and the current application. The logic works correctly, 99.999% of the time. (Applications rarely share objects, even fewer share records in those objects, and it is only links to Task records that could differ.)

It is theoretically possible (although highly unlikely) for the same Task record to be shared between two applications. In that scenario, it is also possible for a notification email generated in one application to be sent to someone who doesn't have access to that application. In that highly improbable scenario, the logic could fail.

To handle that situation, that appID could be stored in the Follower object. The code that generates the record-link for the message could then be moved inside of the loop that finds Follower records and sends emails. But doing that makes the logic more complicated and harder to comprehend, while at the same time reducing performance. So this code uses the "near-bulletproof" strategy.