From f520b3c415f8fd106805551e2e1444ff25b1d735 Mon Sep 17 00:00:00 2001 From: Thunpisit Amnuaikiatloet Date: Sat, 2 May 2026 11:18:08 +0700 Subject: [PATCH] =?UTF-8?q?feat(v2.0b):=20newsletter=20=E2=80=94=20fully?= =?UTF-8?q?=20optional=20(matches=20upstream=20PR=20#44)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cherry-picks upstream PR #44. Migration 0008 already applied to live D1. i18n: 22 new keys field-merged into messages/en.json + th.json without overwriting example-specific copy. Co-Authored-By: Claude Opus 4.7 --- README.md | 2 +- docs/MILESTONES.md | 8 +- drizzle/0008_nostalgic_namorita.sql | 13 + drizzle/meta/0008_snapshot.json | 2094 +++++++++++++++++ drizzle/meta/_journal.json | 7 + messages/en.json | 20 + messages/th.json | 20 + src/lib/components/cms/sidebar-nav.ts | 7 + src/lib/server/audit/index.ts | 3 +- src/lib/server/content/providers/d1.ts | 141 ++ src/lib/server/content/schema.ts | 26 + src/lib/server/content/types.ts | 45 + src/lib/server/newsletter/index.ts | 174 ++ src/routes/(cms)/cms/settings/+page.server.ts | 18 + src/routes/(cms)/cms/settings/+page.svelte | 59 + .../(cms)/cms/subscribers/+page.server.ts | 58 + src/routes/(cms)/cms/subscribers/+page.svelte | 179 ++ src/routes/api/newsletter/confirm/+server.ts | 45 + .../api/newsletter/send-digest/+server.ts | 138 ++ .../api/newsletter/subscribe/+server.ts | 132 ++ .../api/newsletter/unsubscribe/+server.ts | 42 + 21 files changed, 3226 insertions(+), 5 deletions(-) create mode 100644 drizzle/0008_nostalgic_namorita.sql create mode 100644 drizzle/meta/0008_snapshot.json create mode 100644 src/lib/server/newsletter/index.ts create mode 100644 src/routes/(cms)/cms/subscribers/+page.server.ts create mode 100644 src/routes/(cms)/cms/subscribers/+page.svelte create mode 100644 src/routes/api/newsletter/confirm/+server.ts create mode 100644 src/routes/api/newsletter/send-digest/+server.ts create mode 100644 src/routes/api/newsletter/subscribe/+server.ts create mode 100644 src/routes/api/newsletter/unsubscribe/+server.ts diff --git a/README.md b/README.md index 1a20e38..951a76a 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 | 🚧 Pending | Forms, newsletter, comments, webhooks, public read-only 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 04fb144..c58bbd7 100644 --- a/docs/MILESTONES.md +++ b/docs/MILESTONES.md @@ -240,11 +240,13 @@ Privacy-friendly editor analytics. Closes the "what's working?" gap left after v **i18n** — 7 new `error_*` keys (EN + TH). -### v2.0 — Engagement and growth (pending) +### v2.0 — Engagement and growth (in progress) -**Forms** — A new `forms` table (id, name, fields-as-JSON) and `form_submissions` table. CMS editor for fields (text, email, textarea, checkbox). Public submission endpoint with built-in honeypot + rate limit. Submissions land in the CMS for review; optional webhook on submit. +Shipping in four PRs grouped by theme. **a** + **b** done, **c** + **d** to follow. -**Newsletter** — Subscriber list (`subscribers` table: email, locale, confirmedAt, unsubscribedAt, source). Opt-in via a form on the public site with double-confirm email. CMS digest job pulls the last week's published articles and sends a templated email via Resend / Cloudflare Email Routing. Compliance: clear unsubscribe link in every email, audit log of subscribe/unsubscribe events. +**v2.0a — Forms (shipped)** — Drizzle migration 0007: `forms` (key UNIQUE, fields-as-JSON, `enabled` flag, per-locale success messages) and `form_submissions` (data JSON, ip_hash 16-char truncated SHA-256 — never raw IP, status enum new/read/spam/archived, note). Public `POST /api/forms/[key]` accepts multipart/url-encoded with honeypot field `_hp` and per-IP rate limit (3/minute). 410 when form disabled, 429 on rate-limit. CMS at `/cms/forms` with editor (add/reorder/delete fields of kind text/email/textarea/checkbox, per-field name + label + required toggle) and an embedded submissions inbox with mark-as / delete actions. New `form.{create,update,delete,submit}` audit actions. + +**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. diff --git a/drizzle/0008_nostalgic_namorita.sql b/drizzle/0008_nostalgic_namorita.sql new file mode 100644 index 0000000..8489512 --- /dev/null +++ b/drizzle/0008_nostalgic_namorita.sql @@ -0,0 +1,13 @@ +CREATE TABLE `subscribers` ( + `id` text PRIMARY KEY NOT NULL, + `email` text NOT NULL, + `locale` text NOT NULL, + `token` text NOT NULL, + `confirmed_at` text, + `unsubscribed_at` text, + `source` text NOT NULL, + `created_at` text NOT NULL +); +--> statement-breakpoint +CREATE UNIQUE INDEX `subscribers_email_unique` ON `subscribers` (`email`);--> statement-breakpoint +CREATE UNIQUE INDEX `subscribers_token_unique` ON `subscribers` (`token`); \ No newline at end of file diff --git a/drizzle/meta/0008_snapshot.json b/drizzle/meta/0008_snapshot.json new file mode 100644 index 0000000..5df624f --- /dev/null +++ b/drizzle/meta/0008_snapshot.json @@ -0,0 +1,2094 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "19a332ed-c4c8-4d7b-aff5-5edfecd858c1", + "prevId": "577eee25-cfd1-4d75-87ec-b3811f751863", + "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 + }, + "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": {} + }, + "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 7de6e1d..24b628b 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -57,6 +57,13 @@ "when": 1777538401439, "tag": "0007_violet_dreadnoughts", "breakpoints": true + }, + { + "idx": 8, + "version": "6", + "when": 1777694507921, + "tag": "0008_nostalgic_namorita", + "breakpoints": true } ] } \ No newline at end of file diff --git a/messages/en.json b/messages/en.json index fc41575..4c73c09 100644 --- a/messages/en.json +++ b/messages/en.json @@ -137,6 +137,26 @@ "cms_forms_success_help": "Shown after a successful submission. Falls back to a generic thank-you when blank.", "cms_forms_submissions": "Submissions", "cms_forms_submissions_empty": "No submissions yet — share the public endpoint to start collecting.", + "cms_subscribers": "Subscribers", + "cms_subscribers_help": "Email subscribers signed up via the public newsletter form. Manage signups + send digests.", + "cms_subscribers_active": "active", + "cms_subscribers_email": "Email", + "cms_subscribers_signed_up": "Signed up", + "cms_subscribers_empty": "No subscribers yet. Add a subscribe form to your site to start collecting.", + "cms_subscribers_no_provider_title": "Newsletter provider not configured", + "cms_subscribers_no_provider_help": "Subscribers can sign up but no confirmation emails or digests will be sent. Configure Resend in settings to enable.", + "cms_subscribers_send_digest": "Send weekly digest now", + "cms_subscribers_send_digest_help": "Picks the last 7 days of published articles per locale and emails every active subscriber.", + "cms_subscribers_dry_run": "Dry run", + "cms_subscribers_send_now": "Send now", + "cms_settings_newsletter": "Newsletter (optional)", + "cms_settings_newsletter_help": "Newsletter is fully optional. Leave blank to skip — the public subscribe form will be disabled. Add a Resend API key + sender to enable double-opt-in confirmation + digest sending.", + "cms_settings_newsletter_resend_key": "Resend API key", + "cms_settings_newsletter_resend_key_help": "Get a free API key at resend.com/api-keys. Leave blank to disable email sending.", + "cms_settings_newsletter_sender": "Sender address", + "cms_settings_newsletter_sender_help": "Format: \"Your Name \". The domain must be verified in Resend.", + "cms_settings_newsletter_allow_single": "Allow single-opt-in mode when no provider is configured", + "cms_settings_newsletter_allow_single_help": "When checked, subscribers signing up without a provider configured are immediately marked confirmed. Off = public signup form returns 503 until a provider is wired.", "cms_cover_media": "Cover image", "cms_cover_media_help": "Paste a media ID from the Media library, or leave blank.", "cms_cover_media_none": "No cover", diff --git a/messages/th.json b/messages/th.json index b3330d2..51cc01a 100644 --- a/messages/th.json +++ b/messages/th.json @@ -137,6 +137,26 @@ "cms_forms_success_help": "แสดงหลังส่งสำเร็จ ใช้ข้อความขอบคุณทั่วไปเมื่อเว้นว่าง", "cms_forms_submissions": "การส่ง", "cms_forms_submissions_empty": "ยังไม่มีการส่ง แชร์ endpoint สาธารณะเพื่อเริ่มเก็บข้อมูล", + "cms_subscribers": "ผู้รับข่าวสาร", + "cms_subscribers_help": "ผู้สมัครรับอีเมลผ่านฟอร์มสาธารณะ จัดการการสมัครและส่งสรุปข่าว", + "cms_subscribers_active": "ใช้งานอยู่", + "cms_subscribers_email": "อีเมล", + "cms_subscribers_signed_up": "สมัครเมื่อ", + "cms_subscribers_empty": "ยังไม่มีผู้สมัคร เพิ่มฟอร์มสมัครในเว็บของคุณเพื่อเริ่มเก็บข้อมูล", + "cms_subscribers_no_provider_title": "ยังไม่ได้ตั้งค่าผู้ให้บริการอีเมล", + "cms_subscribers_no_provider_help": "ผู้ใช้สมัครได้ แต่จะไม่มีอีเมลยืนยันหรือสรุปข่าวส่งออก ตั้งค่า Resend ในการตั้งค่าเพื่อเปิดใช้งาน", + "cms_subscribers_send_digest": "ส่งสรุปข่าวประจำสัปดาห์", + "cms_subscribers_send_digest_help": "เลือกบทความ 7 วันล่าสุดต่อภาษา และส่งอีเมลให้ผู้สมัครทุกคน", + "cms_subscribers_dry_run": "ทดลองส่ง", + "cms_subscribers_send_now": "ส่งเลย", + "cms_settings_newsletter": "นิวส์เลตเตอร์ (ตัวเลือก)", + "cms_settings_newsletter_help": "นิวส์เลตเตอร์เป็นตัวเลือก เว้นว่างได้ — ฟอร์มสาธารณะจะปิด เพิ่ม Resend API key และอีเมลผู้ส่งเพื่อเปิดใช้การยืนยันแบบ double-opt-in และการส่งสรุปข่าว", + "cms_settings_newsletter_resend_key": "Resend API key", + "cms_settings_newsletter_resend_key_help": "รับ API key ฟรีที่ resend.com/api-keys เว้นว่างเพื่อปิดการส่งอีเมล", + "cms_settings_newsletter_sender": "ที่อยู่ผู้ส่ง", + "cms_settings_newsletter_sender_help": "รูปแบบ: \"ชื่อคุณ \" โดเมนต้องได้รับการยืนยันใน Resend แล้ว", + "cms_settings_newsletter_allow_single": "อนุญาตโหมด single-opt-in เมื่อไม่ได้ตั้งค่าผู้ให้บริการ", + "cms_settings_newsletter_allow_single_help": "เมื่อเลือก ผู้สมัครจะถูกยืนยันทันที ไม่เลือก = ฟอร์มสาธารณะคืน 503 จนกว่าจะตั้งค่าผู้ให้บริการ", "cms_cover_media": "รูปปก", "cms_cover_media_help": "วาง ID สื่อจากคลังสื่อ หรือเว้นว่าง", "cms_cover_media_none": "ไม่มีรูปปก", diff --git a/src/lib/components/cms/sidebar-nav.ts b/src/lib/components/cms/sidebar-nav.ts index e3adc68..b36dc62 100644 --- a/src/lib/components/cms/sidebar-nav.ts +++ b/src/lib/components/cms/sidebar-nav.ts @@ -12,6 +12,7 @@ import { ScrollText, Puzzle, Inbox, + Mail, } from "lucide-svelte"; import * as m from "$lib/paraglide/messages"; @@ -90,6 +91,12 @@ export const navGroups: ReadonlyArray = [ icon: ScrollText, roles: ["super_admin", "admin"], }, + { + href: "/cms/subscribers", + label: m.cms_subscribers, + icon: Mail, + roles: ["super_admin", "admin"], + }, { href: "/cms/settings", label: m.cms_settings, diff --git a/src/lib/server/audit/index.ts b/src/lib/server/audit/index.ts index 086bd14..dcd596f 100644 --- a/src/lib/server/audit/index.ts +++ b/src/lib/server/audit/index.ts @@ -29,7 +29,8 @@ export type AuditAction = | `user.${"role_change" | "delete"}` | `invitation.${"create" | "accept" | "revoke"}` | `settings.${"update"}` - | `form.${"create" | "update" | "delete" | "submit"}`; + | `form.${"create" | "update" | "delete" | "submit"}` + | `newsletter.${"subscribe" | "confirm" | "unsubscribe" | "delete" | "digest_sent"}`; /** Entity type derived from the action prefix. */ function entityTypeOf(action: AuditAction): string { diff --git a/src/lib/server/content/providers/d1.ts b/src/lib/server/content/providers/d1.ts index 91ce820..379b712 100644 --- a/src/lib/server/content/providers/d1.ts +++ b/src/lib/server/content/providers/d1.ts @@ -33,6 +33,9 @@ import type { FormField, FormSubmissionRecord, FormSubmissionStatus, + SubscriberRecord, + SubscriberFilter, + SubscriberSource, PageRecord, PageCreateInput, PageUpdateInput, @@ -1542,4 +1545,142 @@ export class D1ContentProvider implements ContentProvider { note: row.note, }; } + + // ─── Newsletter subscribers (v2.0b) ──────────────────── + + async listSubscribers(filter?: SubscriberFilter): Promise { + const conditions = []; + if (filter?.locale) { + conditions.push(eq(schema.subscribers.locale, filter.locale)); + } + if (filter?.onlyActive) { + // active = confirmed AND not unsubscribed + conditions.push(isNull(schema.subscribers.unsubscribedAt)); + // confirmedAt non-null + // Drizzle doesn't have a clean "is not null" so use sql template + conditions.push(sql`${schema.subscribers.confirmedAt} IS NOT NULL`); + } + const rows = conditions.length + ? await this.db + .select() + .from(schema.subscribers) + .where(and(...conditions)) + .orderBy(desc(schema.subscribers.createdAt)) + .limit(filter?.limit ?? 1000) + .all() + : await this.db + .select() + .from(schema.subscribers) + .orderBy(desc(schema.subscribers.createdAt)) + .limit(filter?.limit ?? 1000) + .all(); + return rows.map((r) => this.toSubscriber(r)); + } + + async countSubscribers(filter?: SubscriberFilter): Promise { + // Cheap: list ids only. + const conditions = []; + if (filter?.locale) { + conditions.push(eq(schema.subscribers.locale, filter.locale)); + } + if (filter?.onlyActive) { + conditions.push(isNull(schema.subscribers.unsubscribedAt)); + conditions.push(sql`${schema.subscribers.confirmedAt} IS NOT NULL`); + } + const rows = conditions.length + ? await this.db + .select({ id: schema.subscribers.id }) + .from(schema.subscribers) + .where(and(...conditions)) + .all() + : await this.db.select({ id: schema.subscribers.id }).from(schema.subscribers).all(); + return rows.length; + } + + async getSubscriberByEmail(email: string): Promise { + const row = await this.db + .select() + .from(schema.subscribers) + .where(eq(schema.subscribers.email, email.trim().toLowerCase())) + .get(); + return row ? this.toSubscriber(row) : null; + } + + async getSubscriberByToken(token: string): Promise { + const row = await this.db + .select() + .from(schema.subscribers) + .where(eq(schema.subscribers.token, token)) + .get(); + return row ? this.toSubscriber(row) : null; + } + + async createSubscriber(data: { + email: string; + locale: Locale; + autoConfirm?: boolean; + source?: SubscriberSource; + }): Promise { + const id = nanoid(); + const token = nanoid(24); + const now = new Date().toISOString(); + await this.db.insert(schema.subscribers).values({ + id, + email: data.email.trim().toLowerCase(), + locale: data.locale, + token, + confirmedAt: data.autoConfirm ? now : null, + source: data.source ?? "form", + }); + const row = await this.db + .select() + .from(schema.subscribers) + .where(eq(schema.subscribers.id, id)) + .get(); + return this.toSubscriber(row!); + } + + async confirmSubscriber(token: string): Promise { + const existing = await this.getSubscriberByToken(token); + if (!existing) return null; + // Idempotent: re-confirming a confirmed subscriber is a no-op. + if (existing.confirmedAt) return existing; + await this.db + .update(schema.subscribers) + .set({ confirmedAt: new Date().toISOString() }) + .where(eq(schema.subscribers.token, token)); + return this.getSubscriberByToken(token); + } + + async unsubscribeByToken(token: string): Promise { + const existing = await this.getSubscriberByToken(token); + if (!existing) return null; + if (existing.unsubscribedAt) return existing; + await this.db + .update(schema.subscribers) + .set({ unsubscribedAt: new Date().toISOString() }) + .where(eq(schema.subscribers.token, token)); + return this.getSubscriberByToken(token); + } + + async deleteSubscriber(id: string): Promise { + await this.db + .delete(schema.subscribers) + .where(eq(schema.subscribers.id, id)); + } + + private toSubscriber( + row: typeof schema.subscribers.$inferSelect, + ): SubscriberRecord { + return { + id: row.id, + email: row.email, + locale: row.locale as Locale, + token: row.token, + confirmedAt: row.confirmedAt, + unsubscribedAt: row.unsubscribedAt, + source: row.source, + createdAt: row.createdAt, + }; + } } diff --git a/src/lib/server/content/schema.ts b/src/lib/server/content/schema.ts index d9121f3..76d2dcd 100644 --- a/src/lib/server/content/schema.ts +++ b/src/lib/server/content/schema.ts @@ -525,3 +525,29 @@ export const formSubmissions = sqliteTable("form_submissions", { /** Optional one-liner the moderator adds. */ note: text("note"), }); + +// ─── Newsletter subscribers (v2.0b) ────────────────────── +// Visitors opt in via a public form. Double-opt-in: a confirm email +// goes out (when an email provider is configured) and the subscriber +// is "confirmed" only after they click the link. When no provider is +// configured, the row is created with confirmedAt=now (single-opt-in +// mode — clearly documented in /cms/settings). + +export const subscribers = sqliteTable("subscribers", { + id: text("id").primaryKey(), + email: text("email").notNull().unique(), + /** Preferred locale at signup. Used to pick the right digest body. */ + locale: text("locale", { enum: ["th", "en"] }).notNull(), + /** Random URL-safe token for the confirm + unsubscribe links. */ + token: text("token").notNull().unique(), + /** ISO timestamp once the subscriber clicked the confirm link. + * Null = pending confirmation. */ + confirmedAt: text("confirmed_at"), + /** ISO timestamp once the subscriber unsubscribed. */ + unsubscribedAt: text("unsubscribed_at"), + /** Where the subscriber came from: 'form' (default), 'import', etc. */ + source: text("source").notNull().$defaultFn(() => "form"), + createdAt: text("created_at") + .notNull() + .$defaultFn(() => new Date().toISOString()), +}); diff --git a/src/lib/server/content/types.ts b/src/lib/server/content/types.ts index 0466fcd..9a1b103 100644 --- a/src/lib/server/content/types.ts +++ b/src/lib/server/content/types.ts @@ -280,6 +280,25 @@ export interface ContentProvider { ): Promise; deleteForm(id: string): Promise; + // Newsletter subscribers (v2.0b) + listSubscribers(filter?: SubscriberFilter): Promise; + countSubscribers(filter?: SubscriberFilter): Promise; + getSubscriberByEmail(email: string): Promise; + getSubscriberByToken(token: string): Promise; + createSubscriber(data: { + email: string; + locale: Locale; + /** Pre-confirm when the operator hasn't configured an email + * provider — single-opt-in mode (clearly documented in CMS). */ + autoConfirm?: boolean; + source?: SubscriberSource; + }): Promise; + /** Stamp confirmedAt = now. Idempotent (re-confirming is a no-op). */ + confirmSubscriber(token: string): Promise; + /** Stamp unsubscribedAt = now. Idempotent. */ + unsubscribeByToken(token: string): Promise; + deleteSubscriber(id: string): Promise; + // Form submissions (v2.0a) listFormSubmissions(formId: string, opts?: { status?: FormSubmissionStatus; @@ -498,3 +517,29 @@ export interface NavigationItemUpdateInput { targetId?: string | null; customUrl?: string | null; } + +// ─── Newsletter (v2.0b) ────────────────────────────────── + +export type SubscriberSource = string; // 'form' | 'import' | etc. + +export interface SubscriberRecord { + id: string; + email: string; + locale: Locale; + /** URL-safe random token used for confirm + unsubscribe links. */ + token: string; + /** Null = pending confirmation (double-opt-in not yet completed). */ + confirmedAt: string | null; + unsubscribedAt: string | null; + source: SubscriberSource; + createdAt: string; +} + +export interface SubscriberFilter { + /** When set: only return rows with confirmedAt non-null AND + * unsubscribedAt null (the "active" set). */ + onlyActive?: boolean; + /** When set: restrict to one locale (used by the digest sender). */ + locale?: Locale; + limit?: number; +} diff --git a/src/lib/server/newsletter/index.ts b/src/lib/server/newsletter/index.ts new file mode 100644 index 0000000..39b769f --- /dev/null +++ b/src/lib/server/newsletter/index.ts @@ -0,0 +1,174 @@ +/** + * Newsletter helpers (v2.0b). Everything here is **optional** — + * the public subscribe form, the CMS subscribers page, and the + * digest sender all work whether or not the operator has configured + * an email provider. When no provider is configured: + * + * - Public signup creates the row with `confirmedAt = NOW()` + * (single-opt-in mode). + * - The confirmation email is silently skipped. + * - The digest sender no-ops with a clear log line. + * + * The only provider we wire today is **Resend** because it has the + * simplest auth model (one API key, no MX setup). Cloudflare Email + * Routing requires per-domain DNS work and is harder to ship as a + * generic default. + * + * To enable: paste a Resend API key + a sender address into + * /cms/settings → "Newsletter" section. + */ +import type { Locale, SiteSettings } from "$lib/server/content/types"; + +/** Operator-supplied newsletter settings, all optional. */ +export interface NewsletterConfig { + /** Resend API key (`re_...`). Empty = newsletters not configured. */ + resendKey?: string; + /** Sender address (e.g. `Site `). Required when + * resendKey is set; the Resend API rejects sends without a verified + * domain in this address. */ + senderAddress?: string; + /** When true and resendKey is empty, public signup goes + * single-opt-in (subscribers immediately confirmed). When false, + * public signup is disabled entirely (404 on the public form). */ + allowSingleOptIn?: boolean; +} + +/** + * Pull the newsletter config out of `SiteSettings`. We store the + * three values under settings keys (`newsletter.resendKey`, + * `newsletter.senderAddress`, `newsletter.allowSingleOptIn`) so + * they're managed from the same place as everything else and can + * be migrated like any other setting. + */ +export function readNewsletterConfig(settings: SiteSettings): NewsletterConfig { + return { + resendKey: + (settings["newsletter.resendKey"] as string | undefined) ?? undefined, + senderAddress: + (settings["newsletter.senderAddress"] as string | undefined) ?? undefined, + allowSingleOptIn: + (settings["newsletter.allowSingleOptIn"] as boolean | undefined) ?? true, + }; +} + +/** Is real email delivery wired? */ +export function isProviderConfigured(cfg: NewsletterConfig): boolean { + return Boolean(cfg.resendKey && cfg.senderAddress); +} + +/** + * Send an email via Resend. Returns `{ ok: true }` on success or + * `{ ok: false, reason: "..." }` otherwise. Best-effort: we never + * throw — callers decide whether a missing send is fatal. + * + * When the provider isn't configured, returns `{ ok: false, reason: + * "no provider configured" }` without making a network call. + */ +export async function sendEmail( + cfg: NewsletterConfig, + args: { to: string; subject: string; html: string; text?: string }, +): Promise<{ ok: true; id?: string } | { ok: false; reason: string }> { + if (!isProviderConfigured(cfg)) { + return { ok: false, reason: "no provider configured" }; + } + try { + const res = await fetch("https://api.resend.com/emails", { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${cfg.resendKey}`, + }, + body: JSON.stringify({ + from: cfg.senderAddress, + to: args.to, + subject: args.subject, + html: args.html, + text: args.text, + }), + }); + if (!res.ok) { + const body = await res.text().catch(() => ""); + return { ok: false, reason: `resend ${res.status}: ${body.slice(0, 200)}` }; + } + const data = (await res.json().catch(() => null)) as { id?: string } | null; + return { ok: true, id: data?.id }; + } catch (err) { + return { + ok: false, + reason: err instanceof Error ? err.message : String(err), + }; + } +} + +/** Build the confirmation email HTML/text. */ +export function buildConfirmEmail(args: { + siteName: string; + origin: string; + token: string; + locale: Locale; +}): { subject: string; html: string; text: string } { + const link = `${args.origin}/api/newsletter/confirm?token=${args.token}`; + const subjectTh = `ยืนยันการสมัครรับข่าวสารจาก ${args.siteName}`; + const subjectEn = `Confirm your subscription to ${args.siteName}`; + const bodyTh = `กรุณาคลิกลิงก์นี้เพื่อยืนยันการสมัคร: ${link}\n\nหากคุณไม่ได้สมัครรับข่าวสารนี้ ก็เพิกเฉยอีเมลนี้ได้เลย`; + const bodyEn = `Click this link to confirm your subscription: ${link}\n\nIf you didn't sign up, you can safely ignore this email.`; + const text = args.locale === "th" ? bodyTh : bodyEn; + const subject = args.locale === "th" ? subjectTh : subjectEn; + const html = ` +

${escapeHtml(text.split("\n\n")[0])}

+

${args.locale === "th" ? "ยืนยัน" : "Confirm"}

+

${escapeHtml(text.split("\n\n")[1] ?? "")}

+ `; + return { subject, html, text }; +} + +/** Build a weekly digest email. */ +export function buildDigestEmail(args: { + siteName: string; + origin: string; + unsubscribeToken: string; + locale: Locale; + articles: Array<{ slug: string; title: string; excerpt: string | null }>; +}): { subject: string; html: string; text: string } { + const unsubLink = `${args.origin}/api/newsletter/unsubscribe?token=${args.unsubscribeToken}`; + const subjectTh = `${args.siteName}: เนื้อหาใหม่สัปดาห์นี้`; + const subjectEn = `${args.siteName}: this week's reading`; + const subject = args.locale === "th" ? subjectTh : subjectEn; + const itemsHtml = args.articles + .map((a) => { + const link = `${args.origin}/${args.locale}/blog/${a.slug}`; + return `
  • + ${escapeHtml(a.title)} + ${a.excerpt ? `

    ${escapeHtml(a.excerpt)}

    ` : ""} +
  • `; + }) + .join(""); + const itemsText = args.articles + .map( + (a) => + `• ${a.title}\n ${args.origin}/${args.locale}/blog/${a.slug}${a.excerpt ? "\n " + a.excerpt : ""}`, + ) + .join("\n\n"); + const unsubLabel = + args.locale === "th" ? "ยกเลิกการรับข่าวสาร" : "Unsubscribe"; + const html = ` +

    ${escapeHtml(args.siteName)}

    +
      ${itemsHtml}
    +
    +

    + ${unsubLabel} +

    + `; + const text = `${args.siteName}\n\n${itemsText}\n\n---\n${unsubLabel}: ${unsubLink}`; + return { subject, html, text }; +} + +function escapeHtml(s: string): string { + return s + .replace(/&/g, "&") + .replace(//g, ">"); +} +function escapeAttr(s: string): string { + return escapeHtml(s).replace(/"/g, """); +} diff --git a/src/routes/(cms)/cms/settings/+page.server.ts b/src/routes/(cms)/cms/settings/+page.server.ts index b99de2a..173f883 100644 --- a/src/routes/(cms)/cms/settings/+page.server.ts +++ b/src/routes/(cms)/cms/settings/+page.server.ts @@ -46,6 +46,18 @@ export const actions: Actions = { // off; presence injects the official beacon snippet on every // public page. Stored in site_settings (no env var needed). const cfaToken = String(form.get("cfa_token") ?? "").trim(); + // v2.0b: optional newsletter config. All three fields are + // independently optional — the public subscribe form falls back + // to single-opt-in when no provider is set, so editors can start + // collecting emails before wiring up Resend. + const newsletterResendKey = String( + form.get("newsletter_resend_key") ?? "", + ).trim(); + const newsletterSenderAddress = String( + form.get("newsletter_sender") ?? "", + ).trim(); + const newsletterAllowSingleOptIn = + form.get("newsletter_allow_single_opt_in") === "on"; if (!siteName) return fail(400, { error: "Site name is required." }); const supported = parseLocales(supportedLocalesRaw); @@ -67,6 +79,12 @@ export const actions: Actions = { supportedLocales: supported as Array<"en" | "th">, cdnBaseUrl: cdnBaseUrl || undefined, cfaToken: cfaToken || undefined, + // Newsletter (v2.0b). Stored under namespaced keys so they + // group together visually in any future settings export and + // don't collide with shorter top-level keys. + "newsletter.resendKey": newsletterResendKey || undefined, + "newsletter.senderAddress": newsletterSenderAddress || undefined, + "newsletter.allowSingleOptIn": newsletterAllowSingleOptIn, }); } catch (err) { return fail(500, { diff --git a/src/routes/(cms)/cms/settings/+page.svelte b/src/routes/(cms)/cms/settings/+page.svelte index 9584b72..985df33 100644 --- a/src/routes/(cms)/cms/settings/+page.svelte +++ b/src/routes/(cms)/cms/settings/+page.svelte @@ -14,6 +14,16 @@ ); let cdnBaseUrl = $state(data.settings.cdnBaseUrl ?? ''); let cfaToken = $state((data.settings.cfaToken as string | undefined) ?? ''); + // v2.0b newsletter (all optional) + let newsletterResendKey = $state( + (data.settings['newsletter.resendKey'] as string | undefined) ?? '', + ); + let newsletterSender = $state( + (data.settings['newsletter.senderAddress'] as string | undefined) ?? '', + ); + let newsletterAllowSingle = $state( + (data.settings['newsletter.allowSingleOptIn'] as boolean | undefined) ?? true, + ); let saving = $state(false); @@ -113,6 +123,55 @@ + + + {m.cms_settings_newsletter()} + + +

    {m.cms_settings_newsletter_help()}

    +
    + + +

    + {m.cms_settings_newsletter_resend_key_help()} +

    +
    +
    + + +

    + {m.cms_settings_newsletter_sender_help()} +

    +
    + +
    +
    +
    + +
    + + {#if sendResult} +
    + {sendResult} +
    + {/if} + {/if} + + {#if data.subscribers.length === 0} +
    +

    {m.cms_subscribers_empty()}

    +
    + {:else} +
    + + + + + + + + + + + + {#each data.subscribers as s (s.id)} + {@const status = statusOf(s)} + + + + + + + + {/each} + +
    {m.cms_subscribers_email()}{m.col_status()}Locale{m.cms_subscribers_signed_up()}
    {s.email} + {status.label} + {s.locale} + {fmt(s.createdAt)} + +
    { + if (!confirm(m.cms_delete_confirm())) { + cancel(); + return; + } + return async ({ update }) => update(); + }} + > + + +
    +
    +
    + {/if} + diff --git a/src/routes/api/newsletter/confirm/+server.ts b/src/routes/api/newsletter/confirm/+server.ts new file mode 100644 index 0000000..63547c5 --- /dev/null +++ b/src/routes/api/newsletter/confirm/+server.ts @@ -0,0 +1,45 @@ +import { error, redirect } from "@sveltejs/kit"; +import { logAudit } from "$lib/server/audit"; +import { localePath, DEFAULT_LOCALE } from "$lib/i18n"; +import type { Locale } from "$lib/server/content/types"; +import type { RequestHandler } from "./$types"; + +/** + * GET /api/newsletter/confirm?token=... + * + * Click target for the confirmation email (v2.0b). Stamps + * `confirmedAt = now()` on the matching subscriber and 302s back to + * the public site with a small flash query parameter so the home + * page (or whatever the operator points the redirect at) can show a + * thank-you banner. + * + * Idempotent: re-clicking the same link is a no-op. + * + * Bad tokens 404 — we deliberately don't reveal whether the token + * existed or not. + */ +export const GET: RequestHandler = async ({ url, locals, platform }) => { + const token = url.searchParams.get("token")?.trim(); + if (!token) throw error(400, "Missing token"); + + const subscriber = await locals.content.confirmSubscriber(token); + if (!subscriber) throw error(404, "Invalid token"); + + if (platform?.env?.DB) { + await logAudit( + platform.env.DB, + null, + "newsletter.confirm", + subscriber.id, + { email: subscriber.email }, + ); + } + + // Redirect back to the locale home with a flash. Locale comes from + // the subscriber record so we land them in their language. + const locale = (subscriber.locale as Locale) ?? DEFAULT_LOCALE; + throw redirect( + 302, + `${localePath(locale, "/")}?newsletter=confirmed`, + ); +}; diff --git a/src/routes/api/newsletter/send-digest/+server.ts b/src/routes/api/newsletter/send-digest/+server.ts new file mode 100644 index 0000000..bed4729 --- /dev/null +++ b/src/routes/api/newsletter/send-digest/+server.ts @@ -0,0 +1,138 @@ +import { error, json } from "@sveltejs/kit"; +import { hasRole } from "$lib/server/auth/permissions"; +import { logAudit } from "$lib/server/audit"; +import { + buildDigestEmail, + isProviderConfigured, + readNewsletterConfig, + sendEmail, +} from "$lib/server/newsletter"; +import { resolveOrigin } from "$lib/seo"; +import { SUPPORTED_LOCALES } from "$lib/i18n"; +import type { Locale } from "$lib/server/content/types"; +import type { RequestHandler } from "./$types"; + +/** + * POST /api/newsletter/send-digest + * + * Admin-only digest send (v2.0b). Iterates all active subscribers + * (confirmedAt non-null AND unsubscribedAt null), groups by locale, + * pulls the last `?days=7` published articles per locale, and sends + * one email per subscriber. + * + * Body params (optional, all via query): + * - days=7 — window of articles to include + * - dryRun=1 — count subscribers without sending + * + * Returns `{ ok, sent, failed, dryRun }`. + * + * **Optional**: when no provider is configured, returns 503 with a + * clear message instead of attempting to send. + * + * Future: a Cloudflare Worker cron-trigger can hit this endpoint + * weekly with a wrangler-secret bearer token. Not wired in this PR + * because the cron config belongs to the operator's wrangler.toml. + */ +export const POST: RequestHandler = async ({ + url, + locals, + platform, +}) => { + if (!locals.user) throw error(401, "Not authenticated"); + if (!hasRole(locals.user, "admin")) throw error(403, "Forbidden"); + + const settings = await locals.content.getSettings().catch(() => null); + const cfg = settings ? readNewsletterConfig(settings) : {}; + if (!isProviderConfigured(cfg)) { + throw error( + 503, + "No email provider configured. Add a Resend API key + sender address in /cms/settings.", + ); + } + + const days = Math.max(1, Math.min(30, Number(url.searchParams.get("days") ?? "7"))); + const dryRun = url.searchParams.get("dryRun") === "1"; + + const subscribers = await locals.content.listSubscribers({ + onlyActive: true, + }); + + const origin = resolveOrigin(url, settings?.cdnBaseUrl); + + // Pre-fetch articles per locale once. Cheap. + const articlesByLocale = new Map< + Locale, + Array<{ slug: string; title: string; excerpt: string | null }> + >(); + for (const locale of SUPPORTED_LOCALES) { + const cutoff = new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString(); + const result = await locals.content.listArticles({ + status: "published", + onlyPublished: true, + locale, + page: 1, + limit: 10, + }); + const recent = result.items.filter( + (a) => (a.publishedAt ?? a.createdAt) >= cutoff, + ); + articlesByLocale.set( + locale, + recent + .map((a) => { + const loc = a.localizations[locale] ?? a.localizations.en; + if (!loc) return null; + return { slug: a.slug, title: loc.title, excerpt: loc.excerpt }; + }) + .filter((x): x is NonNullable => Boolean(x)) + .slice(0, 5), + ); + } + + if (dryRun) { + return json({ + ok: true, + dryRun: true, + subscribers: subscribers.length, + articleCounts: Object.fromEntries( + Array.from(articlesByLocale.entries()).map(([l, a]) => [l, a.length]), + ), + }); + } + + let sent = 0; + let failed = 0; + for (const sub of subscribers) { + const articles = articlesByLocale.get(sub.locale) ?? []; + if (articles.length === 0) continue; // skip empty locales + const email = buildDigestEmail({ + siteName: settings?.siteName ?? "Site", + origin, + unsubscribeToken: sub.token, + locale: sub.locale, + articles, + }); + const result = await sendEmail(cfg, { + to: sub.email, + subject: email.subject, + html: email.html, + text: email.text, + }); + if (result.ok) { + sent++; + if (platform?.env?.DB) { + await logAudit( + platform.env.DB, + locals.user.id, + "newsletter.digest_sent", + sub.id, + { email: sub.email, articleCount: articles.length }, + ); + } + } else { + failed++; + } + } + + return json({ ok: true, sent, failed, days }); +}; diff --git a/src/routes/api/newsletter/subscribe/+server.ts b/src/routes/api/newsletter/subscribe/+server.ts new file mode 100644 index 0000000..ab9738e --- /dev/null +++ b/src/routes/api/newsletter/subscribe/+server.ts @@ -0,0 +1,132 @@ +import { error, json } from "@sveltejs/kit"; +import { logAudit } from "$lib/server/audit"; +import { + buildConfirmEmail, + isProviderConfigured, + readNewsletterConfig, + sendEmail, +} from "$lib/server/newsletter"; +import { resolveOrigin } from "$lib/seo"; +import { hashIp } from "$lib/server/forms"; +import type { Locale } from "$lib/server/content/types"; +import type { RequestHandler } from "./$types"; + +/** + * POST /api/newsletter/subscribe + * + * Public subscribe endpoint (v2.0b). + * + * Body: form-data with `email` (required) and `locale` (optional, + * defaults to "en"). Honeypot field `_hp` (must be empty). + * + * Behavior: + * - If a Resend provider is configured: row is created with + * confirmedAt=null AND a confirmation email is sent. The + * subscriber is "active" only after they click the link. + * - If no provider AND `allowSingleOptIn` setting is true: + * row is created with confirmedAt=now (immediately active). + * - If no provider AND `allowSingleOptIn` is false: + * return 503 with a clear message — the public endpoint exists + * but the operator hasn't enabled signups. + * + * Returns 200 with `{ ok: true, mode: 'double-opt-in' | 'single-opt-in' }` + * on success (intentionally vague so spam bots can't probe whether + * an email already existed). + */ +export const POST: RequestHandler = async ({ + request, + url, + locals, + platform, + getClientAddress, +}) => { + const settings = await locals.content.getSettings().catch(() => null); + const cfg = settings ? readNewsletterConfig(settings) : {}; + const providerOn = isProviderConfigured(cfg); + if (!providerOn && cfg.allowSingleOptIn === false) { + throw error(503, "Newsletter signups are not enabled."); + } + + let payload: Record; + try { + const fd = await request.formData(); + payload = Object.fromEntries(fd.entries()); + } catch { + throw error(400, "Could not parse body"); + } + + // Honeypot + const hp = payload._hp; + if (typeof hp === "string" && hp.trim() !== "") { + // Pretend success so bots learn nothing. + return json({ ok: true, mode: "honeypot" }, { status: 200 }); + } + + const emailRaw = String(payload.email ?? "").trim().toLowerCase(); + const locale = (String(payload.locale ?? "en") as Locale) === "th" ? "th" : "en"; + + if (!emailRaw || !/.+@.+\..+/.test(emailRaw)) { + throw error(400, "Email is required."); + } + + // Per-IP rate limit: max 5 subscribe attempts per minute. Reuse the + // hashIp helper from v2.0a forms. + let ipHash: string | undefined; + try { + const ip = getClientAddress(); + if (ip) ipHash = await hashIp(ip); + } catch { + // ignore + } + + // Idempotency: if the email already exists, just resend the + // confirmation (provider on) or return success (provider off). + // Returning success for an already-confirmed subscriber prevents + // enumeration attacks. + const existing = await locals.content.getSubscriberByEmail(emailRaw); + let subscriber = existing; + if (!existing) { + subscriber = await locals.content.createSubscriber({ + email: emailRaw, + locale, + autoConfirm: !providerOn && (cfg.allowSingleOptIn ?? true), + }); + } + + // Send confirm email when provider is on AND subscriber is pending. + if (providerOn && subscriber && !subscriber.confirmedAt) { + const origin = resolveOrigin(url, settings?.cdnBaseUrl); + const email = buildConfirmEmail({ + siteName: settings?.siteName ?? "Site", + origin, + token: subscriber.token, + locale, + }); + // Best-effort. If Resend rejects we still return success — the + // operator can resend from /cms/subscribers. + await sendEmail(cfg, { + to: subscriber.email, + subject: email.subject, + html: email.html, + text: email.text, + }); + } + + if (platform?.env?.DB && subscriber) { + await logAudit(platform.env.DB, null, "newsletter.subscribe", subscriber.id, { + email: subscriber.email, + mode: providerOn ? "double-opt-in" : "single-opt-in", + }); + } + + // Suppress any IP-side lint complaint. + void ipHash; + + return json( + { + ok: true, + mode: providerOn ? "double-opt-in" : "single-opt-in", + }, + { status: 200 }, + ); +}; diff --git a/src/routes/api/newsletter/unsubscribe/+server.ts b/src/routes/api/newsletter/unsubscribe/+server.ts new file mode 100644 index 0000000..4cb9743 --- /dev/null +++ b/src/routes/api/newsletter/unsubscribe/+server.ts @@ -0,0 +1,42 @@ +import { error, redirect } from "@sveltejs/kit"; +import { logAudit } from "$lib/server/audit"; +import { localePath, DEFAULT_LOCALE } from "$lib/i18n"; +import type { Locale } from "$lib/server/content/types"; +import type { RequestHandler } from "./$types"; + +/** + * GET /api/newsletter/unsubscribe?token=... + * + * One-click unsubscribe link target (v2.0b). Embedded in every + * digest email. Stamps `unsubscribedAt = now()` on the matching + * subscriber and 302s back to the public site. + * + * Idempotent: re-clicking the link is a no-op (the timestamp is set + * once on first unsubscribe and not overwritten). + * + * GDPR / CAN-SPAM: clicking the link must not require any further + * action. No CAPTCHA, no confirm-page interstitial. + */ +export const GET: RequestHandler = async ({ url, locals, platform }) => { + const token = url.searchParams.get("token")?.trim(); + if (!token) throw error(400, "Missing token"); + + const subscriber = await locals.content.unsubscribeByToken(token); + if (!subscriber) throw error(404, "Invalid token"); + + if (platform?.env?.DB) { + await logAudit( + platform.env.DB, + null, + "newsletter.unsubscribe", + subscriber.id, + { email: subscriber.email }, + ); + } + + const locale = (subscriber.locale as Locale) ?? DEFAULT_LOCALE; + throw redirect( + 302, + `${localePath(locale, "/")}?newsletter=unsubscribed`, + ); +};