feat(v2.0a): forms — first slice of v2.0 engagement#43
Merged
Conversation
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
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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)
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
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
Test plan
🤖 Generated with Claude Code