Skip to main content

@web-ts-toolkit/message-service

Template-driven messaging service for Mongoose + Express applications.

This package combines:

  • Mongoose schemas for active, archived, and idempotent-request records
  • a template registry and interpolation engine
  • a direct MessageService API
  • a route factory for mounting message endpoints in Express
  • pluggable email and payment provider interfaces

Installation​

npm install @web-ts-toolkit/message-service mongoose express

Peer dependencies:

  • mongoose >= 8
  • express >= 5

Runtime: Node >=22; use package-root named imports. The shipped README is the installed integration contract, including migration guidance and the two-app recipe verified from installed tarballs on Node 22.0.0 with minimum Express 5.0.0/Mongoose 8.0.0 and installed-current Express 5/Mongoose 9 peers.

Multi-app adaptation and migration notes​

  • Separate apps use distinct connections/databases or disjoint collections, matching schema/service model names, explicit per-app TemplateRegistry instances and provider adapters. Model names alone do not isolate collections. Explicit registries/models do not fall back globally; direct archive() requires the app's registry. Shared-collection tenancy is not provided.
  • Default parties and archive archivedBy use native ObjectIds. Arbitrary string users need String overrides for both active/archive fromUser/toUser and archive archivedBy; message IDs remain ObjectIds. Strings trim without case-folding; textual request scopes retain case. Follow the README's complete recipe and legacy data/scope migration instructions before changing ID storage.
  • adminRoles applies only without a direct recipient or nonempty explicit roles; omitted/empty roles alongside a direct recipient do not add fallback recipients. Principal roles must be omitted/undefined or an array of strings. Action handlers receive the documented principal snapshot, including getter-backed fields.
  • PrepareResult.documents selects attachments per item ([] clears; omitted uses the original payload's documents). actionLeaseMs configures acquisition leases; expiry permits takeover, not cancellation. Successful actions are terminal.
  • MessageAction.parseInput synchronously validates/normalizes unknown submitted input before the handler; direct callers supply handleAction(..., { input }) and HTTP supplies req.body. Invalid input returns a fixed 400; asynchronous parsers are unsupported server errors. Host handlers own durable command/result binding and deduplication by actionAttemptId (see the README recipe).
  • Every nonempty effective paymentCd requires a provider/session for fresh items, even without recipients; missing providers throw PaymentProviderRequiredError. Completed replay never repeats preparation/payment. Email remains best effort; emailRecipientResolver optionally replaces the connection-local user lookup without fallback. Transactional creates skip schema email hooks.

What It Exposes​

Schema exports:

  • buildMessageSchema(config?)
  • buildMessageArchiveSchema()
  • buildMessageRequestSchema()
  • model-name constants such as MESSAGE_MODEL_NAME, MESSAGE_ARCHIVE_MODEL_NAME, and MESSAGE_REQUEST_MODEL_NAME

Template and registry exports:

  • TemplateRegistry
  • defaultRegistry
  • interpolateTemplate(...)
  • interpolateMessageContent(...)
  • resolveUiTemplate(...)
  • filterActions(...)
  • isActionAllowed(...)
  • hasExplicitPermissionGrant(...)

Service and route exports:

  • MessageService
  • createMessageRoutes(options)

Provider exports:

  • EmailProvider, NoopEmailProvider
  • PaymentProvider, NoopPaymentProvider

Typed errors (full set; see README status table for route mappings):

  • MessageNotFoundError (404)
  • TemplateNotFoundError (404)
  • ActionNotFoundError (404)
  • ActionNotAllowedError (403)
  • ActionInputValidationError (400 before handler; fixed public message)
  • MessageArchivedError (410)
  • InvalidMessageUserError (401)
  • InvalidClientRequestIdError (400)
  • ActionConflictError (409)
  • ActionRetryableError (409)
  • ActionNotificationPendingError (202)
  • ClientRequestPendingError (409)
  • ClientRequestFailedError (409)
  • ClientRequestInconsistentStateError (server error)
  • MessageTransactionRequiredError (server error)
  • InvalidPaginationValueError / InvalidMessageServiceOptionError (server error unless handled by host)
  • ActionTemplateMismatchError / MessageModelResolutionError (server error unless handled by host)
  • PaymentSessionCompensationError / PaymentSessionCompensationAggregateError (server error)
  • PaymentProviderRequiredError (server configuration error; sanitized 500)
  • TemplateRegistryValidationError (registration time; not thrown by route handlers)

Quick Start​

import express from 'express';
import mongoose from 'mongoose';
import {
buildMessageArchiveSchema,
buildMessageRequestSchema,
buildMessageSchema,
createMessageRoutes,
defaultRegistry,
MESSAGE_ARCHIVE_MODEL_NAME,
MESSAGE_MODEL_NAME,
MESSAGE_REQUEST_MODEL_NAME,
type MessageTemplate,
type MessageUser,
} from '@web-ts-toolkit/message-service';

const app = express();
app.use(express.json());

declare function resolveAuthenticatedUser(req: express.Request): MessageUser | undefined;

const myAuthMiddleware: express.RequestHandler = (req, res, next) => {
const user = resolveAuthenticatedUser(req);
if (!user) {
res.status(401).json({ message: 'authentication required' });
return;
}
(req as express.Request & { user: MessageUser }).user = user;
next();
};

await mongoose.connect('mongodb://localhost/mydb');

mongoose.model(MESSAGE_MODEL_NAME, buildMessageSchema());
mongoose.model(MESSAGE_ARCHIVE_MODEL_NAME, buildMessageArchiveSchema());
mongoose.model(MESSAGE_REQUEST_MODEL_NAME, buildMessageRequestSchema());

const welcomeTemplate: MessageTemplate = {
templateCd: 'welcome.request',
type: 'request',
description: 'Welcome request',
senderContent: { title: 'Welcome {{name}}', long: 'Sent to reviewers', short: 'Sent' },
receiverContent: { title: 'Review {{name}}', long: 'Please review this request', short: 'Review' },
uiTemplate: 'default-message',
prepareMessage: async ({ user, payload }) => ({
fromUser: user._id,
toRoles: ['reviewer'],
payload,
// Content renders from `templateData`, not `payload`.
templateData: { name: String(payload.name ?? '') },
}),
actions: [
{
actionCd: 'approve',
name: 'Approve',
variant: 'primary',
sender: false,
receiver: true,
runHandler: async ({ actionAttemptId }) => ({ actionAttemptId }),
},
],
};

defaultRegistry.register(welcomeTemplate);

const { router, service } = createMessageRoutes({
getModel: mongoose.model.bind(mongoose),
});

app.use('/api/messages', myAuthMiddleware, router.original);

Prerequisites: Node >=22, mongoose >= 8, express >= 5. Use a MongoDB replica set (or sharded cluster) when you use clientRequestId idempotency or transactional archival; standalone servers throw MessageTransactionRequiredError. Every route requires a resolved user with a valid _id (non-empty string or ObjectId) and returns 401 before any service, template, payment, model, or action effect otherwise.

createMessageRoutes(...) returns both the mounted router and the underlying MessageService instance.

This is the shortest path when you want a working message API quickly and still want direct access to the underlying service for app-specific workflows.

Route Factory​

createMessageRoutes(options) creates a standalone Express router with template-based message routes.

Important options:

  • getModel
  • paymentProvider
  • adminRoles
  • registry
  • authMiddleware
  • getUser
  • getPermissions
  • getIdentity
  • adminPermissionKey

Mounted routes (action mutation is POST-only):

  • POST /new/:templateCd
  • GET /:id/actions/:usertype
  • POST /:id/action/:actionCd

The route factory uses @web-ts-toolkit/express-json-router, so typed message-service errors become normal HTTP responses without extra controller wiring.

Route-factory example with custom request extraction​

import type express from 'express';
import type { MessageUser } from '@web-ts-toolkit/message-service';

const requireAuth: express.RequestHandler = (req, res, next) => {
const user = (req as express.Request & { user?: MessageUser }).user;
if (!user || typeof user._id !== 'string' || user._id.trim() === '') {
res.status(401).json({ message: 'authentication required' });
return;
}
next();
};
type RequestWithAuth = express.Request & {
user?: MessageUser;
permissions?: Record<string, boolean>;
};

const { router } = createMessageRoutes({
getModel: mongoose.model.bind(mongoose),
authMiddleware: [requireAuth],
getUser(req) {
return (req as RequestWithAuth).user;
},
getPermissions(req) {
return (req as RequestWithAuth).permissions ?? {};
},
getIdentity(req) {
return {
tenantId: req.headers['x-tenant-id'],
};
},
});

app.use('/api/messages', router.original);

Create-from-template request example​

await fetch('/api/messages/new/welcome', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
name: 'Ada',
clientRequestId: 'req_123',
}),
});

