mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 20:21:09 +02:00
feat(forms): M3b public-submit endpoint — schließt den Public-Loop
Server-side Public-Submit für unlisted-shared Forms (Plan
docs/plans/forms-module.md M3.b):
- POST /api/v1/forms/public/:token/submit (apps/api):
- Token-resolve via unlistedSnapshots-Tabelle (eq, limit 1).
- Hard-blocks: 404 unbekannt, 410 revoked/expired, 400 wrong
collection, 400 invalid JSON.
- Schema-validiert serverseitig: filtert eingehende answers auf
field-IDs aus dem Snapshot (anti-injection), prüft required
Antwort-Felder + required consent-Felder.
- Hashed IP (SHA-256, hex) als Anti-Spam-Fingerprint, plus
User-Agent + Referer truncated, in submitterMeta.
- Schreibt sync_changes(table='formResponses', op='insert', data,
field_meta, actor='system:forms-public-submit', origin='system')
in einer Transaktion mit set_config('app.current_user_id') für
RLS — mirror vom articles import-extractor.
- Token-scoped rate-limit (10/min) + IP-scoped (30/min), gleiche
Architektur wie unlisted/public-routes.
- Returns { ok: true, responseId, submittedAt }.
- SharedFormView (apps/mana/apps/web): handleSubmit POSTet jetzt an
${PUBLIC_MANA_API_URL || origin:3060}/api/v1/forms/public/:token/submit.
Submitting-State (Disabled-Button + "Sende ..."), Error-Block bei
Server-Fehlern, Submitter-Block (Name + Email, beide optional). Der
DEV-Hinweis ist weg.
Encryption: server speichert plaintext im sync_changes-Blob. Der
Client-side Decrypt-Path ist no-op für non-encrypted shapes
(record-helpers.ts:241), also kein Crash beim Pull. Encrypted-at-rest
für public submissions ist M6 ZK-Mode (eigener per-Form-Key der
Form-Owner client-seitig hält).
Mounted pre-auth in apps/api/src/index.ts neben unlisted/public.
apps/api buildet (1769 modules, no TS errors). svelte-check:
0 errors in forms/. Forms-Modul ist End-to-End nutzbar — User legt
Form an, publisht, setzt visibility=unlisted, kopiert Share-Link,
externe Person füllt aus + sendet, Antwort landet im
ResponsesView des Owners.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
0d85d7c36b
commit
e99fea1938
3 changed files with 363 additions and 26 deletions
|
|
@ -22,7 +22,7 @@
|
|||
|
||||
let {
|
||||
blob,
|
||||
token: _token,
|
||||
token,
|
||||
expiresAt,
|
||||
}: {
|
||||
blob: Record<string, unknown>;
|
||||
|
|
@ -33,7 +33,26 @@
|
|||
const form = $derived(blob as unknown as FormBlob);
|
||||
|
||||
let answers = $state<Record<string, AnswerValue>>({});
|
||||
let submitterEmail = $state('');
|
||||
let submitterName = $state('');
|
||||
let submitted = $state(false);
|
||||
let submitting = $state(false);
|
||||
let submitError = $state<string | null>(null);
|
||||
|
||||
function apiBaseUrl(): string {
|
||||
// Public-form view is served from the same SvelteKit origin as
|
||||
// the Mana webapp, but mana-api is a separate service. The
|
||||
// PUBLIC_MANA_API_URL build-var carries the absolute URL in
|
||||
// production; for local dev fallback to the conventional
|
||||
// :3060 origin used by apps/api.
|
||||
const env = import.meta.env as Record<string, string | undefined>;
|
||||
const fromEnv = env.PUBLIC_MANA_API_URL;
|
||||
if (fromEnv) return fromEnv.replace(/\/$/, '');
|
||||
if (typeof window !== 'undefined') {
|
||||
return window.location.origin.replace(/:5173/, ':3060');
|
||||
}
|
||||
return 'http://localhost:3060';
|
||||
}
|
||||
|
||||
const visibleFields = $derived(resolveVisibleFields(form.fields, form.branching ?? [], answers));
|
||||
|
||||
|
|
@ -69,12 +88,40 @@
|
|||
|
||||
const allRequiredFilled = $derived(visibleFields.filter(isFieldRequired).every(isFieldFilled));
|
||||
|
||||
function handleSubmit(e: SubmitEvent) {
|
||||
async function handleSubmit(e: SubmitEvent) {
|
||||
e.preventDefault();
|
||||
// Public-Submit endpoint not yet wired (M3.b). Once it lands, this
|
||||
// posts answers to /api/v1/forms/public/<token>/submit and shows
|
||||
// the success message.
|
||||
submitted = true;
|
||||
if (submitting) return;
|
||||
submitting = true;
|
||||
submitError = null;
|
||||
|
||||
try {
|
||||
const url = `${apiBaseUrl()}/api/v1/forms/public/${encodeURIComponent(token)}/submit`;
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
answers,
|
||||
submitterEmail: submitterEmail.trim() || null,
|
||||
submitterName: submitterName.trim() || null,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const txt = await res.text().catch(() => '');
|
||||
let msg: string | undefined;
|
||||
try {
|
||||
msg = JSON.parse(txt)?.message;
|
||||
} catch {
|
||||
msg = txt;
|
||||
}
|
||||
submitError = msg || `Übertragung fehlgeschlagen (${res.status})`;
|
||||
return;
|
||||
}
|
||||
submitted = true;
|
||||
} catch (err) {
|
||||
submitError = err instanceof Error ? err.message : 'Verbindung zum Server fehlgeschlagen.';
|
||||
} finally {
|
||||
submitting = false;
|
||||
}
|
||||
}
|
||||
|
||||
function fmtExpiry(iso: string | null): string {
|
||||
|
|
@ -98,12 +145,6 @@
|
|||
{#if submitted}
|
||||
<div class="thanks">
|
||||
<p class="thanks-message">{form.settings.successMessage}</p>
|
||||
<p class="dev-note">
|
||||
<em
|
||||
>Hinweis: Public-Submit ist im aktuellen Mana-Build noch nicht serverseitig verdrahtet —
|
||||
deine Antwort wurde nicht übermittelt.</em
|
||||
>
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
<form class="form-body" onsubmit={handleSubmit}>
|
||||
|
|
@ -233,16 +274,34 @@
|
|||
</div>
|
||||
{/each}
|
||||
|
||||
<div class="submitter-block">
|
||||
<label class="field-label">
|
||||
Dein Name <span class="optional">(optional)</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={submitterName}
|
||||
oninput={(e) => (submitterName = (e.currentTarget as HTMLInputElement).value)}
|
||||
placeholder="Anna Mustermann"
|
||||
/>
|
||||
<label class="field-label">
|
||||
Deine E-Mail <span class="optional">(optional)</span>
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
value={submitterEmail}
|
||||
oninput={(e) => (submitterEmail = (e.currentTarget as HTMLInputElement).value)}
|
||||
placeholder="anna@example.com"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<footer class="form-footer">
|
||||
<button type="submit" class="submit" disabled={!allRequiredFilled}>
|
||||
{form.settings.submitButtonLabel}
|
||||
<button type="submit" class="submit" disabled={!allRequiredFilled || submitting}>
|
||||
{submitting ? 'Sende ...' : form.settings.submitButtonLabel}
|
||||
</button>
|
||||
<p class="dev-note">
|
||||
<em
|
||||
>Public-Submit landet im nächsten Mana-Schritt — vorerst zeigt der Klick nur die
|
||||
Bestätigungsnachricht.</em
|
||||
>
|
||||
</p>
|
||||
{#if submitError}
|
||||
<p class="error">{submitError}</p>
|
||||
{/if}
|
||||
{#if expiresAt}
|
||||
<p class="expiry">Dieser Link läuft am {fmtExpiry(expiresAt)} ab.</p>
|
||||
{/if}
|
||||
|
|
@ -434,18 +493,36 @@
|
|||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.dev-note {
|
||||
margin: 0;
|
||||
font-size: 0.75rem;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.expiry {
|
||||
margin: 0;
|
||||
font-size: 0.75rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.error {
|
||||
margin: 0;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: #fee2e2;
|
||||
border: 1px solid #fca5a5;
|
||||
border-radius: 0.375rem;
|
||||
font-size: 0.8125rem;
|
||||
color: #991b1b;
|
||||
}
|
||||
|
||||
.submitter-block {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
padding-top: 0.5rem;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.optional {
|
||||
font-size: 0.75rem;
|
||||
color: #6b7280;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.thanks {
|
||||
text-align: center;
|
||||
padding: 2rem 1rem;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue