Wednesday, September 1, 2010

Send reminder emails from SharePoint Workflow for overdue tasks

The Problem

A requirement of the workflow I have been working on is to send reminder emails when a task is approaching the due date or overdue. The workflow in question has numerous replicator activities with an execution type of Parallel. The typical, at least all the examples I could find, would be to use a ListenerActivity with one leg waiting on an OnTaskChanged event and the other leg contains a delay activity followed by the necessary activities to send out an email. My problem was, when the delay activity completes and that particular leg kicks off, it is the OWSTimer running those events. I don’t have SPContext and I cannot tell which instance of my replicator sequence is running. I don’t have a reference back to my actual workflow task and as a result, I have no way of knowing if a task is completed or overdue etc. I was really struggling trying to figure out how I was going to accomplish this requirement. Whatever I did, I was going to have to repeat it for several different replicators in my workflow.

The Solution

My solution was to basically develop a “multi-threaded” workflow. To do this I used a ConditionedActivityGroup workflow object. Below is the structure of one of the Sequences in my CAG.

I’m going to explain what each of the activities in this leg of the CAG are doing but I have also included the code for each of them below.

The CAG itself has a Declarative Rule Condition set for its until condition that is checking a private bool called _workflowCompleted. This bool is initiated to false and only set to true when the main workflow sequence is complete.

The first sequence that is not showing is my main workflow. This is where all my tasks are created and completed etc. This activity in the CAG has no when condition and is therefore only executed once. SharePoint is responsible for hydrating and dehydrating this workflow as SharePoint events occur.

The second sequence that is shown below is the sequence that checks for overdue tasks and sends emails as necessary. The when condition for this activity is also checking !_workflowCompleted using a Declarative Rule Condition. Adding the when condition causes this leg of the CAG to run continuously until the _workflowCompleted bool is set to true.

The DelayActivity is initialized with a time span defined in a settings file but could be hard coded. We are going to check for overdue tasks daily.

The GetWorkflowTasks activity gets current tasks for the workflow and assigns the collection of tasks to a local variable.

The while loop iterates the tasks collection.

The IfElse block checks to see if the task is not completed and has a (due date – some adjustment) later than today’s date.

If the task is overdue and not completed we send an email to the assignee to remind them they have a task due. The correlation toke for the SendEmail activity was set to the workflow correlation token.

The increment index simply increments a local variable we use to pull the correct value from the task collection. When the local index variable exceeds the task collection count we exit the loop.

Finally, the clearWorkflowCol CodeActivity sets the local variable that contains the workflow tasks collection to null because SPWorkflowTaskCollection is not a serializable object.

Code for all of the activities above

private SPWorkflowTaskCollection workflowTasks = null;
private int _currentIndex = 0;

private void EmailDelay_InitializeTimeoutDuration(object sender, EventArgs e)
{
    EmailDelay.TimeoutDuration = Settings.Default.EmailThreadDelay;
}

private void getWorkflowTasks_ExecuteCode(object sender, EventArgs e)
{
    workflowTasks = workflowProperties.Workflow.Tasks;
    _currentIndex = 0;

}

private void iterateEmails_Cond(object sender, ConditionalEventArgs e)
{
    if (_currentIndex < workflowTasks.Count)
        e.Result = true;
}

private void sendReminderEmail_MethodInvoking(object sender, EventArgs e)
{
    ((SendEmail)sender).Body = GetEmail(
                                workflowTasks[_currentIndex][SPBuiltInFieldId.Title].ToString(),
                                workflowTasks[_currentIndex]["Due Date"].ToString());
    ((SendEmail)sender).To = workflowProperties.Web.AllUsers.GetByID(Convert.ToInt32(workflowTasks[_currentIndex]["Assigned To"].ToString().Split(";".ToCharArray())[0])).Email;
    ((SendEmail)sender).Subject = string.Format("Reminder - {0}", workflowTasks[_currentIndex][SPBuiltInFieldId.Title].ToString());
  
}

private void ifEmailOverdue_Cond(object sender, ConditionalEventArgs e)
{
    if (workflowTasks[_currentIndex]["Due Date"] != null
        && DateTime.Today >= Convert.ToDateTime(workflowTasks[_currentIndex]["Due Date"].ToString()) - Settings.Default.ReminderEmailStart
        && workflowTasks[_currentIndex][SPBuiltInFieldId.TaskStatus].ToString().ToLower() != "completed")
        e.Result = true;
}

private void incrementIndex_ExecuteCode(object sender, EventArgs e)
{
    _currentIndex++;
}

private void clearWorkflowColl_ExecuteCode(object sender, EventArgs e)
{
    workflowTasks = null;
}

Update InfoPath field value from SharePoint Workflow

The Problem

While developing a SharePoint workflow for an InfoPath form library I ran in to a couple of issues with writing values back to the submitted InfoPath form.  The form data that is stored in the form library is pure xml so it seemed reasonable to me that it was simply a matter of finding the node and updating the value.  Turns out, if you don’t take a couple of very specific steps while doing this you will complete hose up the InfoPath form schema and never be able to open the InfoPath form in Web enabled mode again.  The odd thing is you if you do mess up the schema you can still open it with InfoPath filler but we wanted to keep everything web based for this particular effort. 

The Solution

I wrote a few methods to update the InfoPath form correctly.  The primary method that we call from our code is UpdateInfoPathField. 

UpdateInfoPathField Method

public void UpdateInfoPathField(string nodeXPath, string nodeValue)
{

    SPFile file = workflowProperties.Item.File;
    MemoryStream inputStream = new MemoryStream(file.OpenBinary());
    XmlDocument ipDoc = new XmlDocument();
    ipDoc.Load(inputStream);

    // This was the first trick.  When you are reading the InfoPath form into an XMLDocument, it is very important to specify PreserveWhiteSpace = true
    ipDoc.PreserveWhitespace = true;

    inputStream.Close();
    inputStream.Dispose();
    XPathNavigator ipNav = ipDoc.CreateNavigator();
    
    // The BuildNamespaceManager dynamically builds a namespacemanager to use in XPathNavigator select statements.  
    XmlNamespaceManager nsManager = BuildNamespaceManager(ipNav);

    XPathNavigator nodeNav = ipNav.SelectSingleNode(nodeXPath, nsManager);
    
    // This is the second trick.  In my case the field I was populating is initially null when the form is created.  You have to remove the nil attribute or you will receive terrible errors.
    DeleteNil(nodeNav);

    nodeNav.SetValue(nodeValue);

    MemoryStream outputStream = new MemoryStream();
    ipDoc.Save(outputStream);
    file.SaveBinary(outputStream.ToArray());
    outputStream.Close();
    outputStream.Dispose();
}

BuildnamespaceManager Method

private XmlNamespaceManager BuildNamespaceManager(XPathNavigator ipFormNav)
{
    XmlNamespaceManager nsManager = new XmlNamespaceManager(new NameTable());
    ipFormNav.MoveToFollowing(XPathNodeType.Element);
    foreach (KeyValuePair<string, string> ns in ipFormNav.GetNamespacesInScope(XmlNamespaceScope.All))
    {
        if (ns.Key == string.Empty)
            nsManager.AddNamespace("def", ns.Value);
        else
            nsManager.AddNamespace(ns.Key, ns.Value);
    }
    return nsManager;
}

DeleteNil Method

public void DeleteNil(XPathNavigator node)
{
    if (node.MoveToAttribute("nil", "http://www.w3.org/2001/XMLSchema-instance"))
        node.DeleteSelf();
}