diff --git a/drizzle/0007_violet_dreadnoughts.sql b/drizzle/0007_violet_dreadnoughts.sql new file mode 100644 index 0000000..66b1c99 --- /dev/null +++ b/drizzle/0007_violet_dreadnoughts.sql @@ -0,0 +1,25 @@ +CREATE TABLE `form_submissions` ( + `id` text PRIMARY KEY NOT NULL, + `form_id` text NOT NULL, + `data` text NOT NULL, + `submitted_at` text NOT NULL, + `ip_hash` text, + `status` text DEFAULT 'new' NOT NULL, + `note` text, + FOREIGN KEY (`form_id`) REFERENCES `forms`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE TABLE `forms` ( + `id` text PRIMARY KEY NOT NULL, + `key` text NOT NULL, + `label` text NOT NULL, + `fields` text NOT NULL, + `enabled` integer DEFAULT true NOT NULL, + `success_messages` text, + `created_by` text, + `created_at` text NOT NULL, + `updated_at` text NOT NULL, + FOREIGN KEY (`created_by`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE set null +); +--> statement-breakpoint +CREATE UNIQUE INDEX `forms_key_unique` ON `forms` (`key`); \ No newline at end of file diff --git a/drizzle/meta/0007_snapshot.json b/drizzle/meta/0007_snapshot.json new file mode 100644 index 0000000..baf452c --- /dev/null +++ b/drizzle/meta/0007_snapshot.json @@ -0,0 +1,2013 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "577eee25-cfd1-4d75-87ec-b3811f751863", + "prevId": "252ff8fc-870a-4e8b-b0c5-a1f0be4754d8", + "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": {} + }, + "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 7b15820..7de6e1d 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -50,6 +50,13 @@ "when": 1777510795750, "tag": "0006_adorable_microchip", "breakpoints": true + }, + { + "idx": 7, + "version": "6", + "when": 1777538401439, + "tag": "0007_violet_dreadnoughts", + "breakpoints": true } ] } \ No newline at end of file diff --git a/messages/en.json b/messages/en.json index f91e73e..fc41575 100644 --- a/messages/en.json +++ b/messages/en.json @@ -112,6 +112,31 @@ "error_5xx_title": "Something went wrong on our side.", "error_5xx_subtitle": "We've been notified. Try again in a moment, or head back home while we sort it out.", "error_back_home": "Back home", + "cms_forms": "Forms", + "cms_forms_help": "Build forms (contact, lead capture, RSVP) and review submissions. Each form has a public POST endpoint.", + "cms_forms_new": "New form", + "cms_forms_create": "Create form", + "cms_forms_edit": "Edit form", + "cms_forms_empty": "No forms yet. Create one to start collecting submissions.", + "cms_forms_col_label": "Label", + "cms_forms_col_endpoint": "Endpoint", + "cms_forms_col_fields": "Fields", + "cms_forms_basics": "Basics", + "cms_forms_label": "Label (admin-facing)", + "cms_forms_key": "Key (URL-safe)", + "cms_forms_key_help": "Public endpoint becomes {usage}", + "cms_forms_enabled_label": "Accept submissions", + "cms_forms_enabled": "Live", + "cms_forms_disabled": "Off", + "cms_forms_fields": "Fields", + "cms_forms_fields_empty": "No fields yet. Add one with the buttons above.", + "cms_forms_field_name": "Field name (used in submissions)", + "cms_forms_field_label": "Label (visitor-facing)", + "cms_forms_field_required": "Required", + "cms_forms_success_messages": "Success message (optional)", + "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_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 436eef7..b3330d2 100644 --- a/messages/th.json +++ b/messages/th.json @@ -112,6 +112,31 @@ "error_5xx_title": "มีบางอย่างผิดพลาดที่ฝั่งเรา", "error_5xx_subtitle": "เราได้รับแจ้งแล้ว ลองอีกครั้งในอีกสักครู่ หรือกลับหน้าแรกระหว่างเราแก้ไข", "error_back_home": "กลับหน้าแรก", + "cms_forms": "ฟอร์ม", + "cms_forms_help": "สร้างฟอร์ม (ติดต่อ เก็บลีด ลงทะเบียนงาน) และตรวจสอบการส่ง แต่ละฟอร์มมี endpoint สำหรับ POST สาธารณะ", + "cms_forms_new": "ฟอร์มใหม่", + "cms_forms_create": "สร้างฟอร์ม", + "cms_forms_edit": "แก้ไขฟอร์ม", + "cms_forms_empty": "ยังไม่มีฟอร์ม สร้างฟอร์มแรกเพื่อเริ่มเก็บข้อมูล", + "cms_forms_col_label": "ป้ายกำกับ", + "cms_forms_col_endpoint": "Endpoint", + "cms_forms_col_fields": "ฟิลด์", + "cms_forms_basics": "ข้อมูลพื้นฐาน", + "cms_forms_label": "ป้ายกำกับ (สำหรับแอดมิน)", + "cms_forms_key": "คีย์ (URL-safe)", + "cms_forms_key_help": "Endpoint สาธารณะจะเป็น {usage}", + "cms_forms_enabled_label": "รับการส่ง", + "cms_forms_enabled": "ใช้งาน", + "cms_forms_disabled": "ปิด", + "cms_forms_fields": "ฟิลด์", + "cms_forms_fields_empty": "ยังไม่มีฟิลด์ เพิ่มได้จากปุ่มด้านบน", + "cms_forms_field_name": "ชื่อฟิลด์ (ใช้ในข้อมูลที่ส่ง)", + "cms_forms_field_label": "ป้ายกำกับ (ผู้เยี่ยมชมเห็น)", + "cms_forms_field_required": "ต้องกรอก", + "cms_forms_success_messages": "ข้อความสำเร็จ (ตัวเลือก)", + "cms_forms_success_help": "แสดงหลังส่งสำเร็จ ใช้ข้อความขอบคุณทั่วไปเมื่อเว้นว่าง", + "cms_forms_submissions": "การส่ง", + "cms_forms_submissions_empty": "ยังไม่มีการส่ง แชร์ endpoint สาธารณะเพื่อเริ่มเก็บข้อมูล", "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 5a0647a..e3adc68 100644 --- a/src/lib/components/cms/sidebar-nav.ts +++ b/src/lib/components/cms/sidebar-nav.ts @@ -11,6 +11,7 @@ import { Settings, ScrollText, Puzzle, + Inbox, } from "lucide-svelte"; import * as m from "$lib/paraglide/messages"; @@ -65,6 +66,12 @@ export const navGroups: ReadonlyArray = [ icon: Puzzle, roles: ["super_admin", "admin", "editor"], }, + { + href: "/cms/forms", + label: m.cms_forms, + icon: Inbox, + roles: ["super_admin", "admin", "editor"], + }, ], }, { diff --git a/src/lib/server/audit/index.ts b/src/lib/server/audit/index.ts index aeb4d38..086bd14 100644 --- a/src/lib/server/audit/index.ts +++ b/src/lib/server/audit/index.ts @@ -28,7 +28,8 @@ export type AuditAction = | `media.${"delete"}` | `user.${"role_change" | "delete"}` | `invitation.${"create" | "accept" | "revoke"}` - | `settings.${"update"}`; + | `settings.${"update"}` + | `form.${"create" | "update" | "delete" | "submit"}`; /** 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 0a95b3c..91ce820 100644 --- a/src/lib/server/content/providers/d1.ts +++ b/src/lib/server/content/providers/d1.ts @@ -3,6 +3,7 @@ import { and, desc, eq, + gte, inArray, isNull, like, @@ -28,6 +29,10 @@ import type { SiteSettings, Locale, ContentBlockRecord, + FormRecord, + FormField, + FormSubmissionRecord, + FormSubmissionStatus, PageRecord, PageCreateInput, PageUpdateInput, @@ -1315,4 +1320,226 @@ export class D1ContentProvider implements ContentProvider { createdAt: row.createdAt, }; } + + // ─── Forms (v2.0a) ────────────────────────────────────── + + async listForms(): Promise { + const rows = await this.db + .select() + .from(schema.forms) + .orderBy(desc(schema.forms.updatedAt)) + .all(); + return rows.map((r) => this.toForm(r)); + } + + async getForm(id: string): Promise { + const row = await this.db + .select() + .from(schema.forms) + .where(eq(schema.forms.id, id)) + .get(); + return row ? this.toForm(row) : null; + } + + async getFormByKey(key: string): Promise { + const row = await this.db + .select() + .from(schema.forms) + .where(eq(schema.forms.key, key)) + .get(); + return row ? this.toForm(row) : null; + } + + async createForm(data: { + key: string; + label: string; + fields: FormField[]; + enabled?: boolean; + successMessages?: FormRecord["successMessages"]; + createdBy?: string; + }): Promise { + const id = nanoid(); + const now = new Date().toISOString(); + await this.db.insert(schema.forms).values({ + id, + key: data.key, + label: data.label, + fields: JSON.stringify(data.fields), + enabled: data.enabled ?? true, + successMessages: data.successMessages + ? JSON.stringify(data.successMessages) + : null, + createdBy: data.createdBy ?? null, + createdAt: now, + updatedAt: now, + }); + return (await this.getForm(id))!; + } + + async updateForm( + id: string, + data: Partial<{ + key: string; + label: string; + fields: FormField[]; + enabled: boolean; + successMessages: FormRecord["successMessages"]; + }>, + ): Promise { + const updateFields: Record = { + updatedAt: new Date().toISOString(), + }; + if (data.key !== undefined) updateFields.key = data.key; + if (data.label !== undefined) updateFields.label = data.label; + if (data.fields !== undefined) + updateFields.fields = JSON.stringify(data.fields); + if (data.enabled !== undefined) updateFields.enabled = data.enabled; + if (data.successMessages !== undefined) + updateFields.successMessages = data.successMessages + ? JSON.stringify(data.successMessages) + : null; + await this.db + .update(schema.forms) + .set(updateFields) + .where(eq(schema.forms.id, id)); + return (await this.getForm(id))!; + } + + async deleteForm(id: string): Promise { + await this.db.delete(schema.forms).where(eq(schema.forms.id, id)); + } + + async listFormSubmissions( + formId: string, + opts?: { status?: FormSubmissionStatus; limit?: number }, + ): Promise { + const conditions = [eq(schema.formSubmissions.formId, formId)]; + if (opts?.status) { + conditions.push(eq(schema.formSubmissions.status, opts.status)); + } + const rows = await this.db + .select() + .from(schema.formSubmissions) + .where(and(...conditions)) + .orderBy(desc(schema.formSubmissions.submittedAt)) + .limit(opts?.limit ?? 100) + .all(); + return rows.map((r) => this.toFormSubmission(r)); + } + + async getFormSubmission(id: string): Promise { + const row = await this.db + .select() + .from(schema.formSubmissions) + .where(eq(schema.formSubmissions.id, id)) + .get(); + return row ? this.toFormSubmission(row) : null; + } + + async createFormSubmission(data: { + formId: string; + data: Record; + ipHash?: string; + }): Promise { + const id = nanoid(); + await this.db.insert(schema.formSubmissions).values({ + id, + formId: data.formId, + data: JSON.stringify(data.data), + ipHash: data.ipHash ?? null, + status: "new", + }); + return (await this.getFormSubmission(id))!; + } + + async updateFormSubmission( + id: string, + data: Partial<{ status: FormSubmissionStatus; note: string | null }>, + ): Promise { + const updateFields: Record = {}; + if (data.status !== undefined) updateFields.status = data.status; + if (data.note !== undefined) updateFields.note = data.note; + await this.db + .update(schema.formSubmissions) + .set(updateFields) + .where(eq(schema.formSubmissions.id, id)); + return (await this.getFormSubmission(id))!; + } + + async deleteFormSubmission(id: string): Promise { + await this.db + .delete(schema.formSubmissions) + .where(eq(schema.formSubmissions.id, id)); + } + + async countRecentSubmissions( + formId: string, + ipHash: string, + sinceSeconds: number, + ): Promise { + const cutoff = new Date(Date.now() - sinceSeconds * 1000).toISOString(); + const rows = await this.db + .select({ id: schema.formSubmissions.id }) + .from(schema.formSubmissions) + .where( + and( + eq(schema.formSubmissions.formId, formId), + eq(schema.formSubmissions.ipHash, ipHash), + gte(schema.formSubmissions.submittedAt, cutoff), + ), + ) + .all(); + return rows.length; + } + + private toForm(row: typeof schema.forms.$inferSelect): FormRecord { + let fields: FormField[] = []; + try { + const parsed = JSON.parse(row.fields); + if (Array.isArray(parsed)) fields = parsed; + } catch { + // tolerate malformed JSON; render an empty form rather than 500 + } + let successMessages: FormRecord["successMessages"] = {}; + if (row.successMessages) { + try { + const parsed = JSON.parse(row.successMessages); + if (parsed && typeof parsed === "object") successMessages = parsed; + } catch { + // ignore + } + } + return { + id: row.id, + key: row.key, + label: row.label, + fields, + enabled: Boolean(row.enabled), + successMessages, + createdBy: row.createdBy, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + }; + } + + private toFormSubmission( + row: typeof schema.formSubmissions.$inferSelect, + ): FormSubmissionRecord { + let data: Record = {}; + try { + const parsed = JSON.parse(row.data); + if (parsed && typeof parsed === "object") data = parsed; + } catch { + // ignore + } + return { + id: row.id, + formId: row.formId, + data, + submittedAt: row.submittedAt, + ipHash: row.ipHash, + status: row.status as FormSubmissionStatus, + note: row.note, + }; + } } diff --git a/src/lib/server/content/schema.ts b/src/lib/server/content/schema.ts index e2b22a3..d9121f3 100644 --- a/src/lib/server/content/schema.ts +++ b/src/lib/server/content/schema.ts @@ -468,3 +468,60 @@ export const searchLog = sqliteTable("search_log", { .notNull() .$defaultFn(() => new Date().toISOString()), }); + +// ─── Forms (v2.0a) ─────────────────────────────────────── +// Editor builds a small form (contact, lead capture, RSVP, etc.) by +// declaring a list of fields as JSON. Public visitors submit; rows +// land in `form_submissions` for moderation. Honeypot + per-IP rate +// limit at the endpoint, no captcha by default. + +export const forms = sqliteTable("forms", { + id: text("id").primaryKey(), + /** ASCII-only key referenced from public URLs and webhooks. */ + key: text("key").notNull().unique(), + label: text("label").notNull(), + /** + * JSON array of FormField records. Authored in the CMS UI; rendered + * verbatim on the public endpoint. Schema-less here so adding new + * field kinds doesn't require a migration — see FormField in types. + */ + fields: text("fields").notNull(), + /** When false, the public endpoint returns 410 Gone. */ + enabled: integer("enabled", { mode: "boolean" }).notNull().default(true), + /** Optional success-message override per locale (JSON {en:"…",th:"…"}). */ + successMessages: text("success_messages"), + createdBy: text("created_by").references(() => users.id, { + onDelete: "set null", + }), + createdAt: text("created_at") + .notNull() + .$defaultFn(() => new Date().toISOString()), + updatedAt: text("updated_at") + .notNull() + .$defaultFn(() => new Date().toISOString()), +}); + +export const formSubmissions = sqliteTable("form_submissions", { + id: text("id").primaryKey(), + formId: text("form_id") + .notNull() + .references(() => forms.id, { onDelete: "cascade" }), + /** JSON map of field name → submitted value. Always strings. */ + data: text("data").notNull(), + /** ISO timestamp when the submission landed. */ + submittedAt: text("submitted_at") + .notNull() + .$defaultFn(() => new Date().toISOString()), + /** + * Single-byte hash of the requester's IP (truncated SHA-256). + * Used for rate-limit + spam clustering. Never stored as the full + * IP — privacy-by-default like the v1.8 page-views path. + */ + ipHash: text("ip_hash"), + /** Editor sets this in the CMS once they've reviewed the submission. */ + status: text("status", { enum: ["new", "read", "spam", "archived"] }) + .notNull() + .default("new"), + /** Optional one-liner the moderator adds. */ + note: text("note"), +}); diff --git a/src/lib/server/content/types.ts b/src/lib/server/content/types.ts index 0a608e2..0466fcd 100644 --- a/src/lib/server/content/types.ts +++ b/src/lib/server/content/types.ts @@ -256,6 +256,53 @@ export interface ContentProvider { ): Promise; deleteContentBlock(id: string): Promise; + // Forms (v2.0a) + listForms(): Promise; + getForm(id: string): Promise; + getFormByKey(key: string): Promise; + createForm(data: { + key: string; + label: string; + fields: FormField[]; + enabled?: boolean; + successMessages?: Partial>; + createdBy?: string; + }): Promise; + updateForm( + id: string, + data: Partial<{ + key: string; + label: string; + fields: FormField[]; + enabled: boolean; + successMessages: Partial>; + }>, + ): Promise; + deleteForm(id: string): Promise; + + // Form submissions (v2.0a) + listFormSubmissions(formId: string, opts?: { + status?: FormSubmissionStatus; + limit?: number; + }): Promise; + getFormSubmission(id: string): Promise; + createFormSubmission(data: { + formId: string; + data: Record; + ipHash?: string; + }): Promise; + updateFormSubmission( + id: string, + data: Partial<{ status: FormSubmissionStatus; note: string | null }>, + ): Promise; + deleteFormSubmission(id: string): Promise; + /** Count submissions in the last N seconds matching this ipHash + form. */ + countRecentSubmissions( + formId: string, + ipHash: string, + sinceSeconds: number, + ): Promise; + // Pages (v1.7b) getPage(id: string): Promise; getPageBySlug(slug: string): Promise; @@ -303,6 +350,42 @@ export interface ContentBlockRecord { localizations: Partial>; } +// ─── Forms (v2.0a) ─────────────────────────────────────── + +/** A single field in a form definition. */ +export type FormField = + | { name: string; kind: "text"; label: string; required?: boolean; placeholder?: string; maxLength?: number } + | { name: string; kind: "email"; label: string; required?: boolean; placeholder?: string } + | { name: string; kind: "textarea"; label: string; required?: boolean; placeholder?: string; rows?: number; maxLength?: number } + | { name: string; kind: "checkbox"; label: string; required?: boolean }; + +export type FormSubmissionStatus = "new" | "read" | "spam" | "archived"; + +export interface FormRecord { + id: string; + /** ASCII-only key. Used in the public endpoint URL. */ + key: string; + label: string; + fields: FormField[]; + enabled: boolean; + /** Optional per-locale success message override. */ + successMessages: Partial>; + createdBy: string | null; + createdAt: string; + updatedAt: string; +} + +export interface FormSubmissionRecord { + id: string; + formId: string; + /** Field name → submitted value (always strings; checkboxes "on" / ""). */ + data: Record; + submittedAt: string; + ipHash: string | null; + status: FormSubmissionStatus; + note: string | null; +} + // ─── Pages (v1.7b) ─────────────────────────────────────── // Static pages distinct from articles: About, Contact, Privacy, etc. // Routed at (www)/[locale]/[...slug] catch-all so nested slugs work. diff --git a/src/lib/server/forms/index.ts b/src/lib/server/forms/index.ts new file mode 100644 index 0000000..6c4cb9a --- /dev/null +++ b/src/lib/server/forms/index.ts @@ -0,0 +1,105 @@ +/** + * Helpers for form submission validation + rate limiting (v2.0a). + */ +import type { FormField, FormRecord } from "$lib/server/content/types"; + +export const HONEYPOT_FIELD = "_hp" as const; +export const RATE_LIMIT_WINDOW_SECONDS = 60; +export const RATE_LIMIT_MAX_PER_WINDOW = 3; + +/** + * Hash an IP address with SHA-256 + truncate to 16 hex chars. We never + * store the raw IP — only the hash, used as a coarse cluster key for + * rate limiting + spam detection. + */ +export async function hashIp(ip: string): Promise { + const enc = new TextEncoder().encode(ip); + const buf = await crypto.subtle.digest("SHA-256", enc); + return Array.from(new Uint8Array(buf)) + .map((b) => b.toString(16).padStart(2, "0")) + .join("") + .slice(0, 16); +} + +/** + * Validate the submitted payload against the form's field definitions. + * Returns the cleaned data (only known fields, trimmed strings) or an + * error message describing the first failure. + */ +export function validateSubmission( + form: FormRecord, + payload: Record, +): { ok: true; data: Record } | { ok: false; error: string } { + // Honeypot. A real visitor's browser leaves the hidden input empty; + // most spam bots fill every input. Reject (silently 200) when set. + const hp = payload[HONEYPOT_FIELD]; + if (typeof hp === "string" && hp.trim() !== "") { + return { ok: false, error: "Honeypot tripped." }; + } + + const data: Record = {}; + for (const field of form.fields) { + const raw = payload[field.name]; + const value = typeof raw === "string" ? raw.trim() : ""; + + if (field.kind === "checkbox") { + // Browsers omit unchecked checkboxes from form data; treat that + // as "off". Required-checkbox is the GDPR consent pattern. + const checked = value !== "" && value !== "off"; + if (field.required && !checked) { + return { + ok: false, + error: `Field "${field.label}" is required.`, + }; + } + data[field.name] = checked ? "on" : ""; + continue; + } + + if (field.required && !value) { + return { ok: false, error: `Field "${field.label}" is required.` }; + } + + if (field.kind === "email" && value) { + // Cheap validity check; we deliberately don't run a full RFC + // parser. Stops obviously-bad submissions; real validation is + // the editor's job. + if (!/.+@.+\..+/.test(value)) { + return { ok: false, error: `"${field.label}" is not a valid email.` }; + } + } + + const max = + "maxLength" in field && typeof field.maxLength === "number" + ? field.maxLength + : 5000; + if (value.length > max) { + return { + ok: false, + error: `"${field.label}" exceeds maximum length of ${max} chars.`, + }; + } + + data[field.name] = value; + } + + return { ok: true, data }; +} + +/** Stable JSON re-serialization for FormField[]. */ +export function isValidFieldList(value: unknown): value is FormField[] { + if (!Array.isArray(value)) return false; + for (const item of value) { + if (!item || typeof item !== "object") return false; + if (typeof (item as FormField).name !== "string") return false; + if (typeof (item as FormField).label !== "string") return false; + if ( + !["text", "email", "textarea", "checkbox"].includes( + (item as FormField).kind, + ) + ) { + return false; + } + } + return true; +} diff --git a/src/routes/(cms)/cms/forms/+page.server.ts b/src/routes/(cms)/cms/forms/+page.server.ts new file mode 100644 index 0000000..2770ef7 --- /dev/null +++ b/src/routes/(cms)/cms/forms/+page.server.ts @@ -0,0 +1,12 @@ +import { error, redirect } from "@sveltejs/kit"; +import { canManageTaxonomy } from "$lib/server/auth/permissions"; +import type { PageServerLoad } from "./$types"; + +export const load: PageServerLoad = async ({ locals }) => { + if (!locals.user) throw redirect(302, "/cms/login"); + if (!canManageTaxonomy(locals.user)) { + throw error(403, "Editors and above can manage forms."); + } + const forms = await locals.content.listForms(); + return { forms }; +}; diff --git a/src/routes/(cms)/cms/forms/+page.svelte b/src/routes/(cms)/cms/forms/+page.svelte new file mode 100644 index 0000000..10d660f --- /dev/null +++ b/src/routes/(cms)/cms/forms/+page.svelte @@ -0,0 +1,67 @@ + + + + {m.cms_forms()} — {m.cms_app_name()} + + +
+
+
+

