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.

services.ts
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.

config/notification.ts
import env from '#env'
import type { NotificationConfig } from '@tekir/notification'

export default {
  // FCM for push notifications (optional)
  fcm: {
    serverKey: env.FCM_SERVER_KEY
  }
} satisfies NotificationConfig

Register NotificationProvider in your kernel:

start/kernel.ts
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(): raw SentRecord[] for custom assertions.
  • notify.restore(): exit fake mode.