commons/notification

0.1.0
commons/notification

Role and personal notification inboxes with per-user read state and real-time delivery over SSE.

ModuleContents
notificationTypes and Client. Safe to import anywhere.
notification.serverThe HTTP service. Importing it starts the listener.

Run it inside an application

Copy
import ballerinax/h2.driver as _;              // or mysql.driver / postgresql.driver
import commons/notification.server as _;
Copy
# Config.toml
[commons.notification.server]
port = 9100                      # default
basePath = "/notifications/v1"   # default
ns = "my-app"

[commons.notification.server.auth]
enableJwtAuth = true
jwksUrl = "https://idp.example.com/oauth2/jwks"
rolesClaim = "groups"            # default
enforceScopes = true

[commons.notification.server.db]
dbType = "POSTGRESQL"
url = "jdbc:postgresql://localhost:5432/myapp"
user = "app"
password = "app"

Other settings (see modules/server/config.bal): tablePrefix, scope names, retentionDays (90), purgeIntervalSeconds, maxPageSize, replayLimit, ticketTtlSeconds, corsAllowOrigins.

With neither JWT nor API key enabled, the service trusts x-user-id, x-user-roles (comma-separated) and x-user-scopes headers. That is for local development only.

Visibility

A notification goes to one user (USER) or to a role (ROLE). A role notification is stored once. Who sees it is decided when it is read, from the roles in the reader's token, so the service never needs role membership. Read state is per user: marking a role notification read affects only the caller.

API

MethodPathScope
POST/notificationssendSend. 201, or 200 with the original when idempotencyKey repeats
GET/notificationsreadCaller's inbox, newest first. box=all|personal|role, role, severity, category, correlationId, read, cursor, limit
GET/notifications/unread-countread{total, personal, roles: {role: n}}
GET/notifications/{id}read404 unless visible to the caller
PUT/notifications/{id}/readreadMark read
DELETE/notifications/{id}/readreadMark unread
POST/notifications/read-allreadMark all read; optional {box, role, category, correlationId}
GET/admin/notificationsadminAny recipient: recipientType, recipientId, filters, paging
DELETE/notifications/{id}adminDelete for everyone
POST/stream-ticketread{ticket, expiresIn} for a browser EventSource
GET/streamreadSSE stream

Scopes default to notification:send, notification:read and notification:admin. API-key callers hold all of them.

Events

EventToData
notification.createdRecipient user or everyone in the roleThe notification, read: false
notification.read / notification.unreadThe reader{id, readAt?}
notification.read-allThe reader{count}
notification.deletedRecipients{id}

The stream opens with a : connected comment and sends : keepalive every 15 s while idle.

Browser

EventSource cannot send headers and tickets are single-use, so reconnect with a fresh ticket and the last event ID:

Copy
let lastId;
async function connect() {
  const { ticket } = await api.post("/stream-ticket");
  const url = `${base}/stream?ticket=${ticket}` + (lastId ? `&lastEventId=${lastId}` : "");
  const es = new EventSource(url);
  es.addEventListener("notification.created", e => { lastId = e.lastEventId; render(JSON.parse(e.data)); });
  es.onerror = () => { es.close(); setTimeout(connect, 3000); };
}

Missed notifications, up to replayLimit, are replayed before live events. Replay covers notification.created only. After a long gap, refetch unread-count.

From a workflow

Copy
final notification:Client notifications = check new ("http://localhost:9100/notifications/v1",
    headers = {"x-api-key": apiKey});

notification:Notification sent = check notifications->send({
    recipientType: notification:ROLE,
    recipientId: "PropertyManager",
    severity: notification:WARNING,
    title: "No contractor reply in 48h",
    correlationId: caseId,
    idempotencyKey: string `${caseId}/escalation`
});

Set idempotencyKey when sending from an activity, so a retried activity doesn't notify twice.

Storage

Tables <prefix>item and <prefix>read (default prefix notification_), created by the migration runner on startup. Timestamps are epoch milliseconds. Notifications older than retentionDays, and expired ones, are purged with their read rows.

Tested on H2. The SQL is kept portable to MySQL and PostgreSQL, but it hasn't been run against them yet.

Import

import commons/notification;Copy

Other versions

0.1.0

Metadata

Released date: 27 days ago

Version: 0.1.0

License: Apache-2.0


Compatibility

Platform: any

Ballerina version: 2201.13.4

GraalVM compatible: Yes


Pull count

Total: 1

Current verison: 1


Weekly downloads


Source repository


Keywords

commons

notification

inbox

sse


Contributors