Difference between revisions of "HowTo:Send Notification Messages to Followers"

From AgileApps Support Wiki
imported>Aeric
imported>Aeric
 
(24 intermediate revisions by the same user not shown)
Line 8: Line 8:
:* [[SendEmailUsingTemplate]] (Java API)
:* [[SendEmailUsingTemplate]] (Java API)


===Setup and Testing===
====Setup and Testing====
# Use the [[Object Construction Wizard]] to create the <tt>Followers</tt> object and define an <tt>email_address</tt> field.
# Create the <tt>Followers</tt> object:
# Go to '''[[File:GearIcon.png]] > Objects > Followers > Fields'''
#* Launch the [[Object Construction Wizard]]  
# Create a new [[Multi Object Lookup]] field called <tt>related_to</tt>.
#* Define the fields: <tt>user_name</tt>, <tt>email</tt>.
#* Click '''[Save]''' and then '''[Create]'''.
#:
# Create the linking field:
#* Go to '''[[File:GearIcon.png]] > Objects > Followers > Fields'''
#* Create a new [[Multi Object Lookup]] field called <tt>related_to</tt>.
#: Select the objects that can be followed, or choose ''All Objects''.
#: Select the objects that can be followed, or choose ''All Objects''.
#: (The data in those fields has the format <tt>object_id:record_id</tt>. That's why you can follow any record in the system.)
#: (The data in those fields has the format <tt>object_id:record_id</tt>. That's why you can follow any record in the system.)
# Modify the Case form to create a new '''Related Information''' section that displays Follower records.
#* Click '''[Save]'''.
#: Select the''' Followers''' object
#:
#: Link the Follower's '''Related To''' field to the object's '''ID''' field
# Modify the Case form to create a new tab that displays Followers.
#: Select '''Email Address''' as the field to display
#* Go to '''[[File:GearIcon.png]] > Objects > Cases > Forms > Agent Form'''
#: Sort by email address, in ascending order.
#* Click '''[New Related Information]''':
# Open a Case record and use that section to manually add yourself as a follower.
#*:* '''Object:''' Followers
# Use the sample below to create an [[Email Template]] that will be used to send notifications.<br>(The sample includes the most recent note added to the record history.)
#*:* '''Fields to Link: Followers Field:''' Related To, '''links to Cases Field:''' ID
# Record the Template ID:
#*:* '''Fields to Display:''' User Name, Email<br>(App ID will stay hidden, as it is basically unreadable.)
#: While viewing the list of email templates, click the wrench icon and select '''Edit this View'''.
#*:* '''Sort by:''' Email, '''Order:''' Ascending
#: Move the Record ID to the list of selected fields.
#*:* Click '''[Save]'''.
#: Confirm that you want to make this a global change.
#:
#: That value is the Template ID. It now appears as a column in the list of views, where it can be copied.
# Use the sample code below to create the <tt>Notifications</tt> class.
# Create a class using the code below. Fill in the Template ID.
#:
# Create a record-updated rule that invokes the <tt>notifyFollowers()</tt> method defined in the code.
# Create a record-updated rule that invokes the <tt>notifyFollowers()</tt> method defined in the code.
#: '''Name:''' Send Update Notifications
#* '''Name:''' Send Update Notifications
#: '''Description:''' Tell anyone who is following the record that a change has occurred.  
#* '''Description:''' Tell anyone who is following the record that a change has occurred.  
#: '''Run this Rule:''' Unconditionally
#* '''Run this Rule:''' Unconditionally
#: '''Actions to Perform:''' Invoke Method (Notifications, notifyFollowers)
#* '''Actions to Perform:''' Invoke Method, '''Class:''' Notifications, '''Method:''' notifyFollowers
#* Click '''[Save]'''.
#:
# 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 <tt>1</tt> (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.
# Update the case record and check your inbox for the notification message.
# Update the case record and check your inbox for the notification message.


===Next Steps===
====Next Steps====
Here are some things you can do after you get the basic behavior working:
Here are some things you can do after you get the basic behavior working:
# '''Define a ''Follow'' macro for any object in which you want to add Followers:'''
# '''Define a ''Follow'' macro for any object in which you want to add Followers:'''
#: In that macro, invoke the <tt>addFollower()</tt> method defined in the code below.
#: In that macro, invoke the <tt>addFollower()</tt> method defined in the sample code.
#: The macro will appear in the list of actions available for the record.
#: 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.
#: When clicked, the macro will add a Follower record, with all fields filled in.
Line 50: Line 61:
#: To do it, you hard-code a list of fields you care about in the class. Or you could use the <tt>Functions.getFieldMetadata</tt> API to see send notifications only for [[Audited Fields]].
#: To do it, you hard-code a list of fields you care about in the class. Or you could use the <tt>Functions.getFieldMetadata</tt> API to see send notifications only for [[Audited Fields]].
#:
#:
# '''Allow for "unfollowing"'''
# '''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 <tt>email_address</tt> and <tt>related_to field</tt>. (But then you have to decide which button to display on the form, which will require JavaScript that uses REST APIs to query the Followers object--all of which adds lag time before the form appears.)
#: 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 <tt>email_address</tt> and <tt>related_to field</tt>. (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===
====Email Template====


