npm.io
0.6.0 • Published 17h ago

@mounaji_npm/notifications

Licence
Version
0.6.0
Deps
1
Size
356 kB
Vulns
0
Weekly
0

@mounaji_npm/notifications

Multi-channel notification system — in-app web notifications, email via Resend, and an extensible channel architecture ready for push, WhatsApp, and any custom delivery channel.

Includes: React provider, hooks, bell widget, real-time toasts, history page, and preferences panel.


Install

npm install @mounaji_npm/notifications

Peer dependencies:

npm install react  # >=17.0.0

@mounaji_npm/event-core is a direct dependency — installed automatically.


Quick start

// 1. Wrap your app with NotificationProvider
import { NotificationProvider, WebChannel, EmailChannel } from '@mounaji_npm/notifications';

export default function RootLayout({ children }) {
  const webChannel = new WebChannel({
    onFetch:       () => fetch('/api/events').then(r => r.json()),
    onMarkRead:    (ids) => fetch('/api/events/mark-read', { method: 'POST', body: JSON.stringify({ ids }) }),
    onMarkAllRead: () => fetch('/api/events/mark-all-read', { method: 'POST' }),
  });

  const emailChannel = new EmailChannel({
    apiEndpoint: '/api/notifications/send-email',
  });

  return (
    <NotificationProvider channels={[webChannel, emailChannel]}>
      {children}
      <NotificationToast isDark={true} />
    </NotificationProvider>
  );
}

// 2. Add the bell to your TopNav
import { NotificationBell } from '@mounaji_npm/notifications';
<NotificationBell isDark={isDark} historyPath="/notifications" onNavigate={router.push} />

// 3. Add the history page
import { NotificationHistoryPage } from '@mounaji_npm/notifications';
export default function NotificationsPage() {
  return <NotificationHistoryPage isDark={isDark} />;
}

Exports

import {
  // Channels
  BaseChannel, WebChannel, EmailChannel, PushChannel, ChannelRegistry,

  // Service
  NotificationService,

  // Provider & context
  NotificationProvider, useNotificationContext,

  // Hooks
  useNotifications, useUnreadCount,

  // UI Components
  NotificationBell, NotificationToast,
  NotificationHistoryPage, NotificationPreferences,
  NotificationRecipientsPanel,
} from '@mounaji_npm/notifications';

// Server-side only (Node.js / Next.js Route Handlers)
import { createResendSender, createPushSender } from '@mounaji_npm/notifications/server';

Channels

The channel system is the delivery layer. Each channel implements a standard interface, and the NotificationService orchestrates across all registered channels.

WebChannel — In-app notifications

Fetches and updates stored notifications via adapter functions you provide. Does not hardcode any API path or database client — stays portable across backends.

import { WebChannel } from '@mounaji_npm/notifications';

const webChannel = new WebChannel({
  onFetch:       async () => fetch('/api/events').then(r => r.json()),
  onMarkRead:    async (ids) => fetch('/api/events/mark-read', {
                   method: 'POST',
                   headers: { 'Content-Type': 'application/json' },
                   body: JSON.stringify({ ids }),
                 }),
  onMarkAllRead: async () => fetch('/api/events/mark-all-read', { method: 'POST' }),
});
Constructor option Type Description
onFetch async () => Notification[] Fetches the notification list
onMarkRead async (ids: string[]) => void Marks specific notifications as read
onMarkAllRead async () => void Marks all notifications as read
EmailChannel — Email via API endpoint

Calls your own API route, which uses createResendSender server-side. The Resend API key never enters the client bundle.

import { EmailChannel } from '@mounaji_npm/notifications';

// Option A — POST to your API route
const emailChannel = new EmailChannel({
  apiEndpoint: '/api/notifications/send-email',
});

// Option B — custom async function (e.g. a Next.js Server Action)
const emailChannel = new EmailChannel({
  onSend: async (payload) => sendEmailServerAction(payload),
});
Constructor option Type Description
apiEndpoint string Your POST endpoint that proxies to Resend
onSend async (payload) => void Alternative to apiEndpoint
enabled boolean Defaults to true
PushChannel — Web Push (PWA)

