Notifications
Multi-channel notifications (mail, database, and push) dispatched through a single class-based API.
Introduction
@tekir/notification lets you define a notification once and deliver it across multiple channels (mail, database, push) simultaneously. The database channel stores notifications in a SQL table. The mail channel delegates to @tekir/mail. The push channel sends FCM messages.
import { service } from '@tekir/core'
import type { Notification } from '@tekir/notification'
export const notify = service<Notification>('notification')Configuration
Create config/notification.ts with optional FCM settings. The database channel uses the registered db service automatically, no adapter needed.
import env from '#env'
import type { NotificationConfig } from '@tekir/notification'
export default {
// FCM for push notifications (optional)
fcm: {
serverKey: env.FCM_SERVER_KEY
}
} satisfies NotificationConfigRegister NotificationProvider in your kernel:
import type { TekirApp } from '@tekir/core'
import { NotificationProvider } from '@tekir/notification'
export default function({ app }: TekirApp) {
app.registerAll([NotificationProvider])
}
// NotificationProvider reads config('notification') and uses the
// registered 'db' service for the database channel automatically.Defining Notifications
Extend BaseNotification. Implement via() to declare channels, then implement the corresponding payload method for each channel.
import { BaseNotification } from '@tekir/notification'
import type { ChannelName, MailPayload, DatabasePayload } from '@tekir/notification'
class OrderShippedNotification extends BaseNotification {
constructor(private order: { id: string; trackingNumber: string; userEmail: string }) {
super()
}
// Declare which channels to use. Receives the recipient userId.
via(_userId: string): ChannelName[] {
return ['mail', 'database']
}
toMail(): MailPayload {
return {
to: this.order.userEmail,
subject: 'Your order has shipped!',
html: `<p>Track your order: <strong>${this.order.trackingNumber}</strong></p>`
}
}
toDatabase(): DatabasePayload {
return {
type: 'order.shipped',
title: 'Order Shipped',
body: `Your order ${this.order.id} is on its way.`,
trackingNumber: this.order.trackingNumber
}
}
}via()
Returns an array of ChannelName values: 'mail', 'database', 'push', or 'log'. The method receives the recipient userId, which lets you vary channels per user.
class UrgentAlert extends BaseNotification {
// Send on all three channels simultaneously.
via(_userId: string): ChannelName[] {
return ['mail', 'database', 'push']
}
// ... toMail, toDatabase, toPush
}
class QuietUpdate extends BaseNotification {
// Only store in the database: no email, no push.
via(_userId: string): ChannelName[] {
return ['database']
}
}toMail()
Return a MailPayload. to and subject are required. All other fields are forwarded to @tekir/mail.
toMail(): MailPayload {
return {
to: this.user.email, // required
subject: 'Password Reset', // required
html: `<a href="${this.resetLink}">Reset your password</a>`,
text: `Reset link: ${this.resetLink}`,
replyTo: '[email protected]'
}
}toDatabase()
Return a DatabasePayload. type, title, and body are required. Extra fields are serialized as JSON in the data column.
toDatabase(): DatabasePayload {
return {
type: 'order.shipped', // required, used as a discriminant
title: 'Order Shipped', // required, short display title
body: 'Your order #1234 is on its way.', // required, full message
// Arbitrary extra fields are stored as JSON in the data column.
orderId: this.order.id,
trackingUrl: this.order.trackingUrl
}
}toPush()
Return a PushPayload. The notification is sent to the FCM topic /topics/user_{userId}.
toPush(): PushPayload {
return {
title: 'Your order shipped!', // required
body: `Track it: ${this.order.trackingNumber}`, // required
icon: '/icons/package.png',
data: { orderId: this.order.id }
}
}Sending Notifications
notify.send()
notify.send(userId, notification) dispatches to all channels returned by via(). Channels run concurrently.
import { notify } from '#services'
// Send to a single user. The notification's via() decides which channels are used.
await notify.send(user.id.toString(), new OrderShippedNotification(order))
// Channels run concurrently via Promise.all().notify.sendMany()
notify.sendMany(userIds, notification) sends the same notification to multiple users in parallel.
import { notify } from '#services'
// Send the same notification to multiple users simultaneously.
const adminIds = admins.map((a) => a.id.toString())
await notify.sendMany(adminIds, new SystemAlert('Disk usage above 90%'))Database Channel
When sent on the 'database' channel, notifications are inserted into a notifications table (auto-created). Query them directly on notify.
forUser()
Retrieve all notifications for a user, newest first.
import { notify } from '#services'
import type { HttpContext } from '@tekir/core'
export async function index({ auth, response }: HttpContext) {
const notifications = await notify.forUser(auth.user.id.toString())
return response.ok(notifications)
}markAsRead / markAllAsRead
Set the read_at timestamp on one or all unread notifications.
import { notify } from '#services'
// Mark a single notification as read.
await notify.markAsRead(notificationId)
// Mark every unread notification for a user as read.
await notify.markAllAsRead(user.id.toString())unreadCount()
Count unread notifications. Useful for badge counters.
import { notify } from '#services'
export async function unread({ auth, response }: HttpContext) {
const count = await notify.unreadCount(auth.user.id.toString())
return response.ok({ unread: count })
}Testing
Call notify.fake() before your test. In fake mode nothing is delivered, all dispatches are recorded in memory.
import { notify } from '#services'
import { test, expect } from 'bun:test'
test('sends OrderShippedNotification on the mail and database channels', async () => {
// Enter fake mode: nothing is actually delivered.
notify.fake()
const order = { id: 'ORD-001', trackingNumber: '9400', userEmail: '[email protected]' }
await notify.send('42', new OrderShippedNotification(order))
// Assert that the notification class was dispatched on a specific channel.
notify.assertSent(OrderShippedNotification, 'mail')
notify.assertSent(OrderShippedNotification, 'database')
// Assert delivery to a specific user.
notify.assertSent(OrderShippedNotification, 'mail', '42')
// Inspect the raw records for custom assertions.
const records = notify.getSent()
expect(records).toHaveLength(2)
// Exit fake mode.
notify.restore()
})notify.fake(): enter fake mode.notify.assertSent(Class, channel?, userId?): throws if no match found.notify.getSent(): rawSentRecord[]for custom assertions.notify.restore(): exit fake mode.