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.
What you learn here
Section titled “What you learn here”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.
Feature composition
Section titled “Feature composition”The test loads these features together:
config → tenant/system configtenant → memberships (delivery needs tenant:query:resolve-user-ids)template-resolver → template store, override handlersrenderer-foundation + renderer-mail-html → renders kind "mail"delivery → core: ctx.notify, DeliveryLog, Preferences, extension pointschannel-inApp / channel-email / channel-pushsupport → 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.
- The boot seed stores the
ticket-assignedmail template - Admin calls
support:write:ticket:createover HTTP - The handler inserts the ticket and calls
ctx.notifyfor the assignee (no assignee, no notification) - For each registered channel:
- InApp:
templates.inApp(data), DB insert + SSE push - Email: stored template rendered with
dataas variables, then transport - Push:
templates.push(data), transport
- InApp:
- 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
Source code
Section titled “Source code”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