Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -284,7 +284,7 @@ Khao Pad started as a CMS. Through v1.5 it became a complete content layer (writ
| **v1.7** | Pages, navigation, IA | ✅ Shipped | Media folders, reusable blocks, cookie consent, static pages, navigation manager, seed:legal |
| **v1.8** | Analytics & insight | ✅ Shipped | Privacy-friendly D1 page-views, top articles, search-term insights, per-article sparkline, optional CWA |
| **v1.9** | Performance & trust | ✅ Shipped | Responsive `srcset` via /cdn-cgi/image, edge cache-control hook, branded 404/500, /api/health endpoint |
| **v2.0** | Engagement & growth | 🚧 In progress | ✅ a Forms · ✅ b Newsletter (optional) · 🚧 c Comments · 🚧 d Webhooks + Public REST API |
| **v2.0** | Engagement & growth | 🚧 In progress | ✅ a Forms · ✅ b Newsletter (optional) · c Comments · 🚧 d Webhooks + Public REST API |

**Backlog** (not committed): OAuth providers, block-based editor, AI-assisted authoring, multi-site / workspaces, A/B testing, member-only / paid content.

Expand Down
2 changes: 1 addition & 1 deletion docs/MILESTONES.md
Original file line number Diff line number Diff line change
Expand Up @@ -248,7 +248,7 @@ Shipping in four PRs grouped by theme. **a** + **b** done, **c** + **d** to foll

**v2.0b — Newsletter (shipped, fully optional)** — Drizzle migration 0008: `subscribers` (email UNIQUE, locale, token UNIQUE, confirmedAt, unsubscribedAt, source). Optional everywhere: when no email provider is configured, public signups go single-opt-in (subscribers immediately confirmed) — clearly documented. When the operator sets a Resend API key + sender address in `/cms/settings → Newsletter`, public signups become double-opt-in: a confirmation email goes out via Resend, subscriber is "active" only after they click the link. Public endpoints: `POST /api/newsletter/subscribe` (form-data with email + locale, honeypot `_hp`, per-IP rate-limit-ready via the v2.0a hashIp helper), `GET /api/newsletter/confirm?token=...` (idempotent click target → 302 to localized home with `?newsletter=confirmed`), `GET /api/newsletter/unsubscribe?token=...` (one-click, no interstitial — GDPR/CAN-SPAM compliance). Admin endpoint `POST /api/newsletter/send-digest?days=7&dryRun=1` iterates active subscribers, groups by locale, picks the last week's published articles per locale, sends one email per subscriber via Resend. CMS `/cms/subscribers` (admin+ only) lists subscribers with status badge (pending/active/unsubscribed), exposes manual "Send digest now" + dry-run button when a provider is configured, shows a clear "no provider configured" banner with a link to settings when not. New `newsletter.{subscribe,confirm,unsubscribe,delete,digest_sent}` audit actions. Cron-trigger wiring deferred to operator's wrangler.toml.

**Comments** — Per-article comments with name + email, queued for moderation by default. CMS moderation queue at `/cms/comments`. Akismet-style spam filter is out of scope; rate limit + honeypot is the v2.0 floor. Optional: anchor in `<article>` body.
**v2.0c — Comments (shipped, dual-toggle)** — Drizzle migration 0009: `comments` (id, articleId FK CASCADE, parentId for forward-compat threading, authorName, authorEmail, body plain-text, status enum pending/approved/spam/archived, ipHash 16-char SHA-256 truncate, submittedAt, moderatedBy + moderatedAt) + new `articles.commentsMode` column (`inherit` | `on` | `off`, defaults to `inherit`). Two-layer policy: a site-wide `commentsEnabled` setting in `/cms/settings → Comments` (defaults to **off** so a fresh deploy never accidentally exposes a comment form) AND a per-article radio (`Inherit` / `On` / `Off`) on the article form. The `commentsAllowedForArticle()` helper is a one-line truth table both the public render and the POST endpoint consult. Public `POST /api/comments` (form-data: `article_id`, `name`, `email`, `body`, honeypot `_hp`) reuses the v2.0a hashIp + rate-limit pattern (3 / minute per ip-hash per article). Returns 410 when commentsAllowed=false, 429 when rate-limited. Approved comments render below the article body in a generic `<CommentSection>` (oldest → newest, plain-text only — no markdown/HTML to keep the XSS surface minimal). The submission form posts via `fetch()` so the page doesn't reload; success message says "awaiting moderation". CMS `/cms/comments` (editor+) is a moderation queue with status tabs (pending/approved/spam/archived), batched-resolved article titles for each row, masked email display (`a***@e***.com`), mark-as buttons, mailto reply, and a sidebar entry. The pending count drives a future sidebar badge (read once on dashboard load). New `comment.{create,approve,spam,archive,delete}` audit actions. **Out of scope (deliberate):** threaded replies (parentId is forward-compat schema 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 to commenters when approved.

**Webhooks** — `/cms/webhooks` lets admins register URLs to ping on `article.publish`, `article.unpublish`, `form.submit`, `subscriber.confirm`. Signed with HMAC-SHA256 using a per-webhook secret. Retried with exponential backoff up to 5×.

Expand Down
17 changes: 17 additions & 0 deletions drizzle/0009_futuristic_vulcan.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
CREATE TABLE `comments` (
`id` text PRIMARY KEY NOT NULL,
`article_id` text NOT NULL,
`parent_id` text,
`author_name` text NOT NULL,
`author_email` text NOT NULL,
`body` text NOT NULL,
`status` text DEFAULT 'pending' NOT NULL,
`ip_hash` text,
`submitted_at` text NOT NULL,
`moderated_by` text,
`moderated_at` text,
FOREIGN KEY (`article_id`) REFERENCES `articles`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`moderated_by`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE set null
);
--> statement-breakpoint
ALTER TABLE `articles` ADD `comments_mode` text DEFAULT 'inherit' NOT NULL;
Loading
Loading