Java Code Samples

From AgileApps Support Wiki
Revision as of 13:48, 31 August 2020 by imported>Aeric
(diff) ← Older revision | Latest revision (diff) | Newer revision → (diff)

1 About the Code Samples

These samples assume basic familiarity with the use of the platform's Java APIs. In many cases, it is also helpful to be conversant with the platform's System Objects.
Learn more:

2 Class Template

Use this class as a template for a class that accesses record data and uses it to perform some operation. (The class is intentionally overly-complete. It's a lot easier to remove something you don't need than it is to look up the syntax for things you do need--or to know that syntax even exists.)

package com.platform.yourCompany.yourApplication;

// Basic imports
import com.platform.api.*;
import java.util.*;

// Reference static functions without having to specify the Functions class.
// So Functions.throwError() can be written as throwError().
// (Code is shorter that way, but it's less obvious where things are defined.)
import static com.platform.api.Functions.*;

// These are needed for advanced operations.
//import com.platform.beans.*;
//import static com.platform.api.CONSTANTS.*;

public class YourClass
{
   // Convenience methods to display a message to the user or add it the debug log.
   // Note:
   //   When showMessage() is called multiple times, the strings are concatenated. 
   //   One long string is then displayed when the code returns to the platform.
   //   We add an HTML linebreak (<br>) to separate them. Log messages, on the
   //   other hand, use a Java "\n" (newline) character.
   public void show(String msg) throws Exception { Functions.showMessage(msg+"<br>"); }
   public void log(String msg) throws Exception { Logger.info(msg, "YourClass"); }
   public void debug(String msg) throws Exception { show(msg); log(msg); }

   /**
    * CALLED FROM THE PLATFORM (hence the Parameters argument)
    */
   public void doSomething(Parameters p) throws Exception
   {
      try {
         //Record incoming parameters in the log
         //log( "Method params:\n"+ p.toString().replace(",","\n") );

         String objectID = p.get("object_id");
         String recordID = p.get("id");  

         // Define the parameters for some operation
         Parameters params = Functions.getParametersInstance();
         params.add("key", "value");
         //...

         // Do it.
         // Result.getCode() >= 0 on success, -1 on failure
         Result r = Functions.doSomething(params);
         if (r.getCode() < 0) {
            // Display message to user, add an entry to debug log, and
            // roll back the current transaction (no changes are committed).
            String msg = "Error <doing something>:\n"+ r.getMessage();
            Functions.throwError(msg);      
         }  
         debug("Success");
      } catch (Exception e) {
         String msg = e.getMessage() + "\n methodName(): "+e.getClass().getName(); 
         log(msg);  
         Functions.throwError(msg);
      }
   }

   /**
    * CALLED INTERNALLY(a utility function of some sort)
    */
   public String getSomeValue(String x) throws Exception
   {
      try {
         ...
      } catch (Exception e) {
         String msg = e.getMessage() + "\n methodName(): "+e.getClass().getName(); 
         Functions.throwError(msg);
      }
   }

   /**
    * UNIT TEST. (Note the @TestMethod pragma)
    */
   @TestMethod
   public void test1_DescribeTheTestHere() throws Exception
   {
      String expect = "some result";        
      String actual = methodThatReturnsSomeResult();
      RunTest.assertEquals(expect, actual);
   }

} // end class

Thumbsup.gif

Best Practice:

  1. Wrap code in a try..catch block, to guard against unexpected exceptions. (If not caught, they are simply ignored, and the method fails silently.)
  2. When you detect an error, put a detailed message into the Debug Log. Then call Functions.throwError to generate an exception, display a message for the user, and roll back the current transaction.

3 Error Handling in Java Code

The goal of error handling is identify the error that occurred, where it happened, and (ideally) what data was present at the time. The ideas presented in this section can help to achieve those goals.

3.1 Error Handling Tools

