@devx-retailos/notifications
v0.0.1
Published
Generic notifications layer for retailOS. Pluggable NotificationChannelAdapter interface with built-in SMTP email, Limechat WhatsApp, and Slack webhook adapters. DB-stored templates, delivery logging, idempotency, and multi-instance-safe retry.
Keywords
Readme
@devx-retailos/notifications
Medusa v2 plugin that provides a brand-agnostic notification system for retailOS. Supports multiple delivery channels (email, WhatsApp, Slack, custom), Handlebars-based templates with per-store/org/global specificity, automatic retry with SELECT FOR UPDATE SKIP LOCKED, and idempotent dispatch.
Installation
pnpm add @devx-retailos/notificationsAdd to medusa-config.ts:
import { defineConfig } from "@medusajs/framework/utils"
export default defineConfig({
plugins: [
{
resolve: "@devx-retailos/notifications",
options: {
channels: [
{
type: "email_smtp",
config: {
host: process.env.SMTP_HOST,
port: 587,
secure: false,
from: process.env.SMTP_FROM, // required — "Name <addr>" or bare address
auth: {
user: process.env.SMTP_USER,
pass: process.env.SMTP_PASS,
},
},
},
],
retry: {
cron: "*/15 * * * *", // default
max_attempts: 3, // default
batch_size: 50, // default
},
},
},
],
})Plugin options
| Field | Type | Default | Description |
|---|---|---|---|
| channels | Array<{ type, config }> | [] | Channel adapters to activate. type must match a registered adapter. |
| retry.cron | string | "*/15 * * * *" | Cron for the retry job. |
| retry.max_attempts | number | 3 | Max delivery attempts before a dispatch is permanently failed. |
| retry.batch_size | number | 50 | Rows claimed per retry cycle. |
Built-in adapters
| type | Description | Required config fields |
|---|---|---|
| email_smtp | Sends via SMTP (AWS SES, SendGrid, Postmark, self-hosted) | host, auth.user, auth.pass, from |
| whatsapp_limechat | WhatsApp via Limechat API | api_token, account_id |
| slack_webhook | Slack incoming webhook | webhook_url |
Custom adapter
Implement NotificationChannelAdapter<TConfig> and register it at boot:
import type { NotificationChannelAdapter } from "@devx-retailos/notifications"
const smsTwilioAdapter: NotificationChannelAdapter<TwilioConfig> = {
type: "sms_twilio",
description: "SMS via Twilio",
capabilities: {
supports_attachments: false,
supports_subject: false,
supports_html: false,
is_synchronous: true,
},
validateConfig(config) {
return TwilioConfigSchema.parse(config)
},
async send(config, input) {
// call Twilio API
return { status: "sent", provider_reference: sid }
},
}
// Register in your Medusa subscriber or plugin boot:
const svc = container.resolve(NOTIFICATIONS_MODULE)
svc.registerAdapter(smsTwilioAdapter)Templates
Templates are Handlebars strings stored in retailos_notification_template. The system resolves the most-specific template per channel:
store-scoped > org-scoped > globalCreate a template
await svc.upsertTemplate({
event_key: "order.confirmed",
channel: "email_smtp",
name: "Order confirmation email",
subject_template: "Order {{order_id}} confirmed",
body_template: `
<p>Hi {{customer_name}},</p>
<p>Your order <strong>{{order_id}}</strong> has been confirmed.</p>
{{#if tracking_url}}<p><a href="{{tracking_url}}">Track your order</a></p>{{/if}}
<p>Total: {{formatCurrency total 'INR'}}</p>
`,
// Scope to a specific store (omit for global)
// store_id: "store_abc",
// organization_id: "org_abc",
})Built-in Handlebars helpers
| Helper | Usage | Output |
|---|---|---|
| formatCurrency | {{formatCurrency amount 'INR'}} | ₹99.99 (divides by 100) |
| formatDate | {{formatDate date 'en-IN'}} | Locale-formatted date string |
Variables are HTML-escaped by default. Use {{{triple_braces}}} for trusted HTML.
Service API
import { NOTIFICATIONS_MODULE } from "@devx-retailos/notifications"
import type { NotificationsModuleService } from "@devx-retailos/notifications"
const svc = container.resolve<NotificationsModuleService>(NOTIFICATIONS_MODULE)svc.dispatchEvent(eventKey, data, context)
Resolve templates for an event, render them, and send. The idempotency key is computed as sha256(eventKey:referenceId:channel:recipient) — duplicate calls for the same event + entity are safe.
await svc.dispatchEvent(
"order.confirmed",
{
order_id: "ORD-001",
customer_name: "Alice",
customer_email: "[email protected]",
total: 9999, // paise
},
{
referenceId: "ord_001", // entity whose event this is
organizationId: "org_abc", // narrows template scope
storeId: "store_xyz", // narrows template scope further
recipient: "[email protected]",// overrides customer_email/phone from data
channels: ["email_smtp"], // optional; omit to dispatch all active channels
}
)svc.send(input)
Explicit single-dispatch. Creates a retailos_notification_dispatch row, sends, and updates status.
await svc.send({
channel: "slack_webhook",
recipient: "#ops-alerts",
body: "New order ORD-001 placed.",
event_key: "order.new", // optional, defaults to "explicit_send"
idempotency_key: "slack_ord_001",
dedup_window_seconds: 300, // skip if same key sent within 5 min
})svc.upsertTemplate(input)
Create (no id) or update (with id) a template. Validates Handlebars syntax at write time.
svc.listTemplates(filters?)
const templates = await svc.listTemplates({
event_key: "order.confirmed",
channel: "email_smtp",
is_active: true,
})svc.listDeliveries(filters?)
const { dispatches, total } = await svc.listDeliveries({
entity_type: "order",
entity_id: "ord_001",
status: "failed",
limit: 20,
offset: 0,
})svc.listAdapters()
Returns all registered adapters with is_configured flag.
svc.registerAdapter(adapter)
Register a custom channel adapter at runtime.
Dispatch lifecycle
pending → processing (locked_until = now + 10 min) → sent | failed | skippedlocked_untilprevents double-processing in multi-instance deploys.- The retry job (
*/15 * * * *) reclaimsfailedrows with remaining attempts, staleprocessingrows (lock expired), and long-pending rows. - A
skippedstatus is available for business-logic skips (no recipient, suppressed, etc.).
Scheduled jobs
| Job | Schedule | What it does |
|---|---|---|
| retry-failed-notifications | every 15 min | Claims and retries failed/stale dispatches using SELECT FOR UPDATE SKIP LOCKED |
| cleanup-old-notifications | 02:00 daily | Deletes sent rows older than 90 days, failed/skipped older than 30 days |
Admin API routes
All routes are under /admin/retailos/notifications and require authentication.
| Method | Path | Description |
|---|---|---|
| GET | /templates | List templates (filterable by event_key, channel, is_active, org/store) |
| POST | /templates | Create a new template |
| PUT | /templates/:id | Update an existing template |
| GET | /deliveries | List dispatch history (filterable by entity, status, channel, date range) |
Permissions
Register these keys via syncAllPermissions:
| Key | Description |
|---|---|
| notification.templates.read | View templates |
| notification.templates.write | Create or update templates |
| notification.deliveries.read | View delivery history |
| notification.send | Trigger explicit sends via admin API |
import { NOTIFICATION_PERMISSIONS } from "@devx-retailos/notifications"
import { syncAllPermissions } from "@devx-retailos/core"
await syncAllPermissions(rbac, NOTIFICATION_PERMISSIONS)Database tables
| Table | Description |
|---|---|
| retailos_notification_template | Template definitions per event/channel/scope |
| retailos_notification_dispatch | Per-send audit log with status and retry state |
Run migrations after installing (replace <your-backend> with your project filter):
pnpm --filter <your-backend> db:migrateTesting
pnpm --filter @devx-retailos/notifications test
pnpm --filter @devx-retailos/notifications typecheckTests are colocated at src/modules/notifications/__tests__/. The service layer is tested with a mocked MedusaService base — no real DB or Medusa container required.