Real Web Push via the browser Push API + a Service Worker. The client registers the SW, requests permission and stores the subscription; delivery is done server-side with createPushSender (see below). Needs a VAPID key pair (npx web-push generate-vapid-keys).

import { PushChannel } from '@mounaji_npm/notifications';

const push = new PushChannel({
  vapidPublicKey:    process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY,
  serviceWorkerUrl:  '/sw.js',
  subscribeEndpoint: '/api/push/subscribe',
});

await push.enable();   // call from a user gesture (button click): registers SW + subscribes
await push.disable();  // unsubscribe this device
Option Description
vapidPublicKey App's VAPID public key (safe for the browser)
serviceWorkerUrl Path to your SW file (default /sw.js)
subscribeEndpoint Your API route that stores the subscription (default /api/push/subscribe)

The SW must handle push / notificationclick events to show the OS notification.

Custom channels

Extend BaseChannel to add any delivery channel — WhatsApp, Slack, SMS, webhooks, etc.

import { BaseChannel } from '@mounaji_npm/notifications';

class WhatsAppChannel extends BaseChannel {
  name = 'whatsapp';

  get canSend() { return true; }

  constructor({ token, phoneNumberId }) {
    super();
    this._token = token;
    this._phoneNumberId = phoneNumberId;
  }

  async send({ to, title, message }) {
    await fetch(`https://graph.facebook.com/v18.0/${this._phoneNumberId}/messages`, {
      method: 'POST',
      headers: { Authorization: `Bearer ${this._token}`, 'Content-Type': 'application/json' },
      body: JSON.stringify({
        messaging_product: 'whatsapp',
        to,
        type: 'text',
        text: { body: `${title}\n${message ?? ''}` },
      }),
    });
  }
}
BaseChannel interface
Property / Method Description
name string — unique channel identifier
canFetch boolean (getter) — can retrieve stored notifications
canSend boolean (getter) — can deliver outbound notifications
isEnabled() Returns true if channel is active
fetch() Returns Notification[]
markRead(ids) Marks specific notifications as read
markAllRead() Marks all as read
send(notification) Sends a notification through this channel

NotificationService

Orchestrates all registered channels. Typically created internally by NotificationProvider, but can be instantiated directly for non-React usage or advanced control.

import { NotificationService, WebChannel, EmailChannel } from '@mounaji_npm/notifications';

const service = new NotificationService({
  channels: [webChannel, emailChannel],
});

// Fetch in-app notifications (from the first canFetch channel)
const notifications = await service.fetch();

// Mark specific notifications as read (across all fetch channels)
await service.markRead(['id1', 'id2']);

// Mark all as read
await service.markAllRead();

// Send via specific channels
await service.send({
  title:    'Stock crítico: Harina 000',
  to:       'chef@restaurant.com',   // used by email channel
  channels: ['web', 'email'],        // omit to use all enabled send channels
  severity: 'danger',
  metadata: { ingredient: 'Harina 000', available: '2kg', required: '15kg' },
});

// Add a channel dynamically
service.addChannel(new WhatsAppChannel({ token, phoneNumberId }));
API reference
Method Description
fetch() Fetches from the primary canFetch channel
markRead(ids) Marks read on all canFetch channels
markAllRead() Marks all read on all canFetch channels
send(notification) Sends via channels listed in notification.channels, or all enabled canSend channels
addChannel(channel) Register a channel at runtime
registry Direct access to the ChannelRegistry instance

NotificationProvider

React context root. Wraps your app (or a subtree) to enable all hooks and connected components.

Handles:

  • Initial fetch on mount
  • Background poll every pollInterval milliseconds
  • Real-time bridge — listens to mn:events-updated DOM event dispatched by your API client
import { NotificationProvider, WebChannel, EmailChannel } from '@mounaji_npm/notifications';

<NotificationProvider
  channels={[webChannel, emailChannel]}
  config={{
    pollInterval: 60_000,       // background poll interval in ms (0 = disabled)
    watchEvent: 'mn:events-updated', // DOM event to listen for (default value shown)
  }}
>
  {children}
</NotificationProvider>

Advanced: Pass a pre-built NotificationService instance via service instead of channels:

<NotificationProvider service={myService}>...</NotificationProvider>
Props
Prop Type Default Description
channels BaseChannel[] [] Channel instances to register
service NotificationService Pre-built service; overrides channels if provided
config.pollInterval number 60_000 Background poll in ms; 0 to disable
config.watchEvent string 'mn:events-updated' DOM event that triggers a silent re-fetch

Hooks

useNotifications()

Primary hook. Must be used inside <NotificationProvider> — throws otherwise.

import { useNotifications } from '@mounaji_npm/notifications';

const {
  notifications,  // Notification[] — full list (read + unread)
  unreadCount,    // number
  loading,        // boolean — true during the initial fetch or manual refresh
  refresh,        // async () => void — manual re-fetch (shows loading spinner)
  markRead,       // (id: string) => void — optimistic update + persists via channel
  markAllRead,    // () => void — optimistic update + persists via channel
  send,           // (notification) => Promise<void> — send via service
  service,        // NotificationService — direct access
} = useNotifications();
useUnreadCount()

Lightweight hook — returns only the unread count. Returns 0 safely if there is no provider in the tree (no throw), making it safe for standalone components.

import { useUnreadCount } from '@mounaji_npm/notifications';

const unreadCount = useUnreadCount(); // 0 if no provider in tree

UI Components

All components read Mounaji design tokens (--mn-* CSS variables) and fall back gracefully to built-in defaults.

NotificationBell

Context-aware bell widget for the TopNav. Requires <NotificationProvider> in the tree.

import { NotificationBell } from '@mounaji_npm/notifications';

<NotificationBell
  isDark={isDark}
  historyPath="/notifications"
  onNavigate={router.push}
/>
Prop Type Default Description
isDark boolean true Dark/light mode
historyPath string '/notifications' Path for the "Ver historial completo" link
onNavigate (path) => void Navigation callback (e.g. router.push)

Features:

  • Unread count badge (red dot on the bell icon)
  • Dropdown panel with the 8 most recent notifications
  • Click to expand metadata details per row
  • "Marcar leídas" button (marks all read)
  • "+N más" when over 8 notifications
  • Clicking "Ver historial completo" calls onNavigate(historyPath)
NotificationToast

Real-time toast stack that appears in a corner when new unread notifications arrive. Does not toast notifications that exist on initial load — only new arrivals after mount.

Place it as a sibling inside <NotificationProvider>.

import { NotificationToast } from '@mounaji_npm/notifications';

<NotificationProvider channels={[...]}>
  <App />
  <NotificationToast isDark={isDark} position="bottom-right" duration={5000} />
</NotificationProvider>
Prop Type Default Description
isDark boolean true Dark/light mode
position string 'bottom-right' 'bottom-right' | 'bottom-left' | 'top-right' | 'top-left'
duration number 5000 Auto-dismiss in ms
maxVisible number 5 Max toasts shown simultaneously
NotificationHistoryPage

Full-page notification history. Designed to fill a page layout — set max-width, centered.

import { NotificationHistoryPage } from '@mounaji_npm/notifications';

// app/notifications/page.jsx (Next.js App Router)
export default function NotificationsPage() {
  return <NotificationHistoryPage isDark={isDark} />;
}
Prop Type Default Description
isDark boolean true Dark/light mode
onNavigate (path) => void Navigation callback

Features:

  • Filter tabs: Todas / Sin leer (with unread count badge)
  • Category filter pills: all categories + custom registered ones
  • Notifications grouped by date: Hoy / Ayer / Esta semana / Anteriores
  • Expandable rows with full metadata
  • "Cargar más" pagination (25 per page)
  • "Marcar todas como leídas" button
NotificationPreferences

User-facing panel to enable/disable notification channels.

import { NotificationPreferences } from '@mounaji_npm/notifications';

<NotificationPreferences
  isDark={isDark}
  onSave={(prefs) => saveUserPreferences(prefs)}
/>
Prop Type Default Description
isDark boolean true Dark/light mode
onSave (prefs: Record<string, boolean>) => void Called with channel enable/disable map

Channel rows are derived from the NotificationService.registry — only registered channels appear. Channels not yet implemented (whatsapp) appear with a "Pronto" badge and are disabled.


NotificationRecipientsPanel

Admin panel to decide WHO receives important-event notifications and through which channels. It is fully adapter-driven — it has no opinion about your RBAC or storage backend, so any app can reuse it by wiring four functions. Update the component here and every consuming app inherits the change with a single npm install.