The Java Class Template embodies the error handling principles explained below. To do so, it uses the following tools:

  • Logger.info - Put a text message into the Debug Log. Add "/n" (newline) to create a line break.
    None of the other tools put an entry into the log. This is the only one that does.
  • Functions.showMessage - Display an HTML message onscreen. Add "<br>" to create a line break.
    Multiple calls to showMessage() are concatenated in the message buffer--but only if no Exceptions occurred.
    The contents of the message buffer are displayed when the code returns to the platform.
  • Functions.throwError - Raise an exception to discontinue processing, display a message, and roll back the current transaction.
    The required message argument (Functions.throwError("some message") is displayed to the user.
    That call is equivalent to throw new Exception("some message").
    Those calls overwrite the message buffer, replacing any previous calls and any stored text from calls to showMessage. so only the last call is seen by the user.
    Whenever such a message is displayed, the Debug Log should contain detailed information on the cause. Follow the steps in the next section to be sure it does.
Learn more:
To test various error conditions and see the results, use this class: ErrorHandlingTest Class.

Thumbsup.gif

Tip:
By all means, take advantage of the Unit Test Framework to identify and fix bugs before your users see them.

3.2 Error Handling Principles

  1. Errors are ignored unless you throw them. So:
    a. All calls to platform functions and standard Java functions should be in a try-catch block.
    b. Any code that sees an error (whether inside the try or catch) should call Functions.throwError.
  2. Nothing goes into the log unless you put there. Be sure to capture the information you need for debugging.
  3. A standard Java stack trace is of little value, since it is almost entirely the sequence of platform calls that got to your code. You're more interested in the steps your program followed. To get that information, catch every exception and add the name of the current method to the log, along with the exception's class name:
    a. Call Logger.info. Use the class name as the "category" label (2nd parameter).
    b. Include the method name in the message.
    c. Include the exception's class name, using e.getClass().getName().
    (For a standard Java exception like ArrayIndexOutOfBoundsException, the class name will generally tell you what went wrong.)

3.3 Error Handling Snippets

The following code fragments embody those principles:

  1. For normal code in a try-block (for example, when you get back an unexpected value from a call), generate an exception to interrupt processing and roll back the current transaction:
    // Result.getCode() >= 0 on success, -1 on failure
    Result r = somePlatformAPI();
    if (r.getCode() < 0) {
       // THROW THE ERROR
       String msg = "Error <doing something>:\n"+ r.getMessage();
       Functions.throwError(msg);
    }
    
  2. For a top-level method that is called by the platform, generate a message, log it, and throw it:
    try {
       // TOP LEVEL CODE
    }
    catch (Exception e) {
       String msg = e.getMessage() + "\n yourMethod(): "+e.getClass().getName(); 
       Logger.info( msg, "YourClass" );  // Additional log statement
       Functions.throwError(msg);
    }
    
  3. For an internal method that is called by your code, generate the message and throw it:
    try {
       // INTERNAL CODE
    }
    catch (Exception e) {
       String msg = e.getMessage() + "\n yourMethod(): "+e.getClass().getName(); 
       Functions.throwError(msg);
    }
    
    (There is no need to log the message here, as it will be logged in the code that catches it.)
    That style of error handling generates log entries like these:
    someCallingMethod(): Exception
    someCalledMethod(): ArrayIndexOutOfBoundsException

3.4 Tracing Code Execution

  1. Calls to Functions.showMessage are useful in the normal flow of code, but not in a catch-block.
    (You have to re-throw an exception to be sure it is seen. But when you re-throw it, the message it contains is the only thing the user sees.)
  2. Use showMessage() calls to put breadcrumbs in a block of code. That works as long as no exceptions occur, and the messages are easy to see. But putting the messages in the Debug Log is more reliable. To get the best of both worlds, do both.
  3. The debug method in the Java Class Template is designed for tracing code execution. If the code runs properly, you see the trace messages onscreen. If not, they are still in the Debug Log.
    public void debug(String msg) throws Exception { show(msg); log(msg); }
    

Thumbsup.gif

Tip:
Don't indent debug statements you intend to remove. Instead, start them in column 1, where they're easy to see.
Only indent debug statements you want to keep around for later use. Comment them out when they've served their purpose.

4 Add a Task to a Record

This example uses the addRecord API to create a new Task associated for the current record.

import com.platform.api.Functions;
import com.platform.api.Parameters;
import java.util.*;

public class RecordAdditions
{
   public void addTask(Parameters p) throws Exception
   {      
       String objectID = p.get("object_id");
       String recordID = p.get("id"); 

       String subject = "New Task Record";
       String description = "This task needs to be accomplished.";
       String relatedTo = objectID + ":" + recordID; //ex: "cases:99999999"

       // Create the Task parameters
       Parameters taskParams = Functions.getParametersInstance();
       taskParams.add("subject", subject);
       taskParams.add("description", description);
       taskParams.add("related_to", relatedTo);
       taskParams.add("due_date", new Date() );
          // Add other fields like owner_id, as required     
      
       // Add the Task
       Result r = Functions.addRecord("tasks", taskParams);

       // Check the result here. 
       // On success, Result.getCode() returns zero (0)
       // On failure, it returns -1
   }
}

Notepad.png

Note: Since a Task can be attached to a record in any object, related_to is a Multi Object Lookup field. It's data value therefore contains both an object identifier and the record identifier, separated by a colon.
Learn more: Field Type Reference

5 Batch Update of Related Record Owners

This example assumes that some collection of records is divided into "batches", and that each batch has a single owner. A Batch object contains header records, and each record in the collection has a lookup to the Batch object - which makes them related records.

The goal is to update all of the Related records when there is a change to the Batch record. In this case, the idea is to change the owner of all Related records whenever the Batch record is assigned to a new owner.

The solution is to create a Java method that does the updates, and invoke it only when the owner field changes in the Batch record.

5.1 Defining the Class

Here's a template for the class. It assumes that:

  • The name of the batch object is "Batch_Controls"
  • The name of the object with the related records is "Related_Details"
  • The name of the Lookup field in that object is lookup_field

(Note that object and field names are somewhat different from the display labels that appear on screen. In particular, note that spaces are replaced by underscores.)

package com.platform.yourCompany.yourPackage;

import com.platform.api.*;
import java.util.*;

public class BatchProcessing
{
  /**
   * Process records related to the current record.
   */
  public void updateRelatedRecords(Parameters p) throws Exception
  {
    try { 
       // Object names. (The internal names, not the display labels.)
       String batchObject = "Batch_Controls";  // Not used. Just for clarity.
       String relatedObject = "Related_Details";

       // Get the owner of the current batch record
       //String objectID = p.get("object_id"); // Expected to be the Batch_Controls object
       String batchRecord = p.get("id");
       String batchName = p.get("some_value_that_identifies_the_batch");
       String batchOwner = p.get("owner_id");

       // Search for Related records
       Result searchResult =
          Functions.searchRecords(relatedObject , "id",  // Return record ID from search
             "lookup_field equals '" +batchRecord+ "'"); // lookup_field equals '9999999'
       int searchCode = searchResult.getCode();
       if (searchCode < 0) {
          String msg = "Error searching for Related records";
          log(msg + ":\n" + searchResult.getMessage());
          Functions.throwError(msg);                    
       } else if (searchCode == 0) {
          log("No related records found for "+batchName);
       } else {
          // Records were found (searchCode equals the number)
          ParametersIterator pit = searchResult.getIterator();
          while(pit.hasNext())
          {
             // Get the record ID, for use when updating
             Parameters relatedParams = pit.next();            
             String relatedRecordID = relatedParams.get("id");

             // Specify the field to update
             Parameters params = Functions.getParametersInstance();
             params.add("owner_id", batchOwner);

             // Update related record owner. (Functions.updateRecord() could 
             // also be used, but this method has an option to send an email.)
             Result updateResult = Functions.changeOwnerShipInfo(
                relatedObject, relatedRecordID, batchOwner, false); 
                //Set last param to true to send new owner a notification msg.
             int resultCode = updateResult.getCode();
             if (resultCode < 0) {
                // Some error happened.
                String msg = "Update failed";
                log(msg + ":\n" + updateResult.getMessage()); // Log the details
                Functions.throwError(msg + ".");        // Abort and display error
             }
          } //while
       }  //else
    } catch (Exception e) {
       // Catch surprises and report them.
       String msg = "Unexpected exception";
       log(msg + ":\n" + e.getMessage() );
       Functions.throwError(msg);      
    }
  } // method
  
  public void log(String msg) throws Exception {
    // Put the message in the log.
    Logger.info(msg, "BatchProcessing");
  }
       
  public void debug(String msg) throws Exception { 
    // For interactive debugging. Displays in a popup
    // or at top of user's page, depending on context.
    Functions.showMessage(msg);
    log(msg);
  }
} // end class

5.2 Creating the Update Rule

The next step is create an event Rule that runs only when the owner_id field changes in a Batch record:

  1. GearIcon.png > Objects > Batch > Business Rules
  2. Event Rules, On Batch (record): Owner Changed
  3. [New Rule]
  4. Action: Invoke the Java method, updateRelatedRecords()

5.3 Learn more

6 Manage Files

Files can be added to a specific field in a record, or added to the list of attachments.

6.1 Add a File to a Record

If a record contains a field of type File, use this code to add file content to that field.

For example, here we are adding an image for a book's cover. We assume that image was retrieved as a byte-array using a Web Service or an HttpConnection. The byte array is then passed to the method below, which converts it to an encoded string and stores it in the platform.

import com.platform.api.*;
import com.platform.beans.*;
import com.platform.api.utility.*;  // Encoding class

public class RecordAdditions 
{
   /**
    * Add a byte[] of document/image content to a record in the Books object
    */
   public void addBookCover(byte[] bytes) throws Exception
   {
      Base64Codec base64 = new Base64Codec();
      String encodedString = base64.encodeToString(bytes); 
      PlatformFileBean file = new PlatformFileBean("cover image", encodedString); 

      // Add the file to a new record in the Books object, in the "bookCover" field
      Parameters params = Functions.getParametersInstance();
      params.add("bookCover", file);
      Result result = Functions.addRecord("Books", params); 

      // Alternatively, update an existing record in the Books object
      // (Here the record ID is hardcoded. Generally, it would be passed as a parameter.)
      //String recordID = "76799333"; 
      //Result result = Functions.updateRecord("Books", recordID, params);
   }
}

6.2 Add and Access Record Attachments

6.2.1 Generate an Attachment

This example uses a Document Template to generate a PDF or HTML document, and then attaches the document to the current case. It is expected that the method will be invoked from a Rule.

In outline, the process is:

  1. Get the record ID from the incoming method parameters.
  2. Use the generateDocument API to create a PDF (or HTML) document from an existing template.
  3. Use the getDocument API to retrieve it, in the form of a PlatformFileBean.
  4. Use the addRecord API to attach the document to the case.
package com.platform.yourCompany.yourPackage;

import com.platform.api.*;
import com.platform.beans.*;
//import java.util.*;

public class UtilityFunctions
{
  // This signature allows the method to be invoked from a rule.
  // We assume it is invoked on a Case record.
  public void generateAttachment(com.platform.api.Parameters inParams)
     throws Exception
  {
     String documentTitle = "PUT TITLE OF GENERATED DOCUMENT HERE";
     String templateID = "PUT ID OF DOCUMENT TEMPLATE HERE";

     // Get the record ID from the incoming parameters
     String objectID = inParams.get("object_id");
     String recordID = inParams.get("id");

     // Generate the document
     Result result = Functions.generateDocument(objectID, recordID, templateID, 
                                                CONSTANTS.DOCUMENT.HTML);
                                          // or CONSTANTS.DOCUMENT.PDF 
     int resultCode = result.getCode();
     if (resultCode < 0) {
        String msg = "Document generation failed";
        Logger.info(msg + ":\n" + result.getMessage(), "genAttachment");
        Functions.throwError(msg + ".");
        return;
     }

     // Retrieve the document as a PlatformFileBean
     String docID = result.getId();
     result = Functions.getDocument(docID);
     resultCode = result.getCode();
     if (resultCode < 0) {
        String msg = "Failed to retrieve the document";
        Logger.info(msg + ":\n" + result.getMessage(), "genAttachment");
        Functions.throwError(msg + ".");
        return;
     }
     Parameters docParams = result.getParameters();
     PlatformFileBean fileBean = docParams.getPlatformFileBean(docID);

     // Add the document as an attachment
     Parameters params = Functions.getParametersInstance();
     params.add("title", documentTitle);
     params.add("file_field", fileBean );
     params.add("related_to", objectID+":"+recordID);
     result = Functions.addRecord("attachments", params);
     resultCode = result.getCode();
     if (resultCode < 0) {
        String msg = "Failed to attach document to case";
        Logger.info(msg + ":\n" + result.getMessage(), "genAttachment");
        Functions.throwError(msg + ".");
        return;
     }
  }
}

Thumbsup.gif

Tip:
For a more general solution, add function parameters to specify the document title and the template to use. Then write a wrapper method to supply a specific template, something like this:

public void generate_A1_Attachment(com.platform.api.Parameters inParams)
   throws Exception
{
   String docTitle = "A1 Attachment";
   String templateID = "9ewr8aasd923234ased0897234d";
   generateAttachment(inParams, docTitle, templateID);
}

Then, when you create the invocation Rule, you'll choose which wrapper method to use.

6.2.2 Add an External Attachment

Use this code to add an image, document, or file to a record as an attachment. As before, the byte array is presumed to have come from a Web Service or HttpConnection:

package com.platform.yourCompany.yourApplication;

import com.platform.api.*;
import com.platform.beans.*;
import com.platform.api.utility.*;  // Encoding class

public class RecordAdditions 
{
   /**
    * Add a byte[] of document/image content to the current record as an attachment.
    */
   public void addAttachment(Parameters p, String fileName, byte[] bytes) throws Exception
   {
      // Get the ID of the current record
      String objectID = p.get("object_id");
      String recordID = p.get("id"); 
      String relatedTo = objectID + ":" + recordID; //ex: "cases:99999999"

      Base64Codec base64 = new Base64Codec();
      String encodedString = base64.encodeToString(bytes); 
      PlatformFileBean file = new PlatformFileBean(fileName, encodedString); 

      // Add the file as an attachment to the current record
      Parameters params = Functions.getParametersInstance();
      params.add("title", fileName);
      params.add("file_field", file);
      params.add("related_to", relatedTo);
      Result result = Functions.addRecord("attachments", params); 
   }

   /**
    * Test the addAttachment() method. Invoke this method from a Rule 
    * and inspect the results in the GUI. (This method can be executed
    * from a Rule, because it has the required signature.)
    */
   public void addAttachmentTest(Parameters p) throws Exception
   {
      String fileName = "testfile.txt";
      String fileContent = "This is a test file.";
      byte[] bytes = fileContent.getBytes();
      addAttachment(p, fileName, bytes);
   }
}
6.2.3 Process Attachments

Use this method to process attachments for the current record:

   /**
    * Process the current record's attachments. To test this method, invoke
    * it from an update Rule and inspect the Debug Log.
    */
   public void processAttachments(Parameters p) throws Exception
   {
      // Get the ID of the current record
      String objectID = p.get("object_id");
      String recordID = p.get("id");
      String relatedTo = objectID + ":" + recordID;     //ex: "cases:99999999"

      Result searchResult = 
          Functions.searchRecords("attachments", "*",   //all fields
               "related_to equals '" +relatedTo+ "'");  //related_to equals 'cases:9999999'
      int searchCode = searchResult.getCode();
      if (searchCode < 0)
      {
         String msg = "Error searching Attachments";
         Logger.info(msg + ":\n" + searchResult.getMessage(), "Search");
         Functions.throwError(msg);                     
      }
      else if (searchCode == 0)
      {
         Logger.info("No attachments found for "+relatedTo, "Search");
      }
      else
      {
         // Records were found (searchCode equals the number)
         Logger.info(searchCode+ " attachments found for " + relatedTo, "Search");
         ParametersIterator pit = searchResult.getIterator();
         while(pit.hasNext())
         {
            // Log the search result. Optionally test for a specific record.
            Parameters attachParams = pit.next();             
            String title = attachParams.get("title");
            Logger.info("Attachment title: "+title, "Search");
            if (title.equals("...test for a specific attachment...")) {
               // Operate on a specific attachment here
               break;
            }

            // Work with the search result. 
            // Here, we assume it is text and put it into the log/
            PlatformFileBean file = attachParams.getPlatformFileBean("file_field");
            String encodedContent = file.getEncodedFileContent();
            byte[] bytes = base64.decode(encodedContent); 
            String text = new String(bytes);
            Logger.info("Attachment data: " + text, "Search");
            // ...
         }
      }
   }

7 Set Case Status According to Related Tasks

This code sample sets the Case state to Resolved or Pending, depending on the status of its related Tasks. Comments in the code explain its behavior. The expectation is that code is invoked by a Rule that is triggered when a Task's status changes.

package com.platform.acme.demo.TaskProcessing;

import com.platform.api.*;
import static com.platform.api.Functions.*;

public class TaskProcessing
{
  public void log(String msg) throws Exception {
    // Put the message in the log.
    Logger.info(msg, "TaskProcessing");
  }
       
  public void debug(String msg) throws Exception {
    // Display message in a popup or at top of page, depending on context, and log it.
    Functions.showMessage(msg);
    log(msg);
  }

  /*
   * Call this code whenever a task changes status.
   *   - In the Tasks object, create an Event Rule that runs when a record is updated
   *   - Run the Rule when this expression is true: ISCHANGED(status)
   * Mark the case the task is attached to as "Resolved" if all tasks are completed.
   * Mark it "Pending" if all uncompleted tasks are pending (i.e. waiting on someone else).
   * NOTES: 
   *  1. This code assumes that "Waiting" has been added to the list of "Case Task Status" values
   *  2. Only processes that have started generate tasks, so all relevant processes should
   *     be launched manually or by rule before any tasks are completed. Otherwise, the case
   *     status may be prematurely set to "Resolved".
   */
  public void setCaseStatus(Parameters input_params) throws Exception
  {
    try {
      // Global Picklist "Case Status" defines the values for cases.
      String caseNew = "1";
      String caseOpen = "2";
      String casePending = "3";
      String caseResolved = "4";
      String caseClosed = "5";
      String caseReopened = "6";
      
      // Global Picklist "Case Task Status" defines the values for tasks.
      String taskOpen = "1";
      String taskCompleted = "2"; 
      String taskApproved = "3";
      String taskRejected = "4";
      String taskWaiting = "5";

      // Default case status depends on the current task status.
      String taskID = input_params.get("id");
      String taskStatus = input_params.get("status");
      String caseFlag = "";
      if (taskStatus == taskCompleted) { 
         caseFlag = caseResolved;
      } else if (taskStatus == taskWaiting) {
         caseFlag = casePending;
      } else {
         // TODO: If case status is currently Resolved or Closed, it should change to Reopened.
         //       (We ignore that scenario here. Fix it in production.)
         return;
      }
           
      // Format of the related_to field is object_id:record_id
      String relatedCase = input_params.get("related_to");
      String caseObject = relatedCase.substring(0, relatedCase.indexOf(":"));
      String caseID = relatedCase.substring(relatedCase.indexOf(":")+1, relatedCase.length());
         //debug("Case Obj: "+ caseObject +", ID: "+ caseID +",  Task: "+ taskID);

      // Get all task records related to the same case. Return the status field for each record.
      Result result = Functions.searchRecords("tasks","status,id","related_to = '"+relatedCase+"'");
      int resultCode = result.getCode();
      if (result.getCode() < 0) {
         // Display message to user and add an entry to debug log.
         String msg = "Task search failed. Code="+result.getCode();
         log(msg);
         Functions.throwError(msg + "\n" + result.getMessage() );      
      }  
      
      // Search returned a list of tasks. Examine each of them.
      ParametersIterator iterator = result.getIterator();
      while (iterator.hasNext())
      {
         Parameters params = iterator.next();
         if (taskID == params.get("id")) continue;
            // The input record is included in the search results. Ignore it. 

         String status = params.get("status");
         if ( status.equals(taskOpen) ) 
         {
            // If there is an open task, case status does not change.
            caseFlag = "";
            break;
         }
         else if ( status.equals(taskWaiting) )
         {
            // If all remaining tasks are completed or waiting, 
            // this setting will stand.
            caseFlag == casePending;               
         }
         else
         {
            // Ignore completed/approved/rejected tasks.           
            continue;
         }
      }
      
      if (caseFlag != "")
      {
         // All tasks are either waiting or completed. 
         // Set the case status accordingly
         Parameters fcn_params = Functions.getParametersInstance();
         fcn_params.add("status", caseFlag);
         Functions.updateRecord(caseObject, caseID, fcn_params);
      }
      
    } catch (Exception e) {
       // Catch surprises, display a popup, and put them in the log.
       String msg = "Unexpected exception";
       log(msg + ":\n" + e.getMessage() );
       Functions.throwError(msg + " - see debug log");      
    }
  }
}

8 Add and Access Notes

When you add a Task or Attachment, an entry is added to the record history. But when you add a Private Note, you add it directly to the History object. There, the category field (value ="29") distinguishes Private Notes from other entries in the history, including email messages and other activities.

To add a Private Note to the history, therefore, you must be sure to set that field value. Similarly, it must be part of the search criteria when retrieving history records, to eliminate all but the Private Note records in that object.

Learn more: Adding and Accessing Related System Object Records

8.1 Add a Note

This example adds a note with special handling instructions to a claim record. In this scenario, there is a Rule that is triggered by adding a record when certain conditions are met (for example, when the record pertains to a particular customer, or when a particular flag has been set). The Rule then invokes the method defined here.

To add the note, a record is added to the History object. The record is identified as a Private Note and set up so it references the appropriate Claim.

import com.platform.api.*;
import java.util.*;

public class RecordAdditions
{
   public void addNote(Parameters p) throws Exception
   {      
      // Get the ID of the current record
      String objectID = p.get("object_id");
      String recordID = p.get("id");

      // Formulate the Lookup-target for the note.
      // (A note can be added to a record in any object. So the Lookup target
      //  is a combination of an object identifier and a record identifier.)
      String relatedTo = objectID + ":" + recordID; //ex: "cases:99999999"

      // HTML tags can be included in the text
      String noteText = "<b>Special handling instructions:</b><br>...notes...";

      // Create the Note parameters
      Parameters noteParams = Functions.getParametersInstance();
      noteParams.add("description", noteText);
      noteParams.add("related_to", relatedTo);
      noteParams.add("category", "29");
      
      // Add the Note
      // result code is zero on success, -1 on failure
      Result r = Functions.addRecord("history", noteParams);
      if (r.getCode() < 0) {
         // Display message to user, add an entry to debug log, and
         // roll back the current transaction (no changes are committed).
         Functions.throwError("Failed:\n  " + r.getMessage() );      
      }        
   } 
}

Notes:

  • The string "29" specifies a Private Note. The values are defined in a global picklist.
    To see the full list, go to GearIcon.png > Customization > Global Picklists > History Category.
  • HTML tags included in the note text are interpreted when displayed. So this:
<b>Special handling instructions:</b><br>...notes...
displays like this:
Special handling instructions:
...notes....

8.2 Access Notes

For Tasks and Attachments, you search the appropriate object looking for records that have a related_to value equal to the ID of the record you're interested in. To access notes, you search the History object, and you add one additional filter to restrict to the search to records who have the value "29" in the record's category field. That's the value that identifies the record as a Private Note, rather than an email or the recording of some action that was taken on the record. (To see the list of possible values, go to GearIcon.png > Global Picklists > History Category.)

   /**
    * Process the notes attached to the current record. To test this
    * method, invoke it from an update Rule and inspect the Debug Log.
    */
   public void processNotes(Parameters p) throws Exception
   {
      // Get the ID of the current record
      String objectID = p.get("object_id");
      String recordID = p.get("id");
      String relatedTo = objectID + ":" + recordID;     //ex: "cases:99999999"

      Result searchResult = 
          Functions.searchRecords("history", "*",   //all fields
              "related_to equals '" +relatedTo+ "' AND category='29'");  
             //related_to equals 'cases:9999999' AND category = '29'
      int searchCode = searchResult.getCode();
      if (searchCode < 0)
      {
         String msg = "Error searching History";
         Logger.info(msg + ":\n" + searchResult.getMessage(), "Search");
         Functions.throwError(msg);                     
      }
      else if (searchCode == 0)
      {
         Logger.info("No notes found for "+relatedTo, "Search");
      }
      else
      {
         // Records were found (searchCode equals the number)
         Logger.info(searchCode+ " notes found for " + relatedTo, "Search");
         ParametersIterator pit = searchResult.getIterator();
         while(pit.hasNext())
         {
            // Log the results. Do other processing as needed
            Parameters noteParams = pit.next();
            String text = noteParams.get("description");
            Logger.info("Note: " + text, "Search");
            if (text.contains("...test for a specific note...")) {
               // Operate on a specific note here
               break;
            }
         }
      }
   }

9 Send an Email Message

Start by getting an email address. Here, we'll get it from the record for a Contact with a known ID ("John Smith"):

Result getContactRecordResult = Functions.getRecord("CONTACT",
    "first_name,last_name,email", contactRecID);
Logger.info("Sending Email to contact of Hello World Account..with email address:"
                +(getContactRecordResult.getParameters()).get("email"), "SendMail");

Then use the sendEmailUsingTemplate API to send a message to that person. In order, the parameters are:

  • Related object identifier
  • Identifier of the related record which is contactRecID that was retrieved previously
  • To list which is set by making a nested call to Parameters.get to get the email address of the contact that was just retrieved
  • Cc list which is set by making a nested call to Parameters.get to get the email address of the contact that was just retrieved
  • Description (a text string)
  • Identifier of an Email Template. The template is evaluated at run time, and values are substituted for template variables. The result becomes the body of the message.
  • List of Document Templates identifiers to send as attachments (not used in this example).
Note:
Do not choose a template based on a JSP page for use as an attachment.
Learn more: JSP Attachment Deprecation
  • List of document identifiers in your documents folder to send as attachments (not used in this example)
Result sendEmailResult = Functions.sendEmailUsingTemplate("CONTACT", contactRecID,
          (getContactRecordResult.getParameters()).get("email"),
          (getContactRecordResult.getParameters()).get("email"),
          "Sending Email to Hello World's Primary Contact - John Smith",
          "1869974057twn1678149854", "", ""); 
Logger.info("Done sending mail from Hello World account's Contact John Smith", 
            "SendMail");
if(sendEmailResult.getCode() != 0)
{   
    Logger.info("Error in sending email!" + sendEmailResult.getMessage(), 
                "SendMail"); 
}
else
{
    Logger.info("Success on sendEmail, check inbox : " + sendEmailResult.getMessage(), 
                "SendMail");
}

Like other Java API calls such as addRecord and searchRecords, sendEmailUsingTemplate returns a Result object whose methods you can call to check the result of the call. When sendEmailUsingTemplate succeeds, Result.getCode returns zero (0).

10 Get the Name of an Object

This small routine is useful for general-purpose code that could be running on a variety of objects. Given the object's ID, it returns the singular version of the object name. It's used later, in the sample code that sends notification messages when a record has been updated.

   /*
    * 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);
         // For plural object name, use getDisplayTitle().
         // (Note the mispelling of "singular" in the API.)
         return mdb.getSingluarDisplayTitle();
      }
      catch (Exception e) {
         String msg = e.getMessage() + "\n methodName(): "+e.getClass().getName();
         Functions.throwError(msg);
         return "";  // Required to keep the compiler happy
      }
   }

11 Invoke Web Services Programmatically

These examples show:

  • How to invoke a Web Service from Java Code
  • How to call that code from a JSP page
  • How to add a button to a form, and invoke that code (or any other) when the button is clicked.

11.1 Invoke a Web Service from Java Code

This code sample invokes a Web Service directly from a Java class. The Web Service used in this example is one of the platform's REST APIs. In this case, it's the API to get a record. That API is called as though it is an external service, using the same techniques you will employ for any services you connect to.

In this case:

  • The method is invoked by a process, macro, or Rule that is attached to some record.
  • We assume that the record contains an orderNum field that identifies an existing order.
  • The Web Service is used to retrieve the order date and invoice number for that order.
  • In the examples that follow, the orderNum is entered into a record form.
  • If the orderNum is "1" or "2", we return test values, turning the class into a "mock stub" for the proxy. For "3", we assume that an error occurred, and return no data.
  • We assume that the service address is http://orders.com/order, and that the number of the order to retrieve is passed as part of the URL: http://orders.com/order/{orderNum}. That strategy keeps the sample code simple.
  • The structure of the returned data looks like this:
<ORDER>
    <ORDERNUM>...</ORDERNUM>
    <ORDERDATE>...</ORDERDATE>
    <INVOICENUMBER>...</INVOICENUMBER>
</ORDER>
  • We assume that the Java orders package contains the order-processing classes.
  • In that package, we create a class called WebServiceProxy, to manage connections to the external service.
  • That class defines the getOrderDetails method, which uses the platform's HttpConnection Class to connect to the external service.
  • The returned data structure is parsed using Java's XPathFactory.
Invoking the Service

The method defined in this class invokes the web service:

package com.platform.yourCompany.orders;

import com.platform.api.*;
import java.util.*;
import java.io.StringReader;
import javax.xml.xpath.*;
import org.xml.sax.*;

import static com.platform.api.Functions.*;

public class WebServiceProxy
{
  public static final String WS_URL = "http://orders.com/order/";  
  /*----------------------------
    Output from the external Web Service:
    <ORDER>
        <ORDERNUM>...</ORDERNUM>
        <ORDERDATE>...</ORDERDATE>
        <INVOICENUMBER>...</INVOICENUMBER>
    </ORDER>
   -----------------------------*/

  //Define paths used for parsing
  public static final String ORDERDATE_ELEMENT = "ORDER/ORDERDATE";
  public static final String INVOICENUMBER_ELEMENT = "/ORDER/INVOICENUMBER";

  public Map getOrderDetails(Map params) throws Exception
  {
     Map<String,String> order = new HashMap<String,String>();

     String orderNum = (String) params.get("orderNum");
     if(orderNum != null && orderNum != "" )
     {
        /** Return test values **/
        if (orderNum.equals("1")) {
           order.put("orderDate", "11 Jan 2011");
           order.put("invoiceNumber", "1111111111");
           return order;
        } else if (orderNum.equals("2")) {
           order.put("orderDate", "22 Feb 2022");
           order.put("invoiceNumber", "2222222222");
           return order;
        } else if (orderNum.equals("3")) {
           // On error, return an empty data set
          return order;
        }

        /** Execute the Web Service **/
        String orderDate = "";
        String invoiceNumber = "";
      
        HttpConnection con = 
           new HttpConnection(CONSTANTS.HTTP.METHOD.GET, WS_URL+orderNum);
        int code= con.execute(); 
        String response = con.getResponse();
        try
        {
           // Parse the response to retrieve order date and invoice#.
           XPathFactory factory = XPathFactory.newInstance();
           XPath xPath = factory.newXPath();
           
           orderDate = xPath.evaluate(
             ORDERDATE_ELEMENT, 
             new InputSource(new StringReader(response))
             );
           invoiceNumber = xPath.evaluate(
             INVOICENUMBER_ELEMENT,
             new InputSource(new StringReader(response))
             ); 
           
           order.put("orderDate", orderDate);
           order.put("invoiceNumber", invoiceNumber);
        }
        catch(XPathExpressionException xpe) 
        {
           Logger.info(xpe.getCause(), "HttpConnection");
           showMessage("Parse of Web Service data failed. Check debug log.");
        }
     }
     return order;                         
  }     
}

