Skip to content

In-App & SignalR

GrydNotifications provides a complete in-app notification system with database persistence, real-time delivery via SignalR, read/unread tracking, categories, expiration, and a badge count API.

Architecture

┌──────────────┐     ┌───────────────────┐     ┌──────────────────┐
│ Application  │────►│ InAppNotification │────►│   PostgreSQL     │
│   Layer      │     │     Sender        │     │  (persistence)   │
└──────────────┘     └────────┬──────────┘     └──────────────────┘


                     ┌────────────────┐     ┌──────────────────────┐
                     │  SignalR Hub   │────►│  Client (Browser/    │
                     │ NotificationHub│     │  Mobile via library) │
                     └────────────────┘     └──────────────────────┘

Setup

1. Enable In-App notifications

csharp
builder.Services.AddGrydNotifications(options =>
{
    options.InApp.Enabled = true;
    options.InApp.EnableSignalR = true;
    options.InApp.HubPath = "/hubs/notifications"; // default
    options.InApp.RetentionDays = 90;
    options.InApp.MaxUnreadPerUser = 200;
});

2. Map the SignalR hub

csharp
var app = builder.Build();
app.UseGrydNotifications(); // auto-maps hub if InApp.Enabled == true

Or map explicitly:

csharp
app.MapGrydNotificationsHub("/hubs/notifications");

3. CORS for SignalR (if frontend is on a different origin)

csharp
builder.Services.AddCors(options =>
{
    options.AddPolicy("SignalR", policy =>
    {
        policy.WithOrigins("https://myapp.com")
              .AllowAnyHeader()
              .AllowAnyMethod()
              .AllowCredentials(); // Required for SignalR
    });
});

app.UseCors("SignalR");

SignalR Hub

The NotificationHub requires authentication ([Authorize]). On connection, users are automatically added to groups:

GroupFormatDescription
User groupuser_{userId}All connections for a specific user
Tenant grouptenant_{tenantId}All connections for a tenant (multi-tenancy)

The hub reads claims from the JWT token:

  • sub → userId
  • tenant_id → tenantId (optional)

Hub Events (Server → Client)

EventPayloadDescription
ReceiveNotificationUserNotificationDtoNew notification created
NotificationRead{ id: Guid }Notification marked as read
AllNotificationsReadAll notifications marked as read
NotificationDeleted{ id: Guid }Notification deleted
UnreadCountChanged{ count: int }Badge count updated

Client Integration

JavaScript / TypeScript

typescript
import { HubConnectionBuilder, LogLevel } from "@microsoft/signalr";

const connection = new HubConnectionBuilder()
  .withUrl("/hubs/notifications", {
    accessTokenFactory: () => getAccessToken(), // JWT token
  })
  .withAutomaticReconnect()
  .configureLogging(LogLevel.Information)
  .build();

// Listen for new notifications
connection.on("ReceiveNotification", (notification) => {
  console.log("New notification:", notification);
  // Update drawer UI
  addNotificationToDrawer(notification);
  // Update badge count
  updateBadge(notification.unreadCount);
});

// Listen for read events
connection.on("NotificationRead", ({ id }) => {
  markAsReadInDrawer(id);
});

connection.on("AllNotificationsRead", () => {
  markAllAsReadInDrawer();
});

connection.on("UnreadCountChanged", ({ count }) => {
  updateBadge(count);
});

// Start the connection
await connection.start();

React Hook Example

tsx
import { useEffect, useState } from "react";
import { HubConnectionBuilder } from "@microsoft/signalr";

interface Notification {
  id: string;
  title: string;
  body: string;
  icon?: string;
  category?: string;
  actionUrl?: string;
  isRead: boolean;
  createdAt: string;
}

export function useNotifications(token: string) {
  const [notifications, setNotifications] = useState<Notification[]>([]);
  const [unreadCount, setUnreadCount] = useState(0);

  useEffect(() => {
    const connection = new HubConnectionBuilder()
      .withUrl("/hubs/notifications", {
        accessTokenFactory: () => token,
      })
      .withAutomaticReconnect()
      .build();

    connection.on("ReceiveNotification", (notification: Notification) => {
      setNotifications((prev) => [notification, ...prev]);
      setUnreadCount((prev) => prev + 1);
    });

    connection.on("NotificationRead", ({ id }: { id: string }) => {
      setNotifications((prev) =>
        prev.map((n) => (n.id === id ? { ...n, isRead: true } : n))
      );
      setUnreadCount((prev) => Math.max(0, prev - 1));
    });

    connection.on("AllNotificationsRead", () => {
      setNotifications((prev) => prev.map((n) => ({ ...n, isRead: true })));
      setUnreadCount(0);
    });

    connection.on("UnreadCountChanged", ({ count }: { count: number }) => {
      setUnreadCount(count);
    });

    connection.start();

    return () => {
      connection.stop();
    };
  }, [token]);

  return { notifications, unreadCount };
}

.NET Client (Desktop/Xamarin/MAUI)

csharp
using Microsoft.AspNetCore.SignalR.Client;

var connection = new HubConnectionBuilder()
    .WithUrl("https://api.myapp.com/hubs/notifications", options =>
    {
        options.AccessTokenProvider = () => Task.FromResult(GetJwtToken());
    })
    .WithAutomaticReconnect()
    .Build();

connection.On<UserNotificationDto>("ReceiveNotification", notification =>
{
    Console.WriteLine($"New: {notification.Title}");
});

await connection.StartAsync();

Sending In-App Notifications

Via MediatR Command

csharp
// Simple notification
await _mediator.Send(new SendInAppNotificationCommand
{
    UserId = userId,
    Title = "Task Assigned",
    Body = "You've been assigned to task 'Implement Login'",
    Icon = "📋",
    Category = "tasks",
    ActionUrl = "/tasks/123",
    ActionLabel = "View Task",
    Priority = NotificationPriority.Normal
}, ct);

// With expiration
await _mediator.Send(new SendInAppNotificationCommand
{
    UserId = userId,
    Title = "Flash Sale!",
    Body = "50% off for the next 2 hours",
    Category = "promotions",
    ExpiresAfter = TimeSpan.FromHours(2)
}, ct);

// With collapse key (groups related notifications)
await _mediator.Send(new SendInAppNotificationCommand
{
    UserId = userId,
    Title = "3 new comments on your post",
    Body = "John, Jane, and Bob commented",
    Category = "comments",
    CollapseKey = $"post-comments-{postId}"
}, ct);

With Metadata

Attach arbitrary key-value data for frontend processing:

csharp
await _mediator.Send(new SendInAppNotificationCommand
{
    UserId = userId,
    Title = "Payment Received",
    Body = "Payment of $199.99 received",
    Category = "payments",
    Metadata = new Dictionary<string, string>
    {
        ["paymentId"] = paymentId.ToString(),
        ["amount"] = "199.99",
        ["currency"] = "USD"
    }
}, ct);

REST API Endpoints

List Notifications

http
GET /api/v1/user-notifications?userId={userId}&isRead=false&category=tasks&page=1&pageSize=20

Badge Count

http
GET /api/v1/user-notifications/unread-count?userId={userId}

Returns a single integer — ideal for the notification bell badge.

Mark as Read

http
PUT /api/v1/user-notifications/{id}/read?userId={userId}

Mark All as Read

http
PUT /api/v1/user-notifications/read-all?userId={userId}

Delete

http
DELETE /api/v1/user-notifications/{id}?userId={userId}

INFO

The userId query parameter enforces ownership. A 403 Forbidden is returned if the notification belongs to a different user.

Categories

Categories help organize notifications in the drawer UI:

CategoryIconDescription
tasks📋Task assignments, updates
comments💬Comments, mentions
alerts⚠️System alerts, warnings
orders📦Order status updates
payments💰Payment confirmations
system⚙️System notifications

Categories are free-form strings — define whatever your application needs. The frontend can use them for filtering and icon selection.

Collapse Keys

When a collapse key is provided, a new notification with the same key replaces the previous one instead of creating a duplicate:

csharp
// First notification
await _mediator.Send(new SendInAppNotificationCommand
{
    UserId = userId,
    Title = "1 new comment",
    CollapseKey = "post-123-comments"
}, ct);

// Second notification with same key — replaces the first
await _mediator.Send(new SendInAppNotificationCommand
{
    UserId = userId,
    Title = "3 new comments",
    CollapseKey = "post-123-comments"
}, ct);

This prevents the drawer from filling up with redundant notifications.

Scale-Out with Redis

For applications with multiple server instances, configure a Redis backplane for SignalR:

csharp
builder.Services.AddSignalR()
    .AddStackExchangeRedis("localhost:6379", options =>
    {
        options.Configuration.ChannelPrefix = RedisChannel.Literal("grydnotifications");
    });

This ensures notifications sent from any server instance reach all connected clients.

Released under the MIT License.