npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@jmbackend/notifications-module

v1.0.1

Published

Email and in-app notifications for node-backend-core

Readme

@jmbackend/notifications-module

Email and in-app notifications for the existing CommonJS node-backend-core lifecycle. Canonical module name: notifications. Package version: 1.0.0. The source repository in this workspace is named notification-module (singular).

Installation

After publishing the package:

npm install @jmbackend/notifications-module@^1.0.0

Express 5, express-validator 7 and Sequelize 6 are peer dependencies supplied by the host, as in the other modules. Node >=22 is required. There is no email SDK, Stripe SDK or dependency on users-module.

Module registration

Add the package to the host's existing module list:

modules: [
    "@jmbackend/users-module",
    "@jmbackend/auth-module",
    "@jmbackend/notifications-module"
]

Users/Auth above enable the existing JWT middleware in an application that uses them. Notifications has no required module dependencies; auth is optional for lifecycle ordering. For service-only use, set notifications.http.enabled: false. For a different authentication system, inject notifications.http.authenticate; it must authenticate the request and set req.user.id. HTTP registration fails closed when neither that function nor the public auth.authenticate exists.

The loader calls registerModels, registers migration resources in context.database, then calls register and boot. Shutdown detaches listeners and waits for active event deliveries. Repeated register/boot on the same context does not add listeners or routes. Instances are scoped to the context with WeakMap.

Configuration

Configuration is read once from context.config.notifications:

notifications: {
    http: { enabled: true },
    email: {
        from: "[email protected]",
        provider: applicationEmailTransport
    },
    integrations: []
}

The provider is an application object implementing:

async send({ from, to, subject, text, html, idempotencyKey }) {
    // Delegate to your configured transport. Resolve only when it accepts delivery.
    // Forward idempotencyKey if the transport supports deduplication.
}

No default email transport sends or silently pretends to send email. Without a configured provider and from address, EMAIL delivery fails and persists FAILED. The module itself does not read environment variables. The api-test configuration centralizes NOTIFICATIONS_EMAIL_FROM and the opt-in NOTIFICATIONS_ROLE_ASSIGNED=true integration.

Public services

const { notificationService } = context.app.locals.moduleRegistry
    .getPublicServices("notifications", context);

await notificationService.sendNotification({
    userId: 42,
    channel: "IN_APP",
    type: "order-ready",
    title: "Your order is ready",
    message: "Your order can now be collected.",
    data: { orderId: 123 },
    idempotencyKey: "order:123:ready"
});

| Method | Contract | | --- | --- | | getNotifications({ userId, page = 1, limit = 20, unread }) | Paginated IN_APP inbox: { data, pagination: { page, limit, total, pages } } | | getNotification(id, { userId }) | Owned IN_APP notification; another owner is indistinguishable from a missing record (404) | | createNotification(data) / enqueueNotification(data) | Persist/reuse a PENDING notification; no external delivery | | processNotification(id, options?) | Atomically claim and process one persisted notification | | processNextNotification(options?) | Find one eligible notification, claim it atomically and process it; returns null when none is eligible | | sendNotification(data) | Backward-compatible synchronous enqueue + process facade | | markAsRead(id, userId) | Mark owned IN_APP notification read and return DTO | | markAsUnread(id, userId) | Clear readAt and return DTO | | markAllAsRead(userId) | Mark only that user's unread IN_APP notifications; return { count } | | countUnread(userId) | Return { count } for IN_APP records with null readAt |

The public facade follows Users: bound methods, no repository or inherited CRUD bypass. The implementation extends BaseService. Controllers receive its instrumented instance; getServices returns the uninstrumented facade and ModuleRegistry instruments public access. No hook/instrumentation infrastructure is implemented in this package.

Public services are a trusted server API: create/send may target a user selected by application business logic. Never forward arbitrary frontend payloads to them. All inbox methods require userId; none provides an unrestricted admin listing.

