Aviary
Recipes

Adopting in an existing app

Already have a notifications table and endpoints? Adopt nestjs-notifications gradually — wrap your table with a custom NotificationStore, route writes through the library, and keep your current API working the whole time.

Most apps that reach for this library already have some notifications — a table, a couple of endpoints, a "mark as read" button. You don't have to throw that away. The library is built around a single persistence seam — the NotificationStore interface — so you can point it at your existing table and adopt the rest incrementally.

This recipe is the gradual path: keep your data and API working, gain the library's channels, preferences, delivery tracking, and clients over time.

1. Wrap your table with a custom store

NotificationStore is the whole contract the database channel talks to. Implement it against your existing table/ORM — this is where you bridge any shape differences:

my-notification.store.ts
import type {
  NewStoredNotification,
  NotificationStore,
  StoredNotification,
} from '@dudousxd/nestjs-notifications-database';
import { Injectable } from '@nestjs/common';

@Injectable()
export class MyNotificationStore implements NotificationStore {
  constructor(private readonly repo: MyExistingRepository) {}

  async save(n: NewStoredNotification): Promise<StoredNotification> { /* insert a row */ }
  async markAsRead(id: string): Promise<void> { /* ... */ }
  async markAllAsRead(notifiableType: string, notifiableId: string, tenantId?: string): Promise<void> { /* ... */ }
  async getForNotifiable(notifiableType: string, notifiableId: string, tenantId?: string): Promise<StoredNotification[]> { /* ... */ }
  async getUnread(notifiableType: string, notifiableId: string, tenantId?: string): Promise<StoredNotification[]> { /* ... */ }
  async delete(id: string): Promise<void> { /* ... */ }
  // optional: prune(options), ensureSchema()
}

// register it:
DatabaseChannelModule.forRoot({ store: MyNotificationStore });

The model the library works in is { id, type, notifiableType, notifiableId, tenantId, data, readAt, createdAt, updatedAt }. Your store maps that to/from your columns.

Bridging the common mismatches

Existing schemas rarely match exactly. The store is where you reconcile:

Library fieldYour table often hasBridge
typea name/kind stringmap directly
dataa metadata/payload JSON columnmap directly
readAt (Date | null)a seen/read booleanreadAt != nullseen = true (and stamp now() when marking read)
notifiableType + notifiableIda single user_id FKif you only notify users: type = 'User', id = user_id
tenantId(often nothing)null, or derive from your org/workspace column

The boolean seen ⇄ timestamp readAt mapping is the most common one. Your getUnread filters seen = false; your markAsRead sets seen = true. The library never sees the boolean — it only ever asks for readAt.

2. Route writes through the library

Wherever you currently repo.insert(...) a notification, send it instead — so it flows through every channel you configure (in-app + mail + push + …), not just the table:

// before
await this.notifications.insert({ userId, name: 'INVOICE_PAID', metadata });

// after
await this.notifications.send(user, new InvoicePaid(invoice));
invoice-paid.notification.ts
@Notification()
export class InvoicePaid {
  constructor(private readonly invoice: Invoice) {}
  via() {
    return [Database]; // add Mail, Push, … when you're ready
  }
  toDatabase() {
    return { invoiceId: this.invoice.id, amount: this.invoice.total };
  }
}

Do this one notification type at a time — the rest keep using your old insert path until you migrate them.

3. Keep your existing endpoints (for now)

The library is additive. Your current GET /notifications / "mark seen" endpoints keep working — they read the same table your custom store writes to. Nothing forces you to swap them.

When you're ready, you can replace them with the library's auto-mounted controller (DatabaseChannelModule.forRoot() mounts it by default) or the REST controller factory, and the React widget / headless SDK on the frontend — but that's a later step, not a prerequisite.

4. Two levels of adoption

Light — no migration. Custom store maps to your existing columns (including the seenreadAt trick). Existing endpoints stay. Lowest risk; you mainly gain a clean send() path and the ability to add channels.

Full — a small migration. Add read_at, notifiable_type, notifiable_id, tenant_id columns (migrate seen = true → read_at = now()); now your store is a thin one-to-one mapping. This unlocks the library cleanly: multi-channel delivery, preferences, delivery tracking, scheduled prune, and the ready-made controller / clients.

Use the bundled ORM adapters (@dudousxd/nestjs-notifications-database-typeorm / -mikro-orm / -prisma) as a reference for what a clean store + entity looks like once you've migrated — or adopt one of them outright if your migrated table matches.

What stays separate

If you have an ops alerting path (Slack/PagerDuty for errors), that's a different concern from user notifications — leave it as is. This library is for notifying your users across channels, not for infrastructure alerts (though a Slack user notification channel exists if you want one).

On this page