diff --git a/README.md b/README.md index 951a76a..21880b6 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/docs/MILESTONES.md b/docs/MILESTONES.md index c58bbd7..017fdee 100644 --- a/docs/MILESTONES.md +++ b/docs/MILESTONES.md @@ -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 `
` 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 `` (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×. diff --git a/drizzle/0009_futuristic_vulcan.sql b/drizzle/0009_futuristic_vulcan.sql new file mode 100644 index 0000000..6119153 --- /dev/null +++ b/drizzle/0009_futuristic_vulcan.sql @@ -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; \ No newline at end of file diff --git a/drizzle/meta/0009_snapshot.json b/drizzle/meta/0009_snapshot.json new file mode 100644 index 0000000..9689933 --- /dev/null +++ b/drizzle/meta/0009_snapshot.json @@ -0,0 +1,2217 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "845a62e8-e661-452d-b8b9-3c0cb183b6be", + "prevId": "19a332ed-c4c8-4d7b-aff5-5edfecd858c1", + "tables": { + "accounts": { + "name": "accounts", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "accounts_user_id_users_id_fk": { + "name": "accounts_user_id_users_id_fk", + "tableFrom": "accounts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "article_localizations": { + "name": "article_localizations", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "article_id": { + "name": "article_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "locale": { + "name": "locale", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "excerpt": { + "name": "excerpt", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "seo_title": { + "name": "seo_title", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "seo_description": { + "name": "seo_description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "article_localizations_article_id_articles_id_fk": { + "name": "article_localizations_article_id_articles_id_fk", + "tableFrom": "article_localizations", + "tableTo": "articles", + "columnsFrom": [ + "article_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "article_tags": { + "name": "article_tags", + "columns": { + "article_id": { + "name": "article_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tag_id": { + "name": "tag_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "article_tags_article_id_articles_id_fk": { + "name": "article_tags_article_id_articles_id_fk", + "tableFrom": "article_tags", + "tableTo": "articles", + "columnsFrom": [ + "article_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "article_tags_tag_id_tags_id_fk": { + "name": "article_tags_tag_id_tags_id_fk", + "tableFrom": "article_tags", + "tableTo": "tags", + "columnsFrom": [ + "tag_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "article_tags_article_id_tag_id_pk": { + "columns": [ + "article_id", + "tag_id" + ], + "name": "article_tags_article_id_tag_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "article_versions": { + "name": "article_versions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "article_id": { + "name": "article_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "locale": { + "name": "locale", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "excerpt": { + "name": "excerpt", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "seo_title": { + "name": "seo_title", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "seo_description": { + "name": "seo_description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "article_versions_article_id_articles_id_fk": { + "name": "article_versions_article_id_articles_id_fk", + "tableFrom": "article_versions", + "tableTo": "articles", + "columnsFrom": [ + "article_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "article_versions_created_by_users_id_fk": { + "name": "article_versions_created_by_users_id_fk", + "tableFrom": "article_versions", + "tableTo": "users", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "articles": { + "name": "articles", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cover_media_id": { + "name": "cover_media_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "category_id": { + "name": "category_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "author_id": { + "name": "author_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'draft'" + }, + "published_at": { + "name": "published_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "comments_mode": { + "name": "comments_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'inherit'" + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "articles_slug_unique": { + "name": "articles_slug_unique", + "columns": [ + "slug" + ], + "isUnique": true + } + }, + "foreignKeys": { + "articles_cover_media_id_media_id_fk": { + "name": "articles_cover_media_id_media_id_fk", + "tableFrom": "articles", + "tableTo": "media", + "columnsFrom": [ + "cover_media_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "articles_category_id_categories_id_fk": { + "name": "articles_category_id_categories_id_fk", + "tableFrom": "articles", + "tableTo": "categories", + "columnsFrom": [ + "category_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "articles_author_id_users_id_fk": { + "name": "articles_author_id_users_id_fk", + "tableFrom": "articles", + "tableTo": "users", + "columnsFrom": [ + "author_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "audit_log": { + "name": "audit_log", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "audit_log_user_id_users_id_fk": { + "name": "audit_log_user_id_users_id_fk", + "tableFrom": "audit_log", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "categories": { + "name": "categories", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "categories_slug_unique": { + "name": "categories_slug_unique", + "columns": [ + "slug" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "category_localizations": { + "name": "category_localizations", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "category_id": { + "name": "category_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "locale": { + "name": "locale", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "category_localizations_category_id_categories_id_fk": { + "name": "category_localizations_category_id_categories_id_fk", + "tableFrom": "category_localizations", + "tableTo": "categories", + "columnsFrom": [ + "category_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "comments": { + "name": "comments", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "article_id": { + "name": "article_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "author_name": { + "name": "author_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "author_email": { + "name": "author_email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "ip_hash": { + "name": "ip_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "submitted_at": { + "name": "submitted_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "moderated_by": { + "name": "moderated_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "moderated_at": { + "name": "moderated_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "comments_article_id_articles_id_fk": { + "name": "comments_article_id_articles_id_fk", + "tableFrom": "comments", + "tableTo": "articles", + "columnsFrom": [ + "article_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "comments_moderated_by_users_id_fk": { + "name": "comments_moderated_by_users_id_fk", + "tableFrom": "comments", + "tableTo": "users", + "columnsFrom": [ + "moderated_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "content_block_localizations": { + "name": "content_block_localizations", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "block_id": { + "name": "block_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "locale": { + "name": "locale", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "content_block_localizations_block_id_content_blocks_id_fk": { + "name": "content_block_localizations_block_id_content_blocks_id_fk", + "tableFrom": "content_block_localizations", + "tableTo": "content_blocks", + "columnsFrom": [ + "block_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "content_blocks": { + "name": "content_blocks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "content_blocks_key_unique": { + "name": "content_blocks_key_unique", + "columns": [ + "key" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "form_submissions": { + "name": "form_submissions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "form_id": { + "name": "form_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "data": { + "name": "data", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "submitted_at": { + "name": "submitted_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ip_hash": { + "name": "ip_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'new'" + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "form_submissions_form_id_forms_id_fk": { + "name": "form_submissions_form_id_forms_id_fk", + "tableFrom": "form_submissions", + "tableTo": "forms", + "columnsFrom": [ + "form_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "forms": { + "name": "forms", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "fields": { + "name": "fields", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "success_messages": { + "name": "success_messages", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "forms_key_unique": { + "name": "forms_key_unique", + "columns": [ + "key" + ], + "isUnique": true + } + }, + "foreignKeys": { + "forms_created_by_users_id_fk": { + "name": "forms_created_by_users_id_fk", + "tableFrom": "forms", + "tableTo": "users", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "invitations": { + "name": "invitations", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "accepted_at": { + "name": "accepted_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "accepted_user_id": { + "name": "accepted_user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "invitations_token_unique": { + "name": "invitations_token_unique", + "columns": [ + "token" + ], + "isUnique": true + } + }, + "foreignKeys": { + "invitations_accepted_user_id_users_id_fk": { + "name": "invitations_accepted_user_id_users_id_fk", + "tableFrom": "invitations", + "tableTo": "users", + "columnsFrom": [ + "accepted_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "invitations_created_by_users_id_fk": { + "name": "invitations_created_by_users_id_fk", + "tableFrom": "invitations", + "tableTo": "users", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "media": { + "name": "media", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "filename": { + "name": "filename", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "r2_key": { + "name": "r2_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "alt_text": { + "name": "alt_text", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "folder_id": { + "name": "folder_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "uploaded_by": { + "name": "uploaded_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "media_r2_key_unique": { + "name": "media_r2_key_unique", + "columns": [ + "r2_key" + ], + "isUnique": true + } + }, + "foreignKeys": { + "media_uploaded_by_users_id_fk": { + "name": "media_uploaded_by_users_id_fk", + "tableFrom": "media", + "tableTo": "users", + "columnsFrom": [ + "uploaded_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "media_folders": { + "name": "media_folders", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "navigation_items": { + "name": "navigation_items", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "menu_id": { + "name": "menu_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "labels": { + "name": "labels", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "target_id": { + "name": "target_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "custom_url": { + "name": "custom_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "navigation_items_menu_id_navigation_menus_id_fk": { + "name": "navigation_items_menu_id_navigation_menus_id_fk", + "tableFrom": "navigation_items", + "tableTo": "navigation_menus", + "columnsFrom": [ + "menu_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "navigation_menus": { + "name": "navigation_menus", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "navigation_menus_key_unique": { + "name": "navigation_menus_key_unique", + "columns": [ + "key" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "page_localizations": { + "name": "page_localizations", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "page_id": { + "name": "page_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "locale": { + "name": "locale", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "seo_title": { + "name": "seo_title", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "seo_description": { + "name": "seo_description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "page_localizations_page_id_pages_id_fk": { + "name": "page_localizations_page_id_pages_id_fk", + "tableFrom": "page_localizations", + "tableTo": "pages", + "columnsFrom": [ + "page_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "page_views": { + "name": "page_views", + "columns": { + "date": { + "name": "date", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ref_id": { + "name": "ref_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "page_views_date_path_pk": { + "columns": [ + "date", + "path" + ], + "name": "page_views_date_path_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "pages": { + "name": "pages", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "template": { + "name": "template", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "published_at": { + "name": "published_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "author_id": { + "name": "author_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "pages_slug_unique": { + "name": "pages_slug_unique", + "columns": [ + "slug" + ], + "isUnique": true + } + }, + "foreignKeys": { + "pages_author_id_users_id_fk": { + "name": "pages_author_id_users_id_fk", + "tableFrom": "pages", + "tableTo": "users", + "columnsFrom": [ + "author_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "search_log": { + "name": "search_log", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "term": { + "name": "term", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "no_results": { + "name": "no_results", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "date": { + "name": "date", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sessions": { + "name": "sessions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "sessions_token_unique": { + "name": "sessions_token_unique", + "columns": [ + "token" + ], + "isUnique": true + } + }, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "site_settings": { + "name": "site_settings", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "slug_redirects": { + "name": "slug_redirects", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "old_slug": { + "name": "old_slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "new_slug": { + "name": "new_slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "article_id": { + "name": "article_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "slug_redirects_old_slug_unique": { + "name": "slug_redirects_old_slug_unique", + "columns": [ + "old_slug" + ], + "isUnique": true + } + }, + "foreignKeys": { + "slug_redirects_article_id_articles_id_fk": { + "name": "slug_redirects_article_id_articles_id_fk", + "tableFrom": "slug_redirects", + "tableTo": "articles", + "columnsFrom": [ + "article_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "subscribers": { + "name": "subscribers", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "locale": { + "name": "locale", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "confirmed_at": { + "name": "confirmed_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "unsubscribed_at": { + "name": "unsubscribed_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "subscribers_email_unique": { + "name": "subscribers_email_unique", + "columns": [ + "email" + ], + "isUnique": true + }, + "subscribers_token_unique": { + "name": "subscribers_token_unique", + "columns": [ + "token" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tag_localizations": { + "name": "tag_localizations", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tag_id": { + "name": "tag_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "locale": { + "name": "locale", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "tag_localizations_tag_id_tags_id_fk": { + "name": "tag_localizations_tag_id_tags_id_fk", + "tableFrom": "tag_localizations", + "tableTo": "tags", + "columnsFrom": [ + "tag_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tags": { + "name": "tags", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "tags_slug_unique": { + "name": "tags_slug_unique", + "columns": [ + "slug" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email_verified": { + "name": "email_verified", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'author'" + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "users_email_unique": { + "name": "users_email_unique", + "columns": [ + "email" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "verifications": { + "name": "verifications", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 24b628b..abc2227 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -64,6 +64,13 @@ "when": 1777694507921, "tag": "0008_nostalgic_namorita", "breakpoints": true + }, + { + "idx": 9, + "version": "6", + "when": 1777703335359, + "tag": "0009_futuristic_vulcan", + "breakpoints": true } ] } \ No newline at end of file diff --git a/messages/en.json b/messages/en.json index 4c73c09..d039ef1 100644 --- a/messages/en.json +++ b/messages/en.json @@ -347,5 +347,30 @@ "cms_editor_media_picker_loading": "Loading media…", "cms_editor_draft_available": "Unsaved draft from {when} is available.", "cms_editor_draft_restore": "Restore draft", - "cms_editor_draft_discard": "Discard" + "cms_editor_draft_discard": "Discard", + "cms_comments": "Comments", + "cms_comments_help": "Approve, reject, or archive visitor comments. New ones land in the pending queue.", + "cms_comments_status_pending": "Pending", + "cms_comments_status_approved": "Approved", + "cms_comments_status_spam": "Spam", + "cms_comments_status_archived": "Archived", + "cms_comments_pending_count": "{count} awaiting moderation", + "cms_comments_empty": "Nothing here.", + "cms_comments_reply_email": "Reply via email", + "cms_settings_comments": "Comments", + "cms_settings_comments_help": "Site-wide kill switch for visitor comments. Per-article overrides on the article form still apply.", + "cms_settings_comments_enabled": "Allow comments on articles", + "cms_settings_comments_enabled_help": "When off, the comment form is hidden on every article unless overridden per-article.", + "cms_article_comments_mode": "Comments", + "cms_article_comments_mode_help": "Inherit follows the site setting. On / Off forces this article either way.", + "comments_section_title": "Comments ({count})", + "comments_empty": "No comments yet — be the first.", + "comments_form_title": "Leave a comment", + "comments_field_name": "Name", + "comments_field_email": "Email", + "comments_email_help": "Never published.", + "comments_field_body": "Comment", + "comments_thanks": "Thanks — your comment is awaiting moderation.", + "comments_submit": "Post comment", + "comments_closed": "Comments are closed for this article." } diff --git a/messages/th.json b/messages/th.json index 51cc01a..32a51c5 100644 --- a/messages/th.json +++ b/messages/th.json @@ -347,5 +347,30 @@ "cms_editor_media_picker_loading": "กำลังโหลดสื่อ…", "cms_editor_draft_available": "มีฉบับร่างที่ยังไม่ได้บันทึกจาก {when}", "cms_editor_draft_restore": "เรียกคืนฉบับร่าง", - "cms_editor_draft_discard": "ละทิ้ง" + "cms_editor_draft_discard": "ละทิ้ง", + "cms_comments": "คอมเมนต์", + "cms_comments_help": "อนุมัติ ปฏิเสธ หรือเก็บถาวรคอมเมนต์ของผู้เยี่ยมชม คอมเมนต์ใหม่จะเข้าคิวรอตรวจสอบ", + "cms_comments_status_pending": "รอตรวจ", + "cms_comments_status_approved": "อนุมัติแล้ว", + "cms_comments_status_spam": "สแปม", + "cms_comments_status_archived": "เก็บถาวร", + "cms_comments_pending_count": "{count} รอตรวจสอบ", + "cms_comments_empty": "ไม่มีอะไรที่นี่", + "cms_comments_reply_email": "ตอบกลับทางอีเมล", + "cms_settings_comments": "คอมเมนต์", + "cms_settings_comments_help": "สวิตช์ปิดเปิดคอมเมนต์ทั่วทั้งเว็บ การตั้งค่าระดับบทความยังคงมีผล", + "cms_settings_comments_enabled": "อนุญาตคอมเมนต์ในบทความ", + "cms_settings_comments_enabled_help": "เมื่อปิด ฟอร์มคอมเมนต์จะถูกซ่อนทุกบทความเว้นแต่ตั้งค่าระดับบทความให้เปิด", + "cms_article_comments_mode": "คอมเมนต์", + "cms_article_comments_mode_help": "Inherit ตามการตั้งค่าทั่วเว็บ On/Off บังคับเปิดหรือปิดเฉพาะบทความนี้", + "comments_section_title": "คอมเมนต์ ({count})", + "comments_empty": "ยังไม่มีคอมเมนต์ — เป็นคนแรกสิ", + "comments_form_title": "ฝากคอมเมนต์", + "comments_field_name": "ชื่อ", + "comments_field_email": "อีเมล", + "comments_email_help": "ไม่เผยแพร่", + "comments_field_body": "คอมเมนต์", + "comments_thanks": "ขอบคุณ — คอมเมนต์ของคุณรอการตรวจสอบ", + "comments_submit": "ส่งคอมเมนต์", + "comments_closed": "ปิดรับคอมเมนต์สำหรับบทความนี้" } diff --git a/src/lib/comments/mask.ts b/src/lib/comments/mask.ts new file mode 100644 index 0000000..0c2fada --- /dev/null +++ b/src/lib/comments/mask.ts @@ -0,0 +1,14 @@ +/** + * Mask an email for in-CMS display. `alice@example.com` → + * `a***@e***.com`. Pure helper, safe to import client-side. + */ +export function maskEmail(email: string): string { + const at = email.indexOf("@"); + if (at < 1) return email; + const localPart = email.slice(0, at); + const domainPart = email.slice(at + 1); + const dot = domainPart.lastIndexOf("."); + const tld = dot > 0 ? domainPart.slice(dot) : ""; + const domainHead = dot > 0 ? domainPart.slice(0, dot) : domainPart; + return `${localPart[0]}***@${domainHead[0]}***${tld}`; +} diff --git a/src/lib/components/cms/sidebar-nav.ts b/src/lib/components/cms/sidebar-nav.ts index b36dc62..e8a7002 100644 --- a/src/lib/components/cms/sidebar-nav.ts +++ b/src/lib/components/cms/sidebar-nav.ts @@ -13,6 +13,7 @@ import { Puzzle, Inbox, Mail, + MessageSquare, } from "lucide-svelte"; import * as m from "$lib/paraglide/messages"; @@ -73,6 +74,12 @@ export const navGroups: ReadonlyArray = [ icon: Inbox, roles: ["super_admin", "admin", "editor"], }, + { + href: "/cms/comments", + label: m.cms_comments, + icon: MessageSquare, + roles: ["super_admin", "admin", "editor"], + }, ], }, { diff --git a/src/lib/components/comments/CommentSection.svelte b/src/lib/components/comments/CommentSection.svelte new file mode 100644 index 0000000..62248aa --- /dev/null +++ b/src/lib/components/comments/CommentSection.svelte @@ -0,0 +1,152 @@ + + +
+

+ {m.comments_section_title({ count: String(comments.length) })} +

+ + {#if comments.length === 0} +

+ {m.comments_empty()} +

+ {:else} +
    + {#each comments as c (c.id)} +
  1. +
    {c.authorName}
    + +

    {c.body}

    +
  2. + {/each} +
+ {/if} + + {#if open} +
+

{m.comments_form_title()}

+ + + +
+ + +
+ + + {#if errorMsg} +
{errorMsg}
+ {/if} + {#if success} +
+ {m.comments_thanks()} +
+ {/if} + + +
+ {:else} +

{m.comments_closed()}

+ {/if} +
diff --git a/src/lib/forms/constants.ts b/src/lib/forms/constants.ts new file mode 100644 index 0000000..9caaa2c --- /dev/null +++ b/src/lib/forms/constants.ts @@ -0,0 +1,8 @@ +/** + * Shared (non-server) constants for the form-style endpoints. Safe to + * import from client components because the field-name string isn't + * a secret — real spam bots already fill every input. + */ +export const HONEYPOT_FIELD = "_hp" as const; +export const RATE_LIMIT_WINDOW_SECONDS = 60; +export const RATE_LIMIT_MAX_PER_WINDOW = 3; diff --git a/src/lib/server/audit/index.ts b/src/lib/server/audit/index.ts index dcd596f..1e58f86 100644 --- a/src/lib/server/audit/index.ts +++ b/src/lib/server/audit/index.ts @@ -30,7 +30,8 @@ export type AuditAction = | `invitation.${"create" | "accept" | "revoke"}` | `settings.${"update"}` | `form.${"create" | "update" | "delete" | "submit"}` - | `newsletter.${"subscribe" | "confirm" | "unsubscribe" | "delete" | "digest_sent"}`; + | `newsletter.${"subscribe" | "confirm" | "unsubscribe" | "delete" | "digest_sent"}` + | `comment.${"create" | "approve" | "spam" | "archive" | "delete"}`; /** Entity type derived from the action prefix. */ function entityTypeOf(action: AuditAction): string { diff --git a/src/lib/server/comments/index.ts b/src/lib/server/comments/index.ts new file mode 100644 index 0000000..1f4e9a3 --- /dev/null +++ b/src/lib/server/comments/index.ts @@ -0,0 +1,27 @@ +/** + * v2.0c comment-policy helper. + * + * The dual-toggle: site-wide `settings.commentsEnabled` (defaults to + * `false` so a fresh deploy never accidentally exposes a comment form) + * AND per-article `commentsMode` (`inherit` | `on` | `off`). + * + * `on` — force comments on for this article regardless of site setting. + * `off` — force comments off for this article regardless of site setting. + * `inherit` — defer to the site setting. + */ +import type { ArticleRecord, SiteSettings } from "$lib/server/content/types"; + +export function commentsAllowedForArticle( + article: Pick, + settings: SiteSettings | null, +): boolean { + if (article.commentsMode === "on") return true; + if (article.commentsMode === "off") return false; + // inherit + return settings?.commentsEnabled === true; +} + +// `maskEmail` lives in $lib/comments/mask so the CMS Svelte page can +// import it client-side. Re-export so server-side callers don't need +// to know which directory it sits in. +export { maskEmail } from "$lib/comments/mask"; diff --git a/src/lib/server/content/providers/d1.ts b/src/lib/server/content/providers/d1.ts index 379b712..5c34b9d 100644 --- a/src/lib/server/content/providers/d1.ts +++ b/src/lib/server/content/providers/d1.ts @@ -28,6 +28,10 @@ import type { SearchOptions, SiteSettings, Locale, + CommentRecord, + CommentCreateInput, + CommentFilter, + CommentStatus, ContentBlockRecord, FormRecord, FormField, @@ -345,6 +349,7 @@ export class D1ContentProvider implements ContentProvider { authorId: data.authorId, status: data.status ?? "draft", publishedAt: data.publishedAt ?? null, + commentsMode: data.commentsMode ?? "inherit", createdAt: now, updatedAt: now, }); @@ -436,6 +441,8 @@ export class D1ContentProvider implements ContentProvider { if (data.status !== undefined) updateFields.status = data.status; if (data.publishedAt !== undefined) updateFields.publishedAt = data.publishedAt; + if (data.commentsMode !== undefined) + updateFields.commentsMode = data.commentsMode; await this.db .update(schema.articles) @@ -551,6 +558,7 @@ export class D1ContentProvider implements ContentProvider { status: article.status as ArticleRecord["status"], authorId: article.authorId, publishedAt: article.publishedAt, + commentsMode: article.commentsMode as ArticleRecord["commentsMode"], createdAt: article.createdAt, updatedAt: article.updatedAt, tagIds: tagRows.map((r) => r.tagId), @@ -1683,4 +1691,118 @@ export class D1ContentProvider implements ContentProvider { createdAt: row.createdAt, }; } + + // ─── Comments (v2.0c) ─────────────────────────────────── + + async listComments(filter?: CommentFilter): Promise { + const conditions = []; + if (filter?.articleId) { + conditions.push(eq(schema.comments.articleId, filter.articleId)); + } + if (filter?.status) { + conditions.push(eq(schema.comments.status, filter.status)); + } + const limit = filter?.limit ?? 50; + const offset = filter?.page ? Math.max(0, (filter.page - 1) * limit) : 0; + const query = this.db.select().from(schema.comments); + const rows = await (conditions.length + ? query.where(and(...conditions)) + : query + ) + .orderBy(desc(schema.comments.submittedAt)) + .limit(limit) + .offset(offset) + .all(); + return rows.map((r) => this.toComment(r)); + } + + async getComment(id: string): Promise { + const row = await this.db + .select() + .from(schema.comments) + .where(eq(schema.comments.id, id)) + .get(); + return row ? this.toComment(row) : null; + } + + async createComment(data: CommentCreateInput): Promise { + const id = nanoid(); + await this.db.insert(schema.comments).values({ + id, + articleId: data.articleId, + parentId: data.parentId ?? null, + authorName: data.authorName, + authorEmail: data.authorEmail, + body: data.body, + ipHash: data.ipHash ?? null, + // status defaults to 'pending' via schema default + }); + return (await this.getComment(id))!; + } + + async updateComment( + id: string, + data: { status: CommentStatus; moderatedBy: string }, + ): Promise { + await this.db + .update(schema.comments) + .set({ + status: data.status, + moderatedBy: data.moderatedBy, + moderatedAt: new Date().toISOString(), + }) + .where(eq(schema.comments.id, id)); + return (await this.getComment(id))!; + } + + async deleteComment(id: string): Promise { + await this.db.delete(schema.comments).where(eq(schema.comments.id, id)); + } + + async countPendingComments(): Promise { + const rows = await this.db + .select({ id: schema.comments.id }) + .from(schema.comments) + .where(eq(schema.comments.status, "pending")) + .all(); + return rows.length; + } + + async countRecentComments( + articleId: string, + ipHash: string, + sinceSeconds: number, + ): Promise { + const cutoff = new Date(Date.now() - sinceSeconds * 1000).toISOString(); + const rows = await this.db + .select({ id: schema.comments.id }) + .from(schema.comments) + .where( + and( + eq(schema.comments.articleId, articleId), + eq(schema.comments.ipHash, ipHash), + gte(schema.comments.submittedAt, cutoff), + ), + ) + .all(); + return rows.length; + } + + private toComment( + row: typeof schema.comments.$inferSelect, + ): CommentRecord { + return { + id: row.id, + articleId: row.articleId, + parentId: row.parentId, + authorName: row.authorName, + authorEmail: row.authorEmail, + body: row.body, + status: row.status as CommentStatus, + ipHash: row.ipHash, + submittedAt: row.submittedAt, + moderatedBy: row.moderatedBy, + moderatedAt: row.moderatedAt, + }; + } } diff --git a/src/lib/server/content/schema.ts b/src/lib/server/content/schema.ts index 76d2dcd..af78f43 100644 --- a/src/lib/server/content/schema.ts +++ b/src/lib/server/content/schema.ts @@ -131,6 +131,17 @@ export const articles = sqliteTable("articles", { .notNull() .default("draft"), publishedAt: text("published_at"), + /** + * v2.0c: per-article comment policy. + * `inherit` → use the site-wide `commentsEnabled` setting (default). + * `on` → force comments on for this article. + * `off` → force comments off for this article. + * Site default (off) means a fresh deploy never accidentally exposes + * a comment form before the operator chooses. + */ + commentsMode: text("comments_mode", { enum: ["inherit", "on", "off"] }) + .notNull() + .default("inherit"), createdAt: text("created_at") .notNull() .$defaultFn(() => new Date().toISOString()), @@ -551,3 +562,47 @@ export const subscribers = sqliteTable("subscribers", { .notNull() .$defaultFn(() => new Date().toISOString()), }); + +// ─── Comments (v2.0c) ──────────────────────────────────── +// Per-article comments with name + email + plain-text body. Default +// status `pending` so nothing goes public until the editor approves. +// Same honeypot + ip-hash + rate-limit pattern as the v2.0a forms +// machinery; the same helpers in $lib/server/forms get reused. +// +// `parentId` ships in the schema as forward-compat for threaded +// replies; the v2.0c UI is single-level only. + +export const comments = sqliteTable( + "comments", + { + id: text("id").primaryKey(), + articleId: text("article_id") + .notNull() + .references(() => articles.id, { onDelete: "cascade" }), + parentId: text("parent_id"), + authorName: text("author_name").notNull(), + /** + * Always collected, never displayed publicly. Useful for reply-via + * -email and future gravatar lookup. + */ + authorEmail: text("author_email").notNull(), + /** Plain text — never markdown. Renderer escapes on output. */ + body: text("body").notNull(), + status: text("status", { + enum: ["pending", "approved", "spam", "archived"], + }) + .notNull() + .default("pending"), + /** 16-char SHA-256 truncate; never the raw IP. Cluster key for rate + * limit + spam clustering, never an identifier. */ + ipHash: text("ip_hash"), + submittedAt: text("submitted_at") + .notNull() + .$defaultFn(() => new Date().toISOString()), + /** Editor who flipped status. Null for un-moderated. */ + moderatedBy: text("moderated_by").references(() => users.id, { + onDelete: "set null", + }), + moderatedAt: text("moderated_at"), + }, +); diff --git a/src/lib/server/content/types.ts b/src/lib/server/content/types.ts index 9a1b103..481bdaf 100644 --- a/src/lib/server/content/types.ts +++ b/src/lib/server/content/types.ts @@ -13,6 +13,9 @@ export interface LocalizedContent { seoDescription?: string; } +/** v2.0c per-article comment policy. */ +export type CommentsMode = "inherit" | "on" | "off"; + // ─── Articles ──────────────────────────────────────────── export interface ArticleRecord { id: string; @@ -28,6 +31,8 @@ export interface ArticleRecord { authorId: string; status: "draft" | "published" | "archived"; publishedAt: string | null; + /** v2.0c per-article comment policy; defaults to `inherit`. */ + commentsMode: CommentsMode; createdAt: string; updatedAt: string; /** @@ -49,6 +54,8 @@ export interface ArticleCreateInput { authorId: string; status?: ArticleRecord["status"]; publishedAt?: string; + /** v2.0c per-article comment policy. Defaults to `inherit`. */ + commentsMode?: CommentsMode; /** Must include `en` so the slug can be derived from the English title. */ localizations: { en: LocalizedContent } & Partial< Record @@ -72,6 +79,8 @@ export interface ArticleUpdateInput { tagIds?: string[]; status?: ArticleRecord["status"]; publishedAt?: string | null; + /** v2.0c per-article comment policy. */ + commentsMode?: CommentsMode; localizations?: Partial>; /** See `ArticleCreateInput.actorId`. */ actorId?: string; @@ -141,6 +150,13 @@ export interface SiteSettings { * first-party D1 page-view counter runs regardless. */ cfaToken?: string; + /** + * v2.0c: site-wide comments kill switch. Defaults to `false` so a + * fresh deploy never accidentally exposes a comment form. Per- + * article overrides via `articles.commentsMode` ("on" / "off") + * still apply regardless. + */ + commentsEnabled?: boolean; [key: string]: unknown; } @@ -356,6 +372,64 @@ export interface ContentProvider { parentId: string | null; }>, ): Promise; + + // Comments (v2.0c) + listComments(filter?: CommentFilter): Promise; + getComment(id: string): Promise; + createComment(data: CommentCreateInput): Promise; + updateComment( + id: string, + data: { status: CommentStatus; moderatedBy: string }, + ): Promise; + deleteComment(id: string): Promise; + /** Count pending comments site-wide for the sidebar badge. */ + countPendingComments(): Promise; + /** + * Count comments from this ipHash for this article in the last + * `sinceSeconds`. Mirrors `countRecentSubmissions` from v2.0a forms; + * used by the public POST endpoint for rate limiting. + */ + countRecentComments( + articleId: string, + ipHash: string, + sinceSeconds: number, + ): Promise; +} + +// ─── Comments (v2.0c) ──────────────────────────────────── + +export type CommentStatus = "pending" | "approved" | "spam" | "archived"; + +export interface CommentRecord { + id: string; + articleId: string; + parentId: string | null; + authorName: string; + /** Always collected, never displayed publicly. */ + authorEmail: string; + /** Plain text — never markdown. */ + body: string; + status: CommentStatus; + ipHash: string | null; + submittedAt: string; + moderatedBy: string | null; + moderatedAt: string | null; +} + +export interface CommentCreateInput { + articleId: string; + parentId?: string | null; + authorName: string; + authorEmail: string; + body: string; + ipHash?: string; +} + +export interface CommentFilter { + articleId?: string; + status?: CommentStatus; + page?: number; + limit?: number; } /** A reusable content snippet (v1.7). Per-locale body. */ diff --git a/src/lib/server/forms/index.ts b/src/lib/server/forms/index.ts index 6c4cb9a..2fad752 100644 --- a/src/lib/server/forms/index.ts +++ b/src/lib/server/forms/index.ts @@ -2,10 +2,17 @@ * Helpers for form submission validation + rate limiting (v2.0a). */ import type { FormField, FormRecord } from "$lib/server/content/types"; +import { + HONEYPOT_FIELD, + RATE_LIMIT_MAX_PER_WINDOW, + RATE_LIMIT_WINDOW_SECONDS, +} from "$lib/forms/constants"; -export const HONEYPOT_FIELD = "_hp" as const; -export const RATE_LIMIT_WINDOW_SECONDS = 60; -export const RATE_LIMIT_MAX_PER_WINDOW = 3; +// Re-exported for compatibility with existing server-side imports. +// The constants themselves live in `$lib/forms/constants` so client +// components (e.g. v2.0c CommentSection) can use the honeypot name +// without pulling a server-only module into the client bundle. +export { HONEYPOT_FIELD, RATE_LIMIT_MAX_PER_WINDOW, RATE_LIMIT_WINDOW_SECONDS }; /** * Hash an IP address with SHA-256 + truncate to 16 hex chars. We never diff --git a/src/routes/(cms)/cms/articles/ArticleForm.svelte b/src/routes/(cms)/cms/articles/ArticleForm.svelte index 85b3a89..1801628 100644 --- a/src/routes/(cms)/cms/articles/ArticleForm.svelte +++ b/src/routes/(cms)/cms/articles/ArticleForm.svelte @@ -2,7 +2,12 @@ import { enhance } from '$app/forms'; import * as m from '$lib/paraglide/messages'; import { slugify } from '$lib/utils'; - import type { ArticleRecord, CategoryRecord, TagRecord } from '$lib/server/content/types'; + import type { + ArticleRecord, + CategoryRecord, + CommentsMode, + TagRecord, + } from '$lib/server/content/types'; import MarkdownEditor from '$lib/components/editor/MarkdownEditor.svelte'; type Values = { @@ -23,6 +28,8 @@ tagIds: string[]; /** "yyyy-MM-ddThh:mm" for the local datetime input; "" means publish immediately */ publishedAtLocal: string; + /** v2.0c per-article comment policy. */ + commentsMode: CommentsMode; }; let { @@ -63,6 +70,8 @@ tagIds: formState?.values?.tagIds ?? existing?.tagIds ?? [], publishedAtLocal: formState?.values?.publishedAtLocal ?? isoToLocalInput(existing?.publishedAt) ?? '', + commentsMode: + formState?.values?.commentsMode ?? existing?.commentsMode ?? 'inherit', }); /** @@ -98,6 +107,7 @@ let categoryId = $state(seed.categoryId); let tagIds = $state(seed.tagIds); let publishedAtLocal = $state(seed.publishedAtLocal); + let commentsMode = $state(seed.commentsMode); let loading = $state(false); // "Scheduled" means: status is published AND publishedAt is in the future. @@ -367,6 +377,30 @@ {/if} + +
+ {m.cms_article_comments_mode()} +
+ {#each ['inherit', 'on', 'off'] as mode (mode)} + + {/each} +
+ + {m.cms_article_comments_mode_help()} + +
+
{m.cms_cover_media()}
diff --git a/src/routes/(cms)/cms/articles/[id]/+page.server.ts b/src/routes/(cms)/cms/articles/[id]/+page.server.ts index a85fe1b..3ae4298 100644 --- a/src/routes/(cms)/cms/articles/[id]/+page.server.ts +++ b/src/routes/(cms)/cms/articles/[id]/+page.server.ts @@ -94,6 +94,12 @@ export const actions: Actions = { const publishedAtLocal = String( form.get("published_at_local") ?? "", ).trim(); + const commentsModeRaw = String(form.get("comments_mode") ?? "inherit"); + const commentsMode = ( + ["inherit", "on", "off"].includes(commentsModeRaw) + ? commentsModeRaw + : "inherit" + ) as "inherit" | "on" | "off"; if (!titleEn || !bodyEn) { return fail(400, { @@ -152,6 +158,7 @@ export const actions: Actions = { categoryId: categoryId ? categoryId : null, tagIds, publishedAt: resolvedPublishedAt, + commentsMode, actorId: user.id, localizations: { en: { diff --git a/src/routes/(cms)/cms/articles/new/+page.server.ts b/src/routes/(cms)/cms/articles/new/+page.server.ts index 1821583..69e8101 100644 --- a/src/routes/(cms)/cms/articles/new/+page.server.ts +++ b/src/routes/(cms)/cms/articles/new/+page.server.ts @@ -41,6 +41,12 @@ export const actions: Actions = { const publishedAtLocal = String( form.get("published_at_local") ?? "", ).trim(); + const commentsModeRaw = String(form.get("comments_mode") ?? "inherit"); + const commentsMode = ( + ["inherit", "on", "off"].includes(commentsModeRaw) + ? commentsModeRaw + : "inherit" + ) as "inherit" | "on" | "off"; if (!titleEn || !bodyEn) { return fail(400, { @@ -111,6 +117,7 @@ export const actions: Actions = { categoryId: categoryId || undefined, tagIds: tagIds.length ? tagIds : undefined, publishedAt, + commentsMode, localizations: { en: { title: titleEn, diff --git a/src/routes/(cms)/cms/comments/+page.server.ts b/src/routes/(cms)/cms/comments/+page.server.ts new file mode 100644 index 0000000..32fcd9e --- /dev/null +++ b/src/routes/(cms)/cms/comments/+page.server.ts @@ -0,0 +1,136 @@ +import { error, fail, redirect } from "@sveltejs/kit"; +import { canManageTaxonomy } from "$lib/server/auth/permissions"; +import { logAudit, type AuditAction } from "$lib/server/audit"; +import type { CommentStatus } from "$lib/server/content/types"; +import type { Actions, PageServerLoad } from "./$types"; + +const PAGE_SIZE = 50; + +const VALID_STATUSES: CommentStatus[] = [ + "pending", + "approved", + "spam", + "archived", +]; + +/** + * `/cms/comments` — moderation queue (v2.0c). Editor+ only. + * + * Filtered by status via `?status=…`; defaults to `pending` so + * editors land on the work-to-do view. Resolves articleId → article + * title in one batch so each row can show a clickable title. + */ +export const load: PageServerLoad = async ({ locals, url }) => { + if (!locals.user) throw redirect(302, "/cms/login"); + if (!canManageTaxonomy(locals.user)) { + throw error(403, "Editors and above can moderate comments."); + } + + const statusParam = url.searchParams.get("status") ?? "pending"; + const status: CommentStatus = (VALID_STATUSES as string[]).includes( + statusParam, + ) + ? (statusParam as CommentStatus) + : "pending"; + const page = Math.max(1, Number(url.searchParams.get("page") ?? "1") || 1); + + const comments = await locals.content.listComments({ + status, + page, + limit: PAGE_SIZE + 1, + }); + const hasNext = comments.length > PAGE_SIZE; + const items = comments.slice(0, PAGE_SIZE); + + // Resolve article ids → { slug, title } in parallel for the row + // labels. De-duped so we never fetch the same article twice on a + // page where one article has many comments. + const articleIds = [...new Set(items.map((c) => c.articleId))]; + const articleEntries = await Promise.all( + articleIds.map((id) => locals.content.getArticle(id)), + ); + const articleById = new Map(); + for (const a of articleEntries) { + if (!a) continue; + articleById.set(a.id, { + slug: a.slug, + title: + a.localizations.en?.title ?? + a.localizations.th?.title ?? + a.slug, + }); + } + + // Counts for the status tabs so the editor sees the workload at + // a glance. Pending count also feeds the sidebar badge. + const pendingCount = await locals.content.countPendingComments(); + + return { + items, + articleById: Object.fromEntries(articleById), + status, + page, + hasPrev: page > 1, + hasNext, + pendingCount, + }; +}; + +export const actions: Actions = { + setStatus: async ({ request, locals, platform }) => { + if (!locals.user) throw redirect(302, "/cms/login"); + if (!canManageTaxonomy(locals.user)) { + return fail(403, { error: "Forbidden" }); + } + const fd = await request.formData(); + const id = String(fd.get("id") ?? "").trim(); + const next = String(fd.get("status") ?? "") as CommentStatus; + if (!id || !VALID_STATUSES.includes(next)) { + return fail(400, { error: "Bad request" }); + } + const before = await locals.content.getComment(id); + if (!before) return fail(404, { error: "Comment not found" }); + await locals.content.updateComment(id, { + status: next, + moderatedBy: locals.user.id, + }); + if (platform?.env?.DB) { + const action: AuditAction = + next === "approved" + ? "comment.approve" + : next === "spam" + ? "comment.spam" + : next === "archived" + ? "comment.archive" + : "comment.create"; + await logAudit(platform.env.DB, locals.user.id, action, id, { + from: before.status, + to: next, + }); + } + return { ok: true }; + }, + + delete: async ({ request, locals, platform }) => { + if (!locals.user) throw redirect(302, "/cms/login"); + if (!canManageTaxonomy(locals.user)) { + return fail(403, { error: "Forbidden" }); + } + const fd = await request.formData(); + const id = String(fd.get("id") ?? "").trim(); + if (!id) return fail(400, { error: "Missing comment id" }); + const before = await locals.content.getComment(id); + if (!before) return fail(404, { error: "Comment not found" }); + await locals.content.deleteComment(id); + if (platform?.env?.DB) { + await logAudit( + platform.env.DB, + locals.user.id, + "comment.delete", + id, + { articleId: before.articleId }, + ); + } + return { ok: true }; + }, +}; diff --git a/src/routes/(cms)/cms/comments/+page.svelte b/src/routes/(cms)/cms/comments/+page.svelte new file mode 100644 index 0000000..54775e7 --- /dev/null +++ b/src/routes/(cms)/cms/comments/+page.svelte @@ -0,0 +1,180 @@ + + + + {m.cms_comments()} — {m.cms_app_name()} + + +
+
+
+

{m.cms_comments()}

+

{m.cms_comments_help()}

+
+ {#if data.pendingCount > 0} + + {m.cms_comments_pending_count({ count: String(data.pendingCount) })} + + {/if} +
+ + {#if form?.error} +
+ {form.error} +
+ {/if} + + +
+ {#each tabs as t (t.key)} + + {t.label} + + {/each} +
+ + {#if data.items.length === 0} +
+

{m.cms_comments_empty()}

+
+ {:else} +
+ {#each data.items as c (c.id)} + {@const article = data.articleById[c.articleId]} +
+
+ {c.status} + + {fmt(c.submittedAt)} + + + {c.authorName} + · {maskEmail(c.authorEmail)} + + {#if article} + + → {article.title} + + {/if} +
+
+

{c.body}

+
+ {#each ['approved', 'spam', 'archived'] as next (next)} + {#if c.status !== next} +
+ + + +
+ {/if} + {/each} + + {m.cms_comments_reply_email()} + +
{ + if (!confirm(m.cms_delete_confirm())) { + cancel(); + return; + } + return async ({ update }) => update(); + }} + > + + +
+
+
+
+ {/each} +
+ + {#if data.hasPrev || data.hasNext} +
+ {#if data.hasPrev} + + ← {m.cms_audit_prev()} + + {:else} + + {/if} + {#if data.hasNext} + + {m.cms_audit_next()} → + + {/if} +
+ {/if} + {/if} +
diff --git a/src/routes/(cms)/cms/settings/+page.server.ts b/src/routes/(cms)/cms/settings/+page.server.ts index 173f883..fdea156 100644 --- a/src/routes/(cms)/cms/settings/+page.server.ts +++ b/src/routes/(cms)/cms/settings/+page.server.ts @@ -58,6 +58,11 @@ export const actions: Actions = { ).trim(); const newsletterAllowSingleOptIn = form.get("newsletter_allow_single_opt_in") === "on"; + // v2.0c: site-wide comments kill switch. Defaults to off so a + // fresh deploy never accidentally exposes a comment form. Per- + // article overrides via `articles.commentsMode` ("on" / "off") + // still apply regardless. + const commentsEnabled = form.get("comments_enabled") === "on"; if (!siteName) return fail(400, { error: "Site name is required." }); const supported = parseLocales(supportedLocalesRaw); @@ -85,6 +90,8 @@ export const actions: Actions = { "newsletter.resendKey": newsletterResendKey || undefined, "newsletter.senderAddress": newsletterSenderAddress || undefined, "newsletter.allowSingleOptIn": newsletterAllowSingleOptIn, + // Comments (v2.0c) — site-wide kill switch. + commentsEnabled, }); } catch (err) { return fail(500, { diff --git a/src/routes/(cms)/cms/settings/+page.svelte b/src/routes/(cms)/cms/settings/+page.svelte index 985df33..ffa86a8 100644 --- a/src/routes/(cms)/cms/settings/+page.svelte +++ b/src/routes/(cms)/cms/settings/+page.svelte @@ -24,6 +24,10 @@ let newsletterAllowSingle = $state( (data.settings['newsletter.allowSingleOptIn'] as boolean | undefined) ?? true, ); + // v2.0c — site-wide comments kill switch. Defaults to false. + let commentsEnabled = $state( + (data.settings.commentsEnabled as boolean | undefined) ?? false, + ); let saving = $state(false); @@ -172,6 +176,30 @@ + + + {m.cms_settings_comments()} + + +

{m.cms_settings_comments_help()}

+ +
+
+
+ {#if data.commentsOpen || data.comments.length > 0} + + {/if} +