Skip to content

coldbox-modules/megaphone

Repository files navigation

Megaphone

A protocol-based library for sending Notifications in ColdBox

Documentation

Installation: box install megaphone

Config:

// config/modules/megaphone.cfc
component {

	function configure() {
		return {
			"channels": {
                // this is the unique name for the channel
				"database": {
                    // providers can be used multiple times with different properties, like cbq connections & providers
                    // currently, only the database provider exists
					"provider": "DatabaseProvider@megaphone",
					"properties": {
                        // this is the default table name
						"tableName": "megaphone_notifications",
						"datasource": "megaphone"
					}
				}
			}
		};
	}

}

There is one interface that needs to be adhered to (implements keyword optional) β€” INotifiable:

interface displayName="INotifiable" {

    public string function getNotifiableId();
    public string function getNotifiableType();

}

That goes on any CFC that will be sent notifications. (In most applications it is just User, but it could be more depending on your use case like Team or Company)

Your application's Notifications extend megaphone.models.BaseNotification:

// StockRebalancingCompleteNotification.cfc
component extends="megaphone.models.BaseNotification" accessors="true" {

	property name="stockSymbol";
    property name="completionTimestamp";

	public array function via( required any notifiable ) {
		return [ "database" ];
	}

    public struct function toDatabase( required any notifiable ) {
        return {
            "stockSymbol": getStockSymbol(),
            "completionTimestamp": getCompletionTimestamp()
        };
    }

}

The first required method to implement is via. It receives a notifiable and returns an array of channels to send the notification to.

public array function via( required any notifiable ) {
    return [ "database" ];
}

In this example, every notifiable gets this notification sent to the database, but you could customize that per notifiable. This can be customized based on the notifiable type, e.g. Team notifications go to Slack channels where User notifications go to SMS and the database.

public array function via( required any notifiable ) {
    return notifiable.getNotifiableType() == "Team" ? [ "slack" ] : [ "sms", "database" ];
}

This can also be customized based on the notifiable id, e.g. User A has requested email notifications where User B requested email and SMS notifications.

public array function via( required any notifiable ) {
    return notifiable.getNotificationChannels(); // [ "email" ] for one, [ "email", "sms" ] for another, etc.
}

After implementing via, you need to implement a to{Provider} method for each Provider. For instance, if the DatabaseProvider is an option, than a toDatabase method needs to be implemented.

public struct function toDatabase( required any notifiable ) {
    return {
        "stockSymbol": getStockSymbol(),
        "completionTimestamp": getCompletionTimestamp()
    };
}

Only one toDatabase method would need to be implemented, regardless of how many channels use the DatabaseProvider. For example, if your DatabaseProvider channel was called db, your via method would return [ "db" ] and you would implement a toDatabase method.

(These methods also receive the notifiable instance in case it is needed to generate the notification data for the Provider.)

Notifications are sent using the NotificationService, often aliased as megaphone.

// handlers/StockRebalancing.cfc
component {

    property name="megaphone" inject="NotificationService@megaphone";

    function create( event, rc, prc ) {
        // ...
        var notification = getInstance( "StockRebalancingCompleteNotification" )
        notification.setStockSymbol( "APPL" );
        notification.setCompletionTimestamp( now() );
        megaphone.notify( auth().user(), notification );
        // ...
    }

}

For those of you allergic to calling getInstance (😜), you can also pass a string name and a struct of properties:

// handlers/StockRebalancing.cfc
component {

    property name="megaphone" inject="NotificationService@megaphone";

    function create( event, rc, prc ) {
        // ...
        megaphone.notify(
            auth().user(),
            "StockRebalancingCompleteNotification",
            { "stockSymbol": "APPL", "completionTimestamp": now() }
        );
        // ...
    }

}

Another way to send a Notification is by adding the SendsNotifications@megaphone delegate to a Notifiable instance:

component name="User" delegates="SendsNotifications@megaphone" accessors="true" {

    property name="id";

    public string function getNotifiableId() {
        return getId();
    }

    public string function getNotifiableType() {
        return "User";
    }

}

Then you can call a notify method on the Notifiable instance.

// handlers/StockRebalancing.cfc
component {

    property name="megaphone" inject="NotificationService@megaphone";

    function create( event, rc, prc ) {
        // ...
        auth().user().notify(
            "StockRebalancingCompleteNotification",
            { "stockSymbol": "APPL", "completionTimestamp": now() }
        );
        // ...
    }

}

Providers

DatabaseProvider

First, here's the migration file (it will also be av available in the module under resources/database/migrations):

component {

    function up( schema ) {
        schema.create( "megaphone_notifications",  ( t ) => {
            t.guid( "id" ).primaryKey();
            t.string( "type" ); // notification wirebox id
            t.string( "notifiableId" );
            t.string( "notifiableType" );
            t.longText( "data" ); // serializeJSON of what is returned from `toDatabase`
            t.timestamp( "readDate" ).nullable();
            t.timestamp( "createdDate" ).withCurrent();

            t.index( "type" );
            t.index( "readDate" );
            t.index( name = "idx_megaphone_notifications_notifiable_index", columns = [ "notifiableId", "notifiableType" ] );
        } );
    }

    function down( schema ) {
        schema.dropIfExists( "megaphone_notifications" );
    }

}

MySQL Server sytanx:

CREATE TABLE ` megaphone_notifications` (
    `id` NCHAR(36) NOT NULL,
    `type` VARCHAR(255) NOT NULL,
    `notifiableId` VARCHAR(255) NOT NULL,
    `notifiableType` VARCHAR(255) NOT NULL,
    `data` LONGTEXT NOT NULL,
    `readDate` TIMESTAMP NULL DEFAULT NULL,
    `createdDate` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
    CONSTRAINT `pk_megaphone_notifications_id` PRIMARY KEY (`id`),
    INDEX `idx_megaphone_notifications_type` (`type`),
    INDEX `idx_megaphone_notifications_readDate` (`readDate`),
    INDEX `idx_megaphone_notifications_notifiable_index` (`notifiableId`, `notifiableType`)
)

