Custom Subscriptions

Brightspot comes with subscriptions for common events such as publishing an item or mentions in a conversation. You can define custom subscriptions for other events that occur in your version of Brightspot. These events include actions within Brightspot itself or events occurring in websites published with Brightspot, such as a visitor logging in. As long as you are able to detect the events via code, you can hook them into the notification system and deliver corresponding messages to recipients.

Step 1: Declaring event context

Before creating your own subscription class, you must first determine the event’s context—what type of object will be used to capture the data about the event. The context is used downstream by the message formatters to actually generate the notification. The subscription class must extend from Recordable so that the context can be serialized and saved in the database. Sometimes you can leverage an existing content type for this, but more often you will want to define your own custom type so that you can capture exactly the right data.

For an example of declaring a context for capturing login or logout events, see the snippet Class for storing the context of a login-logout event.

Step 2: Declaring subscriptions

A subscription class extends from the abstract class ToolSubscription<C>, where C is the type of context declared in Step 1: Declaring event context. Your class should contain any fields or methods relevant to configuring how and when a notification is triggered for this subscription. Users will configure their own subscriptions using those fields.

There are four methods inherited from Subscription you should override in your implementation.

abstract class ToolSubscription<C> {

    protected boolean shouldDeliver(Receiver receiver, C context);

    protected boolean overlaps(Subscription<?> other);

    public abstract String toStringFormat(Receiver receiver, C context);

    public abstract String toHtmlFormat(Receiver receiver, C context);
}

The optional method shouldDeliver is your final opportunity to decide whether or not the given Receiver should actually be notified based on the associated Subscription and Context. The method returns true to send the message, and false to not send the message. For example, if a context’s timestamp is outside a certain range, you may not want to send the notification; in that case this method returns false.

The optional method overlaps evaluates if a user configures subscriptions that cause more than one notification for the same event. If the result is true, Brightspot displays a message during the Subscription setup phase that the current configuration results in multiple notifications for the same event or action.

The methods toStringFormat and toHtmlFormat are required for every subscription. They serve as the default message payload for your notification. Both methods are passed the receiver of the notification (usually a ToolUser), and the context (the data about the event or action). Because you have access to the receiver and you have access to any of the subscription’s configuration fields, you can create very personalized notifications. The toStringFormat method should return a plain text representation of notification, and the toHtmlFormat method should return a rich-text markup representation of your notification. (In this context, “rich-text” includes the special tags and attributes that, when detected, will automatically be converted into Brightspot links. For more information about this format, see Formatting Notification Messages.) These methods should never return null; use the method shouldDeliver to determine if one of the methods will return null, and then you can avoid sending the message altogether.

For an example of a custom subscription, see Class for configuring a subscription for login/logout events.

Step 3: Declaring publishers

When an event is triggered, a publisher is created with a reference to the event. The publisher triggers all of the notifications to be sent for a specific type of subscription.

The publisher is also (optionally) responsible for determining the baseline set of receivers that are eligible to receive this type of notification. While this is ultimately controlled by the Subscription#shouldDeliver method, giving the publisher the first say can serve as both a performance optimization as well as a means to make the system more flexible so that subscriptions can be re-used outside of the context of just Brightspot users.

To create a custom publisher, extend the abstract class Publisher<S extends Subscription<C>, C extends Recordable>. Overriding the parent constructor and calling super ensures that the publisher can be initialized with the context. If you do not override any other methods, then Brightspot will query all receiver objects in the database and process their subscriptions to see if a notification should be sent. You can improve and customize this process by overriding one of the following receiver-related methods.

abstract class Publisher<S extends Subscription<C>, C extends Recordable> {

    protected Class<? extends Receiver> getReceiverType();

    protected Query<? extends Receiver> getReceiversQuery();

    protected Iterable<? extends Receiver> getReceivers();

}

The method getReceiverType returns the class representing a receiver. The default implementation returns Receiver.class. You can override this method to return a subclass of this class, which may provide some optimization.

The method getReceiversQuery is the default query for determining the baseline set of receivers. This query returns all receivers of the type returned from getReceiverType. You can override this method to return a query that retrieves an optimized baseline set of receivers. For more information about composing a query, see Querying.

The method getReceivers executes the query returned from getReceiversQuery, returning all of the records matching the query. You can override this method so that the query returns a limited number of matching records. For more information about executing a query, see Querying.

For an example of overriding these methods, see the snippet Class declaring a publisher for subscriptions for login/logout events.

Step 4: Detecting an event

The typical way to detect an event is to trap one of the events in the persistence life cycle, such as beforeSave or afterDelete. As these methods are available in the Record class, you typically use Modification<T> to access those methods.

For an example of detecting an event, see the snippet Class for trapping login/logout event and launching associated publisher.

See also:

Step 5: Invoking the publisher

The final step is to actually detect when your event occurs, instantiate your publisher with the context, and invoke one of the following publish notification methods.

abstract class Publisher<S extends Subscription<C>, C extends Recordable> {

    public final Notification publishNotification();