11.2 Invoke a Web Service from a JSP Page

This JSP page displays a simple form that uses the Web Service Proxy defined above. When the user clicks [Search], retrieved data is displayed:

WebServiceJspFormExample.png

Notes:

  • JavaScript embedded in the page uses the platform's REST exec API to invoke the getOrderDetails() method.
  • The Input XML sent to that method looks like this:
<platform>
   <execClass>
      <clazz>com.platform.yourCompany.orders.WebServiceProxy</clazz>
      <method>getOrderDetails</method>
      <orderNum>...</orderNum>
   </execClass>
</platform>
  • The jQuery .ajax method is used to make the REST request and to process the returned data.
  • The structure of the returned data is defined by the platform's REST exec API. (It's in JSON format, at the request of the AJAX call). The part of the structure we are concerned with here looks like this:
{"platform": {
  "execClass": {
    ...
    "orderNum": "..."
    "orderDate": "...",
    "invoiceNumber": "...",
  },
  ...
}}
JSP Page
<!--User enters record-selection data and clicks [Search].-->
<table>
   <tr>
      <td> Order#: </td> 
      <td><input type="text" id="orderNum" name="orderNum" size=10/></td>
      <td><input type="button" id="submit" name="submit" value="Search"/></td>
   </tr>
</table>
<br></br><br></br>