{m.cms_forms()}

+

{m.cms_forms_help()}

+
+ + {m.cms_forms_new()} + +
+ + {#if data.forms.length === 0} +
+

{m.cms_forms_empty()}

+
+ {:else} +
+ + + + + + + + + + + {#each data.forms as f (f.id)} + + + + + + + {/each} + +
{m.cms_forms_col_label()}{m.cms_forms_col_endpoint()}{m.cms_forms_col_fields()}{m.col_status()}
+ + {f.label} + + + /api/forms/{f.key} + + {f.fields.length} {f.fields.length === 1 ? 'field' : 'fields'} + + + {f.enabled ? m.cms_forms_enabled() : m.cms_forms_disabled()} + +
+
+ {/if} +
diff --git a/src/routes/(cms)/cms/forms/FormEditor.svelte b/src/routes/(cms)/cms/forms/FormEditor.svelte new file mode 100644 index 0000000..1ce817d --- /dev/null +++ b/src/routes/(cms)/cms/forms/FormEditor.svelte @@ -0,0 +1,235 @@ + + +
{ + loading = true; + return async ({ update }) => { + await update(); + loading = false; + }; + }} +> + {#if formState?.error} +
+ {formState.error} +
+ {/if} + +
+

{m.cms_forms_basics()}

+
+ + +
+ +
+ +
+
+

{m.cms_forms_fields()}

+
+ {#each ['text', 'email', 'textarea', 'checkbox'] as kind (kind)} + + {/each} +
+
+ + {#if fields.length === 0} +

{m.cms_forms_fields_empty()}

+ {:else} +
+ {#each fields as field, i (i)} +
+
+ + {field.kind} + +
+ + + +
+
+
+ + +
+ +
+ {/each} +
+ {/if} + + + +
+ +
+

{m.cms_forms_success_messages()}

+

{m.cms_forms_success_help()}

+
+ + +
+
+ +
+ + ← {m.cms_back_to_list()} + + +
+
diff --git a/src/routes/(cms)/cms/forms/[id]/+page.server.ts b/src/routes/(cms)/cms/forms/[id]/+page.server.ts new file mode 100644 index 0000000..2ca0113 --- /dev/null +++ b/src/routes/(cms)/cms/forms/[id]/+page.server.ts @@ -0,0 +1,143 @@ +import { error, fail, redirect } from "@sveltejs/kit"; +import { canManageTaxonomy } from "$lib/server/auth/permissions"; +import { logAudit } from "$lib/server/audit"; +import { isValidFieldList } from "$lib/server/forms"; +import { slugify } from "$lib/utils"; +import type { FormSubmissionStatus } from "$lib/server/content/types"; +import type { Actions, PageServerLoad } from "./$types"; + +const SUBMISSION_LIMIT = 100; + +export const load: PageServerLoad = async ({ locals, params }) => { + if (!locals.user) throw redirect(302, "/cms/login"); + if (!canManageTaxonomy(locals.user)) { + throw error(403, "Editors and above can manage forms."); + } + const form = await locals.content.getForm(params.id); + if (!form) throw error(404, "Form not found"); + const submissions = await locals.content.listFormSubmissions(form.id, { + limit: SUBMISSION_LIMIT, + }); + return { form, submissions }; +}; + +export const actions: Actions = { + save: async ({ request, locals, params, platform }) => { + if (!locals.user) throw redirect(302, "/cms/login"); + if (!canManageTaxonomy(locals.user)) { + return fail(403, { error: "Forbidden" }); + } + const existing = await locals.content.getForm(params.id); + if (!existing) return fail(404, { error: "Form not found" }); + + const fd = await request.formData(); + const label = String(fd.get("label") ?? "").trim(); + const keyRaw = String(fd.get("key") ?? "").trim(); + const enabled = fd.get("enabled") === "on"; + const fieldsRaw = String(fd.get("fields") ?? ""); + const successEn = String(fd.get("success_en") ?? "").trim(); + const successTh = String(fd.get("success_th") ?? "").trim(); + const key = slugify(keyRaw || label); + + if (!key || !label) { + return fail(400, { error: "Key and label are required." }); + } + + let fields: ReturnType; + try { + fields = JSON.parse(fieldsRaw); + } catch { + return fail(400, { error: "Field definitions must be valid JSON." }); + } + if (!isValidFieldList(fields)) { + return fail(400, { error: "Field definitions are malformed." }); + } + + if (key !== existing.key) { + const conflict = await locals.content.getFormByKey(key); + if (conflict) { + return fail(400, { error: `Key "${key}" is already taken.` }); + } + } + + await locals.content.updateForm(params.id, { + key, + label, + fields, + enabled, + successMessages: { + ...(successEn ? { en: successEn } : {}), + ...(successTh ? { th: successTh } : {}), + }, + }); + if (platform?.env?.DB) { + await logAudit( + platform.env.DB, + locals.user.id, + "form.update", + params.id, + { key, label }, + ); + } + return { ok: true }; + }, + + delete: async ({ locals, params, platform }) => { + if (!locals.user) throw redirect(302, "/cms/login"); + if (!canManageTaxonomy(locals.user)) { + return fail(403, { error: "Forbidden" }); + } + const existing = await locals.content.getForm(params.id); + if (!existing) return fail(404, { error: "Form not found" }); + await locals.content.deleteForm(params.id); + if (platform?.env?.DB) { + await logAudit( + platform.env.DB, + locals.user.id, + "form.delete", + params.id, + { key: existing.key }, + ); + } + throw redirect(303, "/cms/forms"); + }, + + /** Update a single submission's status (e.g. "spam" or "archived"). */ + setSubmissionStatus: async ({ request, locals, params }) => { + if (!locals.user) throw redirect(302, "/cms/login"); + if (!canManageTaxonomy(locals.user)) { + return fail(403, { error: "Forbidden" }); + } + const fd = await request.formData(); + const submissionId = String(fd.get("submission_id") ?? "").trim(); + const status = String(fd.get("status") ?? "") as FormSubmissionStatus; + if (!submissionId || !["new", "read", "spam", "archived"].includes(status)) { + return fail(400, { error: "Bad request" }); + } + // Confirm this submission belongs to the form on this page (defense + // in depth; the URL is admin-only but routing typos shouldn't poke + // a different form's row). + const submission = await locals.content.getFormSubmission(submissionId); + if (!submission || submission.formId !== params.id) { + return fail(404, { error: "Submission not found" }); + } + await locals.content.updateFormSubmission(submissionId, { status }); + return { ok: true }; + }, + + deleteSubmission: async ({ request, locals, params }) => { + if (!locals.user) throw redirect(302, "/cms/login"); + if (!canManageTaxonomy(locals.user)) { + return fail(403, { error: "Forbidden" }); + } + const fd = await request.formData(); + const submissionId = String(fd.get("submission_id") ?? "").trim(); + if (!submissionId) return fail(400, { error: "Missing id" }); + const submission = await locals.content.getFormSubmission(submissionId); + if (!submission || submission.formId !== params.id) { + return fail(404, { error: "Submission not found" }); + } + await locals.content.deleteFormSubmission(submissionId); + return { ok: true }; + }, +}; diff --git a/src/routes/(cms)/cms/forms/[id]/+page.svelte b/src/routes/(cms)/cms/forms/[id]/+page.svelte new file mode 100644 index 0000000..74fc772 --- /dev/null +++ b/src/routes/(cms)/cms/forms/[id]/+page.svelte @@ -0,0 +1,145 @@ + + + + {m.cms_forms_edit()} — {m.cms_app_name()} + + +
+
+

{m.cms_forms_edit()}

+
{ + if (!confirm(m.cms_delete_confirm())) { + cancel(); + return; + } + return async ({ update }) => update(); + }} + > + +
+
+ + + +
+
+

+ {m.cms_forms_submissions()} + + ({data.submissions.length}) + +

+

+ /api/forms/{data.form.key} +

+
+ + {#if data.submissions.length === 0} +
+

{m.cms_forms_submissions_empty()}

+
+ {:else} +
+ {#each data.submissions as s (s.id)} +
+ + {s.status} + + {fmtTime(s.submittedAt)} + + + {Object.entries(s.data) + .slice(0, 2) + .map(([k, v]) => `${k}: ${v}`) + .join(' · ')} + + +
+
+ {#each Object.entries(s.data) as [k, v] (k)} +
{k}
+
{v || '—'}
+ {/each} +
+
+ {#each ['new', 'read', 'spam', 'archived'] as status (status)} + {#if s.status !== status} +
+ + + +
+ {/if} + {/each} +
{ + if (!confirm(m.cms_delete_confirm())) { + cancel(); + return; + } + return async ({ update }) => update(); + }} + class="ml-auto" + > + + +
+
+
+
+ {/each} +
+ {/if} +
+
diff --git a/src/routes/(cms)/cms/forms/new/+page.server.ts b/src/routes/(cms)/cms/forms/new/+page.server.ts new file mode 100644 index 0000000..dc30f92 --- /dev/null +++ b/src/routes/(cms)/cms/forms/new/+page.server.ts @@ -0,0 +1,72 @@ +import { error, fail, redirect } from "@sveltejs/kit"; +import { canManageTaxonomy } from "$lib/server/auth/permissions"; +import { logAudit } from "$lib/server/audit"; +import { isValidFieldList } from "$lib/server/forms"; +import { slugify } from "$lib/utils"; +import type { Actions, PageServerLoad } from "./$types"; + +export const load: PageServerLoad = async ({ locals }) => { + if (!locals.user) throw redirect(302, "/cms/login"); + if (!canManageTaxonomy(locals.user)) { + throw error(403, "Editors and above can manage forms."); + } + return {}; +}; + +export const actions: Actions = { + default: 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 label = String(fd.get("label") ?? "").trim(); + const keyRaw = String(fd.get("key") ?? "").trim(); + const enabled = fd.get("enabled") === "on"; + const fieldsRaw = String(fd.get("fields") ?? ""); + const successEn = String(fd.get("success_en") ?? "").trim(); + const successTh = String(fd.get("success_th") ?? "").trim(); + + const key = slugify(keyRaw || label); + if (!key || !label) { + return fail(400, { error: "Key and label are required." }); + } + + let fields: ReturnType; + try { + fields = JSON.parse(fieldsRaw); + } catch { + return fail(400, { error: "Field definitions must be valid JSON." }); + } + if (!isValidFieldList(fields)) { + return fail(400, { error: "Field definitions are malformed." }); + } + + const existing = await locals.content.getFormByKey(key); + if (existing) { + return fail(400, { error: `A form with key "${key}" already exists.` }); + } + + const form = await locals.content.createForm({ + key, + label, + fields, + enabled, + successMessages: { + ...(successEn ? { en: successEn } : {}), + ...(successTh ? { th: successTh } : {}), + }, + createdBy: locals.user.id, + }); + if (platform?.env?.DB) { + await logAudit( + platform.env.DB, + locals.user.id, + "form.create", + form.id, + { key: form.key, label: form.label }, + ); + } + throw redirect(303, `/cms/forms/${form.id}`); + }, +}; diff --git a/src/routes/(cms)/cms/forms/new/+page.svelte b/src/routes/(cms)/cms/forms/new/+page.svelte new file mode 100644 index 0000000..f5f6ec8 --- /dev/null +++ b/src/routes/(cms)/cms/forms/new/+page.svelte @@ -0,0 +1,15 @@ + + + + {m.cms_forms_new()} — {m.cms_app_name()} + + +
+

{m.cms_forms_new()}

+ +
diff --git a/src/routes/api/forms/[key]/+server.ts b/src/routes/api/forms/[key]/+server.ts new file mode 100644 index 0000000..95bc05a --- /dev/null +++ b/src/routes/api/forms/[key]/+server.ts @@ -0,0 +1,110 @@ +import { error, json } from "@sveltejs/kit"; +import { + HONEYPOT_FIELD, + RATE_LIMIT_MAX_PER_WINDOW, + RATE_LIMIT_WINDOW_SECONDS, + hashIp, + validateSubmission, +} from "$lib/server/forms"; +import { logAudit } from "$lib/server/audit"; +import type { RequestHandler } from "./$types"; + +/** + * POST /api/forms/[key] + * + * Public form submission endpoint (v2.0a). Accepts multipart/form-data + * or url-encoded; reads the form definition by key and validates the + * payload. Implements the v2.0 floor for spam defenses: a honeypot + * field (`_hp`) and a per-IP-hash rate limit (3 submissions per minute + * per form). No CAPTCHA. + * + * Returns: + * 201 + { id, message } on success + * 400 on validation error (including honeypot + rate limit) + * 404 when the form key doesn't exist + * 410 when the form's `enabled` flag is off + */ +export const POST: RequestHandler = async ({ + request, + params, + platform, + locals, + getClientAddress, +}) => { + const form = await locals.content.getFormByKey(params.key); + if (!form) throw error(404, "Form not found"); + if (!form.enabled) throw error(410, "Form is no longer accepting submissions"); + + // Parse the body. Forms are typically posted as + // application/x-www-form-urlencoded; multipart works too. + let payload: Record; + try { + const fd = await request.formData(); + payload = Object.fromEntries(fd.entries()); + } catch { + throw error(400, "Could not parse form body"); + } + + const validation = validateSubmission(form, payload); + if (!validation.ok) { + // We deliberately return 400 with the same shape for honeypot + + // real validation errors. Bots learn nothing about which check + // fired; humans see a useful message. + throw error(400, validation.error); + } + + // Per-IP rate limit. Hash the IP so we can match without storing + // the raw value. Best-effort: a missing client address skips the + // check (Cloudflare always supplies one in production). + let ipHash: string | undefined; + try { + const ip = getClientAddress(); + if (ip) ipHash = await hashIp(ip); + } catch { + // ignore; getClientAddress may throw in dev + } + + if (ipHash) { + const recent = await locals.content.countRecentSubmissions( + form.id, + ipHash, + RATE_LIMIT_WINDOW_SECONDS, + ); + if (recent >= RATE_LIMIT_MAX_PER_WINDOW) { + throw error(429, "Too many submissions. Please try again in a minute."); + } + } + + const submission = await locals.content.createFormSubmission({ + formId: form.id, + data: validation.data, + ipHash, + }); + + if (platform?.env?.DB) { + // Audit-log every submission so the editor's audit feed surfaces + // form activity alongside content events. Best-effort. + await logAudit( + platform.env.DB, + // No actor — public submission. We pass null so the FK doesn't + // resolve to a real user; the audit row's userId is nullable + // (ON DELETE SET NULL). + null, + "form.submit", + submission.id, + { formKey: form.key, formLabel: form.label }, + ); + } + + return json( + { + ok: true, + id: submission.id, + message: form.successMessages.en ?? "Thanks — we'll be in touch.", + }, + { status: 201 }, + ); +}; + +// Keep TS happy when the import is otherwise tree-shaken. +void HONEYPOT_FIELD;