    public static <S extends Subscription<C>,
            C extends Recordable> Notification
            publishNotification (Class<S> subscriptionClass, C context);

    public final void publishNotificationAsync(Consumer<Notification> callback);

}

The method publishNotification runs synchronously, and returns a notification once all messages have been delivered. The notification acts as a receipt for the previous operation. It contains data such as when the notification was published, the context, and a list of all the receivers along with their matching subscription and delivery-option preferences. Use the method publishNotification if the event was triggered from a background thread already, as it can be a long running operation.

The method publishNotificationAsync is the asynchronous version of publishNotification. Use this method if your event is triggered from a UI thread. The method returns immediately, and queues up the publish operation in a background thread. You can pass to this method a callback Consumer<Notification> that runs when the operation completes.

If there are matching subscriptions and at least one delivery attempt made, then the notification is saved in the database and you can query for it as needed. If the publish action yields no messages to be delivered, the notification object is created but it is not saved in the database.

Code sample—detecting and notifying about user login and logout events

This sample describes how to create a subscription that sends notifications whenever a user logs in or out of Brightspot. The notification includes the user’s name as well as the action (login or logout).

The following snippet declares the context for a user’s login-logout event. The context includes the user and the action the user performed (login or logout). Context classes must extend from Record.

Class for storing the context of a login-logout event
package com.psddev.cms.debug;

import com.psddev.cms.db.ToolUser;
import com.psddev.dari.db.Record;

public class ToolUserAuthEvent extends Record {

    /* The user who performed some action. */
    private ToolUser user;

    /* The action performed by the user. A separate enum lists
       the two actions, login and logout. */
    private ToolUserAuthAction action;

    /* Cache of the user's label in case the reference is deleted in the future. */
    private String userLabel;

    /* A no-arg constructor is required by Dari for Record objects. */
    public ToolUserAuthEvent() {
    }

    /* Creates a new event for the given user and action. */
    ToolUserAuthEvent(ToolUser user, ToolUserAuthAction action) {
        this.user = user;
        this.action = action;
        this.userLabel = user.getLabel();
    }

    /* Gets the user who performed the action. */
    ToolUser getUser() {
        return user;
    }

    /* Gets the action (login or logout) */
    ToolUserAuthAction getAction() {
        return action;
    }

    /* Gets the user's label */
    public String getUserLabel() {
        return user != null ? user.getLabel() : userLabel;
    }
}

The following snippet declares a subscription to receive notifications when a user attempts to log in or log out. The subscription includes two fields: a set of users and a set of actions. These fields appear in the UI, and subscribers use these fields to configure the users and actions (login or logout) for which they want to receive notifications. If these fields are blank, Brightspot sends notifications for all users and all actions.

Class for configuring a subscription for login/logout events
package com.psddev.cms.debug;

import java.io.IOException;
import java.io.StringWriter;
import java.util.Collections;
import java.util.Set;

import com.psddev.cms.db.ToolUi;
import com.psddev.cms.db.ToolUser;
import com.psddev.cms.notification.Receiver;
import com.psddev.cms.notification.Subscription;
import com.psddev.cms.notification.ToolSubscription;
import com.psddev.dari.util.HtmlWriter;
import com.psddev.dari.util.ObjectUtils;
import org.jsoup.Jsoup;

public final class ToolUserAuthSubscription extends ToolSubscription<ToolUserAuthEvent> {

    /* Send a notification if one of the selected users performs some
       action. If the field is left blank, notify for all users. */
    @ToolUi.DropDown
    @ToolUi.Placeholder("All Users")
    private Set<ToolUser> users;

    /* Send a notification if one of the selected actions is detected. If
       the field is left blank, notify on all actions. */
    @ToolUi.Placeholder("All Actions")
    private Set<ToolUserAuthAction> actions;

    /* Deliver the notification if the user and action is contained in the set of
       users and actions defined by this Subscription. */
    @Override
    protected boolean shouldDeliver(Receiver receiver, ToolUserAuthEvent authEvent) {
        ToolUser user = authEvent.getUser();
        ToolUserAuthAction action = authEvent.getAction();
        return (ObjectUtils.isBlank(users) || users.contains(user))
            && (ObjectUtils.isBlank(this.actions) || actions.contains(action));
    }

    /* Format the notification as rich text markup that can be
       transformed into many different formats. */
    @Override
    protected String toHtmlFormat(Receiver receiver, ToolUserAuthEvent authEvent) {

        ToolUser user = authEvent.getUser();
        ToolUserAuthAction action = authEvent.getAction();
        String userLabel = authEvent.getUserLabel();

        StringWriter html = new StringWriter();
        HtmlWriter writer = new HtmlWriter(html);

        try {
            writer.writeHtml(action);
            writer.writeHtml(" detected from ");

            /* The content tag and data-id attribute serve as a way to tell
               downstream systems that the body of the element represents a
               label for some content that lives in the database, in this case,
               a Brightspot user. This can be transformed. */
            writer.writeStart("content", "data-id", user != null ? user.getId() : null);
            {
                writer.writeHtml(userLabel);
            }
            writer.writeEnd();

            writer.writeHtml(".");

        } catch (IOException e) {
            throw new IllegalStateException(e);
        }

        return html.toString();
    }