: '''Template Name:''' Update Notification
: '''Template Name:''' Update Notification
Line 77: Line 88:
:: $cases.description
:: $cases.description


The source code looks like this:
The template HTML looks like this:
:<syntaxhighlight lang="java" enclose="div">
:<syntaxhighlight lang="java" enclose="div">
<p><b>Case Record#</b> <a href="https://{domain}/networking/servicedesk/index.jsp#_cases/$cases.case_number">$cases.case_number</a>&nbsp;<br />
<p><b>Case Record#</b> <a href="https://{domain}/networking/servicedesk/index.jsp#_cases/$cases.case_number">$cases.case_number</a>&nbsp;<br />
Line 91: Line 102:
</syntaxhighlight>
</syntaxhighlight>


===Code===
====Code====
{{Tip|<br>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:
:* To see the kinds of field metadata that is defined  https://{yourDomain}/networking/rest/field/{objectName}
:* To see the metadata for a particular field, use https://{yourDomain}/networking/rest/field/{objectName}/{fieldName}
:* To see samples of that data, see [[REST API:field Resource#Payload Examples]]
}}
 
:<syntaxhighlight lang="java" enclose="div">
:<syntaxhighlight lang="java" enclose="div">
package com.platform.acme.demo;
package com.platform.acme.demo;
Line 97: Line 114:
// Basic imports
// Basic imports
import com.platform.api.*;
import com.platform.api.*;
import com.platform.beans.*;
import com.platform.beans.EnumerationDetailsBean.*;
import java.util.*;
import java.util.*;


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


/**
/**
  * Prerequisites:  
  * Prerequisites:  
  *  1. There is a Main object that contains records (e.g. cases) that people  
  *  1. There is a Main object with records (e.g. cases) that people want to
  *      want to "follow"--so they receive a notification when there is an update.
  *      "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  
  *  2. There is an object that has related records, one for each person who is  
  *      following records in the Main object. This sample code assumes that the
  *      following records in the Main object. This code assumes that the related
  *      related object is called "Followers".
  *      object is called "Followers".
  *  3. A related record is added to that object when someone becomes a follower.
  *  3. A related record is added to that object when someone becomes a follower.
  *  4. That object has a Lookup field that points to the thing being followed,
  *  4. The related object has a Lookup field that points to the thing being followed,
  *      and an email address to send the notification to.
  *      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:
  * Record Management:
  *  a. For initial testing, some "related records" can be created by hand.
  *  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.
  *  b. In a production setting, a "Follow" action will add a related record.
  *  c. To "unfollow", a user can easily delete the related record.
  *  c. To "unfollow", delete the related record in the UI.
*  d. Ideally, the Form will have OnLoad JavaScript that uses the REST APIs to
*      see if the current user is already a follower, and sets the checkbox state
*      or state of the buttons accordingly.
  *
  *
  * Usage:
  * Usage:
  *  An update Rule on the main object should invoke the notifyFollowers()
  *  1. An update Rule on the main object should invoke the notifyFollowers()
  *   method defined in this class. The method finds all related records and
  *     method defined in this class. That method finds all related records and
  *   sends the messages, using an email template defined for the notifications.
  *     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 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); }


   public void log(String msg) throws Exception {
  /*
    // Put the message in the log.
    * Get singular object name.<br>
    Logger.info(msg, "SendMailToFollowers");
    * (Does not work for User and Team objects.)
  }
    *
       
    * @param objectID The object's identifier, as passed in the incoming Parameters
  public void debug(String msg) throws Exception {
    *                passed to a method called from the platform.
    // Display message in a popup or at top of page, depending on context, and log it.
    */
    Functions.showMessage(msg);
   public String objectName(String objectID) throws Exception
    log(msg);
  {
      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
   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 {
       try {
        // Get information from the current record
          Map m = Functions.getFieldMetaDataMap(objectID);
        String objectID = p.get("object_id");
          Iterator fieldIterator = m.entrySet().iterator();
        String recordID = p.get("id");   
          while ( fieldIterator.hasNext() ) {
              
            // Compare fields to priorValues. Report those that differ.
        // Records that point to this one have this in their "related_to" field.
            // Skip the modified_date field.
         String lookup_target_record = objectID +":"+ recordID;
            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);
      }


         // Message contents. (To get the template ID, modify the templates view
      try {
         // to include the Record ID field.)
         // Construct the message. This message assumes that the record
         String subject = "Record Update Notification";
         // that was modified has a description field.  
         String subject = objName + " record updated";
         String ccList = "";
         String ccList = "";
         String bodyTemplateId = "__ID of the notification template goes here__";
         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";
         String relatedObject = "Followers";
              // This is the name of the object with the related records
       
        // Find all related records whose Lookup field targets the current record
        // Format of the criteria string is: related_to = '{object}:{record}'
         Result result = Functions.searchRecords(relatedObject,  
         Result result = Functions.searchRecords(relatedObject,  
             "email_address", "related_to = '" + lookup_target_record +"'");
             "email", "related_to = '" + target_record +"'");
         int resultCode = result.getCode();  
         int resultCode = result.getCode();  
         if (resultCode > 0)
         if (resultCode > 0)
Line 174: Line 383:
             {
             {
               Parameters params = iterator.next();
               Parameters params = iterator.next();
               String toAddress = params.get("email_address");
               String toAddress = params.get("email");


               // Send message (nulls are parameters for attachments)
               // Send message (nulls are parameters for attachments)
               Result sendEmail = Functions.sendEmailUsingTemplate(
               Result r = Functions.sendEmail(
                   objectID, recordID, toAddress, ccList, subject, bodyTemplateId,  
                   objectID, recordID, toAddress, ccList, subject, body,  
                   null, null);
                   null, null);
               if (sendEmail.getCode() < 0)
               if (r.getCode() < 0) {   
              {   
                   String msg = "Error sending email:\n" + r.getMessage();
                   String msg = "Error sending email: " + sendEmail.getMessage();
                   Functions.throwError(msg);
                   Functions.throwError(msg);    
               }
               }
               count++;
               count++;
             }
             }
             debug(count + " update notifications sent"); // Count is one fo
             debug(count + " update notifications sent");
         }
         }
     } catch (Exception e) {
     } catch (Exception e) {
         // Catch surprises, display a popup, and put them in the log.
         // Catch surprises, display a popup, and put them in the log.
         String msg = "Unexpected exception";
         String msg = e.getMessage() + "\n notifyFollowers(): "+e.getClass().getName();
        log(msg + ":\n" + e.getMessage() );
        Logger.info(msg, "Notifications"); // Additional log statement
         Functions.throwError(msg + " - see debug log");       
         Functions.throwError(msg);
      }
     }
   }
   }
 
 
   /*
   /*
     * Usage: This method should be called by Macro (i.e. a record Action)
     * Usage: This method should be called by Macro (i.e. a record Action)
Line 205: Line 413:
   public void addFollower(Parameters p) throws Exception
   public void addFollower(Parameters p) throws Exception
   {
   {
    try {
      try {
         // Set up the lookup target
         //log( "Macro params:\n"+ p.toString().replace(",","\n") );
         String objectID = p.get("object_id");
         String objectID = p.get("object_id");
         String recordID = p.get("id");   
         String recordID = p.get("id");   
         String lookup_target_record = objectID +":"+ recordID;
         String lookup_target_record = objectID +":"+ recordID; // ex: cases:99999
          
 
         // Get the current user's email address and name
         Map<String,Object> m = Functions.getLoggedInUserInfo();
         String email = Functions.getEnv(ENV.USER.EMAIL);
         //log( "Logged-in User Info:\n"+ m.toString().replace(",","\n") );
         String name  = Functions.getEnv(ENV.USER.FULL_NAME);
         String userName = (String)m.get("full_name");  
          
         String email = (String)m.get("email");    
         // For extra credit, exit here if a record already exists with those values
 
         // Additional credit: Make the adjustments to display and store user's name
         //*************************************************
         // EXTRA CREDIT: Exit here if a record already exists with those values
         //*************************************************


         Parameters params = Functions.getParametersInstance();
         Parameters params = Functions.getParametersInstance();
         params.add("email_address", email);
         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("related_to", lookup_target_record);
         params.add(PLATFORM.PARAMS.RECORD.DO_NOT_EXEC_RULES,"1");
         params.add(PLATFORM.PARAMS.RECORD.DO_NOT_EXEC_RULES,"1"); // No rules
         Functions.addRecord("Followers", params);
         Functions.addRecord("Followers", params);
    }  
      }  
    catch (Exception e) {
      catch (Exception e) {
        // Catch surprises, display a popup, and put them in the log.
         String msg = e.getMessage() + "\n addFollower(2): "+e.getClass().getName();
         String msg = "Unexpected exception";
        Logger.info( msg, "Notifications" );  // Additional log statement
        log(msg + ":\n" + e.getMessage() );
         Functions.throwError(msg);
         Functions.throwError(msg + " - see debug log");    
       }
       }
   }
   }  
 
} // end class
} // end class
</syntaxhighlight>
</syntaxhighlight>
{{Note|<br>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.}}

Latest revision as of 20:56, 13 February 2015

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.