@cosmicdrift/kumiko-framework 0.363.0 → 0.364.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +307 -0
- package/agents/examples/access-control.md +355 -0
- package/agents/examples/anonymous-access-multitenant.md +443 -0
- package/agents/examples/anonymous-access.md +300 -0
- package/agents/examples/apex-landing.md +528 -0
- package/agents/examples/apex-surface-auth.md +355 -0
- package/agents/examples/api-worker-split.md +434 -0
- package/agents/examples/auth-claims.md +351 -0
- package/agents/examples/auth-foundation-providers.md +155 -0
- package/agents/examples/auth-mfa.md +323 -0
- package/agents/examples/basic-entity.md +420 -0
- package/agents/examples/boot-check.md +122 -0
- package/agents/examples/chat-channels.md +254 -0
- package/agents/examples/compliance-profiles-demo.md +307 -0
- package/agents/examples/cross-feature-events.md +317 -0
- package/agents/examples/crypto-shredding-hr.md +314 -0
- package/agents/examples/currencies-global.md +609 -0
- package/agents/examples/currencies-per-tenant.md +453 -0
- package/agents/examples/custom-fields-basic.md +314 -0
- package/agents/examples/custom-handlers.md +397 -0
- package/agents/examples/delivery-notifications.md +432 -0
- package/agents/examples/designer-demo.md +86 -0
- package/agents/examples/e2e-generator.md +211 -0
- package/agents/examples/embedded-entity-form.md +598 -0
- package/agents/examples/embedded.md +649 -0
- package/agents/examples/encrypted-tenant-config.md +405 -0
- package/agents/examples/error-contract.md +539 -0
- package/agents/examples/event-sourcing.md +1287 -0
- package/agents/examples/feature-toggles.md +263 -0
- package/agents/examples/field-access.md +271 -0
- package/agents/examples/files-post-processing.md +433 -0
- package/agents/examples/i18n.md +166 -0
- package/agents/examples/idempotency.md +179 -0
- package/agents/examples/inbound-mail-triage.md +315 -0
- package/agents/examples/lane-routing.md +246 -0
- package/agents/examples/ledger-banking.md +214 -0
- package/agents/examples/ledger-invoicing.md +235 -0
- package/agents/examples/legal-pages.md +393 -0
- package/agents/examples/lifecycle-hooks.md +368 -0
- package/agents/examples/managed-config.md +428 -0
- package/agents/examples/managed-pages.md +353 -0
- package/agents/examples/notes-history-basic.md +345 -0
- package/agents/examples/ownership.md +396 -0
- package/agents/examples/pdf-audit-export.md +270 -0
- package/agents/examples/pipeline-basics.md +792 -0
- package/agents/examples/public-share-token.md +351 -0
- package/agents/examples/rate-limiting.md +211 -0
- package/agents/examples/realtime-sse.md +187 -0
- package/agents/examples/record-detail-layout.md +297 -0
- package/agents/examples/reference-data.md +151 -0
- package/agents/examples/relations.md +280 -0
- package/agents/examples/screens-nav.md +353 -0
- package/agents/examples/search.md +233 -0
- package/agents/examples/secrets-demo.md +417 -0
- package/agents/examples/security-baseline.md +187 -0
- package/agents/examples/seed-migration.md +99 -0
- package/agents/examples/session-revocation.md +259 -0
- package/agents/examples/state-machine.md +920 -0
- package/agents/examples/tags-basic.md +401 -0
- package/agents/examples/tenant-isolation.md +173 -0
- package/agents/examples/tenant-settings.md +275 -0
- package/agents/examples/tier-admin.md +347 -0
- package/agents/examples/timezones.md +366 -0
- package/agents/examples/user-data-rights.md +410 -0
- package/agents/examples/user-profile.md +226 -0
- package/agents/examples/webhook-step.md +597 -0
- package/agents/examples/wizard-form.md +379 -0
- package/agents/examples/workflow-engine.md +1731 -0
- package/agents/examples/writeform-section.md +300 -0
- package/agents/examples.md +74 -0
- package/agents/features.md +768 -0
- package/agents/widgets.md +181 -0
- package/dist/api/auth-middleware.d.ts +2 -0
- package/dist/api/auth-middleware.js +7 -0
- package/dist/api/auth-routes.js +5 -3
- package/dist/api/extra-route.d.ts +3 -0
- package/dist/api/index.d.ts +2 -2
- package/dist/api/index.js +1 -1
- package/dist/api/routes.js +46 -17
- package/dist/api/server.d.ts +2 -1
- package/dist/api/server.js +18 -6
- package/dist/api/session-cookies.d.ts +1 -0
- package/dist/api/session-cookies.js +20 -5
- package/dist/api/sse-route.js +16 -1
- package/dist/bun-db/index.d.ts +1 -1
- package/dist/bun-db/index.js +1 -1
- package/dist/bun-db/query.d.ts +8 -3
- package/dist/bun-db/query.js +27 -7
- package/dist/db/collect-table-metas.js +11 -7
- package/dist/db/eagerload.js +2 -2
- package/dist/db/event-store-executor-context.d.ts +5 -6
- package/dist/db/event-store-executor-context.js +24 -22
- package/dist/db/event-store-executor-read.js +31 -16
- package/dist/db/event-store-executor-write.js +18 -20
- package/dist/db/index.d.ts +1 -1
- package/dist/db/index.js +1 -1
- package/dist/db/lock-guard.d.ts +4 -0
- package/dist/db/lock-guard.js +14 -0
- package/dist/db/migrate-generator.js +1 -1
- package/dist/db/migrate-runner.js +9 -8
- package/dist/db/queries/event-consumer.d.ts +6 -6
- package/dist/db/queries/event-store.d.ts +1 -1
- package/dist/db/queries/projection-rebuild.d.ts +1 -1
- package/dist/db/tenant-db-runner.d.ts +4 -0
- package/dist/db/tenant-db-runner.js +17 -0
- package/dist/db/tenant-db.d.ts +2 -0
- package/dist/db/tenant-db.js +30 -4
- package/dist/derivatives/derivatives-context.js +1 -1
- package/dist/engine/boot-validator/access-declarations.js +2 -2
- package/dist/engine/boot-validator/api-ext.js +3 -3
- package/dist/engine/boot-validator/boot-check.js +1 -1
- package/dist/engine/boot-validator/config-deps.js +13 -13
- package/dist/engine/boot-validator/detail-screens.js +1 -1
- package/dist/engine/boot-validator/entity-handler.d.ts +1 -0
- package/dist/engine/boot-validator/entity-handler.js +84 -35
- package/dist/engine/boot-validator/entity-list-screens.js +2 -2
- package/dist/engine/boot-validator/handler-field-references.js +19 -9
- package/dist/engine/boot-validator/i18n-keys.js +2 -2
- package/dist/engine/boot-validator/nav.js +1 -1
- package/dist/engine/boot-validator/pii-retention.js +8 -8
- package/dist/engine/boot-validator/projection-list-screens.js +5 -5
- package/dist/engine/boot-validator/query-output-columns.js +7 -7
- package/dist/engine/boot-validator/screens.d.ts +2 -1
- package/dist/engine/boot-validator/screens.js +153 -95
- package/dist/engine/boot-validator/url-prefill-fields.js +54 -35
- package/dist/engine/define-handler.d.ts +2 -2
- package/dist/engine/define-handler.js +8 -1
- package/dist/engine/feature-ast/extractors/handlers.js +2 -0
- package/dist/engine/feature-ast/pattern-change-schema.d.ts +2 -0
- package/dist/engine/feature-ast/pattern-change-schema.js +1 -0
- package/dist/engine/feature-ast/render.js +10 -2
- package/dist/engine/feature-entity-handlers.d.ts +2 -2
- package/dist/engine/feature-entity-handlers.js +12 -3
- package/dist/engine/handler-field-references.d.ts +21 -2
- package/dist/engine/handler-field-references.js +55 -17
- package/dist/engine/index.d.ts +2 -2
- package/dist/engine/schema-builder.js +2 -1
- package/dist/engine/soft-delete-cleanup.js +10 -3
- package/dist/engine/steps/retry.js +2 -1
- package/dist/engine/steps/unsafe-projection-delete.js +2 -2
- package/dist/engine/steps/unsafe-projection-upsert.js +2 -2
- package/dist/engine/template-variables.d.ts +3 -0
- package/dist/engine/template-variables.js +3 -0
- package/dist/engine/types/index.d.ts +1 -1
- package/dist/env/_zod-introspect.d.ts +1 -0
- package/dist/errors/classes.js +1 -0
- package/dist/errors/kumiko-error.d.ts +2 -0
- package/dist/errors/kumiko-error.js +2 -0
- package/dist/errors/write-error-info.js +2 -0
- package/dist/event-store/tenant-db-append.js +2 -2
- package/dist/files/assert-file-refs-belong-to-tenant.d.ts +4 -5
- package/dist/files/assert-file-refs-belong-to-tenant.js +7 -15
- package/dist/files/bind-pending-file-refs.d.ts +2 -0
- package/dist/files/bind-pending-file-refs.js +3 -3
- package/dist/files/bind-referenced-files.write.d.ts +1 -1
- package/dist/files/bind-referenced-files.write.js +22 -13
- package/dist/files/create-file-ref-from-bytes.d.ts +6 -3
- package/dist/files/create-file-ref-from-bytes.js +67 -4
- package/dist/files/create-file-ref.d.ts +22 -0
- package/dist/files/create-file-ref.js +53 -25
- package/dist/files/file-handle.d.ts +1 -1
- package/dist/files/file-handle.js +8 -0
- package/dist/files/file-routes.js +1 -1
- package/dist/files/index.d.ts +2 -2
- package/dist/files/index.js +1 -1
- package/dist/files/load-save-file-refs.d.ts +10 -0
- package/dist/files/load-save-file-refs.js +47 -0
- package/dist/files/purge-expired-file-ref-bytes.d.ts +4 -1
- package/dist/files/purge-expired-file-ref-bytes.js +45 -19
- package/dist/jobs/index.d.ts +1 -1
- package/dist/jobs/job-runner.d.ts +4 -1
- package/dist/jobs/job-runner.js +8 -4
- package/dist/observability/escape-hatch-report.d.ts +1 -1
- package/dist/observability/escape-hatch-report.js +17 -5
- package/dist/pipeline/cascade-handler.js +5 -2
- package/dist/pipeline/dispatch-batch.js +79 -31
- package/dist/pipeline/dispatch-shared.d.ts +7 -2
- package/dist/pipeline/dispatch-shared.js +99 -22
- package/dist/pipeline/dispatch-write.js +34 -22
- package/dist/pipeline/index.d.ts +1 -0
- package/dist/pipeline/index.js +1 -0
- package/dist/pipeline/pre-transaction-reservations.d.ts +3 -1
- package/dist/pipeline/pre-transaction-reservations.js +28 -9
- package/dist/rate-limit/bucket.d.ts +3 -3
- package/dist/rate-limit/bucket.js +14 -4
- package/dist/redis/cache-sync-bus.js +9 -3
- package/dist/search/meilisearch-adapter.js +36 -9
- package/package.json +7 -5
- package/src/changes.json +207 -8
|
@@ -0,0 +1,432 @@
|
|
|
1
|
+
<!-- Generated by `bun run gen:agent-docs` from samples/recipes/delivery-notifications. Do not edit. -->
|
|
2
|
+
|
|
3
|
+
# delivery-notifications
|
|
4
|
+
|
|
5
|
+
Delivery flows plus notifications. Package tracking, minus the tracking.
|
|
6
|
+
|
|
7
|
+
## README
|
|
8
|
+
|
|
9
|
+
# Sample: Delivery Notifications
|
|
10
|
+
|
|
11
|
+
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.
|
|
12
|
+
|
|
13
|
+
## What you learn here
|
|
14
|
+
|
|
15
|
+
**Declarative notifications:** `r.notification()` declares the mail variables, the default mail text, the subject and the short inApp/push texts in one place.
|
|
16
|
+
|
|
17
|
+
**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.
|
|
18
|
+
|
|
19
|
+
**Tenant override:** A tenant admin overrides the text through the template-resolver handlers (`upsertTenant`, then `publish`). The next mail for that tenant uses the override; the system text stays for everyone else.
|
|
20
|
+
|
|
21
|
+
**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.
|
|
22
|
+
|
|
23
|
+
**Swappable transports:** Email uses `InMemoryTransport` in tests; in a production setup it would be SMTP. Same interface, different implementation.
|
|
24
|
+
|
|
25
|
+
## Feature composition
|
|
26
|
+
|
|
27
|
+
The test loads these features together:
|
|
28
|
+
|
|
29
|
+
```
|
|
30
|
+
config → tenant/system config
|
|
31
|
+
tenant → memberships (delivery needs tenant:query:resolve-user-ids)
|
|
32
|
+
template-resolver → template store, override handlers
|
|
33
|
+
renderer-foundation + renderer-mail-html → renders kind "mail"
|
|
34
|
+
delivery → core: ctx.notify, DeliveryLog, Preferences, extension points
|
|
35
|
+
channel-inApp / channel-email / channel-push
|
|
36
|
+
support → our business logic (tickets + the notification declaration)
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
The `support` feature `r.requires("delivery")`: channels are features that attach to delivery, but `support` doesn't need to know them.
|
|
40
|
+
|
|
41
|
+
## Flow
|
|
42
|
+
|
|
43
|
+
1. The boot seed stores the `ticket-assigned` mail template
|
|
44
|
+
2. Admin calls `support:write:ticket:create` over HTTP
|
|
45
|
+
3. The handler inserts the ticket and calls `ctx.notify` for the assignee (no assignee, no notification)
|
|
46
|
+
4. For each registered channel:
|
|
47
|
+
- InApp: `templates.inApp(data)`, DB insert + SSE push
|
|
48
|
+
- Email: stored template rendered with `data` as variables, then transport
|
|
49
|
+
- Push: `templates.push(data)`, transport
|
|
50
|
+
5. DeliveryLog entry per channel
|
|
51
|
+
|
|
52
|
+
## Tests
|
|
53
|
+
|
|
54
|
+
- **E2E happy path:** Ticket with assignee → InApp + Email + Push + 3 DeliveryLog entries
|
|
55
|
+
- **Tenant override:** a published tenant template replaces the system mail text
|
|
56
|
+
- **Skip:** Ticket without assignee → no notifications
|
|
57
|
+
- **Access control:** Non-Admin/Support can't create tickets
|
|
58
|
+
|
|
59
|
+
## Source files
|
|
60
|
+
|
|
61
|
+
### src/__tests__/feature.integration.test.ts
|
|
62
|
+
|
|
63
|
+
```ts
|
|
64
|
+
// Sample Integration Test: Delivery Notifications
|
|
65
|
+
//
|
|
66
|
+
// Proves the notification path end-to-end:
|
|
67
|
+
// 1. The boot seed writes the declared mail template into the template store
|
|
68
|
+
// 2. Admin creates a ticket with assignee (via HTTP API), the handler sends via ctx.notify
|
|
69
|
+
// 3. InApp → DB + SSE
|
|
70
|
+
// 4. Email → stored template → mail renderer → Transport
|
|
71
|
+
// 5. Push → Transport
|
|
72
|
+
// 6. DeliveryLog has entries for all 3 channels
|
|
73
|
+
// 7. A tenant overrides the stored mail text and the next mail uses it
|
|
74
|
+
|
|
75
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
76
|
+
import {
|
|
77
|
+
createChannelEmailFeature,
|
|
78
|
+
createInMemoryTransport,
|
|
79
|
+
type EmailMessage,
|
|
80
|
+
} from "@cosmicdrift/kumiko-bundled-features/channel-email";
|
|
81
|
+
import {
|
|
82
|
+
createChannelInAppFeature,
|
|
83
|
+
inAppMessagesTable,
|
|
84
|
+
} from "@cosmicdrift/kumiko-bundled-features/channel-in-app";
|
|
85
|
+
import {
|
|
86
|
+
createChannelPushFeature,
|
|
87
|
+
createInMemoryPushTransport,
|
|
88
|
+
type PushMessage,
|
|
89
|
+
} from "@cosmicdrift/kumiko-bundled-features/channel-push";
|
|
90
|
+
import { createConfigFeature } from "@cosmicdrift/kumiko-bundled-features/config";
|
|
91
|
+
import {
|
|
92
|
+
createDeliveryFeature,
|
|
93
|
+
createDeliveryTestContext,
|
|
94
|
+
deliveryAttemptsTable,
|
|
95
|
+
notificationPreferencesTable,
|
|
96
|
+
} from "@cosmicdrift/kumiko-bundled-features/delivery";
|
|
97
|
+
import { createRendererFoundationFeature } from "@cosmicdrift/kumiko-bundled-features/renderer-foundation";
|
|
98
|
+
import {
|
|
99
|
+
createTemplateResolverFeature,
|
|
100
|
+
TemplateResolverHandlers,
|
|
101
|
+
} from "@cosmicdrift/kumiko-bundled-features/template-resolver";
|
|
102
|
+
import { seedNotificationTemplates } from "@cosmicdrift/kumiko-bundled-features/template-resolver/seeding";
|
|
103
|
+
import {
|
|
104
|
+
createTenantFeature,
|
|
105
|
+
TenantQueries,
|
|
106
|
+
tenantMembershipsTable,
|
|
107
|
+
} from "@cosmicdrift/kumiko-bundled-features/tenant";
|
|
108
|
+
import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
109
|
+
import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
|
|
110
|
+
import type { SessionUser } from "@cosmicdrift/kumiko-framework/engine";
|
|
111
|
+
import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
112
|
+
import {
|
|
113
|
+
createTestUser,
|
|
114
|
+
setupTestStack,
|
|
115
|
+
type TestStack,
|
|
116
|
+
TestUsers,
|
|
117
|
+
unsafePushTables,
|
|
118
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
119
|
+
import { expectErrorIncludes } from "@cosmicdrift/kumiko-framework/testing";
|
|
120
|
+
import { createRendererMailHtmlFeature } from "@cosmicdrift/kumiko-renderer-mail-html";
|
|
121
|
+
import { supportFeature, TICKET_ASSIGNED, ticketTable } from "../feature";
|
|
122
|
+
|
|
123
|
+
// --- Test Infrastructure ---
|
|
124
|
+
|
|
125
|
+
const emailTransport = createInMemoryTransport();
|
|
126
|
+
const pushTransport = createInMemoryPushTransport();
|
|
127
|
+
|
|
128
|
+
// Simple resolvers: userId → address
|
|
129
|
+
const testEmail = (userId: string) => `user-${userId}@example.com`;
|
|
130
|
+
const testPushToken = (userId: string) => `push-token-${userId}`;
|
|
131
|
+
|
|
132
|
+
// Test users
|
|
133
|
+
const admin: SessionUser = TestUsers.admin;
|
|
134
|
+
const assignee = createTestUser({ id: 5, roles: ["Support"] });
|
|
135
|
+
|
|
136
|
+
// --- Feature Setup ---
|
|
137
|
+
|
|
138
|
+
const features = [
|
|
139
|
+
createConfigFeature(),
|
|
140
|
+
createTenantFeature(),
|
|
141
|
+
createTemplateResolverFeature(),
|
|
142
|
+
createRendererFoundationFeature(),
|
|
143
|
+
createRendererMailHtmlFeature(),
|
|
144
|
+
createDeliveryFeature(),
|
|
145
|
+
createChannelInAppFeature(),
|
|
146
|
+
createChannelEmailFeature({
|
|
147
|
+
transport: emailTransport,
|
|
148
|
+
resolveEmail: async (userId) => testEmail(userId),
|
|
149
|
+
}),
|
|
150
|
+
createChannelPushFeature({
|
|
151
|
+
transport: pushTransport,
|
|
152
|
+
resolveToken: async (userId) => testPushToken(userId),
|
|
153
|
+
}),
|
|
154
|
+
supportFeature,
|
|
155
|
+
];
|
|
156
|
+
|
|
157
|
+
let stack: TestStack;
|
|
158
|
+
let db: DbConnection;
|
|
159
|
+
|
|
160
|
+
beforeAll(async () => {
|
|
161
|
+
stack = await setupTestStack({
|
|
162
|
+
features,
|
|
163
|
+
extraContext: (deps) =>
|
|
164
|
+
createDeliveryTestContext(deps, { tenantUserIdsQuery: TenantQueries.resolveUserIds }),
|
|
165
|
+
});
|
|
166
|
+
db = stack.db;
|
|
167
|
+
|
|
168
|
+
await unsafePushTables(db, {
|
|
169
|
+
inAppMessagesTable,
|
|
170
|
+
notificationPreferencesTable,
|
|
171
|
+
tenantMembershipsTable,
|
|
172
|
+
ticketTable,
|
|
173
|
+
});
|
|
174
|
+
await createEventsTable(db);
|
|
175
|
+
// setupTestStack does not run boot seeds; a real app seeds at boot.
|
|
176
|
+
await seedNotificationTemplates(db, stack.registry);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
afterAll(async () => {
|
|
180
|
+
await stack.cleanup();
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
// --- End-to-End Flow ---
|
|
184
|
+
|
|
185
|
+
describe("delivery-notifications sample", () => {
|
|
186
|
+
test("ticket creation fires InApp + Email + Push notifications to assignee", async () => {
|
|
187
|
+
// Reset transports and SSE
|
|
188
|
+
emailTransport.sent.length = 0;
|
|
189
|
+
pushTransport.sent.length = 0;
|
|
190
|
+
stack.events.reset();
|
|
191
|
+
|
|
192
|
+
// --- Act: Admin creates a ticket with an assignee ---
|
|
193
|
+
await stack.http.writeOk(
|
|
194
|
+
"support:write:ticket:create",
|
|
195
|
+
{
|
|
196
|
+
title: "Login-Button reagiert nicht",
|
|
197
|
+
description: "Nach Klick auf Login passiert nichts in Firefox.",
|
|
198
|
+
assigneeId: assignee.id,
|
|
199
|
+
priority: "critical",
|
|
200
|
+
status: "open",
|
|
201
|
+
},
|
|
202
|
+
admin,
|
|
203
|
+
);
|
|
204
|
+
|
|
205
|
+
// --- Assert: InApp ---
|
|
206
|
+
const inAppMessages = await selectMany(db, inAppMessagesTable, { userId: assignee.id });
|
|
207
|
+
expect(inAppMessages).toHaveLength(1);
|
|
208
|
+
expect(inAppMessages[0]?.["title"]).toBe("New ticket: Login-Button reagiert nicht");
|
|
209
|
+
expect(inAppMessages[0]?.["body"]).toContain("Nach Klick auf Login");
|
|
210
|
+
|
|
211
|
+
const sseEvents = stack.events.sse.filter((e) => e.type === "channel-in-app:event:delivered");
|
|
212
|
+
expect(sseEvents).toHaveLength(1);
|
|
213
|
+
expect(sseEvents[0]?.data["userId"]).toBe(assignee.id);
|
|
214
|
+
|
|
215
|
+
// --- Assert: Email ---
|
|
216
|
+
const email = emailTransport.sent.find((e: EmailMessage) => e.to === testEmail(assignee.id));
|
|
217
|
+
expect(email).toBeDefined();
|
|
218
|
+
expect(email?.subject).toContain("Support ticket #");
|
|
219
|
+
expect(email?.subject).toContain("(critical)");
|
|
220
|
+
expect(email?.html).toContain("Login-Button reagiert nicht"); // title
|
|
221
|
+
expect(email?.html).toContain("Nach Klick auf Login"); // description
|
|
222
|
+
expect(email?.html).toContain("Priority: critical");
|
|
223
|
+
expect(email?.html).toContain("Open ticket"); // button
|
|
224
|
+
expect(email?.html).toContain("/support/tickets/"); // button URL
|
|
225
|
+
|
|
226
|
+
// --- Assert: Push ---
|
|
227
|
+
const push = pushTransport.sent.find(
|
|
228
|
+
(p: PushMessage) => p.token === testPushToken(assignee.id),
|
|
229
|
+
);
|
|
230
|
+
expect(push).toBeDefined();
|
|
231
|
+
expect(push?.title).toBe("New ticket");
|
|
232
|
+
expect(push?.body).toContain("Login-Button");
|
|
233
|
+
expect(push?.body).toContain("critical");
|
|
234
|
+
|
|
235
|
+
// --- Assert: DeliveryLog has entries for all 3 channels ---
|
|
236
|
+
const logs = await selectMany(db, deliveryAttemptsTable, {
|
|
237
|
+
notificationType: TICKET_ASSIGNED,
|
|
238
|
+
recipientId: assignee.id,
|
|
239
|
+
});
|
|
240
|
+
expect(logs).toHaveLength(3);
|
|
241
|
+
const channels = logs.map((l) => l["channel"]);
|
|
242
|
+
expect(channels).toContain("inApp");
|
|
243
|
+
expect(channels).toContain("email");
|
|
244
|
+
expect(channels).toContain("push");
|
|
245
|
+
expect(logs.every((l) => l["status"] === "sent")).toBe(true);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
test("a tenant override of the stored mail text replaces the system text", async () => {
|
|
249
|
+
emailTransport.sent.length = 0;
|
|
250
|
+
const tenantAdmin = { ...admin, roles: ["TenantAdmin"] };
|
|
251
|
+
|
|
252
|
+
const draft = await stack.http.writeOk<{ id: string }>(
|
|
253
|
+
TemplateResolverHandlers.upsertTenant,
|
|
254
|
+
{
|
|
255
|
+
slug: TICKET_ASSIGNED,
|
|
256
|
+
kind: "mail",
|
|
257
|
+
locale: "en",
|
|
258
|
+
content:
|
|
259
|
+
'<p>Tenant text: <span data-variable="title"></span></p><p><span data-variable="description"></span> (<span data-variable="priority"></span>)</p><p><a data-button href="{{url}}">Open</a></p>',
|
|
260
|
+
contentFormat: "html",
|
|
261
|
+
meta: { subject: "Tenant subject #{{ticketId}}" },
|
|
262
|
+
variableSchema: {},
|
|
263
|
+
},
|
|
264
|
+
tenantAdmin,
|
|
265
|
+
);
|
|
266
|
+
await stack.http.writeOk(TemplateResolverHandlers.publish, { id: draft.id }, tenantAdmin);
|
|
267
|
+
|
|
268
|
+
await stack.http.writeOk(
|
|
269
|
+
"support:write:ticket:create",
|
|
270
|
+
{ title: "Printer on fire", assigneeId: assignee.id, priority: "normal", status: "open" },
|
|
271
|
+
admin,
|
|
272
|
+
);
|
|
273
|
+
|
|
274
|
+
const email = emailTransport.sent.find((e: EmailMessage) => e.to === testEmail(assignee.id));
|
|
275
|
+
expect(email?.subject).toContain("Tenant subject #");
|
|
276
|
+
expect(email?.html).toContain("Tenant text: Printer on fire");
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
test("ticket without assignee skips notification (recipient returns null)", async () => {
|
|
280
|
+
emailTransport.sent.length = 0;
|
|
281
|
+
pushTransport.sent.length = 0;
|
|
282
|
+
|
|
283
|
+
await stack.http.writeOk(
|
|
284
|
+
"support:write:ticket:create",
|
|
285
|
+
{
|
|
286
|
+
title: "Dokumentation ueberarbeiten",
|
|
287
|
+
description: "Die API-Docs sind veraltet.",
|
|
288
|
+
priority: "low",
|
|
289
|
+
status: "open",
|
|
290
|
+
},
|
|
291
|
+
admin,
|
|
292
|
+
);
|
|
293
|
+
|
|
294
|
+
// No notifications sent — no assignee
|
|
295
|
+
expect(emailTransport.sent).toHaveLength(0);
|
|
296
|
+
expect(pushTransport.sent).toHaveLength(0);
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
test("access control: non-Admin/Support cannot create tickets", async () => {
|
|
300
|
+
const normalUser = createTestUser({ id: 99, roles: ["User"] });
|
|
301
|
+
const error = await stack.http.writeErr(
|
|
302
|
+
"support:write:ticket:create",
|
|
303
|
+
{
|
|
304
|
+
title: "Hacked ticket",
|
|
305
|
+
priority: "normal",
|
|
306
|
+
status: "open",
|
|
307
|
+
},
|
|
308
|
+
normalUser,
|
|
309
|
+
);
|
|
310
|
+
expectErrorIncludes(error, "access_denied");
|
|
311
|
+
});
|
|
312
|
+
});
|
|
313
|
+
```
|
|
314
|
+
|
|
315
|
+
### src/feature.ts
|
|
316
|
+
|
|
317
|
+
```ts
|
|
318
|
+
// Delivery Notifications Sample
|
|
319
|
+
//
|
|
320
|
+
// A support ticket is created and its assignee is notified via inApp, email
|
|
321
|
+
// and push. The mail is a stored template: `r.notification` declares the
|
|
322
|
+
// variables, the default text and the subject, the boot seed writes the text
|
|
323
|
+
// into the template store, and a tenant can override it there.
|
|
324
|
+
//
|
|
325
|
+
// The feature code only declares WHAT to notify. HOW is handled by Delivery.
|
|
326
|
+
|
|
327
|
+
import { buildEntityTable, createEventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
|
|
328
|
+
import {
|
|
329
|
+
createEntity,
|
|
330
|
+
createTextField,
|
|
331
|
+
defineFeature,
|
|
332
|
+
QnTypes,
|
|
333
|
+
qn,
|
|
334
|
+
templateVariable,
|
|
335
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
336
|
+
import { z } from "zod";
|
|
337
|
+
|
|
338
|
+
// --- Entity ---
|
|
339
|
+
|
|
340
|
+
export const ticketEntity = createEntity({
|
|
341
|
+
table: "read_sample_delivery_tickets",
|
|
342
|
+
fields: {
|
|
343
|
+
title: createTextField({
|
|
344
|
+
personal: false,
|
|
345
|
+
reason: "is_business_data",
|
|
346
|
+
required: true,
|
|
347
|
+
maxLength: 200,
|
|
348
|
+
}),
|
|
349
|
+
description: createTextField({ personal: false, reason: "is_business_data", maxLength: 2000 }),
|
|
350
|
+
assigneeId: createTextField({ personal: "ref" }),
|
|
351
|
+
priority: createTextField({ personal: false, reason: "is_business_data", required: true }), // "low" | "normal" | "critical"
|
|
352
|
+
status: createTextField({ personal: false, reason: "is_business_data", required: true }),
|
|
353
|
+
},
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
export const ticketTable = buildEntityTable("ticket", ticketEntity);
|
|
357
|
+
|
|
358
|
+
function ticketExecutor() {
|
|
359
|
+
return createEventStoreExecutor(ticketTable, ticketEntity, { entityName: "ticket" });
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// --- Feature ---
|
|
363
|
+
|
|
364
|
+
export const TICKET_ASSIGNED = qn("support", QnTypes.notify, "ticket-assigned");
|
|
365
|
+
|
|
366
|
+
export const supportFeature = defineFeature("support", (r) => {
|
|
367
|
+
r.requires("delivery");
|
|
368
|
+
|
|
369
|
+
r.entity("ticket", ticketEntity);
|
|
370
|
+
|
|
371
|
+
r.notification("ticket-assigned", {
|
|
372
|
+
variables: {
|
|
373
|
+
ticketId: templateVariable.text({ required: true }),
|
|
374
|
+
title: templateVariable.text({ required: true }),
|
|
375
|
+
description: templateVariable.text({ required: true }),
|
|
376
|
+
priority: templateVariable.text({ required: true }),
|
|
377
|
+
url: templateVariable.link({ required: true }),
|
|
378
|
+
},
|
|
379
|
+
template: {
|
|
380
|
+
en: {
|
|
381
|
+
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>',
|
|
382
|
+
},
|
|
383
|
+
},
|
|
384
|
+
meta: { en: { subject: "Support ticket #{{ticketId}} ({{priority}})" } },
|
|
385
|
+
// inApp and push build their own short text; email renders the stored
|
|
386
|
+
// template with the notify data as variables.
|
|
387
|
+
templates: {
|
|
388
|
+
inApp: (data) => ({
|
|
389
|
+
title: `New ticket: ${data["title"]}`,
|
|
390
|
+
body: (data["description"] as string) || "A ticket was assigned to you.",
|
|
391
|
+
}),
|
|
392
|
+
push: (data) => ({
|
|
393
|
+
title: "New ticket",
|
|
394
|
+
body: `${data["title"]} (${data["priority"]})`,
|
|
395
|
+
}),
|
|
396
|
+
},
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
r.writeHandler(
|
|
400
|
+
"ticket:create",
|
|
401
|
+
z.object({
|
|
402
|
+
title: z.string().min(1),
|
|
403
|
+
description: z.string().optional(),
|
|
404
|
+
assigneeId: z.uuid().optional(),
|
|
405
|
+
priority: z.enum(["low", "normal", "critical"]),
|
|
406
|
+
status: z.string().default("open"),
|
|
407
|
+
}),
|
|
408
|
+
async (event, ctx) => {
|
|
409
|
+
const created = await ticketExecutor().create(event.payload, event.user, ctx.db);
|
|
410
|
+
const assigneeId = event.payload.assigneeId;
|
|
411
|
+
// No assignee, no notification.
|
|
412
|
+
if (!created.isSuccess || assigneeId === undefined) return created;
|
|
413
|
+
if (ctx.notify === undefined) {
|
|
414
|
+
throw new Error("ticket:create needs ctx.notify, mount the delivery feature");
|
|
415
|
+
}
|
|
416
|
+
const ticketId = created.data.id;
|
|
417
|
+
await ctx.notify(TICKET_ASSIGNED, {
|
|
418
|
+
to: assigneeId,
|
|
419
|
+
data: {
|
|
420
|
+
ticketId,
|
|
421
|
+
title: event.payload.title,
|
|
422
|
+
description: event.payload.description ?? "",
|
|
423
|
+
priority: event.payload.priority,
|
|
424
|
+
url: `https://app.example.com/support/tickets/${ticketId}`,
|
|
425
|
+
},
|
|
426
|
+
});
|
|
427
|
+
return created;
|
|
428
|
+
},
|
|
429
|
+
{ access: { roles: ["Admin", "Support"] } },
|
|
430
|
+
);
|
|
431
|
+
});
|
|
432
|
+
```
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
<!-- Generated by `bun run gen:agent-docs` from samples/recipes/designer-demo. Do not edit. -->
|
|
2
|
+
|
|
3
|
+
# designer-demo
|
|
4
|
+
|
|
5
|
+
Kumiko sample feature — scaffolded by `bun kumiko create`
|
|
6
|
+
|
|
7
|
+
## README
|
|
8
|
+
|
|
9
|
+
# Designer Demo
|
|
10
|
+
|
|
11
|
+
Designer-driven feature — live schema edits via the UI skeleton.
|
|
12
|
+
|
|
13
|
+
## What it shows
|
|
14
|
+
|
|
15
|
+
- Patterns from the three hand-rolled view categories the designer targets
|
|
16
|
+
- A minimal feature surface the designer can open and mutate
|
|
17
|
+
|
|
18
|
+
## Source
|
|
19
|
+
|
|
20
|
+
Feature entry point: `src/feature.ts`.
|
|
21
|
+
|
|
22
|
+
## Tests
|
|
23
|
+
|
|
24
|
+
No dedicated automated tests in this folder yet — use it as a composition sample.
|
|
25
|
+
|
|
26
|
+
## Source files
|
|
27
|
+
|
|
28
|
+
### src/feature.ts
|
|
29
|
+
|
|
30
|
+
```ts
|
|
31
|
+
// kumiko-feature-version: 1
|
|
32
|
+
// Demo-Feature für den Designer-Skeleton (C5).
|
|
33
|
+
// Enthält bewusst Patterns aus den 3 hand-rollten View-Kategorien:
|
|
34
|
+
// - entity → Tabelle der Fields
|
|
35
|
+
// - writeHandler → Header + Code-Blocks (schema + handler)
|
|
36
|
+
// - nav → ID/Label/Screen-Card
|
|
37
|
+
// Plus optionalRequires + metric als JSON-Dump-Fallback.
|
|
38
|
+
|
|
39
|
+
import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
|
|
40
|
+
import { z } from "zod";
|
|
41
|
+
|
|
42
|
+
defineFeature("designerDemo", (r) => {
|
|
43
|
+
r.optionalRequires("analytics");
|
|
44
|
+
|
|
45
|
+
r.entity("task", {
|
|
46
|
+
fields: {
|
|
47
|
+
title: { type: "text", required: true },
|
|
48
|
+
done: { type: "boolean", default: false },
|
|
49
|
+
priority: { type: "select", options: ["low", "medium", "high"], default: "medium" },
|
|
50
|
+
},
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
r.writeHandler({
|
|
54
|
+
name: "task:create",
|
|
55
|
+
schema: z.object({ title: z.string(), priority: z.string().optional() }),
|
|
56
|
+
handler: async (_event, _ctx) => {
|
|
57
|
+
return { isSuccess: true, data: { id: "x" } };
|
|
58
|
+
},
|
|
59
|
+
access: { roles: ["user", "admin"] },
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
r.writeHandler({
|
|
63
|
+
name: "task:complete",
|
|
64
|
+
schema: z.object({ id: z.string() }),
|
|
65
|
+
handler: async (_event, _ctx) => {
|
|
66
|
+
return { isSuccess: true, data: {} };
|
|
67
|
+
},
|
|
68
|
+
access: {
|
|
69
|
+
openToAll: {
|
|
70
|
+
reason:
|
|
71
|
+
"sample handler: any signed-in user may complete a task in this designer-skeleton " +
|
|
72
|
+
"demo; there is no per-user task ownership",
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
r.nav({
|
|
78
|
+
id: "tasks",
|
|
79
|
+
label: "Tasks",
|
|
80
|
+
icon: "list",
|
|
81
|
+
screen: "designerDemo:screen:task-list",
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
r.metric("tasks_created", { type: "counter" });
|
|
85
|
+
});
|
|
86
|
+
```
|