When clientRequestId is present and MessageRequest is registered, duplicate retries in the same requester/template scope reuse the original in-flight or completed result instead of producing duplicate side effects.

MessageService​

Use MessageService directly when you want the message workflow without the route factory.

Common methods:

  • createMessage(params) — returns Array<IMessage | IMessageArchive>; user (and payerUser when provided) must carry a valid principal id.
  • createNotification(params) — trusted host-level creation with raw UserId values; performs no principal validation.
  • listMessages({ user, limit?, skip?, populate? })
  • countMessages(user)
  • findMessage(id, options?) / findMessageOrThrow(id, options?) — trusted host-level lookup across active and archive; no principal validation.
  • getActions(messageId, usertype, { user, permissions?, isAdmin?, message?, populate? }) — user is required.
  • handleAction(templateCd, actionCd, options)
  • buildVisibilityFilter(user)

Example:

import { MessageService, TemplateRegistry } from '@web-ts-toolkit/message-service';

const registry = new TemplateRegistry();
registry.register(welcomeTemplate);

const service = new MessageService({
getModel: mongoose.model.bind(mongoose),
registry,
});

Direct notification example​

await service.createNotification({
fromUser: null,
toUser: new mongoose.Types.ObjectId(), // use the real recipient's ObjectId
receiverContent: {
title: 'Deployment finished',
long: 'Your deployment completed successfully.',
short: 'Deployment done',
},
});

