Skip to content

feat(v2.0a): forms — first slice of v2.0 engagement#43

Merged
thunpisit merged 1 commit into
mainfrom
feat/v2.0a-forms
Apr 30, 2026
Merged

feat(v2.0a): forms — first slice of v2.0 engagement#43
thunpisit merged 1 commit into
mainfrom
feat/v2.0a-forms

Conversation

@thunpisit
Copy link
Copy Markdown
Contributor

Summary

First of four PRs for v2.0. Adds public-submittable forms with a CMS editor and an in-CMS moderation inbox.

Schema (migration 0007)

  • `forms` (id, key UNIQUE, label, fields JSON, enabled, success_messages JSON, audit fields)
  • `form_submissions` (id, form_id CASCADE, data JSON, ip_hash 16-char truncated SHA-256, status enum, note)

Public endpoint

`POST /api/forms/[key]` with honeypot field + per-IP rate limit (3/minute). IP never stored raw — only the truncated hash. 410 when form disabled, 429 when rate-limited.

CMS

  • `/cms/forms` list
  • `/cms/forms/new` + `/cms/forms/[id]` shared editor
  • Submissions inbox embedded on the edit page with mark-as / delete actions

Audit

New `form.{create,update,delete,submit}` actions wired through everywhere.

i18n

25 new `cms_forms_*` keys (EN + TH).

Migration

Already applied to live D1.

Roadmap state after this merges

  • ✅ v2.0a Forms (this PR)
  • 🚧 v2.0b Newsletter (next)
  • 🚧 v2.0c Comments
  • 🚧 v2.0d Webhooks + Public REST API

Test plan

  • `pnpm build` succeeds
  • `paraglide compile` succeeds
  • `svelte-check` clean
  • After deploy: `/cms/forms` shows empty state with "New form" button
  • Create a form (e.g. key=`contact`), set fields, save
  • `curl -X POST /api/forms/contact -F "name=test" -F "email=a@b.co" -F "message=hi"` → 201
  • Same submission within a minute (×3 more) → 429 on the 4th
  • Form with `_hp` field set → 400
  • `/cms/forms/[id]` shows the submission in the inbox; mark-as actions work

🤖 Generated with Claude Code

This is the first of four v2.0 PRs. Adds public-submittable forms
with a CMS editor and an in-CMS moderation inbox.

Schema (Drizzle migration 0007)
-------------------------------
- forms: id, key (URL-safe, unique), label, fields (JSON array of
  FormField), enabled (bool), success_messages (per-locale JSON),
  created_by (FK users SET NULL), createdAt, updatedAt.
- form_submissions: id, form_id (FK CASCADE), data (JSON), ip_hash
  (truncated SHA-256, 16 chars — never raw IP), status enum
  (new/read/spam/archived), note, submittedAt.

Provider
--------
- ContentProvider gains 11 new methods: list/get/getByKey/create/
  update/delete for forms, list/get/create/update/delete for
  submissions, plus countRecentSubmissions for rate limiting.
- D1 implementation in providers/d1.ts.

Public endpoint (POST /api/forms/[key])
---------------------------------------
- Reads the form by key. Returns 404 if missing, 410 if disabled.
- Parses multipart/url-encoded body.
- Validates against form.fields:
  - honeypot (`_hp` field, expected empty),
  - per-field required + maxLength,
  - email kind: cheap regex `.+@.+\..+`,
  - checkbox: required-checkbox is GDPR consent pattern.
- Rate limit: 3 submissions per minute per (form, ipHash).
  IP is hashed via SHA-256 truncated to 16 hex chars; raw IP is
  never stored.
- Audit: writes a form.submit row with actorId=null (public).

CMS UI
------
- /cms/forms — list table with label, public endpoint URL, field
  count, enabled badge.
- /cms/forms/new + /cms/forms/[id] — shared FormEditor.svelte.
  Field list with add (text/email/textarea/checkbox), reorder
  (up/down), remove, per-field name + label + required toggle.
- /cms/forms/[id] also embeds a submissions inbox underneath the
  editor: collapsible rows with status badges, mark-as
  (new/read/spam/archived), delete. Submissions are scoped to the
  current form via a defense-in-depth check on every action.
- Sidebar gets a new "Forms" entry under the taxonomy group, gated
  to editor+.

Audit
-----
- New AuditAction members: form.create / form.update / form.delete /
  form.submit. Wired into create / update / delete actions and the
  public submission endpoint.

i18n: 25 new cms_forms_* keys (EN + TH).

Migration 0007 already applied to live D1.

This PR doesn't touch the public site rendering — adding a
\<form\> tag to a page or article is the editor's job. Future v2.x
might ship a markdown shortcode that renders a form inline; out of
scope here.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@thunpisit thunpisit merged commit 109d047 into main Apr 30, 2026
@thunpisit thunpisit deleted the feat/v2.0a-forms branch April 30, 2026 08:56
thunpisit added a commit to codustry/khaopad-example that referenced this pull request Apr 30, 2026
Cherry-picks upstream PR codustry#43. Migration 0007 already applied to live D1
from upstream. i18n: 25 new cms_forms_* keys field-merged into
messages/en.json + th.json without overwriting example-specific copy.

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
thunpisit added a commit that referenced this pull request May 2, 2026
#44)

Second of four v2.0 PRs. Newsletter is wired so it works at three
levels of operator commitment:

  Level 0 (default): no provider configured
    → public subscribe form returns 503 OR (if `allowSingleOptIn` is
      checked, default true) creates rows immediately confirmed.
      Operator can manually export/email later.
  Level 1: Resend API key + sender configured
    → public subscribe sends a real double-opt-in email.
      Subscribers active only after clicking the confirmation link.
  Level 2: cron-trigger pointing at /api/newsletter/send-digest
    → automated weekly digest (operator wires the cron in their
      wrangler.toml; we ship the endpoint).

Schema (Drizzle migration 0008)
-------------------------------
- subscribers: id, email UNIQUE, locale, token UNIQUE (24-char
  nanoid for confirm + unsubscribe links), confirmedAt nullable,
  unsubscribedAt nullable, source, createdAt.

Provider
--------
- ContentProvider gains 8 newsletter methods: list/count/getByEmail/
  getByToken/create (with autoConfirm flag for single-opt-in mode)/
  confirm/unsubscribe/delete. All idempotent where it matters.

Newsletter helper module ($lib/server/newsletter/index.ts)
----------------------------------------------------------
- readNewsletterConfig(settings): pulls newsletter.* keys from
  site_settings.
- isProviderConfigured(cfg): true when both resendKey + senderAddress
  are present.
- sendEmail(cfg, args): Resend POST. Returns { ok: true, id? } or
  { ok: false, reason }. Best-effort — never throws.
- buildConfirmEmail({...}): per-locale subject + html + text.
- buildDigestEmail({...}): per-locale weekly digest with embedded
  unsubscribe link.

Public endpoints
----------------
- POST /api/newsletter/subscribe (honeypot, idempotent on existing
  emails to prevent enumeration, audit-logged)
- GET /api/newsletter/confirm?token=... (idempotent → 302 to
  localized home with ?newsletter=confirmed)
- GET /api/newsletter/unsubscribe?token=... (one-click, no
  interstitial — GDPR/CAN-SPAM)

Admin endpoint
--------------
- POST /api/newsletter/send-digest?days=7&dryRun=1 (admin-only,
  iterates active subscribers, groups by locale, sends via Resend.
  Returns { ok, sent, failed } or { ok, dryRun: true, subscribers,
  articleCounts }. 503 when no provider configured.)

CMS surface
-----------
- /cms/subscribers (admin+): list with status badges (pending /
  active / unsubscribed), provider-status banner, manual "Send
  digest" + dry-run button when provider is on. Sidebar gets a
  "Subscribers" entry under Admin.
- /cms/settings: new "Newsletter (optional)" card with three
  fields: resendKey (masked-ish, font-mono), senderAddress, and
  a checkbox for the allow-single-opt-in fallback behavior.

Audit
-----
- New AuditAction members: newsletter.{subscribe,confirm,
  unsubscribe,delete,digest_sent}.

i18n: 22 new keys (EN + TH) across cms_subscribers_*,
cms_settings_newsletter_*.

Migration 0008 already applied to live D1.

Roadmap state
-------------
v2.0a Forms ✅ (PR #43)
v2.0b Newsletter ✅ (this PR)
v2.0c Comments 🚧
v2.0d Webhooks + Public REST API 🚧

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant