From 03e00643fd7e302ef48d7034493070412d770b29 Mon Sep 17 00:00:00 2001 From: ephraimduncan Date: Sun, 11 Jan 2026 23:22:56 +0000 Subject: [PATCH] feat: spam detection --- .../form/[id]/export-submissions-button.tsx | 40 +- .../app/(main)/form/[id]/form-settings.tsx | 87 +- apps/web/src/app/(main)/form/[id]/page.tsx | 1 + .../(main)/form/[id]/submissions-table.tsx | 80 +- apps/web/src/app/api/s/[id]/route.ts | 15 +- apps/web/src/lib/spam-detection.ts | 35 + bun.lock | 5 + package.json | 5 +- packages/api/routers/api-v1/schemas.ts | 3 + packages/api/routers/api-v1/submissions.ts | 12 +- packages/api/routers/form.ts | 3 + packages/api/routers/formData.ts | 22 + .../db/drizzle/0002_majestic_the_hood.sql | 4 + packages/db/drizzle/meta/0002_snapshot.json | 893 ++++++++++++++++++ packages/db/drizzle/meta/_journal.json | 7 + packages/db/schema/form-data.ts | 5 + packages/db/schema/forms.ts | 2 + tests/helpers/db.ts | 4 + 18 files changed, 1204 insertions(+), 19 deletions(-) create mode 100644 apps/web/src/lib/spam-detection.ts create mode 100644 packages/db/drizzle/0002_majestic_the_hood.sql create mode 100644 packages/db/drizzle/meta/0002_snapshot.json diff --git a/apps/web/src/app/(main)/form/[id]/export-submissions-button.tsx b/apps/web/src/app/(main)/form/[id]/export-submissions-button.tsx index 3c93207..cca715e 100644 --- a/apps/web/src/app/(main)/form/[id]/export-submissions-button.tsx +++ b/apps/web/src/app/(main)/form/[id]/export-submissions-button.tsx @@ -18,21 +18,27 @@ type ExportSubmissionsDropDownButtonProps = { submissions: FormData[]; formKeys: string[]; formTitle: string; + honeypotField: string; }; const createCSVContent = ( submissions: FormData[], formKeys: string[], + honeypotField: string, ): string => { + const filteredKeys = formKeys.filter((key) => key !== honeypotField); + const allKeys = [...filteredKeys, 'isSpam']; + let csvContent = 'data:text/csv;charset=utf-8,'; - const header = formKeys.join(',') + '\n'; + const header = allKeys.join(',') + '\n'; csvContent += header; submissions.forEach((submission) => { if (submission.data && typeof submission.data === 'object') { - const row = formKeys - .map((key) => (submission.data as Record)[key] ?? '') - .join(','); + const dataValues = filteredKeys.map( + (key) => (submission.data as Record)[key] ?? '', + ); + const row = [...dataValues, submission.isSpam].join(','); csvContent += row + '\n'; } }); @@ -40,13 +46,22 @@ const createCSVContent = ( return csvContent; }; -const createJSONContent = (submissions: FormData[]): string => { +const createJSONContent = ( + submissions: FormData[], + honeypotField: string, +): string => { const jsonContent = JSON.stringify( - submissions.map((submission) => - submission.data && typeof submission.data === 'object' - ? submission.data - : {}, - ), + submissions.map((submission) => { + const data = + submission.data && typeof submission.data === 'object' + ? { ...(submission.data as Record) } + : {}; + delete data[honeypotField]; + return { + ...data, + isSpam: submission.isSpam, + }; + }), null, 2, ); @@ -70,9 +85,10 @@ export function ExportSubmissionsDropDownButton({ submissions, formKeys, formTitle, + honeypotField, }: ExportSubmissionsDropDownButtonProps) { const handleDownloadAsCSV = () => { - const csvContent = createCSVContent(submissions, formKeys); + const csvContent = createCSVContent(submissions, formKeys, honeypotField); triggerDownload(csvContent, `${formTitle}_submissions.csv`); toast('Submissions exported as CSV', { icon: , @@ -80,7 +96,7 @@ export function ExportSubmissionsDropDownButton({ }; const handleDownloadAsJSON = () => { - const jsonContent = createJSONContent(submissions); + const jsonContent = createJSONContent(submissions, honeypotField); triggerDownload(jsonContent, `${formTitle}_submissions.json`); toast('Submissions exported as JSON', { icon: , diff --git a/apps/web/src/app/(main)/form/[id]/form-settings.tsx b/apps/web/src/app/(main)/form/[id]/form-settings.tsx index e8a597a..74fb8f6 100644 --- a/apps/web/src/app/(main)/form/[id]/form-settings.tsx +++ b/apps/web/src/app/(main)/form/[id]/form-settings.tsx @@ -3,7 +3,7 @@ import { useRouter } from 'next/navigation'; import { zodResolver } from '@hookform/resolvers/zod'; -import { BellRing, ExternalLink, FolderPen, FolderX } from 'lucide-react'; +import { BellRing, ExternalLink, FolderPen, FolderX, Shield } from 'lucide-react'; import { useForm } from 'react-hook-form'; import { toast } from 'sonner'; import { z } from 'zod'; @@ -52,6 +52,10 @@ const defaultSubmissionEmailSchema = z.object({ defaultSubmissionEmail: z.string().email().optional(), }); +const honeypotFieldSchema = z.object({ + honeypotField: z.string().min(1).optional(), +}); + type FormNameSchema = z.infer; type EnableFormSubmissionsSchema = z.infer; type _EnableSubmissionsRetentionSchema = z.infer; @@ -103,6 +107,11 @@ export function FormSettings({ form, user }: FormSettingsProps) { /> )} + +
@@ -509,3 +518,79 @@ const EnableFormNotifications = ({ ); }; + +type HoneypotFieldSchema = z.infer; + +const HoneypotFieldSetting = ({ + formId, + honeypotField, +}: { + formId: string; + honeypotField: string; +}) => { + const router = useRouter(); + + const honeypotFieldForm = useForm({ + resolver: zodResolver(honeypotFieldSchema), + defaultValues: { + honeypotField, + }, + }); + + const { mutateAsync: updateHoneypotField, isPending: isUpdating } = + api.form.update.useMutation(); + + async function handleHoneypotFieldSubmit(data: HoneypotFieldSchema) { + try { + await updateHoneypotField({ + id: formId, + honeypotField: data.honeypotField, + }); + + toast('Honeypot field name has been updated', { + icon: , + }); + + router.refresh(); + } catch { + toast('Failed to update honeypot field', { + description: 'Please try again later', + icon: , + }); + } + } + + return ( +
+ + ( + +
+ Honeypot Field Name + + Hidden field used for spam detection. Bots fill this field, + humans don't. + +
+ +
+ + + Save + +
+
+
+ )} + /> + + + ); +}; diff --git a/apps/web/src/app/(main)/form/[id]/page.tsx b/apps/web/src/app/(main)/form/[id]/page.tsx index f303815..eb8c06e 100644 --- a/apps/web/src/app/(main)/form/[id]/page.tsx +++ b/apps/web/src/app/(main)/form/[id]/page.tsx @@ -56,6 +56,7 @@ export default async function FormPage({ submissions={formSubmissions as FormData[]} formKeys={form?.keys ?? []} formTitle={form?.title ?? ''} + honeypotField={form?.honeypotField ?? '_gotcha'} />
{ + await markAsSpam( + { + id: submissionId, + isSpam, + }, + { + onSuccess: () => { + router.refresh(); + toast.success( + isSpam ? 'Marked as spam' : 'Marked as not spam', + { + icon: isSpam ? ( + + ) : ( + + ), + }, + ); + }, + }, + ); + }; + const columns: Array> = [ { id: 'select', @@ -227,12 +260,38 @@ export function SubmissionsTable({ }, }, + { + accessorKey: 'isSpam', + header: () => { + return ( + + ); + }, + cell: ({ row }: { row: Row }) => { + const isSpam = row.original.isSpam; + if (!isSpam) { + return null; + } + return ( + + Spam + + ); + }, + }, + { id: 'actions', enableHiding: false, size: 20, cell: ({ row }: { row: Row }) => { const submissionId = row.original.id; + const isSpam = row.original.isSpam; if (!submissionId) { return null; @@ -247,6 +306,25 @@ export function SubmissionsTable({ + + handleToggleSpam({ submissionId, isSpam: !isSpam }) + } + > + + {isSpam ? ( + <> + + Mark as not spam + + ) : ( + <> + + Mark as spam + + )} + + handleFormSubmissionDelete({ submissionId })} diff --git a/apps/web/src/app/api/s/[id]/route.ts b/apps/web/src/app/api/s/[id]/route.ts index d5d996a..d0db918 100644 --- a/apps/web/src/app/api/s/[id]/route.ts +++ b/apps/web/src/app/api/s/[id]/route.ts @@ -3,6 +3,7 @@ import { userAgent } from "next/server"; import { sendMail } from "~/lib/email/mailer"; import { renderNewSubmissionEmail } from "~/lib/email/templates/new-submission"; +import { checkForSpam, stripHoneypotField } from "~/lib/spam-detection"; import { api } from "~/lib/trpc/server"; import { assignFileOrImage, uploadFileFromBlob } from "~/lib/upload-file"; import { type RouterOutputs } from "@formbase/api"; @@ -120,17 +121,25 @@ export async function POST( await processFileUploads(formData, formDataResult.rawFormData); } - const formDataKeys = Object.keys(formData); + const honeypotField = form.honeypotField; + const spamResult = checkForSpam(formData as Record, honeypotField); + const cleanedFormData = stripHoneypotField(formData as Record, honeypotField); + + const formDataKeys = Object.keys(cleanedFormData); const formKeys = form.keys; const updatedKeys = [...new Set([...formKeys, ...formDataKeys])]; await api.formData.setFormData({ - data: formData as Json, + data: cleanedFormData as Json, formId, keys: updatedKeys, + isSpam: spamResult.isSpam, + spamReason: spamResult.spamReason, }); - void handleEmailNotifications(form, formData as Record); + if (!spamResult.isSpam) { + void handleEmailNotifications(form, cleanedFormData); + } const { browser } = userAgent(request); if (!browser.name) { diff --git a/apps/web/src/lib/spam-detection.ts b/apps/web/src/lib/spam-detection.ts new file mode 100644 index 0000000..3daa3cc --- /dev/null +++ b/apps/web/src/lib/spam-detection.ts @@ -0,0 +1,35 @@ +import domains from 'disposable-email-domains'; + +const disposableDomains = new Set(domains); + +export type SpamCheckResult = { + isSpam: boolean; + spamReason: string | null; +}; + +export function checkForSpam( + formData: Record, + honeypotField: string, +): SpamCheckResult { + if (honeypotField in formData && formData[honeypotField]) { + return { isSpam: true, spamReason: 'honeypot' }; + } + + const emailValue = formData['email']; + if (typeof emailValue === 'string' && emailValue.includes('@')) { + const domain = emailValue.split('@')[1]?.toLowerCase(); + if (domain && disposableDomains.has(domain)) { + return { isSpam: true, spamReason: 'disposable_email' }; + } + } + + return { isSpam: false, spamReason: null }; +} + +export function stripHoneypotField( + formData: Record, + honeypotField: string, +): Record { + const { [honeypotField]: _, ...rest } = formData; + return rest; +} diff --git a/bun.lock b/bun.lock index eb41542..c2d6ed1 100644 --- a/bun.lock +++ b/bun.lock @@ -4,6 +4,9 @@ "workspaces": { "": { "name": "formbase", + "dependencies": { + "disposable-email-domains": "^1.0.62", + }, "devDependencies": { "@formbase/tsconfig": "workspace:*", "@ianvs/prettier-plugin-sort-imports": "^4.2.1", @@ -1350,6 +1353,8 @@ "dir-glob": ["dir-glob@3.0.1", "", { "dependencies": { "path-type": "^4.0.0" } }, "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA=="], + "disposable-email-domains": ["disposable-email-domains@1.0.62", "", {}, "sha512-LBQvhRw7mznQTPoyZbsmYeNOZt1pN5aCsx4BAU/3siVFuiM9f2oyKzUaB8v1jbxFjE3aYqYiMo63kAL4pHgfWQ=="], + "doctrine": ["doctrine@3.0.0", "", { "dependencies": { "esutils": "^2.0.2" } }, "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w=="], "dom-helpers": ["dom-helpers@5.2.1", "", { "dependencies": { "@babel/runtime": "^7.8.7", "csstype": "^3.0.2" } }, "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA=="], diff --git a/package.json b/package.json index a40a2a8..dcb36f2 100644 --- a/package.json +++ b/package.json @@ -44,5 +44,8 @@ "turbo": "^2.0.0", "typescript": "5.8.2" }, - "packageManager": "bun@1.3.2" + "packageManager": "bun@1.3.2", + "dependencies": { + "disposable-email-domains": "^1.0.62" + } } diff --git a/packages/api/routers/api-v1/schemas.ts b/packages/api/routers/api-v1/schemas.ts index 9797536..093b0a9 100644 --- a/packages/api/routers/api-v1/schemas.ts +++ b/packages/api/routers/api-v1/schemas.ts @@ -28,6 +28,9 @@ export const submissionSchema = z.object({ formId: z.string(), data: z.record(z.unknown()), createdAt: z.string(), + isSpam: z.boolean(), + spamReason: z.string().nullable(), + manualOverride: z.boolean(), }); export const createFormInputSchema = z.object({ diff --git a/packages/api/routers/api-v1/submissions.ts b/packages/api/routers/api-v1/submissions.ts index 1ee15b8..ecebbc8 100644 --- a/packages/api/routers/api-v1/submissions.ts +++ b/packages/api/routers/api-v1/submissions.ts @@ -28,12 +28,13 @@ export const submissionsRouter = createApiV1Router({ path: '/forms/{formId}/submissions', tags: ['Submissions'], summary: 'List submissions for a form', - description: 'Returns a paginated list of submissions with optional date filtering.', + description: 'Returns a paginated list of submissions with optional date and spam filtering.', }, }) .input( paginationInputSchema.merge(dateRangeInputSchema).extend({ formId: z.string(), + spam: z.enum(['true', 'false']).optional(), }), ) .output( @@ -57,6 +58,9 @@ export const submissionsRouter = createApiV1Router({ endDate.setHours(23, 59, 59, 999); whereConditions.push(lte(formDatas.createdAt, endDate)); } + if (input.spam !== undefined) { + whereConditions.push(eq(formDatas.isSpam, input.spam === 'true')); + } const whereClause = and(...whereConditions); @@ -81,6 +85,9 @@ export const submissionsRouter = createApiV1Router({ formId: submission.formId, data: parseJsonObject(submission.data) ?? {}, createdAt: submission.createdAt.toISOString(), + isSpam: submission.isSpam, + spamReason: submission.spamReason, + manualOverride: submission.manualOverride, })), pagination: { page: input.page, @@ -119,6 +126,9 @@ export const submissionsRouter = createApiV1Router({ formId: submission.formId, data: parseJsonObject(submission.data) ?? {}, createdAt: submission.createdAt.toISOString(), + isSpam: submission.isSpam, + spamReason: submission.spamReason, + manualOverride: submission.manualOverride, }; }), diff --git a/packages/api/routers/form.ts b/packages/api/routers/form.ts index 472f578..58bd247 100644 --- a/packages/api/routers/form.ts +++ b/packages/api/routers/form.ts @@ -126,6 +126,7 @@ export const formRouter = createTRPCRouter({ enableEmailNotifications: z.boolean().optional(), returnUrl: z.string().optional(), defaultSubmissionEmail: z.string().optional(), + honeypotField: z.string().optional(), }), ) .mutation(async ({ ctx, input }) => { @@ -143,6 +144,7 @@ export const formRouter = createTRPCRouter({ returnUrl: input.returnUrl ?? form.returnUrl, defaultSubmissionEmail: input.defaultSubmissionEmail ?? form.defaultSubmissionEmail, + honeypotField: input.honeypotField ?? form.honeypotField, }) .where(eq(forms.id, input.id)); }), @@ -179,6 +181,7 @@ export const formRouter = createTRPCRouter({ enableEmailNotifications: existingForm.enableEmailNotifications, enableSubmissions: existingForm.enableSubmissions, defaultSubmissionEmail: existingForm.defaultSubmissionEmail, + honeypotField: existingForm.honeypotField, }); return { id: newId }; diff --git a/packages/api/routers/formData.ts b/packages/api/routers/formData.ts index 45dd772..7d217ed 100644 --- a/packages/api/routers/formData.ts +++ b/packages/api/routers/formData.ts @@ -118,6 +118,8 @@ export const formDataRouter = createTRPCRouter({ formId: z.string(), data: z.unknown(), keys: z.array(z.string()), + isSpam: z.boolean().optional().default(false), + spamReason: z.string().nullable().optional(), }), ) .mutation(async ({ ctx, input }) => { @@ -127,6 +129,8 @@ export const formDataRouter = createTRPCRouter({ formId: input.formId, id: generateId(15), createdAt: new Date(), + isSpam: input.isSpam, + spamReason: input.spamReason ?? null, }); await tx @@ -140,4 +144,22 @@ export const formDataRouter = createTRPCRouter({ return formdata; }); }), + + markAsSpam: protectedProcedure + .input( + z.object({ + id: z.string(), + isSpam: z.boolean(), + }), + ) + .mutation(async ({ ctx, input }) => { + await assertFormDataOwnership(ctx, input.id); + await ctx.db + .update(formDatas) + .set({ + isSpam: input.isSpam, + manualOverride: true, + }) + .where(eq(formDatas.id, input.id)); + }), }); diff --git a/packages/db/drizzle/0002_majestic_the_hood.sql b/packages/db/drizzle/0002_majestic_the_hood.sql new file mode 100644 index 0000000..8f88f69 --- /dev/null +++ b/packages/db/drizzle/0002_majestic_the_hood.sql @@ -0,0 +1,4 @@ +ALTER TABLE `form_datas` ADD `is_spam` integer DEFAULT false NOT NULL;--> statement-breakpoint +ALTER TABLE `form_datas` ADD `spam_reason` text;--> statement-breakpoint +ALTER TABLE `form_datas` ADD `manual_override` integer DEFAULT false NOT NULL;--> statement-breakpoint +ALTER TABLE `forms` ADD `honeypot_field` text DEFAULT '_gotcha' NOT NULL; \ No newline at end of file diff --git a/packages/db/drizzle/meta/0002_snapshot.json b/packages/db/drizzle/meta/0002_snapshot.json new file mode 100644 index 0000000..32d8791 --- /dev/null +++ b/packages/db/drizzle/meta/0002_snapshot.json @@ -0,0 +1,893 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "57d4c97e-b93c-4e5d-977d-b619497f66a1", + "prevId": "d19b0668-e19b-464b-833e-88f4b97a1222", + "tables": { + "account": { + "name": "account", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "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 + }, + "user_id": { + "name": "user_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 + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "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": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "account_userId_idx": { + "name": "account_userId_idx", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "account_provider_account_idx": { + "name": "account_provider_account_idx", + "columns": [ + "provider_id", + "account_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "api_audit_logs": { + "name": "api_audit_logs", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "api_key_id": { + "name": "api_key_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "method": { + "name": "method", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status_code": { + "name": "status_code", + "type": "integer", + "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 + }, + "request_body": { + "name": "request_body", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "response_time_ms": { + "name": "response_time_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "audit_api_key_idx": { + "name": "audit_api_key_idx", + "columns": [ + "api_key_id" + ], + "isUnique": false + }, + "audit_user_idx": { + "name": "audit_user_idx", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "audit_created_at_idx": { + "name": "audit_created_at_idx", + "columns": [ + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "api_audit_logs_api_key_id_api_keys_id_fk": { + "name": "api_audit_logs_api_key_id_api_keys_id_fk", + "tableFrom": "api_audit_logs", + "tableTo": "api_keys", + "columnsFrom": [ + "api_key_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "api_keys": { + "name": "api_keys", + "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 + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key_hash": { + "name": "key_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key_prefix": { + "name": "key_prefix", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "api_keys_key_hash_unique": { + "name": "api_keys_key_hash_unique", + "columns": [ + "key_hash" + ], + "isUnique": true + }, + "api_key_user_idx": { + "name": "api_key_user_idx", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "api_key_hash_idx": { + "name": "api_key_hash_idx", + "columns": [ + "key_hash" + ], + "isUnique": false + } + }, + "foreignKeys": { + "api_keys_user_id_user_id_fk": { + "name": "api_keys_user_id_user_id_fk", + "tableFrom": "api_keys", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "form_datas": { + "name": "form_datas", + "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 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "is_spam": { + "name": "is_spam", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "spam_reason": { + "name": "spam_reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "manual_override": { + "name": "manual_override", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + } + }, + "indexes": { + "form_idx": { + "name": "form_idx", + "columns": [ + "form_id" + ], + "isUnique": false + }, + "form_data_created_at_idx": { + "name": "form_data_created_at_idx", + "columns": [ + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "form_datas_form_id_forms_id_fk": { + "name": "form_datas_form_id_forms_id_fk", + "tableFrom": "form_datas", + "tableTo": "forms", + "columnsFrom": [ + "form_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "forms": { + "name": "forms", + "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 + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "return_url": { + "name": "return_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "send_email_for_new_submissions": { + "name": "send_email_for_new_submissions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "keys": { + "name": "keys", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enable_submissions": { + "name": "enable_submissions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "enable_retention": { + "name": "enable_retention", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "default_submission_email": { + "name": "default_submission_email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "honeypot_field": { + "name": "honeypot_field", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'_gotcha'" + } + }, + "indexes": { + "form_user_idx": { + "name": "form_user_idx", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "form_created_at_idx": { + "name": "form_created_at_idx", + "columns": [ + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "forms_user_id_user_id_fk": { + "name": "forms_user_id_user_id_fk", + "tableFrom": "forms", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "onboarding_forms": { + "name": "onboarding_forms", + "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 + }, + "form_id": { + "name": "form_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": {}, + "foreignKeys": { + "onboarding_forms_user_id_user_id_fk": { + "name": "onboarding_forms_user_id_user_id_fk", + "tableFrom": "onboarding_forms", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "onboarding_forms_form_id_forms_id_fk": { + "name": "onboarding_forms_form_id_forms_id_fk", + "tableFrom": "onboarding_forms", + "tableTo": "forms", + "columnsFrom": [ + "form_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "session": { + "name": "session", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "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 + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "session_token_unique": { + "name": "session_token_unique", + "columns": [ + "token" + ], + "isUnique": true + }, + "session_userId_idx": { + "name": "session_userId_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "user": { + "name": "user", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "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 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "user_email_unique": { + "name": "user_email_unique", + "columns": [ + "email" + ], + "isUnique": true + }, + "user_email_idx": { + "name": "user_email_idx", + "columns": [ + "email" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "verification": { + "name": "verification", + "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": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + "identifier" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + } + }, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + } +} \ No newline at end of file diff --git a/packages/db/drizzle/meta/_journal.json b/packages/db/drizzle/meta/_journal.json index 9ad44fd..52f2575 100644 --- a/packages/db/drizzle/meta/_journal.json +++ b/packages/db/drizzle/meta/_journal.json @@ -15,6 +15,13 @@ "when": 1767830295183, "tag": "0001_right_menace", "breakpoints": true + }, + { + "idx": 2, + "version": "6", + "when": 1768165812454, + "tag": "0002_majestic_the_hood", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/db/schema/form-data.ts b/packages/db/schema/form-data.ts index 92bba77..9bc21cc 100644 --- a/packages/db/schema/form-data.ts +++ b/packages/db/schema/form-data.ts @@ -16,6 +16,11 @@ export const formDatas = sqliteTable( createdAt: integer('created_at', { mode: 'timestamp_ms' }) .default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`) .notNull(), + isSpam: integer('is_spam', { mode: 'boolean' }).default(false).notNull(), + spamReason: text('spam_reason'), + manualOverride: integer('manual_override', { mode: 'boolean' }) + .default(false) + .notNull(), }, (t) => ({ formIdx: index('form_idx').on(t.formId), diff --git a/packages/db/schema/forms.ts b/packages/db/schema/forms.ts index 0cf2a86..cd8edf3 100644 --- a/packages/db/schema/forms.ts +++ b/packages/db/schema/forms.ts @@ -37,6 +37,7 @@ export const forms = sqliteTable( .default(true) .notNull(), defaultSubmissionEmail: text('default_submission_email'), + honeypotField: text('honeypot_field').default('_gotcha').notNull(), }, (t) => ({ userIdx: index('form_user_idx').on(t.userId), @@ -54,6 +55,7 @@ export const ZUpdateFormSchema = createInsertSchema(forms).pick({ enableSubmissions: true, enableEmailNotifications: true, enableRetention: true, + honeypotField: true, }); export type Form = InferSelectModel; diff --git a/tests/helpers/db.ts b/tests/helpers/db.ts index a44421b..4e6ac92 100644 --- a/tests/helpers/db.ts +++ b/tests/helpers/db.ts @@ -59,6 +59,7 @@ CREATE TABLE IF NOT EXISTS forms ( enable_submissions INTEGER DEFAULT 1 NOT NULL, enable_retention INTEGER DEFAULT 1 NOT NULL, default_submission_email TEXT, + honeypot_field TEXT DEFAULT '_gotcha' NOT NULL, FOREIGN KEY (user_id) REFERENCES user(id) ON DELETE CASCADE ); @@ -67,6 +68,9 @@ CREATE TABLE IF NOT EXISTS form_datas ( form_id TEXT NOT NULL, data TEXT NOT NULL, created_at INTEGER DEFAULT (cast(unixepoch('subsec') * 1000 as integer)) NOT NULL, + is_spam INTEGER DEFAULT 0 NOT NULL, + spam_reason TEXT, + manual_override INTEGER DEFAULT 0 NOT NULL, FOREIGN KEY (form_id) REFERENCES forms(id) ON DELETE CASCADE );