Direct template-action example​

const message = await service.findMessageOrThrow('507f1f77bcf86cd799439011');

await service.handleAction('welcome.request', 'approve', {
message,
user: { _id: new mongoose.Types.ObjectId(), roles: ['reviewer'] },
permissions: { 'message.ack': true },
});

Template Registry​

TemplateRegistry is the in-memory registry for message templates.

const registry = new TemplateRegistry();

registry.register(template);
const found: RegisteredMessageTemplate | undefined = registry.find('welcome.request');
registry.has('welcome.request');
const all: RegisteredMessageTemplate[] = registry.getAll();
registry.unregister('welcome.request');
registry.clear();

Registration takes a mutable MessageTemplate; find()/getAll() return readonly RegisteredMessageTemplate views matching the frozen snapshot depth.

defaultRegistry is a shared global instance for simpler applications.

Prefer a dedicated TemplateRegistry instance when different apps or tests should not share template definitions through process-global state. This does not enforce shared-collection tenant isolation.

Schema Factories​

Register all three schemas when you want full message-service behavior, especially idempotent create flows:

import {
buildMessageArchiveSchema,
buildMessageRequestSchema,
buildMessageSchema,
MESSAGE_ARCHIVE_MODEL_NAME,
MESSAGE_MODEL_NAME,
MESSAGE_REQUEST_MODEL_NAME,
} from '@web-ts-toolkit/message-service';

mongoose.model(MESSAGE_MODEL_NAME, buildMessageSchema());
mongoose.model(MESSAGE_ARCHIVE_MODEL_NAME, buildMessageArchiveSchema());
mongoose.model(MESSAGE_REQUEST_MODEL_NAME, buildMessageRequestSchema());