    /* The notification formatted as plain text. */
    @Override
    protected String toStringFormat(Receiver receiver, ToolUserAuthEvent authEvent) {
        /* Delegate to the toHtmlFormat method and strip out any markup. */
        return Jsoup.parse(toHtmlFormat(receiver, authEvent)).body().text();
    }

    /* Displays alert if this subscription overlaps another subscription for the same event. */
    @Override
    protected boolean overlaps(Subscription<?> other) {
        if (other instanceof ToolUserAuthSubscription) {
            ToolUserAuthSubscription that = (ToolUserAuthSubscription) other;

            /* The other subscription is considered to overlap if there is any
               intersection in the chosen users and chosen actions. */
            return (ObjectUtils.isBlank(this.users) || ObjectUtils.isBlank(that.users) || Collections.disjoint(
                this.users,
                that.users))
                && (ObjectUtils.isBlank(this.actions) || ObjectUtils.isBlank(that.actions)
                || Collections.disjoint(this.actions, that.actions));

        } else {
            return false;
        }
    }
}

The following snippet declares a publisher. In our user login-logout example we restrict the receiver type to just ToolUser, and modify the query to only allow users in the same role as the user who triggered the event to receive a notification. (This is arbitrary business logic but illustrates how the aforementioned methods can be overridden and customized.) This class also provides a preliminary filter on the baseline set of receivers.

Class declaring a publisher for subscriptions for login/logout events
package com.psddev.cms.debug;

import java.util.Optional;

import com.psddev.cms.db.ToolRole;
import com.psddev.cms.db.ToolUser;
import com.psddev.cms.notification.Publisher;
import com.psddev.cms.notification.Receiver;
import com.psddev.dari.db.Query;

public class ToolUserAuthPublisher extends Publisher<ToolUserAuthSubscription, ToolUserAuthEvent> {

    /* Constructor required by superclass to pass in the notification context. */
    ToolUserAuthPublisher(ToolUserAuthEvent context) {
        super(context);
    }

    /* Narrows the types of receivers of this notification to ToolUsers. */
    @Override
    protected Class<? extends Receiver> getReceiverType() {
        return ToolUser.class;
    }

    /* Adds Query predicate to allow only users in the same role as that of the user
       performing the action to receive notifications about that user. */
    @Override
    protected Query<? extends Receiver> getReceiversQuery() {

        ToolRole role = Optional.ofNullable(context.getUser())
            .map(ToolUser::getRole)
            .orElse(null);

        Query<? extends Receiver> query = super.getReceiversQuery();

        query.and("role = ?", role != null ? role : Query.MISSING_VALUE);

        return query;
    }
}

The following snippet traps a login-logout event and starts the notification life cycle. We create a Modification<ToolUserLoginToken>, which modifies the internal Brightspot object that keeps track of users logging in. We override the afterSave and afterDelete methods to serve as the hooks for triggering the event. Whenever a token is saved we assume a user logged in, and whenever the token is deleted we assume the user logged out. Since the saves and deletes can happen in response to a user on the main UI event thread, we call the asynchronous version of the publish notification API.

Class for trapping login/logout event and launching associated publisher
package com.psddev.cms.debug;

import com.psddev.cms.db.ToolUser;
import com.psddev.cms.db.ToolUserLoginToken;
import com.psddev.dari.db.Modification;
import com.psddev.dari.db.Query;

public class ToolUserLoginTokenModification extends Modification<ToolUserLoginToken> {

    private transient boolean isNew;

    /* An intermediate check to see if the token being saved is new (as opposed
       to an existing one that's just being updated). This step is needed
       because once the afterSave callback is triggered, the object will no
       longer be considered "new." */
    @Override
    protected void beforeCommit() {
        super.beforeCommit();
        isNew = getState().isNew();
    }

    /* After a new token is saved, find the user associated with the token and
       publish a new ToolUserAuthEvent notification "asynchronously" with the
       LOGIN action. */
    @Override
    protected void afterSave() {
        super.afterSave();

        if (isNew) {
            ToolUser user = getUser();

            if (user != null) {
                new ToolUserAuthPublisher(new ToolUserAuthEvent(
                    user,
                    ToolUserAuthAction.LOGIN)).publishNotificationAsync();
            }
        }
    }

    /* After a token is deleted, find the user associated with the token and
       publish a new ToolUserAuthEvent notification "asynchronously" with the
       LOGOUT action. */
    @Override
    protected void afterDelete() {
        super.afterDelete();

        ToolUser user = getUser();

        if (user != null) {
            new ToolUserAuthPublisher(new ToolUserAuthEvent(
                user,
                ToolUserAuthAction.LOGOUT)).publishNotificationAsync();
        }
    }

    /* Helper method to find the user associated with a token. */
    private ToolUser getUser() {
        return Query.from(ToolUser.class)
            .where("_id = ?", getState().get("userId"))
            .first();
    }
}