mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 20:21:09 +02:00
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:
parent
57b7a43147
commit
ace1b706e6
10 changed files with 913 additions and 2 deletions
115
apps/mana/apps/web/src/lib/modules/website/forms-embeds.ts
Normal file
115
apps/mana/apps/web/src/lib/modules/website/forms-embeds.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue