Skip to content

Group notifications with SignalR (by marcobisio)

Victor Tomaili edited this page May 3, 2021 · 1 revision

Overview

Hello everyone, today I will (try to) explain a very basic implementation of a notification system based on SignalR. I used the Serenity template to implement a ticketing environment (kind of) and obviously I had the necessity to notify the users when a ticket is created/updated/resolved and so on. Every ticket belongs to a specific support group and therefore the notifications should be sent only to such group. I'm trying to contextualize my specific implementation only to give you a better understanding, but it can be obviously adjusted to your needs.

Before starting, remember that I'm not (and do not pretend to be) a MVC/C# evangelist, but I decided to share part of my work with the community in order to thank (and help) Volkan Ceylan for the kind and precious support the he gave me in the last two month.

For all these reasons, I am obviously open to suggestions and (constructive) criticism.

Nuget Packages used

  • Microsoft.AspNet.SignalR
  • log4net - logging class from Apache, optional
  • Owin - (distinct from Microsoft.Owin)
  • Microsoft.Owin - used to register the Hub
  • Microsoft.Owin.Host.SystemWeb - useful for breakpoints for app.MapSignalR
  • Microsoft.Owin.Security - important for ~/signalr/hubs route to be registered to be used in cshtml view

NotificationHub (Hub)

The Hub class is the core of SignalR: just to know, every client that connects to your application will be "registered" in the Hub.

So here is my implementation of the Hub:

using myApp.Administration.Entities;
using log4net;
using Microsoft.AspNet.SignalR;
using Serenity;
using Serenity.Data;
using System.Collections.Generic;
using System.Threading.Tasks;

namespace myApp.Notification.Hubs
{
    public class NotificationHub : Hub
    {
        static readonly ILog log = LogManager.GetLogger(System.Reflection.MethodBase.GetCurrentMethod().DeclaringType);
        public override Task OnConnected()
        {
            if (Authorization.IsLoggedIn)
            {
                log.Debug("User [" + Authorization.Username + "] has connected to the notification hub.");
                var fieldsUserSupportGroup = UserRow.Fields;

                List<UserSupportGroupRow> userSupportGroups = null;
                using (var connection = SqlConnections.NewFor<UserSupportGroupRow>())
                {
                    // retrieve the support groups to which the user belongs
                    userSupportGroups = connection.List<UserSupportGroupRow>(new Criteria(fieldsUserSupportGroup.UserId) == Authorization.UserDefinition.Id);
                }
                foreach (var userSupportGroup in userSupportGroups)
                {
                    Groups.Add(Context.ConnectionId, userSupportGroup.GroupId.ToString());
                }
            }
            return base.OnConnected();
        }
    }
}

So basically we are overriding the OnConnected() method in order to collect the informations about the connected users and their groups membership. For each support group we create a new group and we add the reference of the client connection (Context.ConnectionId) to such group. We don't have to worry about duplicate connections/groups not even to remove the disconnected users because these things are already handled by SignalR. Note that although the standard behaviour of SignalR prevent duplicate notifications to the same user because at a given time a user can only have one and only one ConnectionId, this is not true if the user access your application from different client (or even just different browser), but in that case SignalR will send the notification(s) to every "client" which is exactly what we want to achieve.

The mapping between users and group is also in the database, but this way we have the mapping only of the "online" users.

Registering the SignalR Hub

In order to register the SignalR Hub in our application, we need an "OWIN Startup class":

using Owin;
using Microsoft.Owin;
using Microsoft.AspNet.SignalR;

[assembly: OwinStartup(typeof(myApp.Notifications.Startup))]

namespace myApp.Notifications
{
	public class Startup
	{
		public void Configuration(IAppBuilder app)
		{
			var idProvider = new CustomUserIdProvider();

			// to send notifications to single users by UserId instead of by Username
			//GlobalHost.DependencyResolver.Register(typeof(IUserIdProvider), () => idProvider);

			// Any connection or hub wire up and configuration should go here
			app.MapSignalR();
		}
	}
}

Notify a single user (referenced by Serenity Username)

If you need to send a message to a specific single user, you can do that (in the BackgroundNotifier class) with the following piece of code:

_notificationHub.Clients.User("admin").globalNotification("this is a notification");

Notify a single user (referenced by Serenity UserId)

This one is a bit more complicated. Suppose that in the BackgroundNotifier you have the information about the UserId (1 for example), but you have to tell SignalR something like "_notificationHub.Clients.User("admin")" and we are out of the "Serenity Authorization" context, so we cannot ask to Serenity to translate such information for us. Obviously we can query the database and translate such information (I mean UserId 1 to Username "admin"), but another cleaner approach is to register a custom IUserIdProvider that allow us to collect user information using the property/field that we prefer:

using Microsoft.AspNet.SignalR;
using Serenity;

namespace myApp.Notifications
{
    public class CustomUserIdProvider : IUserIdProvider
    {
        public string GetUserId(IRequest request)
        {
            var userId = string.Empty;
            if (Authorization.IsLoggedIn)
            {
                userId = Authorization.UserDefinition.Id;
            }
            return userId;
        }
    }
}

By uncommenting the register directive in the Startup class we can eventually use the UserId to send notification in the following way:

_notificationHub.Clients.User("1").globalNotification("this is a notification");

BackgroundNotifier (IRegisteredObject)

Now we need a timer to periodically polling for new "event" to be notified:

using myApp.Notification.Hubs;
using Microsoft.AspNet.SignalR;
using Serenity.Data;
using System;
using System.Data;
using System.Data.SqlClient;
using System.Threading;
using System.Web.Hosting;
using System.Linq;
using System.Collections.Generic;
using log4net;
using System.Configuration;

namespace myApp.Notifications
{
    public class BackgroundNotifier : IRegisteredObject
    {
        static readonly ILog log = LogManager.GetLogger(System.Reflection.MethodBase.GetCurrentMethod().DeclaringType);

        private readonly IHubContext _notificationHub;
        private Timer _timer;
        private static DataTable dtPreviousNotifications;

        public BackgroundNotifier()
        {
            _notificationHub = GlobalHost.ConnectionManager.GetHubContext<NotificationHub>();

            StartTimer();
        }
        private void StartTimer()
        {
            var delayStartby = 5000;
            var repeatEvery = 10000;

            int parsedInterval;
            if (int.TryParse(ConfigurationManager.AppSettings["NotificationsIntervalMsec"], out parsedInterval))
            {
                repeatEvery = parsedInterval;
            }

            _timer = new Timer(BroadcastNotificationToClients, null, delayStartby, repeatEvery);
        }
        private void BroadcastNotificationToClients(object state)
        {
            SqlConnection sqlConnection = null;
            try
            {
                sqlConnection = new SqlConnection(SqlConnections.GetConnectionString("Default").ConnectionString);
                sqlConnection.Open();
                DataTable dtNotifications = new DataTable();
                SqlCommand sqlCmdNotifications = new SqlCommand("SELECT GroupId,TicketId,SiteName FROM Notifications ORDER BY GroupId,TicketId", sqlConnection);
                SqlDataAdapter sqlDaNotifications = new SqlDataAdapter(sqlCmdNotifications);
                sqlDaNotifications.Fill(dtNotifications);

                if (dtPreviousNotifications == null)
                {
                    dtPreviousNotifications = dtNotifications;
                    return;
                }

                DataTable dtNotificationsDistinctSupportGroup = dtNotifications.DefaultView.ToTable(true, "GroupId");

                foreach (DataRow drNotificationsDistinctSupportGroup in dtNotificationsDistinctSupportGroup.Rows)
                {
                    DataRow[] drArrayPreviousNotifications = dtPreviousNotifications.Select("GroupId = " + drNotificationsDistinctSupportGroup["GroupId"]);
                    DataRow[] drArrayNotifications = dtNotifications.Select("GroupId = " + drNotificationsDistinctSupportGroup["GroupId"]);

                    var drArrayNewNotifications = drArrayNotifications.Except(drArrayPreviousNotifications, new NotificationComparer());

                    if (drArrayNewNotifications.Count() > 0)
                    {
                        // it will always be a single element list
                        List<string> g = new List<string>();
                        g.Add(drNotificationsDistinctSupportGroup["GroupId"].ToString());
                        // notify every new tickets to the assigned support group
                        foreach (DataRow tmpDR in drArrayNewNotifications)
                        {
                            _notificationHub.Clients.Groups(g).globalNotification("New ticket in Inbox" + (Convert.IsDBNull(tmpDR["SiteName"]) ? "." : " from site " + tmpDR["SiteName"].ToString() + "."));
                            log.Debug("Successfully notified ticket " + tmpDR["TicketId"].ToString() + " to group " + g.First() + ".");
                        }
                    }
                }
                // clear status
                dtPreviousNotifications = dtNotifications;
            }
            catch (Exception ex)
            {
                log.Error("Unable to send notifications.", ex);
            }
            finally
            {
                if (sqlConnection != null)
                {
                    sqlConnection.Close();
                }
            }

        }

        public void Stop(bool immediate)
        {
            _timer.Dispose();

            HostingEnvironment.UnregisterObject(this);
        }
    }
}

A brief explanation of the code:

  • NotificationsIntervalMsec is a key on the web config:

  • Notifications is a database view that collect the event to notify. Every time the notifier execute a query on such view and compare the result with the previous one (that is stored in the static DataTable "dtPreviousNotifications").

  • globalNotification is the client side javascript method the will be called from the hub.

  • NotificationComparer is a simple implementation of IEqualityComparer

Register the BackgroundNotifier

The BackgroundNotifier class needs to be registered/instantiated, so in your Application_Start method (Global.asax.cs) add the following line of code:

System.Web.Hosting.HostingEnvironment.RegisterObject(new BackgroundNotifier());

Client Side hook (_LayoutHead.cshtml)

So the last thing to do is to implement the "globalNotification" method client side:

<script src="~/Scripts/jquery.signalR-2.2.0.min.js"></script>
<script src="~/signalr/hubs"></script>
<script>
    $(function () {
        var hub = $.connection.notificationHub;

        $.connection.hub.start();

        hub.client.globalNotification = function (notification) {
            var to = {};
            to.timeOut = 60000;
            to.closeButton = true;
            Q.notifySuccess(notification, '', to);
        };
    });
</script>

This code should be put in an external file and included in the template. If <script src="~/signalr/hubs"></script> reports a 404, you may be missing a reference to Microsoft.Owin.Security dll.

Clone this wiki locally