Skip to content

Sample: Delivery Notifications

Shows how a feature sends notifications to multiple channels (InApp + Email + Push) without knowing the channel internals, and how the mail text lives in the template store where a tenant can override it.

Declarative notifications: r.notification() declares the mail variables, the default mail text, the subject and the short inApp/push texts in one place.

Mail text is stored data: The boot seed (seedNotificationTemplates) writes the declared template and meta into the template store as system templates. The email channel renders them with renderer-mail-html. A notification routed to email without template and meta fails its delivery.

Tenant override: Only a SystemAdmin edits templates unless the app unlocks them. The app lists the mail in createTemplateResolverFeature({ tenantEditable: [{ kind: "mail", slug, access: { roles: ["TenantAdmin"] } }] }); then a tenant admin overrides the text through the template-resolver handlers (upsertTenant, then publish) and every other template is refused with 403. The next mail for that tenant uses the override; the system text stays for everyone else.

Sending: The write handler calls ctx.notify(type, { to, data }). The data keys are the template variables; sending fails loudly when a required variable is missing.

Swappable transports: Email uses InMemoryTransport in tests; in a production setup it would be SMTP. Same interface, different implementation.

The test loads these features together:

config → tenant/system config
tenant → memberships (delivery needs tenant:query:resolve-user-ids)
template-resolver → template store, override handlers
renderer-foundation + renderer-mail-html → renders kind "mail"
delivery → core: ctx.notify, DeliveryLog, Preferences, extension points
channel-inApp / channel-email / channel-push
support → our business logic (tickets + the notification declaration)

The support feature r.requires({ features: ["delivery"] }): channels are features that attach to delivery, but support doesn’t need to know them.

  1. The boot seed stores the ticket-assigned mail template
  2. Admin calls support:write:ticket:create over HTTP
  3. The handler inserts the ticket and calls ctx.notify for the assignee (no assignee, no notification)
  4. For each registered channel:
    • InApp: templates.inApp(data), DB insert + SSE push
    • Email: stored template rendered with data as variables, then transport
    • Push: templates.push(data), transport
  5. DeliveryLog entry per channel
  • E2E happy path: Ticket with assignee → InApp + Email + Push + 3 DeliveryLog entries
  • Tenant override: a published tenant template replaces the system mail text
  • Skip: Ticket without assignee → no notifications
  • Access control: Non-Admin/Support can’t create tickets

The feature entry point — embedded straight from the source file, so the code here is exactly what runs. Multi-file samples keep their remaining files next to it on GitHub (link below):

// Delivery Notifications Sample
//
// A support ticket is created and its assignee is notified via inApp, email
// and push. The mail is a stored template: `r.notification` declares the
// variables, the default text and the subject, the boot seed writes the text
// into the template store, and a tenant can override it there.
//
// The feature code only declares WHAT to notify. HOW is handled by Delivery.
import { buildEntityTable, createEventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
import {
createEntity,
createTextField,
defineFeature,
QnTypes,
qn,
templateVariable,
} from "@cosmicdrift/kumiko-framework/engine";
import { z } from "zod";
// --- Entity ---
export const ticketEntity = createEntity({
table: "read_sample_delivery_tickets",
fields: {
title: createTextField({
personal: false,
reason: "is_business_data",
required: true,
maxLength: 200,
}),
description: createTextField({ personal: false, reason: "is_business_data", maxLength: 2000 }),
assigneeId: createTextField({ personal: "ref" }),
priority: createTextField({ personal: false, reason: "is_business_data", required: true }), // "low" | "normal" | "critical"
status: createTextField({ personal: false, reason: "is_business_data", required: true }),
},
});
export const ticketTable = buildEntityTable("ticket", ticketEntity);
function ticketExecutor() {
return createEventStoreExecutor(ticketTable, ticketEntity, { entityName: "ticket" });
}
// --- Feature ---
export const TICKET_ASSIGNED = qn("support", QnTypes.notify, "ticket-assigned");
export const supportFeature = defineFeature("support", (r) => {
r.requires({ features: ["delivery"] });
r.entity({ name: "ticket", ...ticketEntity });
r.notification("ticket-assigned", {
variables: {
ticketId: templateVariable.text({ required: true }),
title: templateVariable.text({ required: true }),
description: templateVariable.text({ required: true }),
priority: templateVariable.text({ required: true }),
url: templateVariable.link({ required: true }),
},
template: {
en: {
body: '<h1>New ticket: <span data-variable="title"></span></h1><p><span data-variable="description"></span></p><p>Priority: <span data-variable="priority"></span></p><p><a data-button href="{{url}}">Open ticket</a></p>',
},
},
meta: { en: { subject: "Support ticket #{{ticketId}} ({{priority}})" } },
// inApp and push build their own short text; email renders the stored
// template with the notify data as variables.
templates: {
inApp: (data) => ({
title: `New ticket: ${data["title"]}`,
body: (data["description"] as string) || "A ticket was assigned to you.",
}),
push: (data) => ({
title: "New ticket",
body: `${data["title"]} (${data["priority"]})`,
}),
},
});
r.writeHandler({
name: "ticket:create",
schema: z.object({
title: z.string().min(1),
description: z.string().optional(),
assigneeId: z.uuid().optional(),
priority: z.enum(["low", "normal", "critical"]),
status: z.string().default("open"),
}),
handler: async (event, ctx) => {
const created = await ticketExecutor().create(event.payload, event.user, ctx.db);
const assigneeId = event.payload.assigneeId;
// No assignee, no notification.
if (!created.isSuccess || assigneeId === undefined) return created;
if (ctx.notify === undefined) {
throw new Error("ticket:create needs ctx.notify, mount the delivery feature");
}
const ticketId = created.data.id;
await ctx.notify(TICKET_ASSIGNED, {
to: assigneeId,
// Known key per ticket: dedups a replayed handler and lets an admin screen ask
// channel-in-app:query:read-state-by-keys whether the assignee has seen it.
idempotencyKey: `ticket-assigned:${ticketId}`,
data: {
ticketId,
title: event.payload.title,
description: event.payload.description ?? "",
priority: event.payload.priority,
url: `https://app.example.com/support/tickets/${ticketId}`,
},
});
return created;
},
access: { roles: ["Admin", "Support"] },
});
});

📄 On GitHub: samples/recipes/delivery-notifications/src/feature.ts