import { NotificationRecipientsPanel } from '@mounaji_npm/notifications';

<NotificationRecipientsPanel
  isDark={isDark}
  roleLabels={{ admin: 'Administrador', cocinero: 'Cocinero' }}
  fetchUsers={() => api.get('/api/rbac/users')}   // → [{ id, email, name, roles: [name] }]
  fetchRoles={() => api.get('/api/rbac/roles')}   // → [{ id, name, label? }]
  loadSettings={() => api.get('/api/admin/notification-settings')}
  saveSettings={(s) => api.put('/api/admin/notification-settings', s)}
/>
Prop Type Default Description
isDark boolean false Dark/light mode (uses --mn-* tokens)
fetchUsers () => Promise<User[]> Users list. User = { id, email, name?, roles: (string | {name})[] }
fetchRoles () => Promise<Role[]> Roles list. Role = { id?, name, label? }
loadSettings () => Promise<Settings> Current settings (see shape below)
saveSettings (s: Settings) => Promise<any> Persists settings; may throw to show an error
roleLabels Record<string,string> {} Pretty labels per role name
channels { key, label, desc }[] email + push Which channel toggles to render

Settings shape (the value passed to saveSettings):

{
  email_enabled: boolean,
  push_enabled: boolean,
  recipient_roles: string[],      // role names — every user with the role is included
  recipient_user_ids: string[],   // specific user ids
  extra_emails: string[],         // free-form external addresses
}

Data contract. The component never talks to a database — it only consumes the arrays your adapters return. On the server, expand the saved recipient_roles / recipient_user_ids into actual emails however your backend models users (e.g. join your roles → users tables), then deliver via EmailChannel / createPushSender.


Server-side: createResendSender

Import from @mounaji_npm/notifications/server — never import in client code. This export path is not bundled by Vite and runs in Node.js only.

import { createResendSender } from '@mounaji_npm/notifications/server';

const sender = createResendSender({
  apiKey: process.env.RESEND_API_KEY,   // required
  from:   'Mounaji <noreply@yourdomain.com>', // optional, defaults to placeholder
});

// Send a raw email
await sender.sendEmail({
  to:      'user@example.com',
  subject: 'Bienvenido a Mounaji',
  html:    '<h1>Hola!</h1>',
  text:    'Hola!',
});

// Send a notification object — HTML template is auto-generated
await sender.sendNotification({
  to:           'chef@restaurant.com',
  notification: {
    title:    'Stock crítico: Harina 000',
    severity: 'danger',
    metadata: { available: '2kg', required: '15kg' },
  },
});
Wire-up in a Next.js Route Handler
// app/api/notifications/send-email/route.js
import { createResendSender } from '@mounaji_npm/notifications/server';

const sender = createResendSender({
  apiKey: process.env.RESEND_API_KEY,
  from:   'Mounaji <noreply@yourdomain.com>',
});

export async function POST(req) {
  const body = await req.json();
  // body is the payload sent by EmailChannel.send()
  await sender.sendNotification({ to: body.to, notification: body });
  return Response.json({ ok: true });
}
Wire-up in a Next.js Server Action
// lib/actions/notifications.js
'use server';
import { createResendSender } from '@mounaji_npm/notifications/server';

const sender = createResendSender({ apiKey: process.env.RESEND_API_KEY });

export async function sendEmailAction(payload) {
  return sender.sendNotification({ to: payload.to, notification: payload });
}
createResendSender options
Option Required Description
apiKey Resend API key — from process.env.RESEND_API_KEY
from Sender address shown in the email; defaults to placeholder
sender.sendEmail(params)
Param Required Description
to Recipient email or array of emails
subject Email subject line
html HTML body
text Plain text body
fromOverride Override the from address for this send
sender.sendNotification(params)
Param Required Description
to Recipient email or array of emails
notification Notification object (title, severity, metadata, ...)
subject Override subject; defaults to notification.title
html Override HTML; auto-generated from notification if omitted
text Override plain text; auto-generated if omitted

Server-side: createPushSender

Import from @mounaji_npm/notifications/server — Node.js only. Requires the optional web-push dependency (npm i web-push) and a VAPID key pair.

// app/api/push/send/route.js
import { createPushSender } from '@mounaji_npm/notifications/server';

export const runtime = 'nodejs'; // web-push needs Node's crypto (never Edge)

const pusher = createPushSender({
  publicKey:  process.env.VAPID_PUBLIC_KEY,
  privateKey: process.env.VAPID_PRIVATE_KEY,
  subject:    'mailto:soporte@example.com',
});

// Fan out to all stored subscriptions:
const { gone } = await pusher.sendToMany(subscriptions, { title, message, url, severity });
// `gone` = endpoints that returned 410/404 — delete them from your store.
Method Description
sendTo(subscription, notification) Send to one subscription → { ok, gone }
sendToMany(subscriptions, notification) Send to many → { gone: string[] } (expired endpoints)

notification: { title, message?, url?, severity?, ... }. The matching client SW receives the payload and calls showNotification.


Architecture overview

<NotificationProvider>          ← React context root
  channels: [WebChannel, EmailChannel]
  config: { pollInterval, watchEvent }
  │
  ├── NotificationService       ← orchestrator
  │     └── ChannelRegistry     ← Map<name, BaseChannel>
  │           ├── WebChannel    ← canFetch, in-app storage
  │           ├── EmailChannel  ← canSend, Resend via API
  │           └── PushChannel   ← stub (Phase 2)
  │
  ├── State: notifications[], loading, unreadCount
  │
  ├── Real-time: window 'mn:events-updated' → silentRefresh()
  ├── Polling:   setInterval(silentRefresh, pollInterval)
  │
  └── Context value exposed to:
        useNotifications()        ← full context
        useUnreadCount()          ← unreadCount only (safe w/o provider)
        <NotificationBell />      ← dropdown widget
        <NotificationToast />     ← corner toast stack
        <NotificationHistoryPage />← full history page
        <NotificationPreferences />← channel toggles

Adding a custom channel (WhatsApp example — Phase 2)

// 1. Extend BaseChannel
import { BaseChannel } from '@mounaji_npm/notifications';

class WhatsAppChannel extends BaseChannel {
  name = 'whatsapp';
  get canSend() { return true; }

  constructor({ token, phoneNumberId }) {
    super();
    this._token         = token;
    this._phoneNumberId = phoneNumberId;
  }

  async send({ to, title, message }) {
    const text = message ? `*${title}*\n${message}` : title;
    await fetch(`https://graph.facebook.com/v18.0/${this._phoneNumberId}/messages`, {
      method:  'POST',
      headers: { Authorization: `Bearer ${this._token}`, 'Content-Type': 'application/json' },
      body:    JSON.stringify({ messaging_product: 'whatsapp', to, type: 'text', text: { body: text } }),
    });
  }
}

// 2. Register it in the provider
<NotificationProvider
  channels={[
    webChannel,
    emailChannel,
    new WhatsAppChannel({
      token:         process.env.WHATSAPP_TOKEN,
      phoneNumberId: process.env.WHATSAPP_PHONE_ID,
    }),
  ]}
>

// 3. Send via WhatsApp
const { send } = useNotifications();
await send({
  title:    'Stock crítico: Harina 000',
  to:       '+5491112345678',
  channels: ['whatsapp'],
  severity: 'danger',
});

Notification shape

The object shape expected by all components and produced by @mounaji_npm/event-core's createEvent:

{
  id:         string;                         // UUID
  type:       string;                         // EVENT_TYPES constant
  category:   string;                         // EVENT_CATEGORIES constant
  severity:   'info' | 'success' | 'warning' | 'danger';
  priority:   'critical' | 'high' | 'normal' | 'low';
  source:     string | null;
  target:     string | null;
  title:      string;
  message?:   string;                         // optional longer description
  metadata?:  Record<string, unknown>;        // shown in expandable panel
  read:       boolean;
  created_at: string;                         // ISO 8601
  color?:     string;                         // CSS color override for the icon bubble
}

Package Role
@mounaji_npm/event-core Event types, bus, registry, DOM bridge — the foundation
@mounaji_npm/topnav-widgets Standalone NotificationBell (no provider required)
@mounaji_npm/api-client HTTP client that dispatches signalEventsUpdate()