Live / progress notifications
Some notifications evolve — an export going 0% → 100%, a job that flips from "running" to "done". Update a single notification row in place across sends with a stable databaseKey, instead of spamming a new row each time.
Most notifications are fire-and-forget: one send() → one row. But some are live — an export that
progresses, a long job that flips from running to done. You want one notification that updates,
not a new row for every tick.
Give the notification a stable databaseKey(notifiable) and the database channel
upserts that row across sends: the first send() inserts it, each later send() updates the same
row in place (refreshing data and re-surfacing it as unread).
Define an updatable notification
import { type Notifiable, Notification } from '@dudousxd/nestjs-notifications-core';
import { Database } from '@dudousxd/nestjs-notifications-database';
import { Sse } from '@dudousxd/nestjs-notifications-sse';
@Notification()
export class ExportProgress {
constructor(
private readonly exportId: string,
private readonly status: 'running' | 'done' | 'failed',
private readonly progress: number,
) {}
via() {
return [Database, Sse];
}
// Same key on every send for this export → one row, updated in place.
databaseKey() {
return `export:${this.exportId}`;
}
toDatabase() {
return { exportId: this.exportId, status: this.status, progress: this.progress };
}
toSse() {
return { type: 'export.progress', exportId: this.exportId, status: this.status, progress: this.progress };
}
}Drive it from your job:
await notifications.send(user, new ExportProgress(id, 'running', 0));
// ...later
await notifications.send(user, new ExportProgress(id, 'running', 60));
// ...finally
await notifications.send(user, new ExportProgress(id, 'done', 100));Three sends, one notification row — its data ends at { status: 'done', progress: 100 }. Pair it
with the sse channel (live badge) and the UI updates the same item as the
bar fills.
Put the percentage in a top-level data.progress (0–100) and the React <Inbox/>
renders the progress bar for you — or read it yourself with the notificationProgress(item) helper. Add a
data.action of { label, url } to surface a download link the moment the job finishes.
Semantics
databaseKeyis the row id — namespace it so it's unique (export:${id}, not just${id}).- On update,
data/type/updatedAtare refreshed,createdAtis preserved, andreadAtis reset tonull— an update is treated as a fresh, unread event (so the bell lights up again on each meaningful change). Throttle your sends if you don't want every 1% to re-notify. - Omit
databaseKey(the default) for normal insert-per-send behavior.
Upsert is backed by the optional NotificationStore.upsert(), implemented for the in-memory store
and the TypeORM / MikroORM / Prisma adapters. A custom store without it falls back to inserting a new
row (and the channel logs a warning) — implement upsert(notification) to get in-place updates.
Live unread badge
A notification bell whose unread count updates the instant a notification arrives — SSE pushes the change, no polling. Combine the SSE channel with the unread count from the database channel.
Delivery tracking
Persist the real per-channel delivery status — sent, failed, delivered, bounced — not just the in-memory SendResult. The delivery-tracking package records every send and updates it from Twilio / SES status webhooks.