<!--Returned data appears here. (With no borders, table starts out invisible.) -->
<table id="result">
</table>

<!--This script attaches an anonymous function to the form button.
    The function invokes the method in the WebServiceProxy class. -->
<script type="text/javascript">
  $("#submit").click( function(){

    var orderNum= $('#orderNum').val();
    var inputXML = "<platform><execClass>"
                 + "<clazz>com.platform.yourCompany.orders.WebServiceProxy</clazz>"
                 + "<method>getOrderDetails</method>"
                 + "<orderNum>" + orderNum + "</orderNum>"
                 + "</execClass></platform>"; 

   try {
    /* jQuery AJAX call to invoke REST API and process returned data */
    $.ajax({
      type: 'POST',
      url: '/networking/rest/class/operation/exec',
      contentType: 'application/xml',
      Accept:'application/json',
      data: inputXML,  
      dataType: 'json',
      success: function(data) {
        /* Clear the results table. (jQuery method that removes */
        /* element content, but not the element or its attributes.) */
        $('#result').empty();  
     
        /* Size the table and make it visible */
        $('#result').attr('border', '1');
        $('#result').attr('style', 'width:300px');
        
        var orderNum = data.platform.execClass.orderNum;
        if (orderNum) {
          var orderDate = data.platform.execClass.orderDate;
          var invoice = data.platform.execClass.invoiceNumber;

          /* Add header rows and table data */
          $('#result').append(
            '<tr><td>Order# </td>'
           +'<td>Order Date</td>'
           +'<td>Invoice Number</td></tr>'
          );
          $('#result').append(
            '<tr><td>'+orderNum+'</td>'
           +'<td>'+orderDate+'</td>'
           +'<td>'+invoice+'</td></tr>'
          );     	
        } else {
          /* Call succeeded, but returned no data */
          $('#result').append('<tr><td>No data found for that order.</td></tr>');
        }
      } /* End of success function */

    }); /*End of ajax call*/

   } catch (e) {
     // JavaScript errors aren't recorded in the debug log.
     // Make sure we see them.
     alert(e);
   };

  }); /* End of processing function */          
