@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
MessageServiceAPI - a route factory for mounting message endpoints in Express
- pluggable email and payment provider interfaces
Installation
- npm
- Yarn
- pnpm
- Bun
npm install @web-ts-toolkit/message-service mongoose express
yarn add @web-ts-toolkit/message-service mongoose express
pnpm add @web-ts-toolkit/message-service mongoose express
bun add @web-ts-toolkit/message-service mongoose express
Peer dependencies:
mongoose >= 8express >= 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
TemplateRegistryinstances and provider adapters. Model names alone do not isolate collections. Explicit registries/models do not fall back globally; directarchive()requires the app's registry. Shared-collection tenancy is not provided. - Default parties and archive
archivedByuse native ObjectIds. Arbitrary string users need String overrides for both active/archivefromUser/toUserand archivearchivedBy; 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. adminRolesapplies 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.documentsselects attachments per item ([]clears; omitted uses the original payload's documents).actionLeaseMsconfigures acquisition leases; expiry permits takeover, not cancellation. Successful actions are terminal.MessageAction.parseInputsynchronously validates/normalizesunknownsubmitted input before the handler; direct callers supplyhandleAction(..., { input })and HTTP suppliesreq.body. Invalid input returns a fixed 400; asynchronous parsers are unsupported server errors. Host handlers own durable command/result binding and deduplication byactionAttemptId(see the README recipe).- Every nonempty effective
paymentCdrequires a provider/session for fresh items, even without recipients; missing providers throwPaymentProviderRequiredError. Completed replay never repeats preparation/payment. Email remains best effort;emailRecipientResolveroptionally 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, andMESSAGE_REQUEST_MODEL_NAME
Template and registry exports:
TemplateRegistrydefaultRegistryinterpolateTemplate(...)interpolateMessageContent(...)resolveUiTemplate(...)filterActions(...)isActionAllowed(...)hasExplicitPermissionGrant(...)
Service and route exports:
MessageServicecreateMessageRoutes(options)
Provider exports:
EmailProvider,NoopEmailProviderPaymentProvider,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:
getModelpaymentProvideradminRolesregistryauthMiddlewaregetUsergetPermissionsgetIdentityadminPermissionKey
Mounted routes (action mutation is POST-only):
POST /new/:templateCdGET /:id/actions/:usertypePOST /: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)— returnsArray<IMessage | IMessageArchive>;user(andpayerUserwhen provided) must carry a valid principal id.createNotification(params)— trusted host-level creation with rawUserIdvalues; 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? })—useris 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:
clientRequestIdis only safe for concurrent retries whenMessageRequestis 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), andtemplateCd - 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.