feat(forms): M8 website-block — formEmbed bindet Mana-Formulare ein

Neuer Block-Type `formEmbed` im Website-Builder
(docs/plans/forms-module.md M8):

- @mana/website-blocks/src/formEmbed/:
  - schema.ts: FormEmbedSchema mit token (32-char base64url) +
    titleOverride + optional resolved-Block (formTitle, fields,
    branching, settings.{submitButtonLabel, successMessage}).
    FormFieldEmbedSchema duplicated leichtgewichtig statt cross-
    package import — website-blocks bleibt self-contained.
  - FormEmbed.svelte: edit/preview rendert Placeholder-Card mit
    Token-Snippet und resolved-Status; public rendert die kompletten
    11 Field-Types inkl. Live-Branching-aware-Render. Submitter-
    Block (Name+Email optional). Submit POSTet an
    /api/v1/forms/public/:token/submit. Lazy-Fallback fetcht
    /api/v1/unlisted/public/:token wenn die publish-resolver-blob
    fehlt. Bot-Honeypot bleibt M8-Polish.
  - FormEmbedInspector.svelte: Token-Input mit base64url-Validierung
    bei blur, optional titleOverride, resolved-Card mit
    Field-Count + Logik-Regel-Count.
- BLOCK_SPECS + BLOCK_SCHEMAS + BLOCK_DEFAULTS um formEmbed
  erweitert. schemas.test.ts erwartet jetzt 12 Block-Types.
- apps/mana/apps/web/src/lib/modules/website/forms-embeds.ts:
  resolveFormEmbed scant formTable nach unlistedToken (linear scan
  ist günstig bei <100 forms pro user, kein Index nötig), dekrypted,
  validiert published-status, gibt resolved-Block zurück.
- publish.ts.resolveEmbedsInTree erweitert um formEmbed-Branch — ruft
  resolveFormEmbed parallel zu resolveEmbed (moduleEmbed) im selben
  Walk.

Trade-offs:
- Token statt formId: bei Token-Rotation (M4b) muss der User den Block
  neu konfigurieren. Der formEmbed-Block-Resolver erkennt das + setzt
  resolved.error; public-Renderer fällt auf lazy-fetch zurück.
- Plaintext stored: das resolved-Blob landet als plaintext im
  public-snapshot, gleiches Trust-Modell wie moduleEmbed (öffentliche
  Website per Definition).

Tests: website-blocks 50/50 grün (12 schema-block-types + per-type
defaults validation). svelte-check 0 errors. forms 26/26 unverändert.

Use-Case: Vereins-Sommerfest. User legt /forms/anmeldung an,
publisht, setzt unlisted, kopiert Token. Im Website-Builder fügt er
einen formEmbed-Block auf der Event-Seite ein, paste Token → bei
Publish wird der Form-Schema inlined → Besucher submitten direkt
auf der Vereins-Website.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-29 02:38:28 +02:00
parent 57b7a43147
commit ace1b706e6
10 changed files with 913 additions and 2 deletions

View file

@ -0,0 +1,115 @@
/**
* formEmbed resolver client-side function that looks up the
* referenced Mana Form by its unlisted-share-token and inlines the
* public schema into `block.props.resolved` at publish time.
*
* Why client-side: the webapp owns both the website AND the forms;
* decrypting the form's title/fields/branching needs the master key,
* which only lives on the client. The resolver runs in the same
* publish transaction as `resolveEmbed` for moduleEmbed blocks.
*
* Token-only lookup (no formId): the block stores just the share-
* token, which is the canonical identity for a published+unlisted
* form. Token rotation invalidates the embed the resolver reports
* a clear error and the public renderer falls back to its lazy fetch.
*
* Plan: docs/plans/forms-module.md M8.
*/
import { decryptRecords } from '$lib/data/crypto';
import { formTable } from '$lib/modules/forms/collections';
import { toForm } from '$lib/modules/forms/queries';
import type { FormEmbedProps } from '@mana/website-blocks';
import type { LocalForm } from '$lib/modules/forms/types';
export async function resolveFormEmbed(
props: FormEmbedProps
): Promise<NonNullable<FormEmbedProps['resolved']>> {
const now = new Date().toISOString();
if (!props.token) {
return {
formTitle: '',
formDescription: null,
fields: [],
branching: [],
settings: { submitButtonLabel: 'Senden', successMessage: 'Danke!' },
resolvedAt: now,
error: 'Kein Token gesetzt',
};
}
try {
// Linear scan over the user's forms — typical count is small
// (<100), Dexie has no index on unlistedToken. The publish
// pipeline runs once per click, so this stays cheap.
const all = (await formTable.toArray()).filter(
(f) => !f.deletedAt && f.unlistedToken === props.token
);
if (all.length === 0) {
return {
formTitle: '',
formDescription: null,
fields: [],
branching: [],
settings: { submitButtonLabel: 'Senden', successMessage: 'Danke!' },
resolvedAt: now,
error: 'Formular zum Token nicht gefunden',
};
}
const decrypted = (await decryptRecords('forms', all)) as LocalForm[];
const form = toForm(decrypted[0]);
if (form.status !== 'published') {
return {
formTitle: form.title,
formDescription: form.description,
fields: [],
branching: [],
settings: {
submitButtonLabel: form.settings.submitButtonLabel,
successMessage: form.settings.successMessage,
},
resolvedAt: now,
error: 'Formular ist nicht veröffentlicht',
};
}
return {
formTitle: form.title,
formDescription: form.description,
fields: form.fields.map((f) => ({
id: f.id,
type: f.type,
label: f.label,
helpText: f.helpText,
required: f.required,
options: f.options,
config: f.config,
})),
branching: form.branching.map((r) => ({
id: r.id,
ifFieldId: r.ifFieldId,
ifOperator: r.ifOperator,
ifValue: r.ifValue,
thenAction: r.thenAction,
thenFieldIds: r.thenFieldIds,
thenSkipToFieldId: r.thenSkipToFieldId,
})),
settings: {
submitButtonLabel: form.settings.submitButtonLabel,
successMessage: form.settings.successMessage,
},
resolvedAt: now,
};
} catch (err) {
return {
formTitle: '',
formDescription: null,
fields: [],
branching: [],
settings: { submitButtonLabel: 'Senden', successMessage: 'Danke!' },
resolvedAt: now,
error: err instanceof Error ? err.message : String(err),
};
}
}

View file

@ -17,6 +17,7 @@
import { getManaApiUrl } from '$lib/api/config';
import { websitesTable, websitePagesTable, websiteBlocksTable } from './collections';
import { resolveEmbed } from './embeds';
import { resolveFormEmbed } from './forms-embeds';
import type {
LocalWebsite,
LocalWebsiteBlock,
@ -26,7 +27,7 @@ import type {
SiteSettings,
PageSeo,
} from './types';
import type { ModuleEmbedProps } from '@mana/website-blocks';
import type { FormEmbedProps, ModuleEmbedProps } from '@mana/website-blocks';
// ─── Snapshot shape ──────────────────────────────────────
@ -149,6 +150,10 @@ async function resolveEmbedsInTree(nodes: SnapshotBlockNode[]): Promise<void> {
const props = node.props as ModuleEmbedProps;
const resolved = await resolveEmbed(props);
node.props = { ...props, resolved };
} else if (node.type === 'formEmbed') {
const props = node.props as FormEmbedProps;
const resolved = await resolveFormEmbed(props);
node.props = { ...props, resolved };
}
if (node.children.length > 0) {
await resolveEmbedsInTree(node.children);