</script>

11.3 Invoke a Web Service from a Form Button

For information about invoking a web service from a form button, see the tech community article Invoking a Web Service Using Form Script.

12 Search and Update

To update a record, this example follows these steps:

In this example, the Contact record that was created in the Add a Contact Recordexample is updated by associating it to the Account record created in the Add an Account Record example.

12.1 Search for Account and Contact Records

Follow these general steps to search for records:

  1. Call searchRecords
  2. Check the result
  3. Process the returned records

First, create a variable to hold the record identifier.

String contactRecID = null;

When a record is added database, the platform assigns it a unique record identifier. The value of a record identifier is opaque and is of no interest to the typical user. However, several Java API calls use the recordID parameter.

To get a record identifier, request the record_id field when making a searchRecords call. Find more information about arguments in searchRecords.

This example calls searchRecords for the CONTACT object. In order, the parameters to searchRecords in this example are:

  • name of the object
  • names of the fields to retrieve which includes record_id
  • filter string (in this case, the filter string specifies that last_name contains "Smith" (the same as for the contact record previously created)
Result contactSearchResult = Functions.searchRecords("CONTACT", 
"record_id,first_name,last_name,email", "last_name contains 'Smith'");

Learn more: JAVA API:Filter Expressions in JAVA APIs

12.1.1 Check the Result

The result code for searchRecords works a little differently than for addRecord.

Debug Example for searchRecords

This example checks the return code by calling Result.getCode, and takes some action, based on the return code:

  • Return codes:
  • less then zero (<0); the call is not successful
  • equal to zero (=0); no records found
  • greater than zero (> 0); successful and the value of the return code is the number of records returned

This code sample assigns the result code to a variable, which then defines the following action:

  • If not successful, an error dialog is displayed
  • If the code is zero, then a message is written to the debug log
  • If successful, then the code is the number of records returned
int contactSearchCode = contactSearchResult.getCode();

if(contactSearchCode < 0)
{
    String msg = "Error searching CONTACT object";
    Logger.info(msg + ":\n" + contactSearchResult.getMessage(), "Search");
    Functions.throwError(msg + ".");                     
}
else if(contactSearchCode == 0)
{
    Logger.info("Contact:No records found using the search function in CONTACT.",
                "Search");
}
else
{
    // If the code "falls through" here, it means records were returned 
    // that need to be processed; use the value of the return code in 
    // the next section ...
}
12.1.2 Process the Returned Records

In earlier examples, the Parameters object was shown to hold name-value pairs for fields for when the addRecord call is made.

The searchRecords call uses the Parameters object, but in a different way - the searchRecords call returns the set of fields that were requested for each record that matches the search criteria as an array of Parameters objects.

To process each Parameters object in the search results, create an instance of ParametersIterator and then specify ParametersIterator.hasNext as the expression of a while loop.

The example resumes with the code "falling through" when checking the result code for searchRecords, meaning that the result code is greater than zero, which is the number of records returned.

The following code sample:

  • Creates an instance of ParametersIterator by calling Result.getIterator
  • Sets up a while loop with ParametersIterator.hasNext as the expression to evaluate at each iteration; Within the while loop, the code will:
  • Creates an instance of Parameters by calling ParametersIterator.next
  • Calls Parameters.get, specifying record_id as the parameter to get the value of the record identifier field which is assigned to a variable named contactRecordId
  • Makes a Java API getRecord call with these parameters:
  • Object identifier
  • List of fields to get
  • The record identifier which is contactRecordId in this case
  • Makes a nested call to Result.getParameters to get the value of the first_name field; Checks if it is equal to "John". If so, the contactRecordId is assigned to the variable named contactRecID and the code breaks out of the loop; The contactRecID variable is used later when calling updateRecord, and sendEmailUsingTemplate
{
   // "Falling through" here means records were returned
   Logger.info("Search for John Smith: Number of records found using 
       search function in Contact:" + contactSearchCode, "Search");
   ParametersIterator contactIterator = contactSearchResult.getIterator();
   while(contactIterator.hasNext())
   {
      Parameters contactParams = contactIterator.next();
      String contactRecordId = contactParams.get("record_id");  
      Result getContactRecordResult = Functions.getRecord("CONTACT", 
         first_name,last_name,flag_primary_contact,description", contactRecordId);
      String firstName = (getContactRecordResult.getParameters()).get("first_name");
      Logger.info("Result from getRecord:\n" + getContactRecordResult.getMessage(),
                      "Search");
      Logger.info("Return code from getRecord:" + getContactRecordResult.getCode(),
                      "Search");
      Logger.info("First name retrieved : " + firstName, "Search");
      if(firstName.equals("John")) {
         contactRecID = contactRecordId; 
         break;
      }
   }
}

12.2 Relate the Account Record to the Contact Record

As objects are manipulated in the platform (added, updated, deleted), associations between objects must be maintained.

For example, when a Contact is created, it must be related to an Account:

  • In the platform user interface, objects are related using the Lookup field as described in Relating Objects Using Lookups
  • In the Java API, objects are related in code by searching for an object and then using a field value that identifies the object to set a field in a related object

Define the Relationship between Objects In this example, the code searches for an Account and then uses the Account Number to set a field in the contact. When relating a record in the Account object to a record in the Contact object, these three key-value pairs are required to define the relationship:

  • reference_type
  • related_to_id
  • related_to_name

The following code searches for an account where the name field contains the text string "Hello". The Account record is created in Add an Account Record. The code follows the same model for a Update Records: call searchRecords, check the result code, and loop to process the returned records.

The following code sample:

  • Sets up a while loop with ParametersIterator.hasNext as the expression to evaluate at each iteration. Within the while loop, the code creates two instances of Parameters:
  • The first instance is created by calling getParametersInstance and is named updateContactParams; This instance is used to update the contact record later
  • The second instance is created by calling ParametersIterator.next and is called accountParams
  • Calls accountParams.get, specifying record_id as the parameter to get the value of the record identifier field, which is assigned to a variable named accountRecordId.
  • Makes a Java API getRecord call using accountRecordId as a parameter
  • Makes a nested call to Result.getParameters to get the value of the name field
  • Checks if the name is equal to "Hello World"; If it is, the code adds some name-value pairs to updateContactParams
  • Assigns the accountRecordId retrieved in the previous account search to related_to_id (This is how record identifiers are used to relate one object to another in the Java API)
  • Makes an updateRecord call, specifying contactRecID and updateContactParams as parameters, which updated the Contact record.
  • Checks the result in the final lines of code in the same way that previous examples did.
Result accountSearchResult = searchRecords ("ACCOUNT", "record_id,name,number,primary_contact_id",
    "name contains 'Hello'");
int accountResultCode = accountSearchResult.getCode();
Logger.info(" Account:Result message for search ALL Account Records is "
            + accountSearchResult.getMessage(), "Search");
Logger.info(" Account:Result code for search ALL Record is " + accountResultCode,
            "Search");
if(accountResultCode < 0)
{
    String msg = "Account could not be retrieved";
    Logger.info(msg + ":\n" + accountSearchResult.getMessage(), "Search");
    Functions.throwError(msg + ".");
}
else if(accountResultCode == 0)
{
    Logger.info("Account:No records found using the search function in ACCOUNT.", 
                "Search");
}
else
{
    Logger.info(
      "Search for Hello World: Number of records found using search function in Account:"
      + accountResultCode, "Search");
    ParametersIterator accountIterator = accountSearchResult.getIterator();
    while(accountIterator.hasNext())
    {
        Parameters updateContactParams = Functions.getParametersInstance();
        Parameters accountParams = accountIterator.next();
        String accountRecordId = accountParams.get("record_id");	      
        Result getAccountRecordResult = 
            Functions.getRecord("ACCOUNT", "name,description", accountRecordId);
        String accountName = (getAccountRecordResult.getParameters()).get("name");
        Logger.info("Result from getRecord on ACCOUNT:\n" 
                    + getAccountRecordResult.getMessage(), "Search");
        Logger.info("Return code from getRecord on ACCOUNT:" 
                   + getAccountRecordResult.getCode(), "Search");
        if(accountName.equals("Hello World")) 
        {
            Logger.info("Account record ID:" + accountRecordId, "Update");
            updateContactParams.add("description", "Updating Contact");
            updateContactParams.add("reference_type", "Account");
            updateContactParams.add("related_to_id", accountRecordId);
            updateContactParams.add("related_to_name", accountName);
            Logger.info("Updating contact record with id:" + contactRecID, "Update");
            Result contactUpdateResult = 
                Functions.updateRecord("CONTACT", contactRecID, updateContactParams);
            if(contactUpdateResult.getCode() == 0) 
            {
               Logger.info("Account:Contact of the Account record updated successfully\n"
                   + contactUpdateResult.getMessage(), "Update");
            }
            else
            {
                String msg = "Error updating contact";
                Logger.info(msg + ":\n" + contactUpdateResult.getMessage(), "Update");
                Functions.throwError(msg + ".");
            }  
        }           
    }
}

13 Send Notification Messages to Followers

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:

13.1 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.

13.2 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.)

13.3 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>

13.4 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.