Appearance
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 == trueOr 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:
| Group | Format | Description |
|---|---|---|
| User group | user_{userId} | All connections for a specific user |
| Tenant group | tenant_{tenantId} | All connections for a tenant (multi-tenancy) |
The hub reads claims from the JWT token:
sub→ userIdtenant_id→ tenantId (optional)
Hub Events (Server → Client)
| Event | Payload | Description |
|---|---|---|
ReceiveNotification | UserNotificationDto | New notification created |
NotificationRead | { id: Guid } | Notification marked as read |
AllNotificationsRead | — | All 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=20Badge 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:
| Category | Icon | Description |
|---|---|---|
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.