feat(website): M4 — forms + moduleEmbed

Adds two new block types and the server-side infrastructure for
untrusted input + cross-module data embedding.

Forms:
- packages/website-blocks/src/form: declarative fields (text, email,
  tel, url, textarea, number) with required / maxLength / placeholder
  per field. Honeypot hidden input in the renderer; public-mode POST
  to a same-origin SvelteKit proxy that forwards to mana-api.
- apps/api: website.submissions table (schema.ts + 0001_submissions.sql)
  + POST /public/submit/:siteSlug/:blockId. Loads the current published
  snapshot, finds the form block, validates payload against its
  declared fields (trim, type check, length cap), rejects honeypot
  submissions silently, rate-limits per IP (10 / 5 min) in-memory.
  Unknown keys are dropped — clients can only submit declared fields.
- Owner-facing: GET/DELETE /sites/:id/submissions + SubmissionsView
  component + /(app)/website/[siteId]/submissions route. Shows
  incoming submissions with status pill + payload preview + delete.
- apps/mana/.../routes/s/[siteSlug]/__submit/[blockId]/+server.ts:
  same-origin proxy so form posts don't trigger CORS and IP / user-
  agent headers are forwarded via SvelteKit's trusted getClientAddress.

M4 first-pass does NOT wire target-module delivery (contacts / notify).
Submissions stay in the inbox until owner-side tool handlers land
(M4.x). `target` enum is intentionally `['inbox']` only for now.

moduleEmbed:
- packages/website-blocks/src/moduleEmbed: source dropdown
  (picture.board | library.entries), max-items, layout (grid | list),
  optional filter object. The `resolved` field on props is populated at
  publish time by the editor-side resolver — public renderer reads it
  directly, no Dexie / API round-trip needed.
- apps/mana/.../website/embeds.ts: per-source resolvers. picture.board
  enforces `isPublic=true`; library.entries respects filter.isFavorite
  / kind / status so owners can expose a subset (e.g. "my favorites").
- buildSnapshot() walks the tree after assembly and fills in
  block.props.resolved for every moduleEmbed. Publish slower, public
  visits fast. No cross-service call at render time.

Validation:
- pnpm run validate:all: 6/6 gates green
- pnpm run check (web): 0 errors, 0 warnings
- apps/api type-check: green

Apply Postgres with:
  psql "$DATABASE_URL" -f apps/api/drizzle/website/0001_submissions.sql

Plan: docs/plans/website-builder.md (M4 shipped)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-23 14:36:52 +02:00
parent 79d112657c
commit 57be0f61b1
20 changed files with 1817 additions and 2 deletions

View file

@ -11,7 +11,7 @@ import { z } from 'zod';
import { and, desc, eq } from 'drizzle-orm';
import type { AuthVariables } from '@mana/shared-hono';
import { errorResponse, validationError } from '../../lib/responses';
import { db, publishedSnapshots } from './schema';
import { db, publishedSnapshots, submissions } from './schema';
import { isValidSlug } from './reserved-slugs';
const routes = new Hono<{ Variables: AuthVariables }>();
@ -243,4 +243,61 @@ routes.post('/sites/:id/rollback/:snapshotId', async (c) => {
return c.json({ rolledBack: true, slug: target[0].slug });
});
// ─── GET /sites/:id/submissions ─────────────────────────
// Owner-only list of inbox submissions for a site.
routes.get('/sites/:id/submissions', async (c) => {
const siteId = c.req.param('id');
if (!siteId) return errorResponse(c, 'siteId required', 400);
const rows = await db
.select({
id: submissions.id,
siteId: submissions.siteId,
blockId: submissions.blockId,
payload: submissions.payload,
targetModule: submissions.targetModule,
status: submissions.status,
errorMessage: submissions.errorMessage,
createdAt: submissions.createdAt,
})
.from(submissions)
.where(eq(submissions.siteId, siteId))
.orderBy(desc(submissions.createdAt))
.limit(200);
return c.json({
submissions: rows.map((r) => ({
id: r.id,
blockId: r.blockId,
payload: r.payload,
targetModule: r.targetModule,
status: r.status,
errorMessage: r.errorMessage,
createdAt: r.createdAt.toISOString(),
})),
});
});
// ─── DELETE /sites/:id/submissions/:submissionId ───────
routes.delete('/sites/:id/submissions/:submissionId', async (c) => {
const siteId = c.req.param('id');
const submissionId = c.req.param('submissionId');
if (!siteId || !submissionId) {
return errorResponse(c, 'siteId + submissionId required', 400);
}
const deleted = await db
.delete(submissions)
.where(and(eq(submissions.id, submissionId), eq(submissions.siteId, siteId)))
.returning({ id: submissions.id });
if (deleted.length === 0) {
return errorResponse(c, 'Submission not found', 404, { code: 'NOT_FOUND' });
}
return c.json({ deleted: true });
});
export const websitePublishRoutes = routes;