Skip to content

feat(v2.0c): comments — third slice of v2.0 engagement#45

Merged
thunpisit merged 1 commit into
mainfrom
feat/v2.0c-comments
May 2, 2026
Merged

feat(v2.0c): comments — third slice of v2.0 engagement#45
thunpisit merged 1 commit into
mainfrom
feat/v2.0c-comments

Conversation

@thunpisit

Copy link
Copy Markdown
Contributor

Summary

Third of four v2.0 PRs. Per-article visitor comments with editor moderation. Dual-toggle policy so a fresh deploy never accidentally exposes a comment form.

Schema (migration 0009)

  • `comments` (id, articleId CASCADE, parentId forward-compat, name+email, body plain-text, status enum, ipHash 16-char SHA-256 truncate, moderation fields)
  • `articles.commentsMode` enum (`inherit` / `on` / `off`), default `inherit`

Dual toggle (Option C)

  • Site-wide `commentsEnabled` setting in `/cms/settings → Comments` (defaults to off)
  • Per-article radio (Inherit / On / Off) in the article form
  • `commentsAllowedForArticle()` is the one-line truth table both surfaces use

Public

  • `POST /api/comments` — honeypot `_hp` + per-IP-hash rate limit (3 / minute)
  • Approved comments rendered below article body via new ``
  • Plain-text only, no markdown — keeps the XSS surface minimal
  • Form posts via fetch(); success message says "awaiting moderation"
  • Returns 410 when commentsAllowed=false, 429 when rate-limited

CMS

  • `/cms/comments` moderation queue, status tabs, masked-email display, batch-resolved article titles, mark-as actions, mailto reply, sidebar entry under taxonomy group

Audit

  • New `comment.{create,approve,spam,archive,delete}` actions, public submission writes with userId=null

Constants module

  • `HONEYPOT_FIELD` + rate-limit constants moved from `$lib/server/forms` (server-only) to `$lib/forms/constants` so client components can import them. Server module re-exports.

i18n

26 new keys (EN + TH).

Migration

0009 already applied to live D1.

Deliberately out of scope

  • Threaded replies (parentId is forward-compat schema only; UI is flat)
  • Comments on Pages
  • Akismet/ML spam filtering
  • Email notifications when approved

Test plan

  • `pnpm build` succeeds
  • `paraglide compile` succeeds
  • `svelte-check` clean
  • After deploy: `/cms/settings → Comments` toggle off → article still shows past comments but form hidden
  • Toggle on, set article to inherit → form appears
  • Submit comment → 201, lands as pending
  • `/cms/comments` shows pending; approve → comment appears on public article
  • Same submitter spams 4× in a minute → 4th hits 429
  • Article `commentsMode=off` overrides site-wide on

🤖 Generated with Claude Code

Per-article visitor comments with editor moderation. Dual-toggle
policy so a fresh deploy never accidentally exposes a comment form.

Schema (Drizzle migration 0009)
-------------------------------
- comments: id, articleId FK CASCADE, parentId (forward-compat for
  threading; UI is flat), authorName, authorEmail (collected, never
  displayed publicly), body (plain text — never markdown to keep
  XSS surface minimal), status enum (pending/approved/spam/
  archived), ipHash (16-char SHA-256 truncate; never raw IP),
  submittedAt, moderatedBy + moderatedAt.
- articles.commentsMode: new column. enum (inherit/on/off),
  default 'inherit'.

Dual toggle
-----------
- Site-wide: settings.commentsEnabled defaults to false. New
  Comments card in /cms/settings.
- Per-article: radio (Inherit / On / Off) on the article form.
- $lib/server/comments.commentsAllowedForArticle() is the one-line
  truth table both the public render and the POST endpoint use.

Public surface
--------------
- POST /api/comments. Reuses v2.0a hashIp + rate-limit (3/min per
  ipHash per article). Honeypot _hp via $lib/forms/constants
  (extracted to a non-server module so client + server share the
  field name). 410 when commentsAllowed=false; 429 on rate limit.
- (www)/[locale]/blog/[slug] page-server load now also fetches
  approved comments + computes commentsOpen.
- New CommentSection.svelte renders approved comments + a
  fetch()-posted submission form. Fields: name (required, ≤80),
  email (required, ≤254, server-side regex), body (required,
  ≤4000). Plain-text rendering with whitespace-pre-wrap.
- Page svelte mounts CommentSection when there are approved
  comments OR when commentsOpen=true.

CMS surface
-----------
- /cms/comments moderation queue. Status tabs (pending/approved/
  spam/archived). Each row: status badge, timestamp, author + masked
  email (a***@e***.com via $lib/comments/mask), linked article
  title (batch-resolved per page), body, mark-as buttons (approved/
  spam/archived), reply-via-email mailto, delete. Pagination
  50/page. Pending count exposed for future sidebar badge.
- Sidebar entry under taxonomy group, gated to editor+.

Audit
-----
- New comment.{create,approve,spam,archive,delete} AuditAction
  members.
- Public submission writes comment.create with userId=null.
- Moderation actions write the matching status-change action with
  the editor's userId.

Constants
---------
- HONEYPOT_FIELD + RATE_LIMIT_WINDOW_SECONDS + RATE_LIMIT_MAX_PER_
  WINDOW moved from $lib/server/forms (server-only) to $lib/forms/
  constants so client components can import them. Server module
  re-exports for backwards compat.

i18n: 26 new comment_* / comments_* / cms_settings_comments_* keys
(EN + TH).

Migration 0009 already applied to live D1.

Out of scope (deliberate non-goals for this slice):
- Threaded replies — parentId is forward-compat only; UI is flat.
- Comments on Pages — Pages are typically static.
- Akismet/ML spam filtering — honeypot+rate-limit is the v2.0 floor.
- Email notifications when comment approved.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@thunpisit thunpisit merged commit 4ef2ce7 into main May 2, 2026
@thunpisit thunpisit deleted the feat/v2.0c-comments branch May 2, 2026 06:59
thunpisit added a commit to codustry/khaopad-example that referenced this pull request May 2, 2026
Cherry-picks upstream PR codustry#45. Migration 0009 already applied to live D1
from upstream. Field-merged: blog/[slug]/+page.svelte (paypers shell
preserved, CommentSection mounted) + 26 new comment_* / comments_* /
cms_settings_comments_* keys in messages/en.json + th.json.

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