Required creation fields: channel, type, title, message. EMAIL requires recipient (one valid email); userId may be null. IN_APP requires a positive integer userId. Optional: html, JSON object data, idempotencyKey, eventId, externalId. Unknown fields and client-supplied status/timestamps are rejected. Metadata is limited to 16 KiB and depth 20, title to 255 characters, message to 10,000, html to 100,000.

REST API

All paths mount under the existing /api/v1 prefix and use the host response envelope { success, message, data }:

| Method | Path | Route alias | | --- | --- | --- | | GET | /notifications/me | notifications.index | | GET | /notifications/me/unread-count | notifications.unreadCount | | GET | /notifications/:id | notifications.show | | PATCH | /notifications/:id/read | notifications.read | | PATCH | /notifications/:id/unread | notifications.unread | | PATCH | /notifications/me/read-all | notifications.readAll |

Ownership always comes from req.user.id. The list accepts only page, limit and unread; ?userId=... is rejected. Body/query user IDs cannot change the owner. There is no POST/send endpoint. The notification DTO omits recipient, html, raw provider errors, idempotency hashes and delivery tokens. It exposes id, userId, type, channel, title, message, data, status and timestamps. Application metadata should contain only information appropriate for that user's inbox.

Swagger uses the host's existing swagger-jsdoc discovery. See /api/docs.json and /api/docs; no second Swagger setup is needed.

Notification channels and providers

  • EMAIL: EmailProvider adapts the generic notification to the injected transport.
  • IN_APP: InAppProvider needs no external I/O; persistence is the delivery mechanism.
  • Future channels: notifications.providers = { PUSH: { async send(notification) {} } } adds a channel to validation and dispatch without modifying NotificationService or migrating an enum. The provider implements its own channel-specific recipient checks. SMS/PUSH/WEBHOOK transports are not included in 1.0.0.

Creation always produces PENDING. In-app records are visible in the inbox as soon as persisted; sendNotification marks successful delivery SENT. Unread means IN_APP + readAt null, independently of delivery status. Email acceptance by the provider means SENT here; it does not assert delivery to a recipient's mailbox.

Idempotency and failure handling

An explicit idempotencyKey takes precedence over eventId, then externalId. The stored SHA-256 key is scoped by channel, type, userId and recipient so one event can legitimately notify multiple recipients. Store eventId/externalId as supplied; the raw explicit key is not retained. A separate canonical request hash detects reuse of the same scoped key with different content and raises ConflictError.

A nullable unique DB index enforces deduplication across processes. An atomic conditional UPDATE acquires a delivery_token only while status=PENDING and the token is null. Concurrent consumers return the current record without sending again. A created but unclaimed PENDING record can later be sent using the same payload/key. Without a key, calls intentionally create separate notifications.

Provider errors are normalized to a bounded message in error and last_error. Only errors explicitly marked retryable: true are retried, using configurable exponential backoff (notifications.delivery). Unclassified errors are terminal by default. sendNotification still rethrows the original provider error for backward compatibility; processNotification returns the persisted state.

A process crash after acquiring delivery leaves a claimed PENDING record with a lease. After the lease expires, another worker can reclaim it. This is not an exactly-once guarantee across external systems; providers should honor the stable logical notification idempotency key on every retry. If a success transition cannot be persisted, the claim is retained because automatic resend is unsafe.

Hooks

All eight public methods accept before/after/error hooks under notifications:

context.hooks.before("notifications", "sendNotification", ({ context }) => {
    context.logger.info("[HOOK BEFORE] notifications.sendNotification");
});
context.hooks.after("notifications", "sendNotification", ({ result, context }) => {
    context.logger.info("[HOOK AFTER] notifications.sendNotification");
});
context.hooks.error("notifications", "sendNotification", ({ error, context }) => {
    context.logger.error("[HOOK ERROR] notifications.sendNotification");
});

The existing core provides { module, method, args, context, result/error } and preserves the original operation error. Log no recipient/message/token payloads. markAsRead/markAsUnread internally call this.getNotification; the internal call does not reenter the proxy. sendNotification's internal persistence does not produce a createNotification hook. This matches the core's existing this binding.

The older sibling node-backend-core lacks ServiceInstrumenter and has an older proxy implementation. A context without an instrumenter preserves service functionality but cannot promise controller hooks. The integration tests target api-test's modern core; the older core was not rewritten for this module.

Domain events

No listeners are enabled by default and no new domain events are invented. Configure one { event, async map(payload, context) } binding per event. map returns one normalized creation payload, an array of them, or null to skip. The module only sees that contract, not Users/Stripe models or SDKs.

examples/integrations.js is an optional application adapter factory for the actual inspected events: welcome, role assignment, permission changes and four billing cases. Copy/adapt its business decisions in the application or import the example explicitly:

const integrations = require("@jmbackend/notifications-module/examples/integrations");
notifications.integrations = integrations({
    welcome: true,
    roleAssigned: true,
    billing: true,
    async resolveRecipients({ event, payload, context }) {
        // Resolve through YOUR application's public APIs; no SDK/model imports
        // inside notifications. Return [{ userId, recipient }].
        return applicationRecipientDirectory.resolve(event, payload);
    }
});

EventBus listeners are registered in boot, removed in shutdown, and use the same instrumented sendNotification as controllers. emitAsync propagates delivery failure; it does not roll back already committed source-module changes. Plain emit cannot await delivery, so prefer emitAsync. No notification after-hook is registered in addition to the event binding. Welcome uses the existing users.created event from the application's Users hook bridge; repeated welcome events deduplicate by user. Billing events use their actual event IDs. Role and permission payloads have no event identity: repeated emissions cannot be reliably distinguished from new assignments. Supply an application operation key if that distinction is required; a permanent user/role key would suppress valid reassignments.

See the event audit for payloads, missing events, the Stripe subscription-update caveat and proposed future normalized domain events.

Templates

templates/email exports welcome, paymentSuccess, paymentFailed, subscriptionCreated and subscriptionCancelled. These small functions return title/message/html and escape inserted HTML text. No template engine or transport dependency is added. Only configure connections to existing events.

Models and migrations

getModels(context).Notification exposes the model. DB table: notifications. Public camelCase maps to the existing snake_case DB convention. Metadata uses Sequelize JSON (the host uses MySQL). There is no User foreign key/association. The migration defines all delivery/read timestamps, error, event/external IDs, internal idempotency/request hashes, delivery claim and inbox indexes.

Run the existing host migration command after installing and registering the package: npm run migrate. Bootstrap only registers metadata. Back up and deploy migrations using the application's existing process; MySQL DDL may commit implicitly despite a surrounding transaction.

Testing

# Workspace development: tests resolve peers and Jest from ../api-test.
npm test -- --silent
npm run test:integration -- --silent
# An alternate compatible host can be selected with CORE_PATH.

Unit and HTTP integration tests run real module layers/core instrumentation with an in-memory persistence substitute and an injected email transport. The api-test suite additionally exercises installed Auth/Users/Role modules, real JWT and the existing application integration registry. Before publication only that test may load the module source directly; production configuration always uses the npm name.

Real MySQL tests require TEST_DB_HOST, TEST_DB_PORT, TEST_DB_NAME, TEST_DB_USER and TEST_DB_PASSWORD pointing to a dedicated test database with no notifications table. They refuse to touch an existing table. They exercise the migration, real unique index, concurrent delivery claims, JSON and ownership. No remote email is sent by tests. There is no lint script configured in the reference packages or this package; syntax and behavior are checked through Node/Jest and npm packaging.

Publishing

npm test -- --silent
npm pack --dry-run
npm publish --access public
# Then in api-test:
npm install @jmbackend/notifications-module@^1.0.0
npm run migrate
npm test -- --runInBand --silent

Publish from this repository with credentials authorized for the @jmbackend scope. The package is public and its files allowlist excludes tests, credentials, node_modules and local artifacts. No publish operation is performed by tests. At implementation time npm returned 404 for version 1.0.0; api-test's lockfile cannot acquire a real resolved/integrity entry until publication. No fake entry, file dependency, sync script or npm link is used.