@cosmicdrift/kumiko-framework 0.362.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 +14 -5
- 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/redis-sse-broker.js +10 -8
- package/dist/api/routes.js +48 -19
- 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 +12 -5
- package/dist/bun-db/query.js +88 -94
- package/dist/crypto/kek-source.js +5 -3
- package/dist/crypto/pii-field-encryption.d.ts +4 -1
- package/dist/crypto/pii-field-encryption.js +2 -2
- package/dist/db/apply-entity-event.js +2 -2
- package/dist/db/collect-table-metas.js +11 -7
- package/dist/db/cursor.js +2 -1
- package/dist/db/dialect.d.ts +1 -0
- package/dist/db/dialect.js +28 -13
- package/dist/db/eagerload.js +2 -2
- package/dist/db/entity-table-from-registry.js +1 -1
- package/dist/db/entity-table-meta.js +5 -13
- package/dist/db/event-store-executor-context.d.ts +5 -6
- package/dist/db/event-store-executor-context.js +29 -27
- package/dist/db/event-store-executor-read.js +40 -25
- 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 +6 -4
- package/dist/db/migrate-runner.js +32 -35
- package/dist/db/parent-ref-clause.js +2 -2
- package/dist/db/queries/backfill-pii.js +11 -9
- package/dist/db/queries/es-ops.js +1 -1
- package/dist/db/queries/event-consumer.js +17 -17
- package/dist/db/queries/event-store-admin.js +6 -6
- package/dist/db/queries/event-store-transfer.js +2 -2
- package/dist/db/queries/event-store.js +2 -2
- package/dist/db/queries/pii-backfill-state.js +2 -2
- package/dist/db/queries/raw-sql.js +1 -1
- package/dist/db/queries/test-stack.js +1 -1
- package/dist/db/reference-data.js +3 -5
- package/dist/db/table-builder.js +6 -4
- package/dist/db/table-introspection.d.ts +16 -0
- package/dist/db/table-introspection.js +76 -0
- 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 +5 -2
- package/dist/db/tenant-db.js +34 -14
- package/dist/derivatives/derivatives-context.js +1 -1
- package/dist/derivatives/variant-key.js +2 -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 +3 -3
- 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 +9 -9
- 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 +155 -96
- package/dist/engine/boot-validator/url-prefill-fields.js +54 -35
- package/dist/engine/changeset-changes.js +3 -3
- package/dist/engine/define-handler.d.ts +2 -2
- package/dist/engine/define-handler.js +11 -7
- package/dist/engine/embedded-derived.js +10 -6
- package/dist/engine/extensions/escape-hatch-usage.js +2 -1
- package/dist/engine/factories.js +5 -6
- package/dist/engine/feature-ast/extractors/events.js +43 -33
- package/dist/engine/feature-ast/extractors/handlers.js +2 -0
- package/dist/engine/feature-ast/patcher.d.ts +3 -3
- package/dist/engine/feature-ast/patcher.js +3 -3
- package/dist/engine/feature-ast/pattern-change-schema.d.ts +10 -8
- package/dist/engine/feature-ast/pattern-change-schema.js +4 -3
- package/dist/engine/feature-ast/patterns.d.ts +3 -3
- package/dist/engine/feature-ast/render.js +16 -5
- package/dist/engine/feature-changelog.d.ts +2 -1
- package/dist/engine/feature-changelog.js +8 -6
- package/dist/engine/feature-config-events-jobs.d.ts +17 -7
- package/dist/engine/feature-config-events-jobs.js +20 -5
- package/dist/engine/feature-entity-handlers.d.ts +2 -2
- package/dist/engine/feature-entity-handlers.js +12 -3
- package/dist/engine/feature-ui-extensions.js +2 -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 +3 -2
- package/dist/engine/index.js +1 -0
- package/dist/engine/ownership.js +7 -20
- package/dist/engine/pattern-library/mixed-schemas.js +0 -1
- package/dist/engine/registry-ingest.d.ts +2 -1
- package/dist/engine/registry-ingest.js +85 -2
- package/dist/engine/registry-validate.d.ts +1 -0
- package/dist/engine/registry-validate.js +38 -8
- package/dist/engine/registry.js +2 -1
- package/dist/engine/schema-builder.js +3 -4
- package/dist/engine/soft-delete-cleanup.js +12 -4
- package/dist/engine/steps/call-feature.js +1 -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 +4 -15
- package/dist/engine/template-variables.d.ts +32 -0
- package/dist/engine/template-variables.js +80 -0
- package/dist/engine/tier-resolver-extension.js +2 -3
- package/dist/engine/types/index.d.ts +2 -2
- package/dist/engine/validate-projection-allowlist.js +12 -13
- package/dist/engine/where-rule-lint.js +8 -7
- 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/reasons.d.ts +1 -0
- package/dist/errors/reasons.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 +2 -2
- 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/local-provider.js +2 -1
- 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/i18n/index.d.ts +1 -1
- package/dist/i18n/index.js +1 -1
- package/dist/i18n/mail-registry.d.ts +2 -0
- package/dist/i18n/mail-registry.js +7 -0
- package/dist/jobs/index.d.ts +1 -1
- package/dist/jobs/job-runner.d.ts +4 -1
- package/dist/jobs/job-runner.js +13 -9
- 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-query.js +2 -3
- package/dist/pipeline/dispatch-shared.d.ts +7 -2
- package/dist/pipeline/dispatch-shared.js +142 -34
- package/dist/pipeline/dispatch-write.js +34 -22
- package/dist/pipeline/event-dispatcher-delivery.js +2 -3
- package/dist/pipeline/index.d.ts +2 -0
- package/dist/pipeline/index.js +2 -0
- package/dist/pipeline/member-read-only-transaction.js +1 -1
- package/dist/pipeline/notify-scope.d.ts +13 -0
- package/dist/pipeline/notify-scope.js +32 -0
- package/dist/pipeline/pre-transaction-reservations.d.ts +3 -1
- package/dist/pipeline/pre-transaction-reservations.js +28 -9
- package/dist/pipeline/system-hooks.js +2 -1
- package/dist/pipeline/system-identity-switch.js +2 -2
- 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 +39 -13
- package/dist/secrets/stored-envelope.js +7 -7
- package/dist/stack/push-entity-projection-tables.js +2 -3
- package/dist/stack/table-helpers.js +10 -26
- package/dist/stack/test-stack.js +2 -5
- package/dist/testing/multipart-helper.d.ts +0 -6
- package/dist/testing/multipart-helper.js +5 -5
- package/dist/testing/without-ambient-temporal.js +4 -4
- package/dist/utils/is-plain-object.d.ts +1 -2
- package/dist/utils/is-plain-object.js +1 -4
- package/package.json +7 -5
- package/src/changes.json +251 -8
|
@@ -0,0 +1,598 @@
|
|
|
1
|
+
<!-- Generated by `bun run gen:agent-docs` from samples/recipes/embedded-entity-form. Do not edit. -->
|
|
2
|
+
|
|
3
|
+
# embedded-entity-form
|
|
4
|
+
|
|
5
|
+
RenderEdit hosted in a Drawer, no screen or route — externally-sourced prefill, controlled mode, and a custom write handler instead of the built-in CRUD create.
|
|
6
|
+
|
|
7
|
+
## README
|
|
8
|
+
|
|
9
|
+
# Embedded entity form
|
|
10
|
+
|
|
11
|
+
Host `RenderEdit` inside a `Drawer` instead of a dedicated screen — no nav
|
|
12
|
+
entry, no route. The form is prefilled from an externally-sourced record
|
|
13
|
+
(a suggestion, not this entity's own detail query), runs in controlled mode
|
|
14
|
+
so the host can read live dirty/valid state and re-validate after patching
|
|
15
|
+
values from outside, and saves through a custom write handler instead of
|
|
16
|
+
the built-in CRUD create.
|
|
17
|
+
|
|
18
|
+
## What it shows
|
|
19
|
+
|
|
20
|
+
- **`RenderEdit` without a screen or route** — `screen` is a plain
|
|
21
|
+
`EntityEditScreenDefinition` literal, never passed to `r.screen`. Nothing
|
|
22
|
+
about `RenderEdit` touches Nav or the router.
|
|
23
|
+
- **Create-mode prefill from an external source** — `initial` comes from a
|
|
24
|
+
`suggestion` prop, `entityId={null}`, no detail-fetch for the entity being
|
|
25
|
+
created.
|
|
26
|
+
- **Controlled mode** — `onChange` reports `{ dirty, valid }` on every
|
|
27
|
+
keystroke; `onControlsReady` hands back `patch`/`validate` so the Drawer's
|
|
28
|
+
footer can restore the suggestion's values and re-validate without a
|
|
29
|
+
remount or a write.
|
|
30
|
+
- **`customSubmit` instead of the built-in CRUD create** — the client sends
|
|
31
|
+
only the fields the user actually changed (`snapshot.changes`, the same
|
|
32
|
+
delta the controlled mode reports); `prospect:accept` merges that onto the
|
|
33
|
+
suggestion it already has server-side.
|
|
34
|
+
- **One write, two entities, atomically** — `prospect:accept` creates the
|
|
35
|
+
prospect, stamps `source`/`acceptedBy`/`acceptedAt` (fields never on the
|
|
36
|
+
edit form, so the client can't set them), and flips the suggestion to
|
|
37
|
+
`accepted` — the same DB transaction, so a suggestion never ends up
|
|
38
|
+
accepted without a prospect or vice versa.
|
|
39
|
+
|
|
40
|
+
## Feature composition
|
|
41
|
+
|
|
42
|
+
```
|
|
43
|
+
suggestion — externally-sourced draft, seeded via the standard CRUD create
|
|
44
|
+
prospect — created only through prospect:accept, never through r.crud
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Flow
|
|
48
|
+
|
|
49
|
+
1. A caller has a `suggestion` (elsewhere seeded, e.g. by an AI extraction
|
|
50
|
+
pipeline) and opens `AcceptSuggestionDrawer` next to it.
|
|
51
|
+
2. The Drawer prefills `RenderEdit` from the suggestion's fields.
|
|
52
|
+
3. The user edits a field or two; `onChange` updates the live status text.
|
|
53
|
+
4. Submit calls `customSubmit`, which dispatches `prospect:accept` with
|
|
54
|
+
just `{ suggestionId, changes }`.
|
|
55
|
+
5. The handler merges `changes` onto the suggestion, creates the prospect,
|
|
56
|
+
and marks the suggestion `accepted`. A second accept on the same
|
|
57
|
+
suggestion is rejected (`unprocessable`).
|
|
58
|
+
|
|
59
|
+
## When to reach for it
|
|
60
|
+
|
|
61
|
+
You have a form that belongs next to something else — a list row, an inbox
|
|
62
|
+
item, a reference field — not on its own page, and saving it needs more
|
|
63
|
+
than a single-entity CRUD write (merging server-known data, writing a
|
|
64
|
+
second record, stamping fields the client must not control). Use the plain
|
|
65
|
+
`writeCommand` + built-in submit path instead when the form both lives on
|
|
66
|
+
its own screen and a single CRUD write is enough.
|
|
67
|
+
|
|
68
|
+
## Source
|
|
69
|
+
|
|
70
|
+
The feature entry point is `src/feature.ts`; the Drawer component is
|
|
71
|
+
`src/web/accept-suggestion-drawer.tsx`. Integration tests under
|
|
72
|
+
`src/__tests__/` cover the merge, the atomic status flip, the double-accept
|
|
73
|
+
rejection, and per-role access.
|
|
74
|
+
|
|
75
|
+
## Source files
|
|
76
|
+
|
|
77
|
+
### src/__tests__/feature.integration.test.ts
|
|
78
|
+
|
|
79
|
+
```ts
|
|
80
|
+
// Embedded Entity Form Sample — Integration Test
|
|
81
|
+
// Proves: prospect:accept merges client changes onto the suggestion it
|
|
82
|
+
// already knows, stamps provenance the client never sent, flips the
|
|
83
|
+
// suggestion to accepted, and rejects a second accept on the same
|
|
84
|
+
// suggestion.
|
|
85
|
+
|
|
86
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
87
|
+
import type { SaveContext } from "@cosmicdrift/kumiko-framework/engine";
|
|
88
|
+
import {
|
|
89
|
+
createTestUser,
|
|
90
|
+
setupTestStack,
|
|
91
|
+
type TestStack,
|
|
92
|
+
TestUsers,
|
|
93
|
+
unsafeCreateEntityTable,
|
|
94
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
95
|
+
import { prospectEntity, prospectsFeature, suggestionEntity } from "../feature";
|
|
96
|
+
|
|
97
|
+
let stack: TestStack;
|
|
98
|
+
|
|
99
|
+
const admin = TestUsers.admin;
|
|
100
|
+
const viewer = createTestUser({ id: 2, roles: ["Viewer"] });
|
|
101
|
+
|
|
102
|
+
async function seedSuggestion(overrides: Partial<Record<string, unknown>> = {}): Promise<string> {
|
|
103
|
+
const created = await stack.http.writeOk<SaveContext>(
|
|
104
|
+
"prospects:write:suggestion:create",
|
|
105
|
+
{ name: "Max Mustermann", email: "max@test.de", company: "Acme", ...overrides },
|
|
106
|
+
admin,
|
|
107
|
+
);
|
|
108
|
+
return created.id as string;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
beforeAll(async () => {
|
|
112
|
+
stack = await setupTestStack({ features: [prospectsFeature] });
|
|
113
|
+
await unsafeCreateEntityTable(stack.db, suggestionEntity);
|
|
114
|
+
await unsafeCreateEntityTable(stack.db, prospectEntity);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
afterAll(async () => {
|
|
118
|
+
await stack.cleanup();
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
describe("prospect:accept", () => {
|
|
122
|
+
test("creates a prospect merged from the suggestion, no client-sent changes", async () => {
|
|
123
|
+
const suggestionId = await seedSuggestion();
|
|
124
|
+
|
|
125
|
+
const accepted = await stack.http.writeOk<SaveContext>(
|
|
126
|
+
"prospects:write:prospect:accept",
|
|
127
|
+
{ suggestionId, changes: {} },
|
|
128
|
+
admin,
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
expect(accepted.data["name"]).toBe("Max Mustermann");
|
|
132
|
+
expect(accepted.data["email"]).toBe("max@test.de");
|
|
133
|
+
expect(accepted.data["company"]).toBe("Acme");
|
|
134
|
+
expect(accepted.data["source"]).toBe(`suggestion:${suggestionId}`);
|
|
135
|
+
expect(accepted.data["acceptedBy"]).toBe(`user:${admin.id}`);
|
|
136
|
+
expect(typeof accepted.data["acceptedAt"]).toBe("string");
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("client changes override the suggestion's field values", async () => {
|
|
140
|
+
const suggestionId = await seedSuggestion({ name: "Draft Name" });
|
|
141
|
+
|
|
142
|
+
const accepted = await stack.http.writeOk<SaveContext>(
|
|
143
|
+
"prospects:write:prospect:accept",
|
|
144
|
+
{ suggestionId, changes: { name: "Corrected Name" } },
|
|
145
|
+
admin,
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
expect(accepted.data["name"]).toBe("Corrected Name");
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test("client-sent provenance fields in changes are ignored, not adopted", async () => {
|
|
152
|
+
const suggestionId = await seedSuggestion();
|
|
153
|
+
|
|
154
|
+
const accepted = await stack.http.writeOk<SaveContext>(
|
|
155
|
+
"prospects:write:prospect:accept",
|
|
156
|
+
{
|
|
157
|
+
suggestionId,
|
|
158
|
+
changes: { name: "X", source: "evil", acceptedBy: "user:999" },
|
|
159
|
+
},
|
|
160
|
+
admin,
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
expect(accepted.data["name"]).toBe("X");
|
|
164
|
+
expect(accepted.data["source"]).toBe(`suggestion:${suggestionId}`);
|
|
165
|
+
expect(accepted.data["acceptedBy"]).toBe(`user:${admin.id}`);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test("flips the suggestion to accepted", async () => {
|
|
169
|
+
const suggestionId = await seedSuggestion();
|
|
170
|
+
|
|
171
|
+
await stack.http.writeOk<SaveContext>(
|
|
172
|
+
"prospects:write:prospect:accept",
|
|
173
|
+
{ suggestionId, changes: {} },
|
|
174
|
+
admin,
|
|
175
|
+
);
|
|
176
|
+
|
|
177
|
+
const detail = await stack.http.queryOk<Record<string, unknown>>(
|
|
178
|
+
"prospects:query:suggestion:detail",
|
|
179
|
+
{ id: suggestionId },
|
|
180
|
+
admin,
|
|
181
|
+
);
|
|
182
|
+
expect(detail["status"]).toBe("accepted");
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test("a second accept on the same suggestion is rejected", async () => {
|
|
186
|
+
const suggestionId = await seedSuggestion();
|
|
187
|
+
|
|
188
|
+
await stack.http.writeOk<SaveContext>(
|
|
189
|
+
"prospects:write:prospect:accept",
|
|
190
|
+
{ suggestionId, changes: {} },
|
|
191
|
+
admin,
|
|
192
|
+
);
|
|
193
|
+
|
|
194
|
+
const error = await stack.http.writeErr(
|
|
195
|
+
"prospects:write:prospect:accept",
|
|
196
|
+
{ suggestionId, changes: {} },
|
|
197
|
+
admin,
|
|
198
|
+
);
|
|
199
|
+
expect(error.code).toBe("unprocessable");
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test("accepting an unknown suggestion is rejected", async () => {
|
|
203
|
+
const error = await stack.http.writeErr(
|
|
204
|
+
"prospects:write:prospect:accept",
|
|
205
|
+
{ suggestionId: "00000000-0000-4000-8000-000000000999", changes: {} },
|
|
206
|
+
admin,
|
|
207
|
+
);
|
|
208
|
+
expect(error.code).toBe("not_found");
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test("only Admin can accept", async () => {
|
|
212
|
+
const suggestionId = await seedSuggestion();
|
|
213
|
+
|
|
214
|
+
const error = await stack.http.writeErr(
|
|
215
|
+
"prospects:write:prospect:accept",
|
|
216
|
+
{ suggestionId, changes: {} },
|
|
217
|
+
viewer,
|
|
218
|
+
);
|
|
219
|
+
expect(error.code).toBe("access_denied");
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
test("clearing the email via changes is accepted, not rejected as an invalid address", async () => {
|
|
223
|
+
const suggestionId = await seedSuggestion();
|
|
224
|
+
|
|
225
|
+
const accepted = await stack.http.writeOk<SaveContext>(
|
|
226
|
+
"prospects:write:prospect:accept",
|
|
227
|
+
{ suggestionId, changes: { email: "" } },
|
|
228
|
+
admin,
|
|
229
|
+
);
|
|
230
|
+
|
|
231
|
+
expect(accepted.data["email"]).toBe("");
|
|
232
|
+
});
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
// kumiko-framework#1905: `suggestion.status` must only ever flip
|
|
236
|
+
// pending → accepted through prospect:accept, which stamps provenance the
|
|
237
|
+
// client can't set itself. A generic suggestion:update write would let an
|
|
238
|
+
// admin set `{ status: "accepted" }` directly, bypassing that path — the
|
|
239
|
+
// CRUD registration now omits the `update` verb, so the handler doesn't
|
|
240
|
+
// exist at all.
|
|
241
|
+
describe("suggestion:update", () => {
|
|
242
|
+
test("does not exist — an admin cannot flip status directly, bypassing prospect:accept", async () => {
|
|
243
|
+
const suggestionId = await seedSuggestion();
|
|
244
|
+
|
|
245
|
+
const error = await stack.http.writeErr(
|
|
246
|
+
"prospects:write:suggestion:update",
|
|
247
|
+
{ id: suggestionId, version: 1, changes: { status: "accepted" } },
|
|
248
|
+
admin,
|
|
249
|
+
);
|
|
250
|
+
|
|
251
|
+
expect(error.code).toBe("not_found");
|
|
252
|
+
});
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
describe("prospect:detail", () => {
|
|
256
|
+
test("reads back the created prospect", async () => {
|
|
257
|
+
const suggestionId = await seedSuggestion();
|
|
258
|
+
const accepted = await stack.http.writeOk<SaveContext>(
|
|
259
|
+
"prospects:write:prospect:accept",
|
|
260
|
+
{ suggestionId, changes: {} },
|
|
261
|
+
admin,
|
|
262
|
+
);
|
|
263
|
+
|
|
264
|
+
const detail = await stack.http.queryOk<Record<string, unknown>>(
|
|
265
|
+
"prospects:query:prospect:detail",
|
|
266
|
+
{ id: accepted.id },
|
|
267
|
+
viewer,
|
|
268
|
+
);
|
|
269
|
+
expect(detail["name"]).toBe("Max Mustermann");
|
|
270
|
+
});
|
|
271
|
+
});
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
### src/entities/prospect.ts
|
|
275
|
+
|
|
276
|
+
```ts
|
|
277
|
+
import {
|
|
278
|
+
createEntity,
|
|
279
|
+
createTextField,
|
|
280
|
+
createTimestampField,
|
|
281
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
282
|
+
|
|
283
|
+
// Created only through `prospect:accept` — never through a built-in CRUD
|
|
284
|
+
// create, so `source`/`acceptedBy`/`acceptedAt` are never on the edit form
|
|
285
|
+
// and a client can't set them itself.
|
|
286
|
+
export const prospectEntity = createEntity({
|
|
287
|
+
table: "read_sample_prospects",
|
|
288
|
+
fields: {
|
|
289
|
+
name: createTextField({ required: true, personal: "self", find: "fuzzy" }),
|
|
290
|
+
email: createTextField({ format: "email", personal: "self", find: "none" }),
|
|
291
|
+
company: createTextField({ personal: false, reason: "is_business_data" }),
|
|
292
|
+
notes: createTextField({ personal: { of: "id" }, find: "none" }),
|
|
293
|
+
source: createTextField({ personal: false, reason: "is_business_data", required: true }),
|
|
294
|
+
acceptedBy: createTextField({ personal: "ref", required: true }),
|
|
295
|
+
acceptedAt: createTimestampField({ required: true }),
|
|
296
|
+
},
|
|
297
|
+
});
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
### src/entities/suggestion.ts
|
|
301
|
+
|
|
302
|
+
```ts
|
|
303
|
+
import {
|
|
304
|
+
createEntity,
|
|
305
|
+
createSelectField,
|
|
306
|
+
createTextField,
|
|
307
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
308
|
+
|
|
309
|
+
// The externally-sourced draft (e.g. an AI extraction from an inbox item).
|
|
310
|
+
// Seeded via the standard CRUD create — `prospect:accept` is the only path
|
|
311
|
+
// that ever moves it from "pending" to "accepted".
|
|
312
|
+
export const suggestionEntity = createEntity({
|
|
313
|
+
table: "read_sample_suggestions",
|
|
314
|
+
fields: {
|
|
315
|
+
name: createTextField({ required: true, personal: "self", find: "fuzzy" }),
|
|
316
|
+
email: createTextField({ format: "email", personal: "self", find: "none" }),
|
|
317
|
+
company: createTextField({ personal: false, reason: "is_business_data" }),
|
|
318
|
+
notes: createTextField({ personal: { of: "id" }, find: "none" }),
|
|
319
|
+
status: createSelectField({
|
|
320
|
+
options: ["pending", "accepted", "rejected"] as const,
|
|
321
|
+
default: "pending",
|
|
322
|
+
}),
|
|
323
|
+
},
|
|
324
|
+
});
|
|
325
|
+
```
|
|
326
|
+
|
|
327
|
+
### src/feature.ts
|
|
328
|
+
|
|
329
|
+
```ts
|
|
330
|
+
// Embedded Entity Form Sample
|
|
331
|
+
// Shows: RenderEdit hosted in a Drawer with no dedicated screen/route,
|
|
332
|
+
// prefilled from an externally-sourced record (a suggestion, not this
|
|
333
|
+
// entity's own detail query), controlled mode (onChange + validate-
|
|
334
|
+
// without-write via onControlsReady), and a custom write handler instead
|
|
335
|
+
// of the built-in CRUD create.
|
|
336
|
+
//
|
|
337
|
+
// Tables:
|
|
338
|
+
// suggestion — an externally-sourced draft (e.g. an AI extraction),
|
|
339
|
+
// seeded via the standard CRUD create
|
|
340
|
+
// prospect — created only through `prospect:accept`, which merges the
|
|
341
|
+
// caller's field-level changes onto the suggestion it
|
|
342
|
+
// already knows server-side, stamps provenance the client
|
|
343
|
+
// can't set itself, and flips the suggestion to accepted —
|
|
344
|
+
// one atomic write the built-in CRUD create can't do.
|
|
345
|
+
|
|
346
|
+
import { createEntityExecutor, defineFeature } from "@cosmicdrift/kumiko-framework/engine";
|
|
347
|
+
import { failNotFound, failUnprocessable } from "@cosmicdrift/kumiko-framework/errors";
|
|
348
|
+
import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
|
|
349
|
+
import { z } from "zod";
|
|
350
|
+
import { prospectEntity } from "./entities/prospect";
|
|
351
|
+
import { suggestionEntity } from "./entities/suggestion";
|
|
352
|
+
|
|
353
|
+
export { prospectEntity } from "./entities/prospect";
|
|
354
|
+
export { suggestionEntity } from "./entities/suggestion";
|
|
355
|
+
|
|
356
|
+
const adminWrite = { access: { roles: ["Admin"] } } as const;
|
|
357
|
+
const openRead = {
|
|
358
|
+
access: {
|
|
359
|
+
openToAll: {
|
|
360
|
+
reason:
|
|
361
|
+
"any signed-in user may read suggestions and prospects in this recipe; writes " +
|
|
362
|
+
"are still gated by adminWrite",
|
|
363
|
+
},
|
|
364
|
+
},
|
|
365
|
+
} as const;
|
|
366
|
+
|
|
367
|
+
const acceptChangesSchema = z.object({
|
|
368
|
+
name: z.string().min(1).optional(),
|
|
369
|
+
email: z.union([z.email(), z.literal("")]).optional(),
|
|
370
|
+
company: z.string().optional(),
|
|
371
|
+
notes: z.string().optional(),
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
export const prospectsFeature = defineFeature("prospects", (r) => {
|
|
375
|
+
// No `update` verb: `suggestion.status` must only ever flip pending →
|
|
376
|
+
// accepted through `prospect:accept` below, which merges the caller's
|
|
377
|
+
// changes onto the suggestion server-side and stamps provenance the
|
|
378
|
+
// client can't set itself. A generic `suggestion:update` write would let
|
|
379
|
+
// an admin set `{ status: "accepted" }` directly, skipping that path
|
|
380
|
+
// entirely and leaving an "accepted" suggestion with no matching
|
|
381
|
+
// prospect (see entities/suggestion.ts).
|
|
382
|
+
r.crud("suggestion", suggestionEntity, {
|
|
383
|
+
write: adminWrite,
|
|
384
|
+
read: openRead,
|
|
385
|
+
verbs: { update: false },
|
|
386
|
+
});
|
|
387
|
+
r.entity("prospect", prospectEntity);
|
|
388
|
+
|
|
389
|
+
const { executor: suggestionExecutor } = createEntityExecutor("suggestion", suggestionEntity);
|
|
390
|
+
const { executor: prospectExecutor } = createEntityExecutor("prospect", prospectEntity);
|
|
391
|
+
|
|
392
|
+
// Custom handler: the client only ever sends the fields the user actually
|
|
393
|
+
// edited (RenderEdit's onChange `changes`, same delta the controlled mode
|
|
394
|
+
// reports) — the handler owns merging that onto the suggestion it already
|
|
395
|
+
// has, so an untouched field never has to round-trip through the browser.
|
|
396
|
+
r.writeHandler(
|
|
397
|
+
"prospect:accept",
|
|
398
|
+
z.object({ suggestionId: z.uuid(), changes: acceptChangesSchema }),
|
|
399
|
+
async (event, ctx) => {
|
|
400
|
+
const suggestion = await suggestionExecutor.detail(
|
|
401
|
+
{ id: event.payload.suggestionId },
|
|
402
|
+
event.user,
|
|
403
|
+
ctx.db,
|
|
404
|
+
);
|
|
405
|
+
if (!suggestion) return failNotFound("suggestion", event.payload.suggestionId);
|
|
406
|
+
if (suggestion["status"] !== "pending") {
|
|
407
|
+
return failUnprocessable("suggestion_already_processed", { status: suggestion["status"] });
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
const created = await prospectExecutor.create(
|
|
411
|
+
{
|
|
412
|
+
name: suggestion["name"],
|
|
413
|
+
email: suggestion["email"],
|
|
414
|
+
company: suggestion["company"],
|
|
415
|
+
notes: suggestion["notes"],
|
|
416
|
+
...event.payload.changes,
|
|
417
|
+
source: `suggestion:${event.payload.suggestionId}`,
|
|
418
|
+
acceptedBy: `user:${event.user.id}`,
|
|
419
|
+
acceptedAt: getTemporal().Now.instant().toString(),
|
|
420
|
+
},
|
|
421
|
+
event.user,
|
|
422
|
+
ctx.db,
|
|
423
|
+
);
|
|
424
|
+
if (!created.isSuccess) return created;
|
|
425
|
+
|
|
426
|
+
// Same ctx.db as the create above — one transaction, so a suggestion
|
|
427
|
+
// never ends up "accepted" without a prospect or vice versa.
|
|
428
|
+
const flipped = await suggestionExecutor.update(
|
|
429
|
+
{
|
|
430
|
+
id: event.payload.suggestionId,
|
|
431
|
+
version: suggestion["version"] as number,
|
|
432
|
+
changes: { status: "accepted" },
|
|
433
|
+
},
|
|
434
|
+
event.user,
|
|
435
|
+
ctx.db,
|
|
436
|
+
);
|
|
437
|
+
if (!flipped.isSuccess) return flipped;
|
|
438
|
+
return created;
|
|
439
|
+
},
|
|
440
|
+
adminWrite,
|
|
441
|
+
);
|
|
442
|
+
|
|
443
|
+
r.queryHandler(
|
|
444
|
+
"prospect:detail",
|
|
445
|
+
z.object({ id: z.uuid() }),
|
|
446
|
+
async (query, ctx) => prospectExecutor.detail(query.payload, query.user, ctx.db),
|
|
447
|
+
openRead,
|
|
448
|
+
);
|
|
449
|
+
});
|
|
450
|
+
```
|
|
451
|
+
|
|
452
|
+
### src/screens/prospect-accept-screen.ts
|
|
453
|
+
|
|
454
|
+
```ts
|
|
455
|
+
import type { EntityEditScreenDefinition } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
456
|
+
|
|
457
|
+
// Not registered via `r.screen` — no nav entry, no route. RenderEdit only
|
|
458
|
+
// reads `entity`/`layout` off this object, so a plain literal is enough to
|
|
459
|
+
// host it inside a Drawer.
|
|
460
|
+
export const prospectAcceptScreen: EntityEditScreenDefinition = {
|
|
461
|
+
id: "prospect-accept-form",
|
|
462
|
+
type: "entityEdit",
|
|
463
|
+
entity: "prospect",
|
|
464
|
+
layout: {
|
|
465
|
+
sections: [{ fields: ["name", "email", "company", "notes"] }],
|
|
466
|
+
},
|
|
467
|
+
};
|
|
468
|
+
```
|
|
469
|
+
|
|
470
|
+
### src/web/accept-suggestion-drawer.tsx
|
|
471
|
+
|
|
472
|
+
```tsx
|
|
473
|
+
import type { RenderEditControls } from "@cosmicdrift/kumiko-renderer";
|
|
474
|
+
import { RenderEdit, useDispatcher, usePrimitives } from "@cosmicdrift/kumiko-renderer";
|
|
475
|
+
import { Drawer } from "@cosmicdrift/kumiko-renderer-web";
|
|
476
|
+
import { type ReactNode, useMemo, useRef, useState } from "react";
|
|
477
|
+
import { z } from "zod";
|
|
478
|
+
import { prospectEntity } from "../entities/prospect";
|
|
479
|
+
import { prospectAcceptScreen } from "../screens/prospect-accept-screen";
|
|
480
|
+
|
|
481
|
+
const acceptSchema = z.object({
|
|
482
|
+
name: z.string().min(1),
|
|
483
|
+
email: z.union([z.email(), z.literal("")]).optional(),
|
|
484
|
+
company: z.string().optional(),
|
|
485
|
+
notes: z.string().optional(),
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
type FormFields = {
|
|
489
|
+
readonly name: string;
|
|
490
|
+
readonly email: string;
|
|
491
|
+
readonly company: string;
|
|
492
|
+
readonly notes: string;
|
|
493
|
+
};
|
|
494
|
+
|
|
495
|
+
export type Suggestion = {
|
|
496
|
+
readonly id: string;
|
|
497
|
+
readonly name: string;
|
|
498
|
+
readonly email?: string;
|
|
499
|
+
readonly company?: string;
|
|
500
|
+
readonly notes?: string;
|
|
501
|
+
};
|
|
502
|
+
|
|
503
|
+
// The create handler's success payload is `{ id, ... }` (see prospect:accept
|
|
504
|
+
// in feature.ts) but RenderEdit's onSubmit only types it as `unknown` —
|
|
505
|
+
// narrow defensively instead of casting through it.
|
|
506
|
+
function extractProspectId(data: unknown): string | undefined {
|
|
507
|
+
if (typeof data !== "object" || data === null) return undefined;
|
|
508
|
+
const id = (data as Record<string, unknown>)["id"];
|
|
509
|
+
return typeof id === "string" ? id : undefined;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
function toFormFields(suggestion: Suggestion): FormFields {
|
|
513
|
+
return {
|
|
514
|
+
name: suggestion.name,
|
|
515
|
+
email: suggestion.email ?? "",
|
|
516
|
+
company: suggestion.company ?? "",
|
|
517
|
+
notes: suggestion.notes ?? "",
|
|
518
|
+
};
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
export type AcceptSuggestionDrawerProps = {
|
|
522
|
+
readonly open: boolean;
|
|
523
|
+
readonly onOpenChange: (open: boolean) => void;
|
|
524
|
+
readonly suggestion: Suggestion;
|
|
525
|
+
readonly onAccepted: (prospectId: string | undefined) => void;
|
|
526
|
+
};
|
|
527
|
+
|
|
528
|
+
// Hosts the prospect create-form inside a Drawer next to the suggestion it
|
|
529
|
+
// came from — no dedicated screen/route, prefilled from the suggestion
|
|
530
|
+
// (an external source, not this entity's own detail query), and saved
|
|
531
|
+
// through `prospect:accept` instead of the built-in CRUD create.
|
|
532
|
+
export function AcceptSuggestionDrawer({
|
|
533
|
+
open,
|
|
534
|
+
onOpenChange,
|
|
535
|
+
suggestion,
|
|
536
|
+
onAccepted,
|
|
537
|
+
}: AcceptSuggestionDrawerProps): ReactNode {
|
|
538
|
+
const { Button } = usePrimitives();
|
|
539
|
+
const dispatcher = useDispatcher();
|
|
540
|
+
const controls = useRef<RenderEditControls<FormFields> | null>(null);
|
|
541
|
+
const [live, setLive] = useState({ dirty: false, valid: true });
|
|
542
|
+
const initial = useMemo(() => toFormFields(suggestion), [suggestion]);
|
|
543
|
+
|
|
544
|
+
if (!open) return null;
|
|
545
|
+
return (
|
|
546
|
+
<Drawer
|
|
547
|
+
open={open}
|
|
548
|
+
onOpenChange={onOpenChange}
|
|
549
|
+
title="Accept suggestion"
|
|
550
|
+
description={
|
|
551
|
+
!live.valid
|
|
552
|
+
? "Please fill in required fields"
|
|
553
|
+
: live.dirty
|
|
554
|
+
? "Unsaved changes"
|
|
555
|
+
: "Unchanged from the suggestion"
|
|
556
|
+
}
|
|
557
|
+
footer={
|
|
558
|
+
<Button
|
|
559
|
+
variant="secondary"
|
|
560
|
+
onClick={() => {
|
|
561
|
+
controls.current?.patch(initial);
|
|
562
|
+
controls.current?.validate();
|
|
563
|
+
}}
|
|
564
|
+
>
|
|
565
|
+
Restore AI suggestion
|
|
566
|
+
</Button>
|
|
567
|
+
}
|
|
568
|
+
>
|
|
569
|
+
<RenderEdit
|
|
570
|
+
key={suggestion.id}
|
|
571
|
+
screen={prospectAcceptScreen}
|
|
572
|
+
entity={prospectEntity}
|
|
573
|
+
featureName="prospects"
|
|
574
|
+
initial={initial}
|
|
575
|
+
entityId={null}
|
|
576
|
+
schema={acceptSchema}
|
|
577
|
+
onChange={({ dirty, valid }) => setLive({ dirty, valid })}
|
|
578
|
+
onControlsReady={(next) => {
|
|
579
|
+
controls.current = next;
|
|
580
|
+
}}
|
|
581
|
+
customSubmit={async (snapshot) => {
|
|
582
|
+
const result = await dispatcher.write("prospects:write:prospect:accept", {
|
|
583
|
+
suggestionId: suggestion.id,
|
|
584
|
+
changes: snapshot.changes,
|
|
585
|
+
});
|
|
586
|
+
return { validationBlocked: false, ...result };
|
|
587
|
+
}}
|
|
588
|
+
onSubmit={(result) => {
|
|
589
|
+
if (result.validationBlocked || !result.isSuccess) return;
|
|
590
|
+
onAccepted(extractProspectId(result.data));
|
|
591
|
+
onOpenChange(false);
|
|
592
|
+
}}
|
|
593
|
+
onCancel={() => onOpenChange(false)}
|
|
594
|
+
/>
|
|
595
|
+
</Drawer>
|
|
596
|
+
);
|
|
597
|
+
}
|
|
598
|
+
```
|