and in SQL Server syntax:

CREATE TABLE [megaphone_notifications] (
    [id] uniqueidentifier NOT NULL,
    [type] VARCHAR(255) NOT NULL,
    [notifiableId] VARCHAR(255) NOT NULL,
    [notifiableType] VARCHAR(255) NOT NULL,
    [data] VARCHAR(MAX) NOT NULL,
    [readDate] DATETIME2,
    [createdDate] DATETIME2 NOT NULL CONSTRAINT [df_megaphone_notifications_createdDate] DEFAULT CURRENT_TIMESTAMP,
    CONSTRAINT [pk_megaphone_notifications_id] PRIMARY KEY ([id]),
    INDEX [idx_megaphone_notifications_type] ([type]),
    INDEX [idx_megaphone_notifications_readDate] ([readDate]),
    INDEX [idx_megaphone_notifications_notifiable_index] ([notifiableId], [notifiableType])
)

There are two ways to retrieve notifications from the database. First, the non-delegated way, injecting the DatabaseNotificationService

component {

    property name="databaseNotificationService" inject="DatabaseNotificationService@megaphone";

    function index( event, rc, prc ) {
        // ...
        var cursor = variables.databaseNotificationService.getUnreadNotifications(
            notifiable = auth().user(),
            channel = "database" // default is "database",
            initialPage = 1 // default is 1,
            maxRows = 25 // default is 25
        );
        // ...
    }

}

Or, the delegated way. First, add the delegate:

component name="User" delegates="HasDatabaseNotifications@megaphone" accessors="true" {

    property name="id";

    public string function getNotifiableId() {
        return getId();
    }

    public string function getNotifiableType() {
        return "User";
    }

}

Then use it:

component {

    function index( event, rc, prc ) {
        // ...
        var cursor = auth().user().getUnreadNotifications(
            channel = "database" // default is "database",
            initialPage = 1 // default is 1,
            maxRows = 25 // default is 25
        );
        // if you want all the defaults:
        var cursor = auth().user().getUnreadNotifications();
        // ...
    }

}

A cursor is for paginating through results and for interacting with the entire collection at once.

cursor.getPagination(); // { "maxRows": 25, "totalPages": 1, "offset": 0, "page": 1, "totalRecords": 5 }
cursor.getResults(); // [ DatabaseNotification ]
for ( var notification in cursor.getResults() ) {
    notification.getMemento(); // { id, type, notifiableType, notifiableId, data, readDate, createdDate }
    notification.getData(); // struct / already deserialized
    notification.getType(); // string β€” notification wirebox id
    notification.markAsRead( readDate = now() ); // sets and saves the readDate, default = now()
    notification.delete(); // deletes the notification from the database
}
cursor.hasPrevious(); // boolean
cursor.previous(); // loads previous page from database
cursor.hasNext(); // boolean
cursor.next(); // loads next page from database
cursor.markAllAsRead( readDate = now() ); // marks all as read, not just current page. default = now()
cursor.deleteAll(); // deletes all, not just current page

EmailProvider

Dependencies

The EmailProvider requires cbmailservices to be installed.

Usage

To send via the EmailProvider, add a toEmail method to your notification:

public Mail function toEmail( notifiable, newMail ) {
    return newMail(
        from: "noreply@example.com",
        subject: "Megaphone Email Notification",
        type: "html",
        bodyTokens: { product: "ColdBox" }
    ).setBody( "
        <p>Thank you for downloading @product@, have a great day!</p>
    " )
}

In addition to the notifiable instance in the parameters, you will receive a newMail function that will give you a Mail instance from cbmailservices.

You must return a Mail instance from the method. You do not need to call send yourself. megaphone will handle sending the email.

The email recipient can be set in two ways. First, you can explicitly set the to in the toEmail method:

public Mail function toEmail( notifiable, newMail ) {
	return newMail(
		to: notifiable.getEmail(),
		from: "noreply@example.com",
		subject: "Megaphone Email Notification",
		type: "html",
		bodyTokens: { product: "ColdBox" }
	).setBody( "
		<p>Thank you for downloading @product@, have a great day!</p>
	" )
}

Second, you can let your notifiable instance define where to send the email. You do this by implementing a new method on your notifiable instance: routeNotificationForEmail.

component name="User" accessors="true" {

    property name="id";
    property name="email";

    public string function getNotifiableId() {
        return getId();
    }

    public string function getNotifiableType() {
        return "User";
    }

    public string function routeNotificationForEmail() {
        return getEmail();
    }

}

Any time you want megaphone to set the recipient implicitly, all your notifiables receiving the email need to implement the routeNotificationForEmail method. If you do not send emails to a notifiable type or you always explicity set the recipient, you may omit this method.

Configuration

An EmailProvider can be configured with three properties:

moduleSettings = {
    "megaphone": {
        "channels": {
            "email": {
                "provider": "EmailProvider@megaphone",
                "properties": {
                    "mailer": "default",
                    "onSuccess": () => {},
                    "onError": () => {}
                }
            }
        }
    }
}

The mailer property is the default mailer for this channel. (It does not have to be the default mailer in cbMailservices.) If you set the mailer when calling newMail in your notification, your custom mailer will override the channel default.

The onSuccess and onError callbacks are the default callbacks called on the Mail object after is has been sent. By default, these are both no-ops.