Why the third schema matters:

  • clientRequestId is only safe for concurrent retries when MessageRequest is registered
  • the service trims surrounding whitespace, preserves case, and rejects non-string, empty, whitespace-only, or over-128-character IDs
  • the idempotency key is scoped to the trimmed clientRequestId, String(user._id), and templateCd
  • the winner reserves the scoped idempotency key before template preparation or payment-session creation runs
  • later duplicate requests in the same scope return the same outcome instead of causing duplicate side effects
  • reusing the same ID from another requester or another template creates a separate batch and cannot return the first scope's messages

Migration note: scoped idempotency adds clientRequestOwnerId to message and reservation records. Replace any old global unique index on clientRequestId with the compound scoped indexes declared by buildMessageSchema() and buildMessageRequestSchema().

Schema config example​

const Message = mongoose.model(
MESSAGE_MODEL_NAME,
buildMessageSchema({
userModelName: 'User',
archiveModelName: MESSAGE_ARCHIVE_MODEL_NAME,
emailNotificationExclusions: ['silent-template'],
}),
);

Providers​

Email and payment integrations are interfaces, not hard dependencies.

Email provider​

import type { EmailProvider } from '@web-ts-toolkit/message-service';

async function sendWithProvider(to: string, subject: string, text: string): Promise<void> {
void { to, subject, text };
}

class SendGridEmailProvider implements EmailProvider {
async sendNotification(to: string, title: string, body: string) {
await sendWithProvider(to, title, body);
}
}

Use the provider with buildMessageSchema({ emailNotifier }) when you want schema-level notification hooks.

Payment provider​

import type { PaymentProvider, UserId } from '@web-ts-toolkit/message-service';

async function createCheckoutSession(user: UserId, code: string, priceArgs?: Record<string, unknown>): Promise<string> {
void user;
void code;
void priceArgs;
return 'session_123';
}

async function expireCheckoutSession(sessionId: string): Promise<void> {
void sessionId;
}

async function refundCheckoutSession(sessionId: string): Promise<void> {
void sessionId;
}

class StripePaymentProvider implements PaymentProvider {
async createSession(user: UserId, code: string, priceArgs?: Record<string, unknown>) {
return await createCheckoutSession(user, code, priceArgs);
}

async expireSession(sessionId: string) {
await expireCheckoutSession(sessionId);
}

async refundPayment(sessionId: string) {
await refundCheckoutSession(sessionId);
}
}

Pass it to createMessageRoutes({ paymentProvider }) or new MessageService({ paymentProvider }).

Typed Errors​

import {
MessageNotFoundError,
TemplateNotFoundError,
ActionNotFoundError,
ActionNotAllowedError,
ActionInputValidationError,
MessageArchivedError,
InvalidMessageUserError,
InvalidClientRequestIdError,
ActionConflictError,
ActionRetryableError,
ActionNotificationPendingError,
ClientRequestPendingError,
ClientRequestFailedError,
ClientRequestInconsistentStateError,
MessageTransactionRequiredError,
InvalidPaginationValueError,
InvalidMessageServiceOptionError,
ActionTemplateMismatchError,
MessageModelResolutionError,
PaymentSessionCompensationError,
PaymentSessionCompensationAggregateError,
PaymentProviderRequiredError,
TemplateRegistryValidationError,
} from '@web-ts-toolkit/message-service';

These are useful both in route handlers and in direct service usage with instanceof checks. See the package README.md Typed errors table for the full direct-service meanings and route status mappings (404/403/410/401/400/409/202, remainder propagated as server errors).

try {
await service.findMessageOrThrow('missing-id');
} catch (error) {
if (error instanceof MessageNotFoundError) {
console.error('message not found');
}
}

When To Use It​

Use @web-ts-toolkit/message-service when you want:

  • template-based notification and action workflows
  • Mongoose-backed persistence for active and archived messages
  • an Express router you can mount quickly, with a direct service API underneath
  • idempotent create flows and pluggable provider integration

If your app only needs a thin email wrapper or a one-off notification table, this package may be broader than you need.