Skip to content

Sample: Delivery Notifications

Shows how a feature sends notifications to multiple channels (InApp + Email + Push) without knowing the channel internals.

Declarative notifications: r.notification() fires automatically after a CRUD handler — the feature author writes zero ctx.notify() calls.

Per-channel templates: Each channel only receives the data it needs. InApp gets a short title/body, Email gets structured sections for the renderer, Push gets compact text.

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

The test loads 7 features together:

config → tenant/system config
tenant → memberships (delivery needs tenant:query:resolve-user-ids)
delivery → core: ctx.notify, DeliveryLog, Preferences, extension points
channel-inApp → r.useExtension("deliveryChannel", "inApp", ...)
channel-email → r.useExtension("deliveryChannel", "email", ...) + renderer + transport
channel-push → r.useExtension("deliveryChannel", "push", ...) + transport
renderer-simple → r.useExtension("renderer", "simple", { kinds: ["notification"], render })
support → our business logic (tickets + notification definitions)

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

  1. Admin calls support:write:ticket:create over HTTP
  2. CrudExecutor inserts the ticket, returns the SaveContext
  3. Lifecycle pipeline: postSave hook fires r.notification("ticket-assigned")
  4. recipient(result) → assigneeId or null (skip)
  5. data(result) → raw data (title, description, ticketId, priority)
  6. For each registered channel:
    • templates[channelName](data) transforms into channel-specific format
    • InApp: DB insert + SSE push
    • Email: renderer → HTML → transport
    • Push: transport
  7. DeliveryLog entry per channel
  • E2E happy path: Ticket with assignee → InApp + Email + Push + 3 DeliveryLog entries
  • Recipient null 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
//
// Shows how a feature sends notifications via multiple channels (inApp + email + push).
// Uses r.notification() for declarative notifications with per-channel templates.
//
// Flow: Admin assigns a support ticket to a user → the user gets notified
// - InApp: toast + badge in the app
// - Email: full HTML with rendered content
// - Push: native notification
//
// 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 } from "@cosmicdrift/kumiko-framework/engine";
import { z } from "zod";
// --- Entity ---
export const ticketEntity = createEntity({
table: "read_sample_delivery_tickets",
fields: {
title: createTextField({ required: true, maxLength: 200 }),
description: createTextField({ maxLength: 2000 }),
assigneeId: createTextField(),
priority: createTextField({ required: true }), // "low" | "normal" | "critical"
status: createTextField({ required: true }),
},
});
export const ticketTable = buildEntityTable("ticket", ticketEntity);
function ticketExecutor() {
return createEventStoreExecutor(ticketTable, ticketEntity, { entityName: "ticket" });
}
// --- Feature ---
export const supportFeature = defineFeature("support", (r) => {
r.requires("delivery");
r.entity("ticket", ticketEntity);
// Real CRUD handler (not stub) — returns SaveContext for lifecycle hooks
const createHandler = r.writeHandler(
"ticket:create",
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"),
}),
async (event, ctx) => ticketExecutor().create(event.payload, event.user, ctx.db),
{ access: { roles: ["Admin", "Support"] } },
);
// Declarative notification: fires automatically after ticket.create postSave.
//
// - recipient: returns assignee ID, or null to skip (no assignee = no notification)
// - data: extracts raw fields from the save result
// - templates: per-channel transformations
// inApp → short title/body for toast
// email → structured template (header, sections, button) for renderer
// push → short title/body for native notification
r.notification("ticket-assigned", {
trigger: { on: createHandler },
recipient: (result) => {
const assigneeId = result.data["assigneeId"] as string | undefined;
return assigneeId ?? null;
},
data: (result) => ({
ticketId: result.id,
title: result.data["title"] as string,
description: (result.data["description"] as string) ?? "",
priority: result.data["priority"] as string,
}),
templates: {
inApp: (data) => ({
title: `Neues Ticket: ${data["title"]}`,
body: (data["description"] as string) || "Dir wurde ein Ticket zugewiesen.",
}),
email: (data) => ({
subject: `Support-Ticket #${data["ticketId"]} (${data["priority"]})`,
header: `Neues Ticket: ${data["title"]}`,
sections: [
{ text: (data["description"] as string) || "Kein Beschreibungstext." },
{ text: `Prioritaet: ${data["priority"]}` },
{
button: {
label: "Ticket oeffnen",
url: `/support/tickets/${data["ticketId"]}`,
},
},
],
footer: "Automatische Benachrichtigung — nicht antworten.",
}),
push: (data) => ({
title: "Neues Ticket",
body: `${data["title"]} (${data["priority"]})`,
}),
},
});
});

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